From a71754dc1a47a199dd957ca712364aa6d294710a Mon Sep 17 00:00:00 2001 From: Nikhil Sonti Date: Mon, 23 Feb 2026 14:54:14 -0800 Subject: [PATCH 1/3] fix: cdp retry on disconnect and crash --- apps/server/src/browser/backends/cdp.ts | 108 ++++++++++++++++++---- packages/shared/src/constants/timeouts.ts | 2 + 2 files changed, 90 insertions(+), 20 deletions(-) diff --git a/apps/server/src/browser/backends/cdp.ts b/apps/server/src/browser/backends/cdp.ts index 9da036cf..3f3b6755 100644 --- a/apps/server/src/browser/backends/cdp.ts +++ b/apps/server/src/browser/backends/cdp.ts @@ -4,6 +4,9 @@ import { type RawSend, } from '@browseros/cdp-protocol/create-api' import type { ProtocolApi } from '@browseros/cdp-protocol/protocol-api' +import { EXIT_CODES } from '@browseros/shared/constants/exit-codes' +import { TIMEOUTS } from '@browseros/shared/constants/timeouts' +import { logger } from '../../lib/logger' import type { CdpTarget, CdpBackend as ICdpBackend } from './types' interface PendingRequest { @@ -11,6 +14,8 @@ interface PendingRequest { reject: (reason: Error) => void } +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + // biome-ignore lint/correctness/noUnusedVariables: declaration merging adds ProtocolApi properties to the class interface CdpBackend extends ProtocolApi {} // biome-ignore lint/suspicious/noUnsafeDeclarationMerging: intentional — Object.assign fills these at runtime @@ -20,6 +25,7 @@ class CdpBackend implements ICdpBackend { private messageId = 0 private pending = new Map() private connected = false + private disconnecting = false private eventHandlers = new Map void)[]>() private sessionCache = new Map() @@ -32,37 +38,99 @@ class CdpBackend implements ICdpBackend { } async connect(): Promise { - const versionResponse = await fetch( - `http://localhost:${this.port}/json/version`, - ) - const version = (await versionResponse.json()) as { - webSocketDebuggerUrl: string + const maxRetries = TIMEOUTS.CDP_CONNECT_MAX_RETRIES + const retryDelay = TIMEOUTS.CDP_CONNECT_RETRY_DELAY + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await this.attemptConnect() + return + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + if (attempt < maxRetries) { + logger.warn( + `CDP connection attempt ${attempt}/${maxRetries} failed: ${msg}. Retrying in ${retryDelay}ms...`, + ) + await sleep(retryDelay) + } else { + throw new Error( + `CDP connection failed after ${maxRetries} attempts: ${msg}`, + ) + } + } } - const wsUrl = version.webSocketDebuggerUrl + } + private attemptConnect(): Promise { return new Promise((resolve, reject) => { - this.ws = new WebSocket(wsUrl) + fetch(`http://localhost:${this.port}/json/version`) + .then((res) => res.json()) + .then((version) => { + const wsUrl = (version as { webSocketDebuggerUrl: string }) + .webSocketDebuggerUrl + const ws = new WebSocket(wsUrl) + + ws.onopen = () => { + this.ws = ws + this.connected = true + this.disconnecting = false + resolve() + } + + ws.onerror = (event) => { + reject(new Error(`CDP WebSocket error: ${event}`)) + } + + ws.onclose = () => { + this.connected = false + this.ws = null + this.handleUnexpectedClose() + } + + ws.onmessage = (event) => { + this.handleMessage(event.data as string) + } + }) + .catch(reject) + }) + } - this.ws.onopen = () => { - this.connected = true - resolve() - } + private handleUnexpectedClose(): void { + if (this.disconnecting) return - this.ws.onerror = (event) => { - reject(new Error(`CDP WebSocket error: ${event}`)) - } + logger.error( + 'CDP WebSocket closed unexpectedly, attempting reconnection...', + ) + this.reconnectOrCrash() + } - this.ws.onclose = () => { - this.connected = false + private async reconnectOrCrash(): Promise { + const maxRetries = TIMEOUTS.CDP_CONNECT_MAX_RETRIES + const retryDelay = TIMEOUTS.CDP_CONNECT_RETRY_DELAY + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + logger.info(`CDP reconnection attempt ${attempt}/${maxRetries}...`) + await sleep(retryDelay) + await this.attemptConnect() + logger.info('CDP reconnected successfully') + return + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + logger.warn( + `CDP reconnection attempt ${attempt}/${maxRetries} failed: ${msg}`, + ) } + } - this.ws.onmessage = (event) => { - this.handleMessage(event.data as string) - } - }) + logger.error( + `CDP reconnection failed after ${maxRetries} attempts, exiting for restart`, + ) + process.exit(EXIT_CODES.GENERAL_ERROR) } async disconnect(): Promise { + this.disconnecting = true if (this.ws) { this.ws.close() this.ws = null diff --git a/packages/shared/src/constants/timeouts.ts b/packages/shared/src/constants/timeouts.ts index 0da1f7ed..cf719217 100644 --- a/packages/shared/src/constants/timeouts.ts +++ b/packages/shared/src/constants/timeouts.ts @@ -21,6 +21,8 @@ export const TIMEOUTS = { // CDP connection CDP_CONNECT: 10_000, + CDP_CONNECT_RETRY_DELAY: 1_000, + CDP_CONNECT_MAX_RETRIES: 3, // External API calls KLAVIS_FETCH: 30_000, From 27f306366da82dbc6df3769c06ad7f1a37fd8fcc Mon Sep 17 00:00:00 2001 From: Nikhil Sonti Date: Mon, 23 Feb 2026 15:09:44 -0800 Subject: [PATCH 2/3] fix: review comments --- apps/server/src/browser/backends/cdp.ts | 17 +++++++++-------- packages/shared/src/constants/limits.ts | 4 ++++ packages/shared/src/constants/timeouts.ts | 1 - 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/server/src/browser/backends/cdp.ts b/apps/server/src/browser/backends/cdp.ts index 3f3b6755..92a8ea96 100644 --- a/apps/server/src/browser/backends/cdp.ts +++ b/apps/server/src/browser/backends/cdp.ts @@ -5,6 +5,7 @@ import { } from '@browseros/cdp-protocol/create-api' import type { ProtocolApi } from '@browseros/cdp-protocol/protocol-api' import { EXIT_CODES } from '@browseros/shared/constants/exit-codes' +import { CDP_LIMITS } from '@browseros/shared/constants/limits' import { TIMEOUTS } from '@browseros/shared/constants/timeouts' import { logger } from '../../lib/logger' import type { CdpTarget, CdpBackend as ICdpBackend } from './types' @@ -14,8 +15,6 @@ interface PendingRequest { reject: (reason: Error) => void } -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - // biome-ignore lint/correctness/noUnusedVariables: declaration merging adds ProtocolApi properties to the class interface CdpBackend extends ProtocolApi {} // biome-ignore lint/suspicious/noUnsafeDeclarationMerging: intentional — Object.assign fills these at runtime @@ -38,7 +37,7 @@ class CdpBackend implements ICdpBackend { } async connect(): Promise { - const maxRetries = TIMEOUTS.CDP_CONNECT_MAX_RETRIES + const maxRetries = CDP_LIMITS.CONNECT_MAX_RETRIES const retryDelay = TIMEOUTS.CDP_CONNECT_RETRY_DELAY for (let attempt = 1; attempt <= maxRetries; attempt++) { @@ -51,7 +50,7 @@ class CdpBackend implements ICdpBackend { logger.warn( `CDP connection attempt ${attempt}/${maxRetries} failed: ${msg}. Retrying in ${retryDelay}ms...`, ) - await sleep(retryDelay) + await Bun.sleep(retryDelay) } else { throw new Error( `CDP connection failed after ${maxRetries} attempts: ${msg}`, @@ -68,9 +67,11 @@ class CdpBackend implements ICdpBackend { .then((version) => { const wsUrl = (version as { webSocketDebuggerUrl: string }) .webSocketDebuggerUrl + let opened = false const ws = new WebSocket(wsUrl) ws.onopen = () => { + opened = true this.ws = ws this.connected = true this.disconnecting = false @@ -78,13 +79,13 @@ class CdpBackend implements ICdpBackend { } ws.onerror = (event) => { - reject(new Error(`CDP WebSocket error: ${event}`)) + if (!opened) reject(new Error(`CDP WebSocket error: ${event}`)) } ws.onclose = () => { this.connected = false this.ws = null - this.handleUnexpectedClose() + if (opened) this.handleUnexpectedClose() } ws.onmessage = (event) => { @@ -105,13 +106,13 @@ class CdpBackend implements ICdpBackend { } private async reconnectOrCrash(): Promise { - const maxRetries = TIMEOUTS.CDP_CONNECT_MAX_RETRIES + const maxRetries = CDP_LIMITS.CONNECT_MAX_RETRIES const retryDelay = TIMEOUTS.CDP_CONNECT_RETRY_DELAY for (let attempt = 1; attempt <= maxRetries; attempt++) { try { logger.info(`CDP reconnection attempt ${attempt}/${maxRetries}...`) - await sleep(retryDelay) + await Bun.sleep(retryDelay) await this.attemptConnect() logger.info('CDP reconnected successfully') return diff --git a/packages/shared/src/constants/limits.ts b/packages/shared/src/constants/limits.ts index 2d0aae5e..c3cbd771 100644 --- a/packages/shared/src/constants/limits.ts +++ b/packages/shared/src/constants/limits.ts @@ -25,6 +25,10 @@ export const PAGINATION = { DEFAULT_PAGE_SIZE: 20, } as const +export const CDP_LIMITS = { + CONNECT_MAX_RETRIES: 3, +} as const + export const CONTENT_LIMITS = { BODY_CONTEXT_SIZE: 10_000, MAX_QUEUE_SIZE: 1_000, diff --git a/packages/shared/src/constants/timeouts.ts b/packages/shared/src/constants/timeouts.ts index cf719217..3d16060f 100644 --- a/packages/shared/src/constants/timeouts.ts +++ b/packages/shared/src/constants/timeouts.ts @@ -22,7 +22,6 @@ export const TIMEOUTS = { // CDP connection CDP_CONNECT: 10_000, CDP_CONNECT_RETRY_DELAY: 1_000, - CDP_CONNECT_MAX_RETRIES: 3, // External API calls KLAVIS_FETCH: 30_000, From 5861e0a4d68ed98a872a07b48ec167f9f3da1acf Mon Sep 17 00:00:00 2001 From: Nikhil Sonti Date: Mon, 23 Feb 2026 15:16:46 -0800 Subject: [PATCH 3/3] fix: review comments --- apps/server/src/browser/backends/cdp.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/server/src/browser/backends/cdp.ts b/apps/server/src/browser/backends/cdp.ts index 92a8ea96..54187d62 100644 --- a/apps/server/src/browser/backends/cdp.ts +++ b/apps/server/src/browser/backends/cdp.ts @@ -25,6 +25,7 @@ class CdpBackend implements ICdpBackend { private pending = new Map() private connected = false private disconnecting = false + private reconnecting = false private eventHandlers = new Map void)[]>() private sessionCache = new Map() @@ -97,12 +98,25 @@ class CdpBackend implements ICdpBackend { } private handleUnexpectedClose(): void { - if (this.disconnecting) return + if (this.disconnecting || this.reconnecting) return + + this.rejectPendingRequests() logger.error( 'CDP WebSocket closed unexpectedly, attempting reconnection...', ) - this.reconnectOrCrash() + this.reconnecting = true + this.reconnectOrCrash().finally(() => { + this.reconnecting = false + }) + } + + private rejectPendingRequests(): void { + const error = new Error('CDP connection lost') + for (const request of this.pending.values()) { + request.reject(error) + } + this.pending.clear() } private async reconnectOrCrash(): Promise {