diff --git a/.changeset/shared-worker-terminal-lifecycle.md b/.changeset/shared-worker-terminal-lifecycle.md new file mode 100644 index 00000000..fbe7a87a --- /dev/null +++ b/.changeset/shared-worker-terminal-lifecycle.md @@ -0,0 +1,5 @@ +--- +'@treecrdt/wa-sqlite': patch +--- + +Invalidate every shared-worker client when one client drops the shared database, and prune stale ports before resetting the worker session. diff --git a/packages/treecrdt-wa-sqlite/e2e/src/sync.ts b/packages/treecrdt-wa-sqlite/e2e/src/sync.ts index 0cfd80ff..c53d7612 100644 --- a/packages/treecrdt-wa-sqlite/e2e/src/sync.ts +++ b/packages/treecrdt-wa-sqlite/e2e/src/sync.ts @@ -555,6 +555,14 @@ export async function closeSharedOpfsCrossTabClient(): Promise { if (client) await client.close(); } +export async function dropSharedOpfsCrossTabClient(): Promise { + sharedOpfsCrossTabUnsubscribe?.(); + sharedOpfsCrossTabUnsubscribe = null; + const client = sharedOpfsCrossTabClient; + sharedOpfsCrossTabClient = null; + if (client) await client.drop(); +} + export async function runTreecrdtSyncSubscribeE2E(): Promise<{ ok: true }> { const docId = `e2e-sync-subscribe-${crypto.randomUUID()}`; const a = await createTreecrdtClient({ storage: memoryStorage, docId }); @@ -860,6 +868,7 @@ declare global { __mutateSharedOpfsCrossTabTree?: typeof mutateSharedOpfsCrossTabTree; __sharedOpfsCrossTabState?: typeof sharedOpfsCrossTabState; __closeSharedOpfsCrossTabClient?: typeof closeSharedOpfsCrossTabClient; + __dropSharedOpfsCrossTabClient?: typeof dropSharedOpfsCrossTabClient; } } @@ -874,4 +883,5 @@ if (typeof window !== 'undefined') { window.__mutateSharedOpfsCrossTabTree = mutateSharedOpfsCrossTabTree; window.__sharedOpfsCrossTabState = sharedOpfsCrossTabState; window.__closeSharedOpfsCrossTabClient = closeSharedOpfsCrossTabClient; + window.__dropSharedOpfsCrossTabClient = dropSharedOpfsCrossTabClient; } diff --git a/packages/treecrdt-wa-sqlite/e2e/tests/cross-tab.spec.ts b/packages/treecrdt-wa-sqlite/e2e/tests/cross-tab.spec.ts index eb33e358..79db0d65 100644 --- a/packages/treecrdt-wa-sqlite/e2e/tests/cross-tab.spec.ts +++ b/packages/treecrdt-wa-sqlite/e2e/tests/cross-tab.spec.ts @@ -6,7 +6,8 @@ async function waitForCrossTabHarness(page: Page) { () => typeof (window as any).__openSharedOpfsCrossTabClient === 'function' && typeof (window as any).__mutateSharedOpfsCrossTabTree === 'function' && - typeof (window as any).__sharedOpfsCrossTabState === 'function', + typeof (window as any).__sharedOpfsCrossTabState === 'function' && + typeof (window as any).__dropSharedOpfsCrossTabClient === 'function', ); } @@ -98,6 +99,13 @@ async function closeClient(page: Page) { }); } +async function dropClient(page: Page) { + await page.evaluate(async () => { + const drop = (window as any).__dropSharedOpfsCrossTabClient; + if (drop) await drop(); + }); +} + const scenarios: Array<{ name: string; filePrefix: string; @@ -236,3 +244,49 @@ for (const scenario of scenarios) { } }); } + +test('shared-worker drop invalidates peers and permits a clean replacement', async ({ + context, +}, testInfo) => { + if (testInfo.project.name !== 'chromium-dev') test.skip(); + test.setTimeout(120_000); + + const suffix = `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`; + const docId = `e2e-cross-tab-drop-${suffix}`; + const filename = `/e2e-ct-drop-${suffix}.db`; + const pageA = await context.newPage(); + const pageB = await context.newPage(); + pageA.on('console', (msg) => console.log(`[pageA][${msg.type()}] ${msg.text()}`)); + pageB.on('console', (msg) => console.log(`[pageB][${msg.type()}] ${msg.text()}`)); + + try { + await Promise.all([waitForCrossTabHarness(pageA), waitForCrossTabHarness(pageB)]); + await Promise.all([ + openClient(pageA, docId, filename, 'shared-worker'), + openClient(pageB, docId, filename, 'shared-worker'), + ]); + + const inserted = await mutateTree(pageA, { + replicaLabel: 'cross-tab-drop', + action: 'insert', + nodeInt: 711, + }); + await expect + .poll(async () => (await state(pageB)).eventCount, { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1); + expect((await state(pageB)).childrenByParent['0'.repeat(32)]).toContain(inserted.node); + + await dropClient(pageA); + await expect(state(pageB)).rejects.toThrow('TreeCRDT shared database was dropped'); + + expect(await openClient(pageB, docId, filename, 'shared-worker')).toEqual({ + mode: 'worker', + runtime: 'shared-worker', + storage: 'opfs', + }); + expect((await state(pageB)).childrenByParent['0'.repeat(32)]).toEqual([]); + } finally { + await Promise.allSettled([dropClient(pageA), dropClient(pageB)]); + await Promise.allSettled([pageA.close(), pageB.close()]); + } +}); diff --git a/packages/treecrdt-wa-sqlite/src/client.ts b/packages/treecrdt-wa-sqlite/src/client.ts index 618f1ce8..598e1f23 100644 --- a/packages/treecrdt-wa-sqlite/src/client.ts +++ b/packages/treecrdt-wa-sqlite/src/client.ts @@ -456,8 +456,7 @@ async function createSharedWorkerClient(opts: { number, { resolve: (value: any) => void; reject: (err: Error) => void } >(); - let terminalError: Error | null = null; - let closed = false; + let closedReason: Error | null = null; let callQueue: Promise = Promise.resolve(); const closedError = new Error(CLIENT_CLOSED_ERROR); @@ -468,12 +467,18 @@ async function createSharedWorkerClient(opts: { ); const callRaw = (method: M, params: RpcParams): Promise> => { - if (closed) return Promise.reject(closedError); + if (closedReason) return Promise.reject(closedReason); const id = nextId++; - if (terminalError) return Promise.reject(terminalError); return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); - port.postMessage({ id, method, params } satisfies RpcRequest); + try { + port.postMessage({ id, method, params } satisfies RpcRequest); + } catch (err) { + const postError = new Error( + `shared worker post failed: ${err instanceof Error ? err.message : String(err)}`, + ); + cleanup(postError); + } }); }; const call = (method: M, params: RpcParams): Promise> => { @@ -483,19 +488,27 @@ async function createSharedWorkerClient(opts: { }; const materialized = createClientMaterializationDispatcher({ broadcast: (event) => { - if (closed || terminalError) return; + if (closedReason) return; try { port.postMessage({ type: 'materialized', event } satisfies RpcPushMessage); - } catch { - // Closing tabs can race a final materialization notification. + } catch (err) { + const postError = new Error( + `shared worker post failed: ${err instanceof Error ? err.message : String(err)}`, + ); + cleanup(postError); } }, }); const onMessage = (ev: MessageEvent) => { const data = ev.data; - if ('type' in data && data.type === 'materialized') { - materialized.emitIncomingEvent(data.event); + if ('type' in data) { + if (data.type === 'materialized') { + materialized.emitIncomingEvent(data.event); + return; + } + const err = new Error(data.error || 'shared worker terminated'); + cleanup(err); return; } const response = data as RpcResponse; @@ -507,22 +520,35 @@ async function createSharedWorkerClient(opts: { }; const onMessageError = () => { const err = new Error('shared worker message error'); - terminalError = err; - for (const { reject } of pending.values()) reject(err); - pending.clear(); + try { + port.postMessage({ + id: nextId++, + method: 'close', + params: [], + } satisfies RpcRequest<'close'>); + } catch { + // The port may already be unusable; cleanup below is still required. + } + cleanup(err); }; - const cleanup = () => { - if (closed) return; - closed = true; + const onWorkerError = (ev: ErrorEvent) => { + const err = new Error(ev.message || 'shared worker error'); + cleanup(err); + }; + const cleanup = (reason: Error = closedError) => { + if (closedReason) return; + closedReason = reason; materialized.close(); - for (const { reject } of pending.values()) reject(closedError); + for (const { reject } of pending.values()) reject(reason); pending.clear(); port.removeEventListener('message', onMessage); port.removeEventListener('messageerror', onMessageError); + sharedWorker.removeEventListener('error', onWorkerError); port.close(); }; port.addEventListener('message', onMessage); port.addEventListener('messageerror', onMessageError); + sharedWorker.addEventListener('error', onWorkerError); port.start(); let initResult: RpcResult<'init'>; @@ -530,7 +556,7 @@ async function createSharedWorkerClient(opts: { initResult = await call('init', [opts.baseUrl ?? '/', opts.filename, opts.storage, opts.docId]); } catch (err) { try { - if (!terminalError) await call('close', [] as RpcParams<'close'>); + if (!closedReason) await call('close', [] as RpcParams<'close'>); } catch { // Initialization may not have completed, but the shared worker still needs its port removed. } finally { @@ -543,7 +569,7 @@ async function createSharedWorkerClient(opts: { if (opts.requireOpfs && effectiveStorage !== 'opfs') { const reason = opfsError ? `: ${opfsError}` : ''; try { - if (!terminalError) await call('close', [] as RpcParams<'close'>); + if (!closedReason) await call('close', [] as RpcParams<'close'>); } catch { // ignore close errors on init failure } finally { @@ -553,18 +579,18 @@ async function createSharedWorkerClient(opts: { } const closeImpl = async () => { - if (closed) return; + if (closedReason) return; try { - if (!terminalError) await call('close', [] as RpcParams<'close'>); + await call('close', [] as RpcParams<'close'>); } finally { cleanup(); } }; const dropImpl = async () => { - if (closed) return; + if (closedReason) return; try { - if (!terminalError) await call('drop', [] as RpcParams<'drop'>); + await call('drop', [] as RpcParams<'drop'>); } finally { cleanup(); } diff --git a/packages/treecrdt-wa-sqlite/src/common-worker.ts b/packages/treecrdt-wa-sqlite/src/common-worker.ts index 2547be86..0826ef7a 100644 --- a/packages/treecrdt-wa-sqlite/src/common-worker.ts +++ b/packages/treecrdt-wa-sqlite/src/common-worker.ts @@ -33,12 +33,13 @@ export class CommonWorkerSession { } async closeDbAndReset(): Promise { - if (this.db?.close) await this.db.close(); + const db = this.db; this.db = null; this.api = null; this.storedFilename = undefined; this.storedStorage = 'memory'; this.onAfterReset(); + if (db?.close) await db.close(); } async drop(): Promise { diff --git a/packages/treecrdt-wa-sqlite/src/rpc.ts b/packages/treecrdt-wa-sqlite/src/rpc.ts index 2159cc6c..2315f0d1 100644 --- a/packages/treecrdt-wa-sqlite/src/rpc.ts +++ b/packages/treecrdt-wa-sqlite/src/rpc.ts @@ -1,6 +1,8 @@ import type { Operation } from '@treecrdt/interface'; import type { MaterializationEvent, MaterializationOutcome } from '@treecrdt/interface/engine'; +export const SHARED_WORKER_DROPPED_ERROR = 'TreeCRDT shared database was dropped'; + export type RpcStorageMode = 'memory' | 'opfs'; export type RpcSqlParam = number | string | null | Uint8Array; @@ -61,10 +63,15 @@ export type RpcResponse = | { id: number; ok: true; result: RpcResult } | { id: number; ok: false; error: string }; -export type RpcPushMessage = { - type: 'materialized'; - event: MaterializationEvent; -}; +export type RpcPushMessage = + | { + type: 'materialized'; + event: MaterializationEvent; + } + | { + type: 'terminal'; + error: string; + }; export function rpcBinaryResult(bytes: Uint8Array | null): Uint8Array | null { if (bytes === null) return null; diff --git a/packages/treecrdt-wa-sqlite/src/shared-worker.ts b/packages/treecrdt-wa-sqlite/src/shared-worker.ts index 877f472d..8dcc02ea 100644 --- a/packages/treecrdt-wa-sqlite/src/shared-worker.ts +++ b/packages/treecrdt-wa-sqlite/src/shared-worker.ts @@ -2,6 +2,7 @@ import type { MaterializationEvent } from '@treecrdt/interface/engine'; import { transferablesForRpcBinaryResult, + SHARED_WORKER_DROPPED_ERROR, type RpcInitResult, type RpcMethod, type RpcParams, @@ -41,6 +42,7 @@ const ports = new Set(); const session = new SharedCommonWorkerSession(); const coreHandlers = createCommonWorkerRpcHandlers(session); let callQueue: Promise = Promise.resolve(); +let finalResetQueued = false; const settleQueue = (promise: Promise): Promise => promise.then( @@ -52,12 +54,52 @@ function broadcastMaterialized(event: MaterializationEvent, exclude?: MessagePor if (event.changes.length === 0) return; for (const port of ports) { if (port === exclude) continue; - port.postMessage({ type: 'materialized', event }); + postToPort(port, { type: 'materialized', event }); } } -function isClientPushMessage(message: RpcRequest | RpcPushMessage): message is RpcPushMessage { - return 'type' in message && message.type === 'materialized'; +function postToPort(port: MessagePort, message: unknown, transfer: Transferable[] = []): boolean { + try { + port.postMessage(message, transfer); + return true; + } catch { + prunePort(port); + return false; + } +} + +function scheduleFinalReset(): void { + if (finalResetQueued || ports.size > 0) return; + finalResetQueued = true; + const reset = callQueue.then(async () => { + finalResetQueued = false; + if (ports.size === 0) await session.closeDbAndReset(); + }); + callQueue = settleQueue(reset); +} + +function prunePort(port: MessagePort): void { + const removed = detachPort(port); + port.close(); + if (removed) scheduleFinalReset(); +} + +function detachPort(port: MessagePort): boolean { + const removed = ports.delete(port); + port.onmessage = null; + port.onmessageerror = null; + return removed; +} + +function invalidatePeers(sourcePort: MessagePort): void { + const terminal: RpcPushMessage = { + type: 'terminal', + error: SHARED_WORKER_DROPPED_ERROR, + }; + for (const port of ports) { + if (port === sourcePort) continue; + if (postToPort(port, terminal)) detachPort(port); + } } (self as unknown as SharedWorkerGlobal).onconnect = (ev: MessageEvent) => { @@ -66,8 +108,8 @@ function isClientPushMessage(message: RpcRequest | RpcPushMessage): message is R ports.add(port); port.onmessage = (message: MessageEvent) => { const data = message.data; - if (isClientPushMessage(data)) { - broadcastMaterialized(data.event, port); + if ('type' in data) { + if (data.type === 'materialized') broadcastMaterialized(data.event, port); return; } @@ -77,18 +119,28 @@ function isClientPushMessage(message: RpcRequest | RpcPushMessage): message is R request.method === 'treePayload' || request.method === 'treeParent' ? transferablesForRpcBinaryResult(result) : []; - port.postMessage({ id: request.id, ok: true, result }, transfer); + postToPort(port, { id: request.id, ok: true, result }, transfer); }; const respondError = (error: string) => { - port.postMessage({ id: request.id, ok: false, error }); + postToPort(port, { id: request.id, ok: false, error }); }; - const run = callQueue.then(() => handleRequest(port, request)); + let handled = false; + const run = callQueue.then(() => { + if (!ports.has(port)) return undefined; + handled = true; + return handleRequest(port, request); + }); callQueue = settleQueue(run); run.then( - (result) => respondSuccess(result), - (err) => respondError(err instanceof Error ? err.message : String(err)), + (result) => { + if (handled) respondSuccess(result); + }, + (err) => { + if (handled) respondError(err instanceof Error ? err.message : String(err)); + }, ); }; + port.onmessageerror = () => prunePort(port); port.start(); }; @@ -107,7 +159,7 @@ async function handleRequest( } if (request.method === 'drop') { - await session.drop(); + await drop(sourcePort); return undefined; } @@ -154,7 +206,16 @@ async function init( } async function close(port: MessagePort) { - ports.delete(port); + detachPort(port); if (ports.size > 0) return; await session.closeDbAndReset(); } + +async function drop(sourcePort: MessagePort): Promise { + try { + await session.drop(); + } finally { + invalidatePeers(sourcePort); + detachPort(sourcePort); + } +} diff --git a/packages/treecrdt-wa-sqlite/tests/shared-worker-lifecycle.test.ts b/packages/treecrdt-wa-sqlite/tests/shared-worker-lifecycle.test.ts new file mode 100644 index 00000000..29260a0e --- /dev/null +++ b/packages/treecrdt-wa-sqlite/tests/shared-worker-lifecycle.test.ts @@ -0,0 +1,187 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +import { SHARED_WORKER_DROPPED_ERROR, type RpcRequest } from '../src/rpc.js'; + +const mocks = vi.hoisted(() => ({ + clearOpfsStorage: vi.fn(), + openTreecrdtDb: vi.fn(), +})); + +vi.mock('../src/opfs.js', () => ({ clearOpfsStorage: mocks.clearOpfsStorage })); +vi.mock('../src/open.js', () => ({ openTreecrdtDb: mocks.openTreecrdtDb })); + +class FakePort { + onmessage: ((event: MessageEvent) => void) | null = null; + onmessageerror: ((event: MessageEvent) => void) | null = null; + readonly outbound: any[] = []; + closeCalls = 0; + failPosts = false; + + postMessage(message: unknown): void { + if (this.failPosts) throw new Error('stale port'); + this.outbound.push(message); + } + + start(): void {} + + close(): void { + this.closeCalls += 1; + } + + send(request: RpcRequest): void { + this.onmessage?.({ data: request } as MessageEvent); + } + + messageError(): void { + this.onmessageerror?.({} as MessageEvent); + } +} + +type FakeScope = { + onconnect: ((event: MessageEvent) => void) | null; +}; + +let scope: FakeScope; + +function opened(close = vi.fn(async () => undefined)) { + const api = { + headLamport: vi.fn(async () => 7), + }; + return { + close, + result: { db: { close }, api, storage: 'memory' as const, filename: ':memory:' }, + }; +} + +function connect(): FakePort { + const port = new FakePort(); + scope.onconnect?.({ ports: [port] } as unknown as MessageEvent); + return port; +} + +function request(port: FakePort, id: number, method: RpcRequest['method']): void { + const params = + method === 'init' ? (['/', undefined, 'memory', 'lifecycle-test'] as const) : ([] as const); + port.send({ id, method, params } as RpcRequest); +} + +async function response(port: FakePort, id: number): Promise { + await vi.waitFor(() => { + expect(port.outbound.some((message) => message.id === id)).toBe(true); + }); + return port.outbound.find((message) => message.id === id); +} + +async function initialize(port: FakePort, id: number): Promise { + request(port, id, 'init'); + expect(await response(port, id)).toMatchObject({ ok: true }); +} + +beforeEach(async () => { + vi.resetModules(); + mocks.clearOpfsStorage.mockReset(); + mocks.openTreecrdtDb.mockReset(); + scope = { onconnect: null }; + vi.stubGlobal('self', scope); + await import('../src/shared-worker.js'); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test('drop terminates every peer and permits a fresh shared session', async () => { + const first = opened(); + const second = opened(); + mocks.openTreecrdtDb.mockResolvedValueOnce(first.result).mockResolvedValueOnce(second.result); + const source = connect(); + const peer = connect(); + + await initialize(source, 1); + await initialize(peer, 2); + + request(source, 3, 'drop'); + expect(await response(source, 3)).toMatchObject({ ok: true }); + expect(peer.outbound).toContainEqual({ + type: 'terminal', + error: SHARED_WORKER_DROPPED_ERROR, + }); + expect(first.close).toHaveBeenCalledOnce(); + expect(peer.onmessage).toBeNull(); + + const replacement = connect(); + request(replacement, 4, 'init'); + expect((await response(replacement, 4)).ok).toBe(true); + expect(mocks.openTreecrdtDb).toHaveBeenCalledTimes(2); +}); + +test('a failed drop rejects its source but still terminally invalidates peers', async () => { + const closeError = new Error('close failed'); + const first = opened( + vi.fn(async () => { + throw closeError; + }), + ); + const second = opened(); + mocks.openTreecrdtDb.mockResolvedValueOnce(first.result).mockResolvedValueOnce(second.result); + const source = connect(); + const peer = connect(); + await initialize(source, 1); + await initialize(peer, 2); + + request(source, 3, 'drop'); + expect(await response(source, 3)).toMatchObject({ ok: false, error: 'close failed' }); + expect(peer.outbound).toContainEqual({ + type: 'terminal', + error: SHARED_WORKER_DROPPED_ERROR, + }); + + const replacement = connect(); + request(replacement, 4, 'init'); + expect((await response(replacement, 4)).ok).toBe(true); +}); + +test('message errors and failed broadcasts prune stale ports before final close', async () => { + const first = opened(); + let emitMaterialized: ((event: any) => void) | undefined; + mocks.openTreecrdtDb.mockImplementationOnce(async (options) => { + emitMaterialized = options.onMaterialized; + return first.result; + }); + const live = connect(); + const messageErrorPort = connect(); + const failedPostPort = connect(); + await initialize(live, 1); + await initialize(messageErrorPort, 2); + await initialize(failedPostPort, 3); + + messageErrorPort.messageError(); + failedPostPort.failPosts = true; + emitMaterialized?.({ headSeq: 1, changes: [{ node: 'n' }] }); + expect(messageErrorPort.closeCalls).toBe(1); + expect(failedPostPort.closeCalls).toBe(1); + expect(failedPostPort.onmessage).toBeNull(); + + request(live, 4, 'close'); + expect((await response(live, 4)).ok).toBe(true); + expect(first.close).toHaveBeenCalledOnce(); +}); + +test('closing one client preserves the database until the final client closes', async () => { + const first = opened(); + mocks.openTreecrdtDb.mockResolvedValue(first.result); + const firstPort = connect(); + const finalPort = connect(); + await initialize(firstPort, 1); + await initialize(finalPort, 2); + + request(firstPort, 3, 'close'); + expect((await response(firstPort, 3)).ok).toBe(true); + expect(first.close).not.toHaveBeenCalled(); + request(finalPort, 4, 'headLamport'); + expect(await response(finalPort, 4)).toMatchObject({ ok: true, result: 7 }); + + request(finalPort, 5, 'close'); + expect((await response(finalPort, 5)).ok).toBe(true); + expect(first.close).toHaveBeenCalledOnce(); +}); diff --git a/packages/treecrdt-wa-sqlite/tests/worker-cleanup.test.ts b/packages/treecrdt-wa-sqlite/tests/worker-cleanup.test.ts index 3d1bab34..6a4d43f2 100644 --- a/packages/treecrdt-wa-sqlite/tests/worker-cleanup.test.ts +++ b/packages/treecrdt-wa-sqlite/tests/worker-cleanup.test.ts @@ -1,7 +1,7 @@ import { afterEach, expect, test, vi } from 'vitest'; import { createTreecrdtClient } from '../src/client.js'; -import type { RpcRequest } from '../src/rpc.js'; +import { SHARED_WORKER_DROPPED_ERROR, type RpcRequest } from '../src/rpc.js'; type Runtime = 'dedicated-worker' | 'shared-worker'; type RpcResponse = @@ -10,11 +10,13 @@ type RpcResponse = class FakeEndpoint { readonly listeners = new Map void>>(); + readonly workerErrorListeners = new Set<(event: any) => void>(); readonly requests: RpcRequest[] = []; terminated = false; portClosed = false; + throwOnPost = false; - constructor(private readonly respond: (request: RpcRequest) => RpcResponse) {} + constructor(private readonly respond: (request: RpcRequest) => RpcResponse | undefined) {} addEventListener(type: string, listener: (event: any) => void) { const listeners = this.listeners.get(type) ?? new Set(); @@ -27,13 +29,27 @@ class FakeEndpoint { } postMessage(request: RpcRequest) { + if (this.throwOnPost) throw new Error('port is unavailable'); this.requests.push(request); const response = this.respond(request); + if (!response) return; queueMicrotask(() => { for (const listener of this.listeners.get('message') ?? []) listener({ data: response }); }); } + emit(message: unknown) { + for (const listener of this.listeners.get('message') ?? []) listener({ data: message }); + } + + emitMessageError() { + for (const listener of this.listeners.get('messageerror') ?? []) listener({}); + } + + emitWorkerError(message: string) { + for (const listener of this.workerErrorListeners) listener({ message }); + } + start() {} terminate() { @@ -45,7 +61,10 @@ class FakeEndpoint { } } -function installEndpoint(runtime: Runtime, respond: (request: RpcRequest) => RpcResponse) { +function installEndpoint( + runtime: Runtime, + respond: (request: RpcRequest) => RpcResponse | undefined, +) { const endpoint = new FakeEndpoint(respond); if (runtime === 'dedicated-worker') { vi.stubGlobal( @@ -61,6 +80,14 @@ function installEndpoint(runtime: Runtime, respond: (request: RpcRequest) => Rpc 'SharedWorker', class { port = endpoint; + + addEventListener(type: string, listener: (event: any) => void) { + if (type === 'error') endpoint.workerErrorListeners.add(listener); + } + + removeEventListener(type: string, listener: (event: any) => void) { + if (type === 'error') endpoint.workerErrorListeners.delete(listener); + } }, ); } @@ -81,6 +108,17 @@ function clientOptions(runtime: Runtime) { function expectCleaned(runtime: Runtime, endpoint: FakeEndpoint) { expect(runtime === 'dedicated-worker' ? endpoint.terminated : endpoint.portClosed).toBe(true); expect([...endpoint.listeners.values()].every((listeners) => listeners.size === 0)).toBe(true); + if (runtime === 'shared-worker') expect(endpoint.workerErrorListeners.size).toBe(0); +} + +async function openSharedClientWithoutRpcResponses() { + const endpoint = installEndpoint('shared-worker', (request) => + request.method === 'init' + ? { id: request.id, ok: true, result: { storage: 'memory', filename: ':memory:' } } + : undefined, + ); + const client = await createTreecrdtClient(clientOptions('shared-worker')); + return { client, endpoint }; } afterEach(() => { @@ -129,3 +167,52 @@ for (const runtime of ['dedicated-worker', 'shared-worker'] as const) { expectCleaned(runtime, endpoint); }); } + +test('shared-worker terminal invalidation rejects pending calls and closes the client', async () => { + const { client, endpoint } = await openSharedClientWithoutRpcResponses(); + const pending = client.meta.headLamport(); + await vi.waitFor(() => { + expect(endpoint.requests.some((request) => request.method === 'headLamport')).toBe(true); + }); + + endpoint.emit({ type: 'terminal', error: SHARED_WORKER_DROPPED_ERROR }); + + await expect(pending).rejects.toThrow(SHARED_WORKER_DROPPED_ERROR); + await expect(client.meta.headLamport()).rejects.toThrow(SHARED_WORKER_DROPPED_ERROR); + expectCleaned('shared-worker', endpoint); + await client.close(); + await client.close(); +}); + +test('shared-worker runtime errors reject pending calls and close the client', async () => { + const { client, endpoint } = await openSharedClientWithoutRpcResponses(); + const pending = client.meta.headLamport(); + await vi.waitFor(() => { + expect(endpoint.requests.some((request) => request.method === 'headLamport')).toBe(true); + }); + + endpoint.emitWorkerError('shared worker script failed'); + + await expect(pending).rejects.toThrow('shared worker script failed'); + expectCleaned('shared-worker', endpoint); +}); + +test('shared-worker message errors send a best-effort close before cleanup', async () => { + const { client, endpoint } = await openSharedClientWithoutRpcResponses(); + + endpoint.emitMessageError(); + + expect(endpoint.requests.at(-1)?.method).toBe('close'); + await expect(client.meta.headLamport()).rejects.toThrow('shared worker message error'); + expectCleaned('shared-worker', endpoint); +}); + +test('shared-worker post failures reject the call and clean up the client', async () => { + const { client, endpoint } = await openSharedClientWithoutRpcResponses(); + endpoint.throwOnPost = true; + + await expect(client.meta.headLamport()).rejects.toThrow( + 'shared worker post failed: port is unavailable', + ); + expectCleaned('shared-worker', endpoint); +});