fix: stop keeping unanswered plugin hook calls and old slot callbacks around

This commit is contained in:
Linus Rath
2026-10-14 03:02:12 +02:00
parent ada81d5f8f
commit bf1d8fd2c3
2 changed files with 54 additions and 6 deletions
@@ -124,3 +124,27 @@ describe('shouldShow context', () => {
expect(msg?.context).toEqual({ nested: { n: 1 } });
});
});
describe('host bookkeeping', () => {
it('fails a hook call whose arguments cannot be sent, without keeping it pending', async () => {
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
instances.push(inst);
vi.spyOn(inst.iframe.contentWindow!, 'postMessage').mockImplementation(() => { throw new DOMException('no clone', 'DataCloneError'); });
await expect(inst.invokeHook('onEmailOpen', [() => {}])).rejects.toThrow(/no clone/);
expect((inst as unknown as { pendingHookInvokes: Map<string, unknown> }).pendingHookInvokes.size).toBe(0);
});
it('drops callbacks of props that were replaced', () => {
const inst = createSlotInstance({
plugin: plugin(), slot: 'email-banner', code: '', locale: 'en', tier: 'untrusted', extraProps: { a: () => 1 },
hostContainer: document.body, onResize: () => {},
});
instances.push(inst);
const table = (inst as unknown as { callbackTable: Map<string, unknown> }).callbackTable;
for (let i = 0; i < 50; i++) inst.updateProps({ onAttach: () => i });
expect(table.size).toBe(1);
});
});
+30 -6
View File
@@ -122,6 +122,8 @@ export class SandboxInstance {
private pendingShouldShow = new Map<string, (show: boolean) => void>();
/** Host-side function references the sandbox can call back via 'callback-invoke'. */
private callbackTable = new Map<string, (...args: unknown[]) => unknown>();
/** Ids in callbackTable that came from the current props. */
private propCallbackIds = new Set<string>();
constructor(
private plugin: InstalledPlugin,
@@ -137,7 +139,9 @@ export class SandboxInstance {
// Slot iframes get `extraProps`; encode any function values now so the
// structured-clone send doesn't drop them.
if (initPayload.mode === 'slot') {
const before = new Set(this.callbackTable.keys());
initPayload.extraProps = encodeCallbacks(initPayload.extraProps, this.callbackTable) as Record<string, unknown>;
this.propCallbackIds = new Set([...this.callbackTable.keys()].filter((k) => !before.has(k)));
}
this.readyPromise = new Promise<void>((res) => { this.resolveReady = res; });
@@ -315,9 +319,23 @@ export class SandboxInstance {
if (this.destroyed) return Promise.reject(new Error('sandbox destroyed'));
const id = uid();
const p = new Promise<unknown>((resolve, reject) => {
this.pendingHookInvokes.set(id, { resolve, reject });
// The bus gives up on a slow handler, but the entry used to stay here
// until the iframe answered, which a hung plugin never does.
const timer = setTimeout(() => {
if (this.pendingHookInvokes.delete(id)) reject(new Error(`hook ${hookName} got no answer`));
}, HOOK_INVOKE_TTL_MS);
this.pendingHookInvokes.set(id, {
resolve: (v) => { clearTimeout(timer); resolve(v); },
reject: (e) => { clearTimeout(timer); reject(e); },
});
});
this.send({ type: 'hook-invoke', id, hookName, args });
try {
this.send({ type: 'hook-invoke', id, hookName, args });
} catch (err) {
// Arguments that can't be cloned: fail this call, don't leak it.
this.pendingHookInvokes.get(id)?.reject(err as Error);
this.pendingHookInvokes.delete(id);
}
return p;
}
@@ -354,10 +372,13 @@ export class SandboxInstance {
updateProps(props: Record<string, unknown>): void {
if (this.destroyed) return;
// Stale references would leak if we kept growing the table without
// bound; for now we let it grow until destroy(). A future refinement
// could diff old vs new props and drop entries no longer referenced.
const encoded = encodeCallbacks(props, this.callbackTable) as Record<string, unknown>;
// Callbacks from the props being replaced are dropped, so the table
// holds only what the slot can currently reach.
const fresh = new Map<string, (...args: unknown[]) => unknown>();
const encoded = encodeCallbacks(props, fresh) as Record<string, unknown>;
for (const id of this.propCallbackIds) this.callbackTable.delete(id);
this.propCallbackIds = new Set(fresh.keys());
for (const [id, fn] of fresh) this.callbackTable.set(id, fn);
this.send({ type: 'props-update', props: encoded });
}
@@ -397,6 +418,9 @@ function warnDevSandboxOnce(): void {
+ 'They can reach the whole app here; test isolation against a production build.');
}
/** How long the host waits for a hook answer; longer than any bus budget. */
const HOOK_INVOKE_TTL_MS = 11 * 60_000;
function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}