diff --git a/packages/browser-runtime/src/automation/cdp-commander.test.ts b/packages/browser-runtime/src/automation/cdp-commander.test.ts new file mode 100644 index 00000000..96e4af38 --- /dev/null +++ b/packages/browser-runtime/src/automation/cdp-commander.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CdpCommander, rejectPendingCommands } from "./cdp-commander"; + +describe("CdpCommander", () => { + let commandCallback: ((result?: unknown) => void) | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + commandCallback = undefined; + global.chrome = { + debugger: { + sendCommand: vi.fn((_target, _command, _params, callback) => { + commandCallback = callback as (result?: unknown) => void; + }), + }, + runtime: { lastError: undefined }, + } as unknown as typeof chrome; + }); + + it("rejects a pending CDP command when its signal is aborted", async () => { + const controller = new AbortController(); + const pending = new CdpCommander(1, controller.signal).sendCommand( + "Accessibility.getFullAXTree", + {}, + ); + controller.abort(new Error("cancel CDP")); + + await expect(pending).rejects.toThrow("cancel CDP"); + expect(() => commandCallback?.({ nodes: [] })).not.toThrow(); + }); + + it("rejects commands when debugger cleanup starts", async () => { + const pending = new CdpCommander(2).sendCommand("DOM.enable", {}); + rejectPendingCommands(2, "Debugger detaching"); + + await expect(pending).rejects.toThrow( + "CDP command 'DOM.enable' aborted: Debugger detaching", + ); + }); + + it("cleans up a command after its own timeout", async () => { + const pending = new CdpCommander(3).sendCommand("DOM.getDocument", {}, 50); + const assertion = expect(pending).rejects.toThrow("timed out after 50ms"); + await vi.advanceTimersByTimeAsync(50); + await assertion; + + expect(() => rejectPendingCommands(3, "late cleanup")).not.toThrow(); + }); +}); diff --git a/packages/browser-runtime/src/automation/cdp-commander.ts b/packages/browser-runtime/src/automation/cdp-commander.ts index b5d3d678..d05da88f 100644 --- a/packages/browser-runtime/src/automation/cdp-commander.ts +++ b/packages/browser-runtime/src/automation/cdp-commander.ts @@ -8,37 +8,58 @@ const DEFAULT_CDP_TIMEOUT = 10000; const pendingCommands = new Map< number, - Set<{ reject: (error: Error) => void; command: string }> + Set<{ abort: (error: Error) => void; command: string }> >(); export function rejectPendingCommands(tabId: number, reason: string): void { const pending = pendingCommands.get(tabId); if (pending) { - for (const { reject, command } of pending) { - reject(new Error(`CDP command '${command}' aborted: ${reason}`)); + for (const { abort, command } of [...pending]) { + abort(new Error(`CDP command '${command}' aborted: ${reason}`)); } - pending.clear(); pendingCommands.delete(tabId); } } export class CdpCommander { - constructor(readonly tabId: number) {} + constructor( + readonly tabId: number, + private readonly signal?: AbortSignal, + ) {} async sendCommand( command: string, params: Record, timeout: number = DEFAULT_CDP_TIMEOUT, + signal: AbortSignal | undefined = this.signal, ): Promise { return new Promise((resolve, reject) => { - const pendingEntry = { reject, command }; - - const timeoutId = setTimeout(() => { + let settled = false; + let timeoutId: ReturnType; + const cleanup = () => { + clearTimeout(timeoutId); + signal?.removeEventListener("abort", handleAbort); const pending = pendingCommands.get(this.tabId); - if (pending) { - pending.delete(pendingEntry); - } - reject( + pending?.delete(pendingEntry); + if (pending?.size === 0) pendingCommands.delete(this.tabId); + }; + const rejectCommand = (error: Error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const handleAbort = () => { + rejectCommand( + signal?.reason instanceof Error + ? signal.reason + : new Error(`CDP command '${command}' aborted`), + ); + }; + const pendingEntry = { abort: rejectCommand, command }; + + timeoutId = setTimeout(() => { + rejectCommand( new Error(`CDP command '${command}' timed out after ${timeout}ms`), ); }, timeout); @@ -47,23 +68,25 @@ export class CdpCommander { pendingCommands.set(this.tabId, new Set()); } pendingCommands.get(this.tabId)!.add(pendingEntry); + signal?.addEventListener("abort", handleAbort, { once: true }); + if (signal?.aborted) { + handleAbort(); + return; + } chrome.debugger.sendCommand( { tabId: this.tabId }, command, params, (result) => { - clearTimeout(timeoutId); - - const pending = pendingCommands.get(this.tabId); - if (pending) { - pending.delete(pendingEntry); - } - - if (chrome.runtime.lastError) { + const lastError = chrome.runtime.lastError; + if (settled) return; + settled = true; + cleanup(); + if (lastError) { reject( new Error( - `Failed to send CDP command '${command}': ${chrome.runtime.lastError.message}`, + `Failed to send CDP command '${command}': ${lastError.message}`, ), ); } else {