mirror of
https://github.com/navidrome/navidrome.git
synced 2025-07-16 16:41:16 +03:00
* fix: eliminate race condition in plugin system Added compilation waiting mechanism to prevent WASM plugins from being instantiated before their background compilation completes. This fixes the intermittent error 'source module must be compiled before instantiation' that occurred when tests or plugin usage happened before asynchronous compilation finished. Changes include: - Added manager reference to wasmBasePlugin for compilation synchronization - Modified all plugin adapter constructors to accept manager parameter - Updated getInstance() to wait for compilation before loading instances - Fixed runtime test to handle manually created plugins appropriately The race condition was caused by plugins trying to compile WASM modules synchronously during Load() calls while background compilation was still in progress. This change ensures proper coordination between the compilation and instantiation phases. * fix: add plugin-clean target to Makefile for easier plugin cleanup Signed-off-by: Deluan <deluan@navidrome.org> * refactor: reorder plugin constructor parameters and add nil safety Moved manager parameter to third position in pluginConstructor signature for\nbetter parameter ordering consistency.\n\nAlso added nil check for adapter creation to prevent registration of failed\nplugin adapters, which could lead to nil-pointer dereferences. Plugin\ncreation failures are now logged with context and gracefully skipped.\n\nChanges:\n- Reordered pluginConstructor parameters: manager moved before runtime\n- Updated all 4 adapter constructor signatures to match new order\n- Added nil safety check in registerPlugin to skip failed adapters\n- Updated runtime test to use new parameter order\n\nThis improves both code consistency and runtime safety by preventing\nnil adapters from being registered in the plugin manager. * fix: prevent concurrent WASM compilation race condition * refactor: remove unnecessary manager parameter from plugin constructors * fix: update parameter name in newWasmSchedulerCallback for consistency Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
370 lines
12 KiB
Go
370 lines
12 KiB
Go
package plugins
|
|
|
|
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative api/api.proto
|
|
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/http/http.proto
|
|
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/config/config.proto
|
|
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/websocket/websocket.proto
|
|
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/scheduler/scheduler.proto
|
|
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/cache/cache.proto
|
|
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/artwork/artwork.proto
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/core/scrobbler"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/plugins/api"
|
|
"github.com/navidrome/navidrome/plugins/schema"
|
|
"github.com/navidrome/navidrome/utils/singleton"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"github.com/tetratelabs/wazero"
|
|
)
|
|
|
|
const (
|
|
CapabilityMetadataAgent = "MetadataAgent"
|
|
CapabilityScrobbler = "Scrobbler"
|
|
CapabilitySchedulerCallback = "SchedulerCallback"
|
|
CapabilityWebSocketCallback = "WebSocketCallback"
|
|
CapabilityLifecycleManagement = "LifecycleManagement"
|
|
)
|
|
|
|
// pluginCreators maps capability types to their respective creator functions
|
|
type pluginConstructor func(wasmPath, pluginID string, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin
|
|
|
|
var pluginCreators = map[string]pluginConstructor{
|
|
CapabilityMetadataAgent: newWasmMediaAgent,
|
|
CapabilityScrobbler: newWasmScrobblerPlugin,
|
|
CapabilitySchedulerCallback: newWasmSchedulerCallback,
|
|
CapabilityWebSocketCallback: newWasmWebSocketCallback,
|
|
}
|
|
|
|
// WasmPlugin is the base interface that all WASM plugins implement
|
|
type WasmPlugin interface {
|
|
// PluginID returns the unique identifier of the plugin (folder name)
|
|
PluginID() string
|
|
// Instantiate creates a new instance of the plugin and returns it along with a cleanup function
|
|
Instantiate(ctx context.Context) (any, func(), error)
|
|
}
|
|
|
|
type plugin struct {
|
|
ID string
|
|
Path string
|
|
Capabilities []string
|
|
WasmPath string
|
|
Manifest *schema.PluginManifest // Loaded manifest
|
|
Runtime api.WazeroNewRuntime
|
|
ModConfig wazero.ModuleConfig
|
|
compilationReady chan struct{}
|
|
compilationErr error
|
|
}
|
|
|
|
func (p *plugin) waitForCompilation() error {
|
|
timeout := pluginCompilationTimeout()
|
|
select {
|
|
case <-p.compilationReady:
|
|
case <-time.After(timeout):
|
|
err := fmt.Errorf("timed out waiting for plugin %s to compile", p.ID)
|
|
log.Error("Timed out waiting for plugin compilation", "name", p.ID, "path", p.WasmPath, "timeout", timeout, "err", err)
|
|
return err
|
|
}
|
|
if p.compilationErr != nil {
|
|
log.Error("Failed to compile plugin", "name", p.ID, "path", p.WasmPath, p.compilationErr)
|
|
}
|
|
return p.compilationErr
|
|
}
|
|
|
|
// Manager is a singleton that manages plugins
|
|
type Manager struct {
|
|
plugins map[string]*plugin // Map of plugin folder name to plugin info
|
|
mu sync.RWMutex // Protects plugins map
|
|
schedulerService *schedulerService // Service for handling scheduled tasks
|
|
websocketService *websocketService // Service for handling WebSocket connections
|
|
lifecycle *pluginLifecycleManager // Manages plugin lifecycle and initialization
|
|
adapters map[string]WasmPlugin // Map of plugin folder name + capability to adapter
|
|
}
|
|
|
|
// GetManager returns the singleton instance of Manager
|
|
func GetManager() *Manager {
|
|
return singleton.GetInstance(func() *Manager {
|
|
return createManager()
|
|
})
|
|
}
|
|
|
|
// createManager creates a new Manager instance. Used in tests
|
|
func createManager() *Manager {
|
|
m := &Manager{
|
|
plugins: make(map[string]*plugin),
|
|
lifecycle: newPluginLifecycleManager(),
|
|
}
|
|
|
|
// Create the host services
|
|
m.schedulerService = newSchedulerService(m)
|
|
m.websocketService = newWebsocketService(m)
|
|
|
|
return m
|
|
}
|
|
|
|
// registerPlugin adds a plugin to the registry with the given parameters
|
|
// Used internally by ScanPlugins to register plugins
|
|
func (m *Manager) registerPlugin(pluginID, pluginDir, wasmPath string, manifest *schema.PluginManifest) *plugin {
|
|
// Create custom runtime function
|
|
customRuntime := m.createRuntime(pluginID, manifest.Permissions)
|
|
|
|
// Configure module and determine plugin name
|
|
mc := newWazeroModuleConfig()
|
|
|
|
// Check if it's a symlink, indicating development mode
|
|
isSymlink := false
|
|
if fileInfo, err := os.Lstat(pluginDir); err == nil {
|
|
isSymlink = fileInfo.Mode()&os.ModeSymlink != 0
|
|
}
|
|
|
|
// Store plugin info
|
|
p := &plugin{
|
|
ID: pluginID,
|
|
Path: pluginDir,
|
|
Capabilities: slice.Map(manifest.Capabilities, func(cap schema.PluginManifestCapabilitiesElem) string { return string(cap) }),
|
|
WasmPath: wasmPath,
|
|
Manifest: manifest,
|
|
Runtime: customRuntime,
|
|
ModConfig: mc,
|
|
compilationReady: make(chan struct{}),
|
|
}
|
|
|
|
// Start pre-compilation of WASM module in background
|
|
go func() {
|
|
precompilePlugin(p)
|
|
// Check if this plugin implements InitService and hasn't been initialized yet
|
|
m.initializePluginIfNeeded(p)
|
|
}()
|
|
|
|
// Register the plugin
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.plugins[pluginID] = p
|
|
|
|
// Register one plugin adapter for each capability
|
|
for _, capability := range manifest.Capabilities {
|
|
capabilityStr := string(capability)
|
|
constructor := pluginCreators[capabilityStr]
|
|
if constructor == nil {
|
|
// Warn about unknown capabilities, except for LifecycleManagement (it does not have an adapter)
|
|
if capability != CapabilityLifecycleManagement {
|
|
log.Warn("Unknown plugin capability type", "capability", capability, "plugin", pluginID)
|
|
}
|
|
continue
|
|
}
|
|
adapter := constructor(wasmPath, pluginID, customRuntime, mc)
|
|
if adapter == nil {
|
|
log.Error("Failed to create plugin adapter", "plugin", pluginID, "capability", capabilityStr, "path", wasmPath)
|
|
continue
|
|
}
|
|
m.adapters[pluginID+"_"+capabilityStr] = adapter
|
|
}
|
|
|
|
log.Info("Discovered plugin", "folder", pluginID, "name", manifest.Name, "capabilities", manifest.Capabilities, "wasm", wasmPath, "dev_mode", isSymlink)
|
|
return m.plugins[pluginID]
|
|
}
|
|
|
|
// initializePluginIfNeeded calls OnInit on plugins that implement LifecycleManagement
|
|
func (m *Manager) initializePluginIfNeeded(plugin *plugin) {
|
|
// Skip if already initialized
|
|
if m.lifecycle.isInitialized(plugin) {
|
|
return
|
|
}
|
|
|
|
// Check if the plugin implements LifecycleManagement
|
|
for _, capability := range plugin.Manifest.Capabilities {
|
|
if capability == CapabilityLifecycleManagement {
|
|
m.lifecycle.callOnInit(plugin)
|
|
m.lifecycle.markInitialized(plugin)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// ScanPlugins scans the plugins directory, discovers all valid plugins, and registers them for use.
|
|
func (m *Manager) ScanPlugins() {
|
|
// Clear existing plugins
|
|
m.mu.Lock()
|
|
m.plugins = make(map[string]*plugin)
|
|
m.adapters = make(map[string]WasmPlugin)
|
|
m.mu.Unlock()
|
|
|
|
// Get plugins directory from config
|
|
root := conf.Server.Plugins.Folder
|
|
log.Debug("Scanning plugins folder", "root", root)
|
|
|
|
// Fail fast if the compilation cache cannot be initialized
|
|
_, err := getCompilationCache()
|
|
if err != nil {
|
|
log.Error("Failed to initialize plugins compilation cache. Disabling plugins", err)
|
|
return
|
|
}
|
|
|
|
// Discover all plugins using the shared discovery function
|
|
discoveries := DiscoverPlugins(root)
|
|
|
|
var validPluginNames []string
|
|
for _, discovery := range discoveries {
|
|
if discovery.Error != nil {
|
|
// Handle global errors (like directory read failure)
|
|
if discovery.ID == "" {
|
|
log.Error("Plugin discovery failed", discovery.Error)
|
|
return
|
|
}
|
|
// Handle individual plugin errors
|
|
log.Error("Failed to process plugin", "plugin", discovery.ID, discovery.Error)
|
|
continue
|
|
}
|
|
|
|
// Log discovery details
|
|
log.Debug("Processing entry", "name", discovery.ID, "isSymlink", discovery.IsSymlink)
|
|
if discovery.IsSymlink {
|
|
log.Debug("Processing symlinked plugin directory", "name", discovery.ID, "target", discovery.Path)
|
|
}
|
|
log.Debug("Checking for plugin.wasm", "wasmPath", discovery.WasmPath)
|
|
log.Debug("Manifest loaded successfully", "folder", discovery.ID, "name", discovery.Manifest.Name, "capabilities", discovery.Manifest.Capabilities)
|
|
|
|
validPluginNames = append(validPluginNames, discovery.ID)
|
|
|
|
// Register the plugin
|
|
m.registerPlugin(discovery.ID, discovery.Path, discovery.WasmPath, discovery.Manifest)
|
|
}
|
|
|
|
log.Debug("Found valid plugins", "count", len(validPluginNames), "plugins", validPluginNames)
|
|
}
|
|
|
|
// PluginNames returns the folder names of all plugins that implement the specified capability
|
|
func (m *Manager) PluginNames(capability string) []string {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
var names []string
|
|
for name, plugin := range m.plugins {
|
|
for _, c := range plugin.Manifest.Capabilities {
|
|
if string(c) == capability {
|
|
names = append(names, name)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return names
|
|
}
|
|
|
|
func (m *Manager) getPlugin(name string, capability string) (*plugin, WasmPlugin) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
info, infoOk := m.plugins[name]
|
|
adapter, adapterOk := m.adapters[name+"_"+capability]
|
|
|
|
if !infoOk {
|
|
log.Warn("Plugin not found", "name", name)
|
|
return nil, nil
|
|
}
|
|
if !adapterOk {
|
|
log.Warn("Plugin adapter not found", "name", name, "capability", capability)
|
|
return nil, nil
|
|
}
|
|
return info, adapter
|
|
}
|
|
|
|
// LoadPlugin instantiates and returns a plugin by folder name
|
|
func (m *Manager) LoadPlugin(name string, capability string) WasmPlugin {
|
|
info, adapter := m.getPlugin(name, capability)
|
|
if info == nil {
|
|
log.Warn("Plugin not found", "name", name, "capability", capability)
|
|
return nil
|
|
}
|
|
|
|
log.Debug("Loading plugin", "name", name, "path", info.Path)
|
|
|
|
// Wait for the plugin to be ready before using it.
|
|
if err := info.waitForCompilation(); err != nil {
|
|
log.Error("Plugin is not ready, cannot be loaded", "plugin", name, "capability", capability, "err", err)
|
|
return nil
|
|
}
|
|
|
|
if adapter == nil {
|
|
log.Warn("Plugin adapter not found", "name", name, "capability", capability)
|
|
return nil
|
|
}
|
|
return adapter
|
|
}
|
|
|
|
// EnsureCompiled waits for a plugin to finish compilation and returns any compilation error.
|
|
// This is useful when you need to wait for compilation without loading a specific capability,
|
|
// such as during plugin refresh operations or health checks.
|
|
func (m *Manager) EnsureCompiled(name string) error {
|
|
m.mu.RLock()
|
|
plugin, ok := m.plugins[name]
|
|
m.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return fmt.Errorf("plugin not found: %s", name)
|
|
}
|
|
|
|
return plugin.waitForCompilation()
|
|
}
|
|
|
|
// LoadAllPlugins instantiates and returns all plugins that implement the specified capability
|
|
func (m *Manager) LoadAllPlugins(capability string) []WasmPlugin {
|
|
names := m.PluginNames(capability)
|
|
if len(names) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var plugins []WasmPlugin
|
|
for _, name := range names {
|
|
plugin := m.LoadPlugin(name, capability)
|
|
if plugin != nil {
|
|
plugins = append(plugins, plugin)
|
|
}
|
|
}
|
|
return plugins
|
|
}
|
|
|
|
// LoadMediaAgent instantiates and returns a media agent plugin by folder name
|
|
func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
|
|
plugin := m.LoadPlugin(name, CapabilityMetadataAgent)
|
|
if plugin == nil {
|
|
return nil, false
|
|
}
|
|
agent, ok := plugin.(*wasmMediaAgent)
|
|
return agent, ok
|
|
}
|
|
|
|
// LoadAllMediaAgents instantiates and returns all media agent plugins
|
|
func (m *Manager) LoadAllMediaAgents() []agents.Interface {
|
|
plugins := m.LoadAllPlugins(CapabilityMetadataAgent)
|
|
|
|
return slice.Map(plugins, func(p WasmPlugin) agents.Interface {
|
|
return p.(agents.Interface)
|
|
})
|
|
}
|
|
|
|
// LoadScrobbler instantiates and returns a scrobbler plugin by folder name
|
|
func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
|
plugin := m.LoadPlugin(name, CapabilityScrobbler)
|
|
if plugin == nil {
|
|
return nil, false
|
|
}
|
|
s, ok := plugin.(scrobbler.Scrobbler)
|
|
return s, ok
|
|
}
|
|
|
|
// LoadAllScrobblers instantiates and returns all scrobbler plugins
|
|
func (m *Manager) LoadAllScrobblers() []scrobbler.Scrobbler {
|
|
plugins := m.LoadAllPlugins(CapabilityScrobbler)
|
|
|
|
return slice.Map(plugins, func(p WasmPlugin) scrobbler.Scrobbler {
|
|
return p.(scrobbler.Scrobbler)
|
|
})
|
|
}
|