diff --git a/plugins/README.md b/plugins/README.md index 3e24d10fb..1b4df460d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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 diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 2291b1f88..70eddf617 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -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 { diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 4d8e23880..ca5492fd2 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -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", diff --git a/plugins/manifest.go b/plugins/manifest.go index 17906da80..388cf9cfa 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -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 +} diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index ee5cf37ee..fcccdf81e 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -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., diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index a91759324..7941c5951 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -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()) + }) + }) })