Skip to content

Commit e077bba

Browse files
committed
fix(browser): recover stale CDP sessions
1 parent 89f8ffc commit e077bba

2 files changed

Lines changed: 356 additions & 14 deletions

File tree

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

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { bindDomains, type Domains, type Transport } from './generated.ts';
1010

1111
type Pending = {
12+
ws: WebSocket;
1213
resolve: (v: unknown) => void;
1314
reject: (e: unknown) => void;
1415
};
@@ -46,6 +47,7 @@ export class Session implements Transport {
4647
private nextId = 1;
4748
private pending = new Map<number, Pending>();
4849
private activeSessionId: string | undefined;
50+
private reattachPromise?: Promise<void>;
4951
private eventListeners: Array<(method: string, params: unknown, sessionId?: string) => void> = [];
5052
private callResultListeners: Array<(method: string, params: unknown, result: unknown) => void> = [];
5153

@@ -124,15 +126,29 @@ export class Session implements Transport {
124126
else res();
125127
};
126128
const timer = setTimeout(() => finish(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
127-
ws.addEventListener('open', () => finish());
129+
ws.addEventListener('open', () => {
130+
if (done) {
131+
try { ws.close(); } catch { /* ignore */ }
132+
return;
133+
}
134+
const previous = this.ws;
135+
this.ws = ws;
136+
this.activeSessionId = undefined;
137+
finish();
138+
if (previous && previous !== ws) {
139+
try { previous.close(); } catch { /* ignore */ }
140+
}
141+
});
128142
ws.addEventListener('error', (e) => finish(new Error(`WS error: ${(e as any)?.message ?? 'connect failed (likely 403, permission not granted, or port closed)'}`)));
129-
ws.addEventListener('message', (e) => this.onMessage(String(e.data)));
143+
ws.addEventListener('message', (e) => this.onMessage(String(e.data), ws));
130144
ws.addEventListener('close', () => {
131-
for (const [, p] of this.pending) p.reject(new Error('CDP socket closed'));
132-
this.pending.clear();
145+
this.rejectPending(ws, new Error('CDP socket closed'));
146+
if (this.ws === ws) {
147+
this.ws = undefined;
148+
this.activeSessionId = undefined;
149+
}
133150
finish(new Error('WS closed before open (likely 403 or port closed)'));
134151
});
135-
this.ws = ws;
136152
});
137153
}
138154

@@ -206,17 +222,36 @@ export class Session implements Transport {
206222
}
207223

208224
// Transport implementation. Called by the generated domain bindings.
209-
_call(method: string, params: unknown = {}): Promise<unknown> {
210-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
225+
async _call(method: string, params: unknown = {}): Promise<unknown> {
226+
const browserLevel = isBrowserLevel(method);
227+
const sentSessionId = browserLevel ? undefined : this.activeSessionId;
228+
try {
229+
return await this.send(method, params, sentSessionId);
230+
} catch (error) {
231+
if (!sentSessionId || !isMissingSessionError(error)) throw error;
232+
233+
// Chrome explicitly rejected the command before executing it, so this is
234+
// safe to retry once. Socket drops are deliberately not retried: Chrome
235+
// may have applied a click or submission before the response was lost.
236+
if (this.activeSessionId === sentSessionId) this.activeSessionId = undefined;
237+
if (!this.activeSessionId) await this.reattachFirstPage();
238+
if (!this.activeSessionId) throw error;
239+
return this.send(method, params, this.activeSessionId);
240+
}
241+
}
242+
243+
private send(method: string, params: unknown, sessionId?: string): Promise<unknown> {
244+
const ws = this.ws;
245+
if (!ws || ws.readyState !== WebSocket.OPEN) {
211246
return Promise.reject(new Error('Not connected. Call session.connect(...) first.'));
212247
}
248+
213249
const id = this.nextId++;
214250
const msg: Record<string, unknown> = { id, method, params: params ?? {} };
215-
if (this.activeSessionId && !isBrowserLevel(method)) {
216-
msg.sessionId = this.activeSessionId;
217-
}
251+
if (sessionId) msg.sessionId = sessionId;
218252
return new Promise((resolve, reject) => {
219253
this.pending.set(id, {
254+
ws,
220255
resolve: (v) => {
221256
for (const fn of this.callResultListeners) {
222257
try { fn(method, params, v); } catch { /* ignore */ }
@@ -225,16 +260,50 @@ export class Session implements Transport {
225260
},
226261
reject,
227262
});
228-
this.ws!.send(JSON.stringify(msg));
263+
try {
264+
ws.send(JSON.stringify(msg));
265+
} catch (error) {
266+
this.pending.delete(id);
267+
reject(error);
268+
}
229269
});
230270
}
231271

232-
private onMessage(raw: string): void {
272+
private async reattachFirstPage(): Promise<void> {
273+
if (this.reattachPromise) return this.reattachPromise;
274+
275+
const attempt = this.attachFirstPage();
276+
this.reattachPromise = attempt;
277+
try {
278+
await attempt;
279+
} finally {
280+
if (this.reattachPromise === attempt) this.reattachPromise = undefined;
281+
}
282+
}
283+
284+
private async attachFirstPage(): Promise<void> {
285+
const { targetInfos } = await this.domains.Target.getTargets({});
286+
const pages = targetInfos as PageTarget[];
287+
const targetId = pages.find(isUsablePageTarget)?.targetId
288+
?? (await this.domains.Target.createTarget({ url: 'about:blank' })).targetId;
289+
await this.use(targetId);
290+
}
291+
292+
private rejectPending(ws: WebSocket, error: Error): void {
293+
for (const [id, pending] of this.pending) {
294+
if (pending.ws !== ws) continue;
295+
this.pending.delete(id);
296+
pending.reject(error);
297+
}
298+
}
299+
300+
private onMessage(raw: string, ws: WebSocket): void {
301+
if (ws !== this.ws) return;
233302
let m: any;
234303
try { m = JSON.parse(raw); } catch { return; }
235304
if (typeof m.id === 'number') {
236305
const p = this.pending.get(m.id);
237-
if (!p) return;
306+
if (!p || p.ws !== ws) return;
238307
this.pending.delete(m.id);
239308
if (m.error) p.reject(new CdpError(m.error.code, m.error.message, m.error.data));
240309
else p.resolve(m.result);
@@ -258,6 +327,12 @@ function isBrowserLevel(method: string): boolean {
258327
return method.startsWith('Browser.') || method.startsWith('Target.');
259328
}
260329

330+
function isMissingSessionError(error: unknown): boolean {
331+
return error instanceof CdpError
332+
&& error.code === -32001
333+
&& error.message.includes('Session with given id not found');
334+
}
335+
261336
/**
262337
* Resolve a WebSocket URL for one of the explicit connect forms:
263338
* { wsUrl } — passthrough.
@@ -329,6 +404,15 @@ export async function listPageTargets(session: Session): Promise<PageTarget[]> {
329404
);
330405
}
331406

407+
function isUsablePageTarget(target: PageTarget): boolean {
408+
return target.type === 'page'
409+
&& !target.url.startsWith('chrome://')
410+
&& !target.url.startsWith('chrome-untrusted://')
411+
&& !target.url.startsWith('devtools://')
412+
&& !target.url.startsWith('chrome-extension://')
413+
&& !target.url.startsWith('about:');
414+
}
415+
332416
/**
333417
* Scan OS-specific user-data directories for Chromium-based browsers that
334418
* currently have remote debugging enabled (a `DevToolsActivePort` file exists
@@ -423,4 +507,3 @@ async function tryReadDevToolsActivePort(
423507
return undefined;
424508
}
425509
}
426-

0 commit comments

Comments
 (0)