fix(plugins): read the loaded plugin from a local, not the shared map (#6014)

Plugins load concurrently through an errgroup. loadPluginWithConfig wrote
m.plugins under m.mu but read it back unlocked to pass to callPluginInit,
so one goroutine's write raced another's read. Caught by -race on master
(run 32608293134): all 640 specs passed, the job failed only on the race.

Capture the pointer while holding the lock and use the local. Holding m.mu
across callPluginInit would be wrong, since that runs arbitrary plugin code.
This commit is contained in:
Deluan Quintão 2026-08-22 22:02:25 -04:00 committed by GitHub
parent 3cb9850872
commit fc9d93d22a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -434,8 +434,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
return fmt.Errorf("manifest validation: %w", err)
}
m.mu.Lock()
m.plugins[p.ID] = &plugin{
loadedPlugin := &plugin{
name: p.ID,
path: p.Path,
manifest: pkg.Manifest,
@ -449,13 +448,16 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
fsConfig: fsConfig,
lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls),
}
m.mu.Lock()
m.plugins[p.ID] = loadedPlugin
m.mu.Unlock()
loaded = true
// Init is the plugin's first chance to run arbitrary code: open sockets, create task queues,
// schedule work. Only a caller that already intends to reach the network asks for it.
// Use the local: loads run concurrently, so reading the map back here would race the writes.
if m.transient == nil || m.transient.runInit {
callPluginInit(ctx, m.plugins[p.ID])
callPluginInit(ctx, loadedPlugin)
}
return nil