feat(plugins): add auto-reload functionality for plugins with file watcher support

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan
2025-12-21 23:57:57 -05:00
parent 5f854d9f04
commit e5b435f2b0
7 changed files with 609 additions and 5 deletions

View File

@@ -226,9 +226,10 @@ type inspectOptions struct {
}
type pluginsOptions struct {
Enabled bool
Folder string
CacheSize string
Enabled bool
Folder string
CacheSize string
AutoReload bool
}
type extAuthOptions struct {
@@ -634,6 +635,7 @@ func setViperDefaults() {
viper.SetDefault("plugins.folder", "")
viper.SetDefault("plugins.enabled", false)
viper.SetDefault("plugins.cachesize", "100MB")
viper.SetDefault("plugins.autoreload", false)
// DevFlags. These are used to enable/disable debugging and incomplete features
viper.SetDefault("devlogsourceline", false)

View File

@@ -172,6 +172,50 @@ if !ok {
}
```
## Runtime Loading
Navidrome supports loading, unloading, and reloading plugins at runtime without restarting the server.
### Auto-Reload (File Watcher)
Enable automatic plugin reloading when files change:
```toml
[Plugins]
Enabled = true
AutoReload = true # Default: false
```
When enabled, Navidrome watches the plugins folder and automatically:
- **Loads** new `.wasm` files when they are created
- **Reloads** plugins when their `.wasm` file is modified
- **Unloads** plugins when their `.wasm` file is removed
This is especially useful during plugin development - just rebuild your plugin and it will be automatically reloaded.
### Programmatic API
The plugin Manager exposes methods for runtime plugin management:
```go
manager := plugins.GetManager()
// Load a new plugin (file must exist at <plugins_folder>/<name>.wasm)
err := manager.LoadPlugin("my-plugin")
// Unload a running plugin
err := manager.UnloadPlugin("my-plugin")
// Reload a plugin (unload + load)
err := manager.ReloadPlugin("my-plugin")
```
### Notes on Runtime Loading
- **In-flight requests**: When a plugin is unloaded, existing plugin instances continue working until their request completes. New requests use the reloaded version.
- **Config changes**: Plugin configuration (`PluginConfig.<name>`) is read at load time. Changes require a reload.
- **Failed reloads**: If loading fails after unloading, the plugin remains unloaded. Check logs for errors.
## Security
Plugins run in a secure WebAssembly sandbox with these restrictions:

View File

@@ -16,6 +16,7 @@ import (
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/rjeczalik/notify"
"github.com/tetratelabs/wazero"
)
@@ -36,6 +37,12 @@ type Manager struct {
ctx context.Context
cancel context.CancelFunc
cache wazero.CompilationCache
// File watcher fields (used when AutoReload is enabled)
watcherEvents chan notify.EventInfo
watcherDone chan struct{}
debounceTimers map[string]*time.Timer
debounceMu sync.Mutex
}
// pluginInstance represents a loaded plugin
@@ -90,11 +97,22 @@ func (m *Manager) Start(ctx context.Context) error {
return err
}
// Start file watcher if auto-reload is enabled
if autoReloadEnabled() {
if err := m.startWatcher(); err != nil {
log.Error(ctx, "Failed to start plugin file watcher", err)
// Non-fatal - plugins are still loaded, just no auto-reload
}
}
return nil
}
// Stop shuts down the plugin manager and releases all resources.
func (m *Manager) Stop() error {
// Stop file watcher first
m.stopWatcher()
if m.cancel != nil {
m.cancel()
}
@@ -273,7 +291,7 @@ func (m *Manager) loadPlugin(name, wasmPath string) error {
return err
}
if exit != 0 {
return err
return fmt.Errorf("calling %s: %d", ManifestFunction, exit)
}
// Parse manifest (validation happens during unmarshal via generated code)
@@ -342,6 +360,83 @@ func (m *Manager) createMetadataAgent(instance *pluginInstance) (*MetadataAgent,
return NewMetadataAgent(instance.name, plugin), nil
}
// UnloadPlugin removes a plugin from the manager and closes its resources.
// Returns an error if the plugin is not found.
func (m *Manager) UnloadPlugin(name string) error {
m.mu.Lock()
instance, ok := m.plugins[name]
if !ok {
m.mu.Unlock()
return fmt.Errorf("plugin %q not found", name)
}
delete(m.plugins, name)
m.mu.Unlock()
// Close the compiled plugin outside the lock with a grace period
// to allow in-flight requests to complete
if instance.compiled != nil {
// Use a brief timeout for cleanup
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := instance.compiled.Close(ctx); err != nil {
log.Error("Error closing plugin during unload", "plugin", name, err)
}
}
log.Info(m.ctx, "Unloaded plugin", "plugin", name)
return nil
}
// LoadPlugin loads a new plugin by name from the plugins folder.
// The plugin file must exist at <plugins_folder>/<name>.wasm.
// Returns an error if the plugin is already loaded or fails to load.
func (m *Manager) LoadPlugin(name string) error {
m.mu.RLock()
_, exists := m.plugins[name]
m.mu.RUnlock()
if exists {
return fmt.Errorf("plugin %q is already loaded", name)
}
folder := m.pluginsFolder()
if folder == "" {
return fmt.Errorf("no plugins folder configured")
}
wasmPath := filepath.Join(folder, name+".wasm")
if _, err := os.Stat(wasmPath); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("plugin file not found: %s", wasmPath)
}
return err
}
if err := m.loadPlugin(name, wasmPath); err != nil {
return fmt.Errorf("failed to load plugin %q: %w", name, err)
}
log.Info(m.ctx, "Loaded plugin", "plugin", name)
return nil
}
// ReloadPlugin unloads and reloads a plugin by name.
// If the plugin was loaded and unload succeeds but reload fails,
// the plugin remains unloaded and the error is returned.
func (m *Manager) ReloadPlugin(name string) error {
if err := m.UnloadPlugin(name); err != nil {
return fmt.Errorf("failed to unload plugin %q: %w", name, err)
}
if err := m.LoadPlugin(name); err != nil {
log.Error(m.ctx, "Failed to reload plugin, plugin remains unloaded", "plugin", name, err)
return fmt.Errorf("failed to reload plugin %q: %w", name, err)
}
log.Info(m.ctx, "Reloaded plugin", "plugin", name)
return nil
}
// Verify interface implementations at compile time
var (
_ agents.PluginLoader = (*Manager)(nil)

160
plugins/manager_test.go Normal file
View File

@@ -0,0 +1,160 @@
package plugins
import (
"context"
"os"
"path/filepath"
"runtime"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Manager", func() {
var (
manager *Manager
ctx context.Context
testdataDir string
tmpDir string
)
BeforeEach(func() {
ctx = GinkgoT().Context()
// Get testdata directory
_, currentFile, _, ok := runtime.Caller(0)
Expect(ok).To(BeTrue())
testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata")
// Create temp dir for plugins
var err error
tmpDir, err = os.MkdirTemp("", "plugins-test-*")
Expect(err).ToNot(HaveOccurred())
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = tmpDir
// Create a fresh manager for each test
manager = &Manager{
plugins: make(map[string]*pluginInstance),
}
err = manager.Start(ctx)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
})
copyTestPlugin := func(destName string) string {
srcPath := filepath.Join(testdataDir, "test-plugin.wasm")
destPath := filepath.Join(tmpDir, destName+".wasm")
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
return destPath
}
Describe("LoadPlugin", func() {
It("loads a new plugin by name", func() {
copyTestPlugin("new-plugin")
err := manager.LoadPlugin("new-plugin")
Expect(err).ToNot(HaveOccurred())
names := manager.PluginNames(string(CapabilityMetadataAgent))
Expect(names).To(ContainElement("new-plugin"))
})
It("returns error when plugin file does not exist", func() {
err := manager.LoadPlugin("nonexistent")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("plugin file not found"))
})
It("returns error when plugin is already loaded", func() {
copyTestPlugin("duplicate")
err := manager.LoadPlugin("duplicate")
Expect(err).ToNot(HaveOccurred())
err = manager.LoadPlugin("duplicate")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("already loaded"))
})
It("returns error when plugins folder is not configured", func() {
conf.Server.Plugins.Folder = ""
conf.Server.DataFolder = ""
err := manager.LoadPlugin("test")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no plugins folder configured"))
})
})
Describe("UnloadPlugin", func() {
It("removes a loaded plugin", func() {
copyTestPlugin("to-unload")
err := manager.LoadPlugin("to-unload")
Expect(err).ToNot(HaveOccurred())
err = manager.UnloadPlugin("to-unload")
Expect(err).ToNot(HaveOccurred())
names := manager.PluginNames(string(CapabilityMetadataAgent))
Expect(names).ToNot(ContainElement("to-unload"))
})
It("returns error when plugin not found", func() {
err := manager.UnloadPlugin("nonexistent")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not found"))
})
})
Describe("ReloadPlugin", func() {
It("unloads and reloads a plugin", func() {
copyTestPlugin("to-reload")
err := manager.LoadPlugin("to-reload")
Expect(err).ToNot(HaveOccurred())
err = manager.ReloadPlugin("to-reload")
Expect(err).ToNot(HaveOccurred())
names := manager.PluginNames(string(CapabilityMetadataAgent))
Expect(names).To(ContainElement("to-reload"))
})
It("returns error when plugin not found", func() {
err := manager.ReloadPlugin("nonexistent")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to unload"))
})
It("keeps plugin unloaded if reload fails", func() {
copyTestPlugin("fail-reload")
err := manager.LoadPlugin("fail-reload")
Expect(err).ToNot(HaveOccurred())
// Remove the wasm file so reload will fail
wasmPath := filepath.Join(tmpDir, "fail-reload.wasm")
err = os.Remove(wasmPath)
Expect(err).ToNot(HaveOccurred())
err = manager.ReloadPlugin("fail-reload")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to reload"))
// Plugin should no longer be loaded
names := manager.PluginNames(string(CapabilityMetadataAgent))
Expect(names).ToNot(ContainElement("fail-reload"))
})
})
})

View File

@@ -18,7 +18,7 @@ var _ = Describe("MetadataAgent", func() {
)
BeforeEach(func() {
ctx = context.Background()
ctx = GinkgoT().Context()
// Load the test plugin
_, currentFile, _, ok := runtime.Caller(0)

153
plugins/watcher.go Normal file
View File

@@ -0,0 +1,153 @@
package plugins
import (
"path/filepath"
"strings"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/rjeczalik/notify"
)
const (
// debounceDuration is the time to wait before acting on file events
// to handle multiple rapid events for the same file
debounceDuration = 500 * time.Millisecond
)
// startWatcher starts the file watcher for the plugins folder.
// It watches for CREATE, WRITE, and REMOVE events on .wasm files.
func (m *Manager) startWatcher() error {
folder := m.pluginsFolder()
if folder == "" {
return nil
}
m.watcherEvents = make(chan notify.EventInfo, 10)
m.watcherDone = make(chan struct{})
m.debounceTimers = make(map[string]*time.Timer)
m.debounceMu = sync.Mutex{}
// Watch the plugins folder (not recursive)
// We filter for .wasm files in the event handler
if err := notify.Watch(folder, m.watcherEvents, notify.Create, notify.Write, notify.Remove, notify.Rename); err != nil {
close(m.watcherEvents)
return err
}
log.Info(m.ctx, "Started plugin file watcher", "folder", folder)
go m.watcherLoop()
return nil
}
// stopWatcher stops the file watcher
func (m *Manager) stopWatcher() {
if m.watcherEvents == nil {
return
}
notify.Stop(m.watcherEvents)
close(m.watcherDone)
// Cancel any pending debounce timers
m.debounceMu.Lock()
for _, timer := range m.debounceTimers {
timer.Stop()
}
m.debounceTimers = nil
m.debounceMu.Unlock()
log.Debug(m.ctx, "Stopped plugin file watcher")
}
// watcherLoop processes file watcher events
func (m *Manager) watcherLoop() {
for {
select {
case event, ok := <-m.watcherEvents:
if !ok {
return
}
m.handleWatcherEvent(event)
case <-m.ctx.Done():
return
case <-m.watcherDone:
return
}
}
}
// handleWatcherEvent processes a single file watcher event with debouncing
func (m *Manager) handleWatcherEvent(event notify.EventInfo) {
path := event.Path()
// Only process .wasm files
if !strings.HasSuffix(path, ".wasm") {
return
}
pluginName := strings.TrimSuffix(filepath.Base(path), ".wasm")
log.Debug(m.ctx, "Plugin file event", "plugin", pluginName, "event", event.Event(), "path", path)
// Debounce: cancel any pending timer for this plugin and start a new one
m.debounceMu.Lock()
if timer, exists := m.debounceTimers[pluginName]; exists {
timer.Stop()
}
eventType := event.Event()
m.debounceTimers[pluginName] = time.AfterFunc(debounceDuration, func() {
m.processPluginEvent(pluginName, eventType)
})
m.debounceMu.Unlock()
}
// processPluginEvent handles the actual plugin load/unload/reload after debouncing
func (m *Manager) processPluginEvent(pluginName string, eventType notify.Event) {
// Clean up debounce timer entry
m.debounceMu.Lock()
delete(m.debounceTimers, pluginName)
m.debounceMu.Unlock()
switch {
case eventType&notify.Remove != 0 || eventType&notify.Rename != 0:
// File removed or renamed away - unload if loaded
if err := m.UnloadPlugin(pluginName); err != nil {
// Plugin may not have been loaded, that's okay
log.Debug(m.ctx, "Plugin not loaded, skipping unload", "plugin", pluginName, err)
}
case eventType&notify.Create != 0:
// New file - load it
if err := m.LoadPlugin(pluginName); err != nil {
log.Error(m.ctx, "Failed to load new plugin", "plugin", pluginName, err)
}
case eventType&notify.Write != 0:
// File modified - check if it's loaded and reload
m.mu.RLock()
_, isLoaded := m.plugins[pluginName]
m.mu.RUnlock()
if isLoaded {
if err := m.ReloadPlugin(pluginName); err != nil {
log.Error(m.ctx, "Failed to reload plugin", "plugin", pluginName, err)
}
} else {
// Not loaded yet, try to load it (might be a new file that was written after create)
if err := m.LoadPlugin(pluginName); err != nil {
log.Error(m.ctx, "Failed to load plugin", "plugin", pluginName, err)
}
}
}
}
// autoReloadEnabled returns true if auto-reload is enabled
func autoReloadEnabled() bool {
return conf.Server.Plugins.AutoReload
}

150
plugins/watcher_test.go Normal file
View File

@@ -0,0 +1,150 @@
package plugins
import (
"context"
"os"
"path/filepath"
"runtime"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Watcher", func() {
var (
manager *Manager
ctx context.Context
testdataDir string
tmpDir string
)
BeforeEach(func() {
ctx = GinkgoT().Context()
// Get testdata directory
_, currentFile, _, ok := runtime.Caller(0)
Expect(ok).To(BeTrue())
testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata")
// Create temp dir for plugins
var err error
tmpDir, err = os.MkdirTemp("", "plugins-watcher-test-*")
Expect(err).ToNot(HaveOccurred())
// Setup config with AutoReload enabled
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = tmpDir
conf.Server.Plugins.AutoReload = true
// Create a fresh manager for each test
manager = &Manager{
plugins: make(map[string]*pluginInstance),
}
err = manager.Start(ctx)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
})
copyTestPlugin := func(destName string) string {
srcPath := filepath.Join(testdataDir, "test-plugin.wasm")
destPath := filepath.Join(tmpDir, destName+".wasm")
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
return destPath
}
Describe("Auto-reload via file watcher", func() {
It("loads a plugin when a new wasm file is created", func() {
// Copy plugin file to trigger CREATE event
copyTestPlugin("watch-create")
// Wait for debounce + processing
Eventually(func() []string {
return manager.PluginNames(string(CapabilityMetadataAgent))
}, 2*time.Second, 100*time.Millisecond).Should(ContainElement("watch-create"))
})
It("reloads a plugin when the wasm file is modified", func() {
// First, load a plugin
copyTestPlugin("watch-modify")
// Wait for it to be loaded
Eventually(func() []string {
return manager.PluginNames(string(CapabilityMetadataAgent))
}, 2*time.Second, 100*time.Millisecond).Should(ContainElement("watch-modify"))
// Get the original plugin info
originalInfo := manager.GetPluginInfo()["watch-modify"]
Expect(originalInfo.Name).ToNot(BeEmpty())
// Modify the file (re-copy to trigger WRITE event)
wasmPath := filepath.Join(tmpDir, "watch-modify.wasm")
data, err := os.ReadFile(wasmPath)
Expect(err).ToNot(HaveOccurred())
// Touch the file to trigger write event
err = os.WriteFile(wasmPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// Wait for reload - the plugin should still be there
// We can't easily verify it was reloaded without adding tracking,
// but at least verify it's still loaded
Consistently(func() []string {
return manager.PluginNames(string(CapabilityMetadataAgent))
}, 1*time.Second, 100*time.Millisecond).Should(ContainElement("watch-modify"))
})
It("unloads a plugin when the wasm file is removed", func() {
// First, load a plugin
wasmPath := copyTestPlugin("watch-remove")
// Wait for it to be loaded
Eventually(func() []string {
return manager.PluginNames(string(CapabilityMetadataAgent))
}, 2*time.Second, 100*time.Millisecond).Should(ContainElement("watch-remove"))
// Remove the file
err := os.Remove(wasmPath)
Expect(err).ToNot(HaveOccurred())
// Wait for it to be unloaded
Eventually(func() []string {
return manager.PluginNames(string(CapabilityMetadataAgent))
}, 2*time.Second, 100*time.Millisecond).ShouldNot(ContainElement("watch-remove"))
})
})
Describe("Watcher disabled", func() {
BeforeEach(func() {
// Stop existing manager and create one without auto-reload
_ = manager.Stop()
conf.Server.Plugins.AutoReload = false
manager = &Manager{
plugins: make(map[string]*pluginInstance),
}
err := manager.Start(ctx)
Expect(err).ToNot(HaveOccurred())
})
It("does not auto-load plugins when AutoReload is disabled", func() {
// Copy plugin file
copyTestPlugin("no-watch")
// Wait a bit and verify plugin is NOT loaded
Consistently(func() []string {
return manager.PluginNames(string(CapabilityMetadataAgent))
}, 1*time.Second, 100*time.Millisecond).ShouldNot(ContainElement("no-watch"))
})
})
})