fix: keep a plugin off when it is turned off during its first start

This commit is contained in:
Linus Rath
2026-11-03 15:03:59 +01:00
parent cb95c8ba39
commit 2cbb84fe1b
2 changed files with 96 additions and 0 deletions
@@ -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', () => {
+17
View File
@@ -238,6 +238,15 @@ export function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
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<void> {
// 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:<id>` 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