fix(plugins): don't treat transient absence from /api/plugins as uninstall (#741)

* fix(plugins): don't treat transient absence from /api/plugins as uninstall

The backend clears its plugin registry at the start of load_plugins()
and repopulates it incrementally while HTTP stays up, so every backend
restart (desktop: Audio Quality soundfont switch, LAN toggle, update
restart) serves a window of partial — even empty — /api/plugins
responses. loadPlugins() treated absence from the current response as
an uninstall, with three destructive consequences for still-loaded
plugins:

1. Their settings-panel and screen DOM were wiped while their
   _loadedPluginScripts entry survived, so the NEXT refetch failed the
   DOM-existence check and re-evaluated the plugin's screen.js
   mid-session. For the desktop audio_engine plugin that re-ran init()
   against the surviving native audio chain and exactly duplicated
   every VST/NAM/IR stage (the alpha testers' "chain duplicates after
   leaving the Audio menu" / blown-out gain reports).
2. _reconcilePluginStyles dropped their stylesheet, leaving them
   visible but unstyled until they reappeared.
3. The stale-contribution sweep unmounted their UI contributions and
   unregistered their capability participant with no re-registration
   path (plugin scripts don't re-run thanks to the loadedScripts
   guard).

Absence is now a non-signal everywhere in loadPlugins: the DOM wipe and
style reconcile are scoped to plugins the response actually names, and
the absence sweep is removed. Present plugins still fully re-sync via
_registerLegacyPluginUiContributions each round; failed plugins are
present in the response and still cleaned up; nav is rebuilt from the
response so genuinely uninstalled plugins drop out of it, and their
(un-unloadable) already-evaluated scripts keep their DOM until reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: update idempotence contract to the absence-is-not-uninstall invariant

The removed-plugin sweep contract pinned the old behavior this branch
deletes; pin the new invariant instead (no absence sweep + respondedIds
scoping on the DOM/style reconcilers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-07-03 14:45:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d2b2a7e9f7
commit 68e29a8b6e
4 changed files with 182 additions and 32 deletions
+9 -4
View File
@@ -204,15 +204,20 @@ test('does not collide tags across two different plugins', () => {
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
});
test('reconcile removes the <link> of a plugin that vanished from /api/plugins', () => {
test('reconcile keeps the <link> of a plugin absent from a partial response', () => {
const { inject, reconcile, headLinks } = setupSandbox();
inject(plug({ id: 'a' }));
inject(plug({ id: 'b' }));
assert.equal(headLinks.length, 2);
// `a` is no longer returned (uninstalled) — its stylesheet must be dropped.
// `a` is missing from this response. That happens transiently during a
// backend restart (the plugin registry repopulates while HTTP stays up),
// so absence is NOT an uninstall signal — the still-loaded plugin must
// keep its stylesheet or it renders visible-but-unstyled until it
// reappears. Explicit removal still happens via the not-ready/unstyled
// paths (tests below).
reconcile([plug({ id: 'b' })]);
assert.equal(headLinks.length, 1);
assert.equal(headLinks[0].dataset.pluginId, 'b');
assert.equal(headLinks.length, 2);
assert.deepEqual(headLinks.map((l) => l.dataset.pluginId).sort(), ['a', 'b']);
});
test('reconcile removes the <link> of a plugin that is no longer ready', () => {