From 57da22801dbae67c619b056ec2cbea78a1fbf333 Mon Sep 17 00:00:00 2001 From: Wing Huang Date: Thu, 19 Mar 2026 22:30:28 +0800 Subject: [PATCH] fix(extension): suppress ERR_CONNECTION_REFUSED noise and harden WebSocket lifecycle Problems: - When the opencli daemon is not running, the extension's WebSocket reconnect loop produces noisy ERR_CONNECTION_REFUSED console errors that cannot be caught or suppressed via try/catch (Chrome limitation). - WebSocket constructor could throw during Service Worker teardown. - Race conditions between connect() calls from multiple alarm/event triggers could create duplicate sockets. - ws.close() in onerror handler could throw if SW context is gone. Fixes: - Probe daemon reachability via fetch(HEAD) before opening WebSocket. A failed fetch is silent; only connect WebSocket when daemon is up. - Add 'connecting' guard flag to prevent concurrent connect() calls. - Check WebSocket CLOSING state to avoid creating new socket while old one is still shutting down. - Capture socket in local variable to avoid stale reference races. - Wrap socket.close() in try/catch in onerror handler. - Only send responses if the socket is still the active one and OPEN. --- extension/dist/background.js | 60 ++++++++++++++++++++++----- extension/src/background.ts | 79 ++++++++++++++++++++++++++++++------ 2 files changed, 117 insertions(+), 22 deletions(-) diff --git a/extension/dist/background.js b/extension/dist/background.js index 79fee83..2761ba6 100644 --- a/extension/dist/background.js +++ b/extension/dist/background.js @@ -1,6 +1,7 @@ const DAEMON_PORT = 19825; const DAEMON_HOST = "localhost"; const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; +const DAEMON_HTTP_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`; const WS_RECONNECT_BASE_DELAY = 2e3; const WS_RECONNECT_MAX_DELAY = 6e4; @@ -94,6 +95,7 @@ function registerListeners() { let ws = null; let reconnectTimer = null; let reconnectAttempts = 0; +let connecting = false; const _origLog = console.log.bind(console); const _origWarn = console.warn.bind(console); const _origError = console.error.bind(console); @@ -117,15 +119,48 @@ console.error = (...args) => { _origError(...args); forwardLog("error", args); }; -function connect() { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; +async function isDaemonReachable() { try { - ws = new WebSocket(DAEMON_WS_URL); + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 2e3); + await fetch(DAEMON_HTTP_URL, { method: "HEAD", signal: ctrl.signal }); + clearTimeout(timer); + return true; } catch { + return false; + } +} +function connect() { + if (connecting) return; + if (ws) { + const state = ws.readyState; + if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) return; + if (state === WebSocket.CLOSING) return; + } + connecting = true; + isDaemonReachable().then((reachable) => { + if (!reachable) { + connecting = false; + ws = null; + scheduleReconnect(); + return; + } + openWebSocket(); + }); +} +function openWebSocket() { + let socket; + try { + socket = new WebSocket(DAEMON_WS_URL); + } catch (err) { + connecting = false; + ws = null; scheduleReconnect(); return; } - ws.onopen = () => { + ws = socket; + connecting = false; + socket.onopen = () => { console.log("[opencli] Connected to daemon"); reconnectAttempts = 0; if (reconnectTimer) { @@ -133,22 +168,27 @@ function connect() { reconnectTimer = null; } }; - ws.onmessage = async (event) => { + socket.onmessage = async (event) => { try { const command = JSON.parse(event.data); const result = await handleCommand(command); - ws?.send(JSON.stringify(result)); + if (ws === socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(result)); + } } catch (err) { console.error("[opencli] Message handling error:", err); } }; - ws.onclose = () => { + socket.onclose = () => { console.log("[opencli] Disconnected from daemon"); - ws = null; + if (ws === socket) ws = null; scheduleReconnect(); }; - ws.onerror = () => { - ws?.close(); + socket.onerror = () => { + try { + socket.close(); + } catch { + } }; } function scheduleReconnect() { diff --git a/extension/src/background.ts b/extension/src/background.ts index 86577a2..8511aa7 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -6,12 +6,13 @@ */ import type { Command, Result } from './protocol'; -import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; +import { DAEMON_WS_URL, DAEMON_HTTP_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; import * as cdp from './cdp'; let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; let reconnectAttempts = 0; +let connecting = false; // ─── Console log forwarding ────────────────────────────────────────── // Hook console.log/warn/error to forward logs to daemon via WebSocket. @@ -34,43 +35,97 @@ console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error // ─── WebSocket connection ──────────────────────────────────────────── +/** + * Probe whether the daemon is reachable before opening a WebSocket. + * This avoids the noisy ERR_CONNECTION_REFUSED console error that Chrome + * prints for failed WebSocket connections (which cannot be suppressed). + */ +async function isDaemonReachable(): Promise { + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 2000); + // A simple fetch to the daemon HTTP port — any response (even 404) means it's up. + await fetch(DAEMON_HTTP_URL, { method: 'HEAD', signal: ctrl.signal }); + clearTimeout(timer); + return true; + } catch { + return false; + } +} + function connect(): void { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + // Guard against all in-progress or active states + if (connecting) return; + if (ws) { + const state = ws.readyState; + if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) return; + // CLOSING state — wait for onclose to fire, which will schedule reconnect + if (state === WebSocket.CLOSING) return; + } + + connecting = true; + // Probe first, then connect — avoids ERR_CONNECTION_REFUSED noise + isDaemonReachable().then((reachable) => { + if (!reachable) { + connecting = false; + ws = null; + scheduleReconnect(); + return; + } + openWebSocket(); + }); +} + +function openWebSocket(): void { + let socket: WebSocket; try { - ws = new WebSocket(DAEMON_WS_URL); - } catch { + socket = new WebSocket(DAEMON_WS_URL); + } catch (err) { + // WebSocket constructor can throw if the Service Worker is being + // terminated or the URL is unreachable at construction time. + connecting = false; + ws = null; scheduleReconnect(); return; } + ws = socket; + connecting = false; - ws.onopen = () => { + socket.onopen = () => { console.log('[opencli] Connected to daemon'); - reconnectAttempts = 0; // Reset on successful connection + reconnectAttempts = 0; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } }; - ws.onmessage = async (event) => { + socket.onmessage = async (event) => { try { const command = JSON.parse(event.data as string) as Command; const result = await handleCommand(command); - ws?.send(JSON.stringify(result)); + // Only send if this socket is still the active one and open + if (ws === socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(result)); + } } catch (err) { console.error('[opencli] Message handling error:', err); } }; - ws.onclose = () => { + socket.onclose = () => { console.log('[opencli] Disconnected from daemon'); - ws = null; + // Only clear if this is still the active socket (avoid race with a newer connect()) + if (ws === socket) ws = null; scheduleReconnect(); }; - ws.onerror = () => { - ws?.close(); + socket.onerror = () => { + // onerror is always followed by onclose, so just make sure the socket + // is closed cleanly. Wrap in try/catch since close() can throw if the + // Service Worker context is being torn down. + try { socket.close(); } catch { /* ignored */ } }; }