diff options
| author | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-04-30 13:09:27 +0530 |
|---|---|---|
| committer | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-04-30 13:09:27 +0530 |
| commit | b6d752222947229112132ac0fbd87f355fc78974 (patch) | |
| tree | c3c9424f25b5b87945079dc653ec72b0b510022d /src | |
| parent | 63b16957587fe20566129ffc5191f66f24a70872 (diff) | |
fix: stop Chrome remote-debugging consent popup storm
Previously a single consent-pending socket could spawn up to 5 stacked
'Allow remote debugging?' dialogs. Three causes:
- onopen reset _reconnectAttempts, but Chrome accepts the WS upgrade
before the user clicks Allow, so a held socket looked healthy.
- ensureAlive's health probe used the full 15s CDP timeout; while the
user was reading the dialog the probe would time out, trigger a
stop()+start(), and a fresh popup would stack on top.
- onclose auto-reconnected unconditionally even when consent had never
been granted on that connection.
Now a connection is only considered healthy once the first CDP
roundtrip completes (attachFirstPage). ensureAlive does a 2s short
probe before tearing down the socket. onclose bails out of auto-
reconnect when consent was never observed, so a denied dialog
surfaces a clean error instead of looping.
Diffstat (limited to 'src')
| -rw-r--r-- | src/daemon.ts | 49 |
1 files changed, 43 insertions, 6 deletions
diff --git a/src/daemon.ts b/src/daemon.ts index 7dad1a6..79c363a 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -157,6 +157,7 @@ export class BrowserDaemon { private _connectTimer: ReturnType<typeof setTimeout> | null = null; private _reconnectAttempts = 0; private _stopping = false; + private _consentObserved = false; private remote: RemoteConfig | null = null; constructor(namespace = "default", remote?: RemoteConfig) { @@ -244,6 +245,7 @@ export class BrowserDaemon { this._targetId = null; this._currentDialog = null; this._eventBuffer = []; + this._consentObserved = false; } /** @@ -253,12 +255,23 @@ export class BrowserDaemon { async ensureAlive(): Promise<void> { const isOpen = this.ws?.readyState === WebSocket.OPEN; if (isOpen && this._sessionId) { - // Quick health check with a lightweight CDP call + // Two-stage health check. The first probe uses a short timeout so we + // don't spend 15s waiting on a ws that's been silenced by Chrome's + // consent dialog. A short-timeout failure is not proof the ws is + // dead — give it one more chance with the full timeout before + // tearing down. Tearing down here is what causes the popup storm: + // every reconnect re-prompts the user for remote-debugging consent. try { - await this.cdp("Target.getTargets", {}); + await this.sendRawCdp("Target.getTargets", {}, null, 2_000); return; } catch { - // Connection is stale — reconnect + // probe 1 failed — could be transient, could be consent-pending + } + try { + await this.sendRawCdp("Target.getTargets", {}, null); + return; + } catch { + // genuinely stale — fall through to reconnect } } @@ -489,7 +502,11 @@ export class BrowserDaemon { } this._connectResolve = null; this._connectReject = null; - this._reconnectAttempts = 0; + // NOTE: do not clear _reconnectAttempts here. Chrome accepts the + // WS upgrade before the user clicks Allow on the remote-debugging + // consent dialog, so a successful onopen does not prove the + // connection is usable. We only consider the connection healthy + // once a CDP roundtrip completes — see attachFirstPage. resolve(); }; @@ -515,9 +532,21 @@ export class BrowserDaemon { // If we're shutting down intentionally, don't reconnect if (this._stopping) return; + // If we never saw a successful CDP roundtrip on this connection, + // the close almost certainly means the user denied (or hasn't yet + // accepted) Chrome's "Allow remote debugging?" prompt. Reconnecting + // would just spawn another popup. Bail out and let the next tool + // call surface a clear error. + if (!this._consentObserved) { + this._sessionId = null; + return; + } + // Auto-reconnect with backoff if (this._reconnectAttempts < CDP_MAX_RECONNECT_ATTEMPTS) { this._sessionId = null; + // Reset for the new connection — it must re-prove itself. + this._consentObserved = false; const delay = Math.min( CDP_RECONNECT_BASE_MS * 2 ** this._reconnectAttempts, 15_000, @@ -602,6 +631,7 @@ export class BrowserDaemon { method: string, params: Record<string, unknown>, sessionId: string | null, + timeoutMs: number = CDP_REQUEST_TIMEOUT_MS, ): Promise<unknown> { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return Promise.reject(new Error("Browser not connected. Is Chrome running?")); @@ -615,8 +645,8 @@ export class BrowserDaemon { return new Promise((resolve, reject) => { const timer = setTimeout(() => { this._pending.delete(id); - reject(new Error(`CDP timeout after ${CDP_REQUEST_TIMEOUT_MS}ms: ${method}`)); - }, CDP_REQUEST_TIMEOUT_MS); + reject(new Error(`CDP timeout after ${timeoutMs}ms: ${method}`)); + }, timeoutMs); this._pending.set(id, { resolve, reject, timer, method, sessionId }); try { @@ -635,6 +665,13 @@ export class BrowserDaemon { targetInfos: Array<{ targetId: string; type: string; url: string }>; }; + // First CDP roundtrip succeeded — consent has been granted. Only now + // is it safe to reset the reconnect counter; otherwise a ws that opens + // but is silently held by Chrome's consent dialog would let us loop + // forever stacking popups on top of each other. + this._consentObserved = true; + this._reconnectAttempts = 0; + const pages = result.targetInfos.filter( (t) => t.type === "page" && !isInternalUrl(t.url), ); |
