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
116 changes: 102 additions & 14 deletions packages/bcode-browser/src/cdp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { bindDomains, type Domains, type Transport } from './generated.ts';

type Pending = {
ws: WebSocket;
resolve: (v: unknown) => void;
reject: (e: unknown) => void;
};
Expand Down Expand Up @@ -46,6 +47,7 @@ export class Session implements Transport {
private nextId = 1;
private pending = new Map<number, Pending>();
private activeSessionId: string | undefined;
private reattachPromise?: Promise<void>;
private eventListeners: Array<(method: string, params: unknown, sessionId?: string) => void> = [];
private callResultListeners: Array<(method: string, params: unknown, result: unknown) => void> = [];

Expand Down Expand Up @@ -124,15 +126,29 @@ export class Session implements Transport {
else res();
};
const timer = setTimeout(() => finish(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
ws.addEventListener('open', () => finish());
ws.addEventListener('open', () => {
if (done) {
try { ws.close(); } catch { /* ignore */ }
return;
}
const previous = this.ws;
this.ws = ws;
this.activeSessionId = undefined;
finish();
if (previous && previous !== ws) {
try { previous.close(); } catch { /* ignore */ }
}
});
ws.addEventListener('error', (e) => finish(new Error(`WS error: ${(e as any)?.message ?? 'connect failed (likely 403, permission not granted, or port closed)'}`)));
ws.addEventListener('message', (e) => this.onMessage(String(e.data)));
ws.addEventListener('message', (e) => this.onMessage(String(e.data), ws));
ws.addEventListener('close', () => {
for (const [, p] of this.pending) p.reject(new Error('CDP socket closed'));
this.pending.clear();
this.rejectPending(ws, new Error('CDP socket closed'));
if (this.ws === ws) {
this.ws = undefined;
this.activeSessionId = undefined;
}
finish(new Error('WS closed before open (likely 403 or port closed)'));
});
this.ws = ws;
});
}

Expand Down Expand Up @@ -206,17 +222,36 @@ export class Session implements Transport {
}

// Transport implementation. Called by the generated domain bindings.
_call(method: string, params: unknown = {}): Promise<unknown> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
async _call(method: string, params: unknown = {}): Promise<unknown> {
const browserLevel = isBrowserLevel(method);
const sentSessionId = browserLevel ? undefined : this.activeSessionId;
try {
return await this.send(method, params, sentSessionId);
} catch (error) {
if (!sentSessionId || !isMissingSessionError(error)) throw error;

// Chrome explicitly rejected the command before executing it, so this is
// safe to retry once. Socket drops are deliberately not retried: Chrome
// may have applied a click or submission before the response was lost.
if (this.activeSessionId === sentSessionId) this.activeSessionId = undefined;
if (!this.activeSessionId) await this.reattachFirstPage();
Comment on lines +236 to +237

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Page-domain calls issued during reattachment are sent without sessionId and fail rather than recovering. Keep the stale ID until the serialized reattach replaces it, so overlapping calls receive Chrome’s explicit stale-session rejection and join the same recovery.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/bcode-browser/src/cdp/session.ts, line 236:

<comment>Page-domain calls issued during reattachment are sent without `sessionId` and fail rather than recovering. Keep the stale ID until the serialized reattach replaces it, so overlapping calls receive Chrome’s explicit stale-session rejection and join the same recovery.</comment>

<file context>
@@ -206,17 +222,36 @@ export class Session implements Transport {
+      // Chrome explicitly rejected the command before executing it, so this is
+      // safe to retry once. Socket drops are deliberately not retried: Chrome
+      // may have applied a click or submission before the response was lost.
+      if (this.activeSessionId === sentSessionId) this.activeSessionId = undefined;
+      if (!this.activeSessionId) await this.reattachFirstPage();
+      if (!this.activeSessionId) throw error;
</file context>
Suggested change
if (this.activeSessionId === sentSessionId) this.activeSessionId = undefined;
if (!this.activeSessionId) await this.reattachFirstPage();
if (this.activeSessionId === sentSessionId) await this.reattachFirstPage();
if (!this.activeSessionId || this.activeSessionId === sentSessionId) throw error;
Fix with cubic

if (!this.activeSessionId) throw error;
return this.send(method, params, this.activeSessionId);
}
}

private send(method: string, params: unknown, sessionId?: string): Promise<unknown> {
const ws = this.ws;
if (!ws || ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error('Not connected. Call session.connect(...) first.'));
}

const id = this.nextId++;
const msg: Record<string, unknown> = { id, method, params: params ?? {} };
if (this.activeSessionId && !isBrowserLevel(method)) {
msg.sessionId = this.activeSessionId;
}
if (sessionId) msg.sessionId = sessionId;
return new Promise((resolve, reject) => {
this.pending.set(id, {
ws,
resolve: (v) => {
for (const fn of this.callResultListeners) {
try { fn(method, params, v); } catch { /* ignore */ }
Expand All @@ -225,16 +260,55 @@ export class Session implements Transport {
},
reject,
});
this.ws!.send(JSON.stringify(msg));
try {
ws.send(JSON.stringify(msg));
} catch (error) {
this.pending.delete(id);
reject(error);
}
});
}

private onMessage(raw: string): void {
private async reattachFirstPage(): Promise<void> {
if (this.reattachPromise) return this.reattachPromise;

const attempt = this.attachFirstPage();
this.reattachPromise = attempt;
try {
await attempt;
} finally {
if (this.reattachPromise === attempt) this.reattachPromise = undefined;
}
}

private async attachFirstPage(): Promise<void> {
const { targetInfos } = await this.domains.Target.getTargets({});
const pages = targetInfos as PageTarget[];
const targetId = pages.find(isUsablePageTarget)?.targetId
?? (await this.domains.Target.createTarget({ url: 'about:blank' })).targetId;
const sessionId = await this.use(targetId);
await Promise.allSettled(
['Page', 'DOM', 'Runtime', 'Network'].map(

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Recovery restores only four hard-coded domains, so callers that enabled Debugger, Fetch, or another generated domain lose their subscription after reattach; it also enables unrequested domains. In particular, unrequested Runtime.enable immediately emits existing-context events, which can resolve an in-flight waitFor callback before its intended event; record successful *.enable calls and their params, then replay only those.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/bcode-browser/src/cdp/session.ts, line 291:

<comment>Recovery restores only four hard-coded domains, so callers that enabled `Debugger`, `Fetch`, or another generated domain lose their subscription after reattach; it also enables unrequested domains. In particular, unrequested `Runtime.enable` immediately emits existing-context events, which can resolve an in-flight `waitFor` callback before its intended event; record successful `*.enable` calls and their params, then replay only those.</comment>

<file context>
@@ -286,7 +286,12 @@ export class Session implements Transport {
-    await this.use(targetId);
+    const sessionId = await this.use(targetId);
+    await Promise.allSettled(
+      ['Page', 'DOM', 'Runtime', 'Network'].map(
+        domain => this.send(`${domain}.enable`, {}, sessionId),
+      ),
</file context>
Fix with cubic

domain => this.send(`${domain}.enable`, {}, sessionId),
),
);
}

private rejectPending(ws: WebSocket, error: Error): void {
for (const [id, pending] of this.pending) {
if (pending.ws !== ws) continue;
this.pending.delete(id);
pending.reject(error);
}
}

private onMessage(raw: string, ws: WebSocket): void {
if (ws !== this.ws) return;
let m: any;
try { m = JSON.parse(raw); } catch { return; }
if (typeof m.id === 'number') {
const p = this.pending.get(m.id);
if (!p) return;
if (!p || p.ws !== ws) return;
this.pending.delete(m.id);
if (m.error) p.reject(new CdpError(m.error.code, m.error.message, m.error.data));
else p.resolve(m.result);
Expand All @@ -258,6 +332,12 @@ function isBrowserLevel(method: string): boolean {
return method.startsWith('Browser.') || method.startsWith('Target.');
}

function isMissingSessionError(error: unknown): boolean {
return error instanceof CdpError
&& error.code === -32001
&& error.message.includes('Session with given id not found');
}

/**
* Resolve a WebSocket URL for one of the explicit connect forms:
* { wsUrl } — passthrough.
Expand Down Expand Up @@ -329,6 +409,15 @@ export async function listPageTargets(session: Session): Promise<PageTarget[]> {
);
}

function isUsablePageTarget(target: PageTarget): boolean {
return target.type === 'page'
&& !target.url.startsWith('chrome://')
&& !target.url.startsWith('chrome-untrusted://')
&& !target.url.startsWith('devtools://')
&& !target.url.startsWith('chrome-extension://')
&& !target.url.startsWith('about:');

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Recovery opens an extra blank tab whenever the only usable page is already about:blank. Treat existing blank targets as usable; the fallback should create a tab only when no attachable page exists.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/bcode-browser/src/cdp/session.ts, line 413:

<comment>Recovery opens an extra blank tab whenever the only usable page is already `about:blank`. Treat existing blank targets as usable; the fallback should create a tab only when no attachable page exists.</comment>

<file context>
@@ -329,6 +404,15 @@ export async function listPageTargets(session: Session): Promise<PageTarget[]> {
+    && !target.url.startsWith('chrome-untrusted://')
+    && !target.url.startsWith('devtools://')
+    && !target.url.startsWith('chrome-extension://')
+    && !target.url.startsWith('about:');
+}
+
</file context>
Fix with cubic

}

/**
* Scan OS-specific user-data directories for Chromium-based browsers that
* currently have remote debugging enabled (a `DevToolsActivePort` file exists
Expand Down Expand Up @@ -423,4 +512,3 @@ async function tryReadDevToolsActivePort(
return undefined;
}
}

Loading
Loading