feat: add support for experimental WebAssembly threads

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-01-01 13:19:52 -05:00
parent a2ace6e84e
commit c6fe02a49c
6 changed files with 149 additions and 6 deletions

View File

@ -132,6 +132,27 @@ Every plugin must include a `manifest.json` file. Example:
**Required fields:** `name`, `author`, `version`
#### Experimental Features
Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported:
- **`threads`** Enables WebAssembly threads support (for plugins compiled with multi-threading)
```json
{
"name": "Threaded Plugin",
"author": "Author Name",
"version": "1.0.0",
"experimental": {
"threads": {
"reason": "Required for concurrent audio processing"
}
}
}
```
> **Note:** Experimental features may have compatibility or performance implications. Use only when necessary.
---
## Capabilities

View File

@ -12,6 +12,8 @@ import (
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/scheduler"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/experimental"
"golang.org/x/sync/errgroup"
)
@ -268,11 +270,19 @@ func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error {
}
// Compile the plugin with all host functions
runtimeConfig := wazero.NewRuntimeConfig().
WithCompilationCache(m.cache).
WithCloseOnContextDone(true)
// Enable experimental threads if requested in manifest
if pkg.Manifest.HasExperimentalThreads() {
runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads)
log.Debug(m.ctx, "Enabling experimental threads support", "plugin", name)
}
extismConfig := extism.PluginConfig{
EnableWasi: true,
RuntimeConfig: wazero.NewRuntimeConfig().
WithCompilationCache(m.cache).
WithCloseOnContextDone(true),
EnableWasi: true,
RuntimeConfig: runtimeConfig,
}
compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, hostFunctions)
if err != nil {

View File

@ -33,9 +33,33 @@
},
"permissions": {
"$ref": "#/$defs/Permissions"
},
"experimental": {
"$ref": "#/$defs/Experimental"
}
},
"$defs": {
"Experimental": {
"type": "object",
"description": "Experimental features that may change or be removed in future versions",
"additionalProperties": false,
"properties": {
"threads": {
"$ref": "#/$defs/ThreadsFeature"
}
}
},
"ThreadsFeature": {
"type": "object",
"description": "Enable experimental WebAssembly threads support",
"additionalProperties": false,
"properties": {
"reason": {
"type": "string",
"description": "Explanation for why threads support is needed"
}
}
},
"Permissions": {
"type": "object",
"description": "Permissions required by the plugin",

View File

@ -11,5 +11,7 @@ func (m *Manifest) AllowedHosts() []string {
return m.Permissions.Http.AllowedHosts
}
// TODO: ConfigPermission is defined in the schema but not currently enforced.
// Plugins always receive their config section. Implement permission checking or remove from schema.
// HasExperimentalThreads returns true if the manifest requests experimental threads support.
func (m *Manifest) HasExperimentalThreads() bool {
return m.Experimental != nil && m.Experimental.Threads != nil
}

View File

@ -23,6 +23,12 @@ type ConfigPermission struct {
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// Experimental features that may change or be removed in future versions
type Experimental struct {
// Threads corresponds to the JSON schema field "threads".
Threads *ThreadsFeature `json:"threads,omitempty" yaml:"threads,omitempty" mapstructure:"threads,omitempty"`
}
// HTTP access permissions for a plugin
type HTTPPermission struct {
// List of allowed host patterns for HTTP requests (e.g., 'api.example.com',
@ -78,6 +84,9 @@ type Manifest struct {
// A brief description of what the plugin does
Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"`
// Experimental corresponds to the JSON schema field "experimental".
Experimental *Experimental `json:"experimental,omitempty" yaml:"experimental,omitempty" mapstructure:"experimental,omitempty"`
// The display name of the plugin
Name string `json:"name" yaml:"name" mapstructure:"name"`
@ -187,6 +196,12 @@ func (j *SubsonicAPIPermission) UnmarshalJSON(value []byte) error {
return nil
}
// Enable experimental WebAssembly threads support
type ThreadsFeature struct {
// Explanation for why threads support is needed
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// WebSocket service permissions for establishing WebSocket connections
type WebSocketPermission struct {
// List of allowed host patterns for WebSocket connections (e.g.,

View File

@ -145,4 +145,75 @@ var _ = Describe("Manifest", func() {
Expect(hosts).To(Equal([]string{"api.example.com", "*.spotify.com"}))
})
})
Describe("HasExperimentalThreads", func() {
It("returns false when no experimental section", func() {
m := &Manifest{}
Expect(m.HasExperimentalThreads()).To(BeFalse())
})
It("returns false when experimental section has no threads", func() {
m := &Manifest{
Experimental: &Experimental{},
}
Expect(m.HasExperimentalThreads()).To(BeFalse())
})
It("returns true when threads feature is present", func() {
m := &Manifest{
Experimental: &Experimental{
Threads: &ThreadsFeature{},
},
}
Expect(m.HasExperimentalThreads()).To(BeTrue())
})
It("returns true when threads feature has a reason", func() {
reason := "Required for concurrent processing"
m := &Manifest{
Experimental: &Experimental{
Threads: &ThreadsFeature{
Reason: &reason,
},
},
}
Expect(m.HasExperimentalThreads()).To(BeTrue())
})
It("parses experimental.threads from JSON", func() {
data := []byte(`{
"name": "Threaded Plugin",
"author": "Test Author",
"version": "1.0.0",
"experimental": {
"threads": {
"reason": "To use multi-threaded WASM module"
}
}
}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).ToNot(HaveOccurred())
Expect(m.HasExperimentalThreads()).To(BeTrue())
Expect(m.Experimental.Threads.Reason).ToNot(BeNil())
Expect(*m.Experimental.Threads.Reason).To(Equal("To use multi-threaded WASM module"))
})
It("parses experimental.threads without reason from JSON", func() {
data := []byte(`{
"name": "Threaded Plugin",
"author": "Test Author",
"version": "1.0.0",
"experimental": {
"threads": {}
}
}`)
var m Manifest
err := json.Unmarshal(data, &m)
Expect(err).ToNot(HaveOccurred())
Expect(m.HasExperimentalThreads()).To(BeTrue())
})
})
})