From 2cbb84fe1b07650e525409cff2b05c406052bdc0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:03:59 +0100 Subject: [PATCH] fix: keep a plugin off when it is turned off during its first start --- lib/__tests__/plugin-sandbox-loader.test.ts | 79 +++++++++++++++++++++ lib/plugin-sandbox/loader.ts | 17 +++++ 2 files changed, 96 insertions(+) diff --git a/lib/__tests__/plugin-sandbox-loader.test.ts b/lib/__tests__/plugin-sandbox-loader.test.ts index 24efba369..a551ff9c4 100644 --- a/lib/__tests__/plugin-sandbox-loader.test.ts +++ b/lib/__tests__/plugin-sandbox-loader.test.ts @@ -194,6 +194,85 @@ describe('loadSandboxedPlugin when a frame is slow to boot', () => { expect(mocks.createBackgroundInstance).toHaveBeenCalledTimes(1); expect(lastStatus()).toMatchObject({ status: 'error', error: 'bundle threw' }); }); + + it('shows no error and leaves no frame when a plugin turned off while starting times out', async () => { + mocks.getCode.mockResolvedValue(CODE); + const frame = hung(); + mocks.createBackgroundInstance.mockReturnValueOnce(frame); + + const run = loadSandboxedPlugin(plugin()); + await framesStarted(1); + unloadSandboxedPlugin('managed-plugin'); + await vi.advanceTimersByTimeAsync(30_000); + await run; + + expect(frame.destroy).toHaveBeenCalled(); + expect(statuses.some((s) => s.status === 'error')).toBe(false); + expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'disabled' }); + }); +}); + +describe('a plugin turned off during its first start, with no remembered slots', () => { + function startingFrame() { + let finish!: (info: unknown) => void; + let fail!: (err: Error) => void; + const bg = fakeBackground(); + bg.initPromise = new Promise((res, rej) => { finish = res; fail = rej; }); + mocks.createBackgroundInstance.mockImplementation(() => bg); + return { bg, finish, fail }; + } + const INFO = { hooks: [], slots: [{ name: 'sidebar-section', hasShouldShow: false, order: 100 }], shortcuts: [] }; + const started = () => vi.waitFor(() => expect(mocks.createBackgroundInstance).toHaveBeenCalled()); + + beforeEach(() => { + localStorage.clear(); + mocks.getCode.mockResolvedValue(CODE); + }); + + it('does not come up running when its init then succeeds', async () => { + const { bg, finish } = startingFrame(); + const load = loadSandboxedPlugin(plugin()); + await started(); + + unloadSandboxedPlugin('managed-plugin'); + finish(INFO); + await load; + + expect(bg.destroy).toHaveBeenCalled(); + expect(getActive('managed-plugin')).toBeUndefined(); + expect(statuses.some((s) => s.status === 'running')).toBe(false); + expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'disabled' }); + }); + + it('shows no error when its init then fails', async () => { + const { bg, fail } = startingFrame(); + const load = loadSandboxedPlugin(plugin()); + await started(); + + unloadSandboxedPlugin('managed-plugin'); + fail(new Error('bundle threw')); + await load; + + expect(bg.destroy).toHaveBeenCalled(); + expect(statuses.some((s) => s.status === 'error')).toBe(false); + expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'disabled' }); + }); + + it('runs after all when it is turned back on while still starting', async () => { + const { bg, finish } = startingFrame(); + const load = loadSandboxedPlugin(plugin()); + await started(); + + unloadSandboxedPlugin('managed-plugin'); + const again = loadSandboxedPlugin(plugin()); + finish(INFO); + await Promise.all([load, again]); + + expect(bg.destroy).not.toHaveBeenCalled(); + expect(mocks.createBackgroundInstance).toHaveBeenCalledTimes(1); + expect(getActive('managed-plugin')).toBeDefined(); + expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'running' }); + }); }); describe('loadSandboxedPlugin bundle sourcing', () => { diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 226f541e0..40833760b 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -238,6 +238,15 @@ export function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { return run; } +/** + * Turned off during a start with no remembered slots: unload found nothing + * registered, so the frame is still up and the status still unset. + */ +function stopCancelledStart(pluginId: string, background: { destroy(): void } | null): void { + try { background?.destroy(); } catch { /* ignore */ } + storeAccessor?.setPluginStatus(pluginId, 'disabled'); +} + async function startSandboxedPlugin(plugin: InstalledPlugin, retried = false): Promise { // A plugin that declares network access doesn't start while third-party // traffic is off; the rest run (#903). @@ -280,6 +289,10 @@ async function startSandboxedPlugin(plugin: InstalledPlugin, retried = false): P INIT_TIMEOUT_MS, `[plugin-sandbox] "${plugin.id}" init`, ); + if (startsCancelled.has(plugin.id)) { + stopCancelledStart(plugin.id, bg); + return; + } // Wire hook proxies: every hookName the plugin registered gets a HookBus // entry whose handler dispatches into the sandbox. `shortcut:` hooks @@ -360,6 +373,10 @@ async function startSandboxedPlugin(plugin: InstalledPlugin, retried = false): P } catch (err) { // Turned off while it was starting: unload has set the status. if (background?.closed && getActive(plugin.id)?.background !== background) return; + if (startsCancelled.has(plugin.id)) { + stopCancelledStart(plugin.id, background); + return; + } // Offered from the last start: take the slots back down. if (background && getActive(plugin.id)?.background === background) deregisterActive(plugin.id); // A busy machine can take longer than the limit to boot a frame, and the