From 628be43ae9b54315a25f663cf5369f6597276a2c Mon Sep 17 00:00:00 2001 From: xh123637 <3324733895@qq.com> Date: Thu, 29 Oct 2026 23:40:31 +0800 Subject: [PATCH] fix(browser): reject pending CDP calls on Session invalidation --- packages/bcode-browser/src/cdp/session.ts | 10 ++++-- .../bcode-browser/test/cdp-session.test.ts | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 1eceb2615..473148dbf 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -194,8 +194,9 @@ export class Session implements Transport { /** * Permanently retire this Session object. * - * Invalidation rejects all future `connect`/`_call` attempts and closes the - * socket; `SessionStore.invalidate` removes the entry so a later lookup gets + * Invalidation rejects all future `connect`/`_call` attempts, rejects + * in-flight calls with the same error, and closes the socket; + * `SessionStore.invalidate` removes the entry so a later lookup gets * a fresh Session. `browser_execute` timeouts use scoped execution instead, * preserving this object and its browser connection for the next call. */ @@ -206,6 +207,11 @@ export class Session implements Transport { const ws = this.ws; this.ws = undefined; this.activeSessionId = undefined; + // Reject in-flight calls here: the socket's close listener treats it as + // superseded once `this.ws` is cleared, so it will not drain `pending` + // when the close event lands. + for (const [, p] of this.pending) p.reject(error); + this.pending.clear(); try { ws?.close(); } catch { /* ignore */ } } diff --git a/packages/bcode-browser/test/cdp-session.test.ts b/packages/bcode-browser/test/cdp-session.test.ts index e6250cbf2..1880bd501 100644 --- a/packages/bcode-browser/test/cdp-session.test.ts +++ b/packages/bcode-browser/test/cdp-session.test.ts @@ -24,6 +24,17 @@ const emit = (method: string, params: unknown) => { server.publish(channel, JSON.stringify({ method, params })) } +// A server that never answers CDP requests, so calls stay in-flight. +const silentServer = Bun.serve({ + port: 0, + fetch(req, srv) { + return srv.upgrade(req) ? undefined : new Response("nope", { status: 400 }) + }, + websocket: { + message() {}, + }, +}) + beforeAll(async () => { await session.connect({ wsUrl: `ws://127.0.0.1:${server.port}/` }) }) @@ -31,6 +42,7 @@ beforeAll(async () => { afterAll(() => { session.close() server.stop(true) + silentServer.stop(true) }) test("waitFor resolves on a matching event, respecting the predicate", async () => { @@ -146,6 +158,27 @@ test("invalidate before the socket exists rejects the in-flight connect", async expect(s.isConnected()).toBe(false) }) +// In-flight calls on an established connection: the socket's close listener +// treats the socket as superseded once invalidate() clears it, so only +// invalidate() itself can settle calls that are still awaiting a response. +test("invalidate rejects an in-flight CDP call with the invalidation error", async () => { + const s = new Session() + await s.connect({ wsUrl: `ws://127.0.0.1:${silentServer.port}/` }) + const call = s._call("Test.hold", {}) + s.invalidate(new Error("retired by test")) + await expect(call).rejects.toThrow("retired by test") +}) + +test("invalidate rejects every in-flight CDP call, not just the first", async () => { + const s = new Session() + await s.connect({ wsUrl: `ws://127.0.0.1:${silentServer.port}/` }) + const first = s._call("Test.hold", {}) + const second = s._call("Test.holdToo", {}) + s.invalidate(new Error("retired by test")) + await expect(first).rejects.toThrow("retired by test") + await expect(second).rejects.toThrow("retired by test") +}) + test("invalidate while the socket is connecting closes it and rejects", async () => { const s = new Session() const connecting = s.connect({ wsUrl: `ws://127.0.0.1:${server.port}/`, timeoutMs: 1_000 })