diff --git a/lib/__tests__/plugin-host-bridge-origin.test.ts b/lib/__tests__/plugin-host-bridge-origin.test.ts index 5dd2d1598..462ff0b53 100644 --- a/lib/__tests__/plugin-host-bridge-origin.test.ts +++ b/lib/__tests__/plugin-host-bridge-origin.test.ts @@ -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 }).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 }).callbackTable; + + for (let i = 0; i < 50; i++) inst.updateProps({ onAttach: () => i }); + + expect(table.size).toBe(1); + }); +}); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index fd8aeb133..475c9049c 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -122,6 +122,8 @@ export class SandboxInstance { private pendingShouldShow = new Map void>(); /** Host-side function references the sandbox can call back via 'callback-invoke'. */ private callbackTable = new Map unknown>(); + /** Ids in callbackTable that came from the current props. */ + private propCallbackIds = new Set(); 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; + this.propCallbackIds = new Set([...this.callbackTable.keys()].filter((k) => !before.has(k))); } this.readyPromise = new Promise((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((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): 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; + // Callbacks from the props being replaced are dropped, so the table + // holds only what the slot can currently reach. + const fresh = new Map unknown>(); + const encoded = encodeCallbacks(props, fresh) as Record; + 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); }