Summary
The plugin loader supports upgrading a plugin within a running session — but for
scriptType: "module" plugins that's unsound: an ES module can't be un-executed. The old
module instance survives the "upgrade" with all its live state. For a stateful audio plugin
(stems), that means an orphaned audio graph that keeps playing alongside the new
instance's graph.
Mechanism
static/js/plugin-loader.js (~645–682):
loadedScripts.get(plugin.id) !== wantedVersion → on a version change it calls
_removePluginScriptTags(plugin.id) and injects screen.js?v=<newVersion> (cache-bust is
deliberate: "so a plugin upgrade within the same browser session fetches the new screen.js").
- Removing a
<script type="module"> tag does not tear down the already-evaluated module.
The old instance's module state, event listeners, capability registrations, and Web Audio
nodes all stay live.
For the stems plugin specifically (plugins/stems, scriptType: "module"):
- Both instances observe the playback lifecycle → both build audio graphs on the next
song (double audio, initially sample-aligned so it's easy to miss).
- Only the newest instance installs the
#audio shims, so transport commands (seek/pause/
rate) reach only it. After a seek, the orphan keeps playing from the pre-seek position —
audibly desynced vocals/instruments while the on-screen clock, notes, and lyrics follow the
live instance.
Any stateful module plugin is affected (audio graphs, MutationObservers, WS handlers,
window.playSong wraps); audio is just the loudest failure mode.
When it triggers
Any in-session version change picked up by _refreshPluginsSoon / loadPlugins(): Plugin
Manager updates, and in dev, a git pull in a plugin dir while the app is running (this repo
bumped stems to 0.9.0 today — a running session would have orphaned 0.8.2).
Possibly related field report (unconfirmed link)
Persistent post-fast-forward lyric/vocal desync observed on one song ("Marks Of The Evil
One", zip pack). Pack data was verified internally consistent (lyrics ↔ vocals stem ↔ full
mix cross-correlated within ~9 ms across the whole song), and the lyric renderer is
time-faithful — the symptom matches the orphan mechanism (audio from the wrong position
after seek while the clock is right). However the report claims it survived a full app
restart, which an orphan should not — so this issue is filed for the loader hole on its own
merits; the field bug may have an additional cause. Repro attempts ongoing.
Suggested directions
- Minimum: make module-plugin upgrade not silently unsound — e.g. require a
window.__<plugin>Instance.destroy()-style handshake (stem_mixer already implements this
pattern) before injecting the new version, and skip the upgrade (with a "reload required"
notice) when the plugin doesn't expose one.
- Alternatively: on any module-plugin version change, prompt/force a renderer reload instead
of hot-swapping.
- The
plugin-runtime-idempotent.v1 standard could grow an explicit teardown contract for
module plugins (the module-cache idempotency it relies on only covers same-version
re-injection, not upgrades).
🤖 Generated with Claude Code
Summary
The plugin loader supports upgrading a plugin within a running session — but for
scriptType: "module"plugins that's unsound: an ES module can't be un-executed. The oldmodule instance survives the "upgrade" with all its live state. For a stateful audio plugin
(stems), that means an orphaned audio graph that keeps playing alongside the new
instance's graph.
Mechanism
static/js/plugin-loader.js(~645–682):loadedScripts.get(plugin.id) !== wantedVersion→ on a version change it calls_removePluginScriptTags(plugin.id)and injectsscreen.js?v=<newVersion>(cache-bust isdeliberate: "so a plugin upgrade within the same browser session fetches the new screen.js").
<script type="module">tag does not tear down the already-evaluated module.The old instance's module state, event listeners, capability registrations, and Web Audio
nodes all stay live.
For the stems plugin specifically (
plugins/stems,scriptType: "module"):song (double audio, initially sample-aligned so it's easy to miss).
#audioshims, so transport commands (seek/pause/rate) reach only it. After a seek, the orphan keeps playing from the pre-seek position —
audibly desynced vocals/instruments while the on-screen clock, notes, and lyrics follow the
live instance.
Any stateful module plugin is affected (audio graphs, MutationObservers, WS handlers,
window.playSongwraps); audio is just the loudest failure mode.When it triggers
Any in-session version change picked up by
_refreshPluginsSoon/loadPlugins(): PluginManager updates, and in dev, a
git pullin a plugin dir while the app is running (this repobumped stems to 0.9.0 today — a running session would have orphaned 0.8.2).
Possibly related field report (unconfirmed link)
Persistent post-fast-forward lyric/vocal desync observed on one song ("Marks Of The Evil
One", zip pack). Pack data was verified internally consistent (lyrics ↔ vocals stem ↔ full
mix cross-correlated within ~9 ms across the whole song), and the lyric renderer is
time-faithful — the symptom matches the orphan mechanism (audio from the wrong position
after seek while the clock is right). However the report claims it survived a full app
restart, which an orphan should not — so this issue is filed for the loader hole on its own
merits; the field bug may have an additional cause. Repro attempts ongoing.
Suggested directions
window.__<plugin>Instance.destroy()-style handshake (stem_mixer already implements thispattern) before injecting the new version, and skip the upgrade (with a "reload required"
notice) when the plugin doesn't expose one.
of hot-swapping.
plugin-runtime-idempotent.v1standard could grow an explicit teardown contract formodule plugins (the module-cache idempotency it relies on only covers same-version
re-injection, not upgrades).
🤖 Generated with Claude Code