Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/bcode-browser/src/cdp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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 */ }
}

Expand Down
33 changes: 33 additions & 0 deletions packages/bcode-browser/test/cdp-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,25 @@ 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}/` })
})

afterAll(() => {
session.close()
server.stop(true)
silentServer.stop(true)
})

test("waitFor resolves on a matching event, respecting the predicate", async () => {
Expand Down Expand Up @@ -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 })
Expand Down