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
+115
View File
@@ -0,0 +1,115 @@
// Verify loadPlugins' plugin-DOM wipe loops in static/app.js: a plugin that is
// merely ABSENT from the current /api/plugins response (transient partial
// response while the backend's plugin registry is repopulating after a
// restart) must keep its settings panel and screen DOM. Wiping it while its
// _loadedPluginScripts entry survives made the next refetch fail the
// DOM-existence check and re-evaluate the plugin's screen.js mid-session —
// which duplicated the desktop audio_engine's native signal chain. Plugins
// the response knows about but that failed hydration are still wiped, as is
// junk DOM carrying no plugin id.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// Slice the wipe block out of loadPlugins by its stable landmarks: from the
// nav reset that opens it to the comment introducing the next section.
function extractWipeBlock(src) {
const start = src.indexOf("navContainer.innerHTML = '';");
assert.ok(start !== -1, 'wipe block start (nav reset) not found');
const end = src.indexOf('// Plugin settings area hosts', start);
assert.ok(end !== -1, 'wipe block end marker not found');
return src.slice(start, end);
}
function makeEl(pluginId, id) {
return {
dataset: pluginId != null ? { pluginId } : {},
id: id || (pluginId != null ? `plugin-${pluginId}` : ''),
removed: false,
remove() {
this.removed = true;
const idx = this._parent ? this._parent.indexOf(this) : -1;
if (idx >= 0) this._parent.splice(idx, 1);
},
};
}
function runWipe({ respondedIds, alreadyHydrated, settingsChildren, screens }) {
const src = fs.readFileSync(APP_JS, 'utf8');
const block = extractWipeBlock(src);
settingsChildren.forEach((el) => { el._parent = settingsChildren; });
const container = { children: settingsChildren };
const sandbox = {
navContainer: { innerHTML: 'seed' },
mobileNavContainer: { innerHTML: 'seed' },
_pluginSettingsContainers: () => [container],
respondedIds,
alreadyHydrated,
document: {
querySelectorAll: (sel) => {
assert.equal(sel, '.screen[id^="plugin-"]');
return screens.slice();
},
},
};
vm.runInNewContext(block, sandbox, { filename: 'wipe-block.js' });
return sandbox;
}
test('plugin absent from the response keeps its settings + screen DOM', () => {
const settings = makeEl('audio_engine');
const screen = makeEl('audio_engine');
runWipe({
respondedIds: new Set(), // partial response: plugin missing
alreadyHydrated: new Set(), // scan loop never saw it either
settingsChildren: [settings],
screens: [screen],
});
assert.equal(settings.removed, false, 'settings panel must survive a partial response');
assert.equal(screen.removed, false, 'screen must survive a partial response');
});
test('plugin present in the response but not hydrated is wiped', () => {
const settings = makeEl('stale_plugin');
const screen = makeEl('stale_plugin');
runWipe({
respondedIds: new Set(['stale_plugin']),
alreadyHydrated: new Set(),
settingsChildren: [settings],
screens: [screen],
});
assert.equal(settings.removed, true);
assert.equal(screen.removed, true);
});
test('hydrated plugin present in the response is preserved', () => {
const settings = makeEl('audio_engine');
const screen = makeEl('audio_engine');
runWipe({
respondedIds: new Set(['audio_engine']),
alreadyHydrated: new Set(['audio_engine']),
settingsChildren: [settings],
screens: [screen],
});
assert.equal(settings.removed, false);
assert.equal(screen.removed, false);
});
test('junk DOM without a plugin id is still removed', () => {
const junkSettings = makeEl(null);
// Screen whose id strips to '' (no dataset.pluginId, bare "plugin-" id).
const junkScreen = makeEl(null, 'plugin-');
runWipe({
respondedIds: new Set(['whatever']),
alreadyHydrated: new Set(),
settingsChildren: [junkSettings],
screens: [junkScreen],
});
assert.equal(junkSettings.removed, true);
assert.equal(junkScreen.removed, true);
});
+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', () => {