diff --git a/ts/packages/popup/src/port.test.ts b/ts/packages/popup/src/port.test.ts index df771aec..06d284bc 100644 --- a/ts/packages/popup/src/port.test.ts +++ b/ts/packages/popup/src/port.test.ts @@ -73,6 +73,31 @@ async function roundTrip(app: MessagePort, popup: MessagePort): Promise void): unknown[] { + const microtasks: Array<() => void> = [] + const thrown: unknown[] = [] + vi.stubGlobal('queueMicrotask', (task: () => void) => void microtasks.push(task)) + try { + start() + for (let task = microtasks.shift(), runs = 1; task; task = microtasks.shift(), runs++) { + // A microtask that keeps queueing itself would otherwise hang the suite. + if (runs > 100) throw new Error('microtasks never settle') + try { + task() + } catch (error) { + thrown.push(error) + } + } + } finally { + vi.unstubAllGlobals() + } + return thrown +} + describe('MessagePort handshake [POPUP-PORT-001]', () => { it('authenticates both endpoints and resolves entangled ports after the echo', async () => { const h = listen() @@ -354,6 +379,31 @@ describe('PortCarrier [POPUP-PORT-002]', () => { carrier.close() }) + it('delivers the rest of its backlog in order when a handler throws [POPUP-API-003] [POPUP-KEEPER-001]', async () => { + const channel = new MessageChannel() + channel.port2.postMessage({ type: 'queued' }) + const backlog = [{ type: 'fails' }, { type: 'held' }, { type: 'fails' }] + const carrier = new PortCarrier(channel.port1, APP_ORIGIN, backlog) + const failure = new Error('handler failure') + const received: unknown[] = [] + const thrown = runMicrotasks(() => + carrier.on((value) => { + received.push(value) + if ((value as { type: string }).type === 'fails') throw failure + }), + ) + await tick() + expect(received).toEqual([ + { type: 'fails' }, + { type: 'held' }, + { type: 'fails' }, + { type: 'queued' }, + ]) + expect(thrown).toHaveLength(2) + for (const error of thrown) expect(error).toBe(failure) + carrier.close() + }) + it('hands the undelivered backlog on when a delivered value makes the document leave [POPUP-CONNECTION-003]', async () => { const channel = new MessageChannel() channel.port2.postMessage({ type: 'queued' }) @@ -371,6 +421,28 @@ describe('PortCarrier [POPUP-PORT-002]', () => { expect(handedOn).toEqual([{ type: 'second' }, { type: 'third' }, { type: 'queued' }]) }) + it('hands the rest on and stops when a handler detaches then throws [POPUP-API-003] [POPUP-CONNECTION-003]', async () => { + const channel = new MessageChannel() + channel.port2.postMessage({ type: 'queued' }) + const backlog = [{ type: 'leave' }, { type: 'second' }] + const carrier = new PortCarrier(channel.port1, APP_ORIGIN, backlog) + const failure = new Error('handler failure') + const received: unknown[] = [] + let handedOn: unknown[] = [] + const thrown = runMicrotasks(() => + carrier.on((value) => { + received.push(value) + handedOn = carrier.detach().backlog + throw failure + }), + ) + await tick() + expect(thrown).toHaveLength(1) + expect(thrown[0]).toBe(failure) + expect(received).toEqual([{ type: 'leave' }]) + expect(handedOn).toEqual([{ type: 'second' }, { type: 'queued' }]) + }) + it('surrenders an unstarted port untouched, with only the backlog it was given', () => { const channel = new MessageChannel() const carrier = new PortCarrier(channel.port1, APP_ORIGIN, [{ type: 'held' }]) diff --git a/ts/packages/popup/src/port.ts b/ts/packages/popup/src/port.ts index be053896..dad598ba 100644 --- a/ts/packages/popup/src/port.ts +++ b/ts/packages/popup/src/port.ts @@ -256,9 +256,16 @@ export class PortCarrier implements Carrier { port.start() // A microtask still precedes every task that dispatches the port's own values. Values leave // the backlog one at a time, so a handler that hands the port on hands the rest on with it. - queueMicrotask(() => { - while (this.port === port && this.backlog.length) handler(this.backlog.shift()) - }) + // A handler's exception is the caller's: the rest drains in a fresh microtask, still ahead + // of the port, while the exception propagates. + const drain = (): void => { + try { + while (this.port === port && this.backlog.length) handler(this.backlog.shift()) + } finally { + if (this.port === port && this.backlog.length) queueMicrotask(drain) + } + } + queueMicrotask(drain) return () => { if (this.port === port) { port.onmessage = null