Skip to content

Commit 9321645

Browse files
committed
fix(browser): avoid unsafe recovery replay
1 parent a818634 commit 9321645

2 files changed

Lines changed: 83 additions & 23 deletions

File tree

packages/bcode-browser/src/cdp/session.ts

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ export class Session implements Transport {
8383
* and we connect directly to the supplied endpoint.
8484
*/
8585
async connect(opts: ConnectOptions = {}): Promise<void> {
86+
// No-argument connect is an ensure-connected operation. Reopening the
87+
// same configured endpoint would discard the active target session and
88+
// make the next page command run against the browser-level socket.
89+
if (!opts.wsUrl && !opts.profileDir && this.isConnected()) return;
90+
8691
const timeoutMs = opts.timeoutMs ?? 5_000;
8792
if (opts.wsUrl || opts.profileDir) {
8893
const wsUrl = await resolveWsUrl(opts, timeoutMs);
@@ -294,16 +299,33 @@ export class Session implements Transport {
294299
}
295300

296301
private async attachPage(staleSessionId: string, staleTargetId?: string): Promise<void> {
302+
if (!staleTargetId) {
303+
if (this.activeSessionId === staleSessionId) {
304+
this.activeSessionId = undefined;
305+
this.activeTargetId = undefined;
306+
this.enabledDomains.delete(staleSessionId);
307+
}
308+
throw new Error(
309+
'CDP target session was lost and its target is unknown; command was not retried on another page.',
310+
);
311+
}
297312
const domainsToRestore = [...(this.enabledDomains.get(staleSessionId)?.entries() ?? [])];
298313
const { targetInfos } = await this.domains.Target.getTargets({});
299314
const pages = targetInfos as PageTarget[];
300-
const exactTarget = staleTargetId
301-
? pages.find(target => target.type === 'page' && target.targetId === staleTargetId)
302-
: undefined;
303-
const targetId = exactTarget?.targetId
304-
?? (!staleTargetId ? pages.find(isUsablePageTarget)?.targetId : undefined)
305-
?? (await this.domains.Target.createTarget({ url: 'about:blank' })).targetId;
306-
const sessionId = await this.use(targetId);
315+
const exactTarget = pages.find(
316+
target => target.type === 'page' && target.targetId === staleTargetId,
317+
);
318+
if (!exactTarget) {
319+
if (this.activeSessionId === staleSessionId) {
320+
this.activeSessionId = undefined;
321+
this.activeTargetId = undefined;
322+
this.enabledDomains.delete(staleSessionId);
323+
}
324+
throw new Error(
325+
`CDP target ${staleTargetId} was closed; command was not retried on another page.`,
326+
);
327+
}
328+
const sessionId = await this.use(exactTarget.targetId);
307329
await Promise.all(
308330
domainsToRestore.map(
309331
([method, params]) => this.send(method, params, sessionId),
@@ -440,15 +462,6 @@ export async function listPageTargets(session: Session): Promise<PageTarget[]> {
440462
);
441463
}
442464

443-
function isUsablePageTarget(target: PageTarget): boolean {
444-
return target.type === 'page'
445-
&& !target.url.startsWith('chrome://')
446-
&& !target.url.startsWith('chrome-untrusted://')
447-
&& !target.url.startsWith('devtools://')
448-
&& !target.url.startsWith('chrome-extension://')
449-
&& (!target.url.startsWith('about:') || target.url === 'about:blank');
450-
}
451-
452465
/**
453466
* Scan OS-specific user-data directories for Chromium-based browsers that
454467
* currently have remote debugging enabled (a `DevToolsActivePort` file exists

packages/bcode-browser/test/cdp-recovery.test.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -235,9 +235,10 @@ test("reattach reuses an existing about:blank target", async () => {
235235
}
236236
})
237237

238-
test("reattach creates a blank page instead of switching to another live target", async () => {
238+
test("a missing original target is reported without replaying on another page", async () => {
239239
let attachCount = 0
240-
let createdTarget: unknown
240+
let createCount = 0
241+
let commandCount = 0
241242
const attachedTargets: string[] = []
242243
const server = Bun.serve({
243244
port: 0,
@@ -266,15 +267,16 @@ test("reattach creates a blank page instead of switching to another live target"
266267
return
267268
}
268269
if (message.method === "Target.createTarget") {
269-
createdTarget = message.params
270-
socket.send(JSON.stringify({ id: message.id, result: { targetId: "fresh-page" } }))
270+
createCount++
271+
socket.send(JSON.stringify({ id: message.id, result: { targetId: "unexpected-page" } }))
271272
return
272273
}
273274
if (["Page.enable", "DOM.enable", "Runtime.enable", "Network.enable"].includes(message.method)) {
274275
socket.send(JSON.stringify({ id: message.id, result: {} }))
275276
return
276277
}
277278
if (message.method === "Runtime.evaluate") {
279+
commandCount++
278280
if (message.sessionId === "session-1") {
279281
socket.send(JSON.stringify({
280282
id: message.id,
@@ -293,12 +295,57 @@ test("reattach creates a blank page instead of switching to another live target"
293295
try {
294296
await session.connect({ wsUrl: wsUrl(server) })
295297
await session.use("old-page")
298+
await expect(session.domains.Runtime.evaluate({ expression: "submit()" }))
299+
.rejects.toThrow("CDP target old-page was closed")
300+
301+
expect(commandCount).toBe(1)
302+
expect(createCount).toBe(0)
303+
expect(attachCount).toBe(1)
304+
expect(attachedTargets).toEqual(["old-page"])
305+
} finally {
306+
session.close()
307+
server.stop(true)
308+
}
309+
})
310+
311+
test("no-argument connect preserves a healthy socket and active target", async () => {
312+
let connectionCount = 0
313+
const commandSessions: string[] = []
314+
const server = Bun.serve({
315+
port: 0,
316+
fetch(req, bunServer) {
317+
if (!bunServer.upgrade(req)) return new Response("nope", { status: 400 })
318+
connectionCount++
319+
return undefined
320+
},
321+
websocket: {
322+
message(socket, raw) {
323+
const message = JSON.parse(String(raw))
324+
if (message.method === "Target.attachToTarget") {
325+
socket.send(JSON.stringify({ id: message.id, result: { sessionId: "session-1" } }))
326+
return
327+
}
328+
if (message.method !== "Runtime.evaluate") return
329+
commandSessions.push(message.sessionId)
330+
socket.send(JSON.stringify({
331+
id: message.id,
332+
result: { result: { type: "boolean", value: true } },
333+
}))
334+
},
335+
close() {},
336+
},
337+
})
338+
const session = new Session()
339+
340+
try {
341+
await session.connect({ wsUrl: wsUrl(server) })
342+
await session.use("page-1")
343+
await session.connect()
296344
const result = await session.domains.Runtime.evaluate({ expression: "true" })
297345

298346
expect(result.result.value).toBe(true)
299-
expect(createdTarget).toEqual({ url: "about:blank" })
300-
expect(attachCount).toBe(2)
301-
expect(attachedTargets).toEqual(["old-page", "fresh-page"])
347+
expect(connectionCount).toBe(1)
348+
expect(commandSessions).toEqual(["session-1"])
302349
} finally {
303350
session.close()
304351
server.stop(true)

0 commit comments

Comments
 (0)