From 8818d61f69a78b269022df34e345222dd0f365bb Mon Sep 17 00:00:00 2001 From: Sean Sun <1194458432@qq.com> Date: Sun, 29 Mar 2026 07:01:53 +0800 Subject: [PATCH 1/4] fix(extension): use offscreen document for persistent WebSocket connection Chrome MV3 Service Workers are suspended by the browser when idle, causing the WebSocket connection to the daemon to drop silently. The existing keepalive alarm re-connects after wake, but there is a window where commands fail and the CLI reports 'not connected'. Fix: move the WebSocket into a chrome.offscreen document whose lifetime is tied to the browser window, not the Service Worker. The SW becomes a thin message-relay layer; the offscreen document owns the connection. Changes: - extension/src/offscreen.ts (new): WebSocket host with auto-reconnect - extension/offscreen.html (new): offscreen document entry point - extension/src/background.ts: delegate WS ops to offscreen via messages - extension/manifest.json: add 'offscreen' permission - extension/vite.config.ts: add offscreen as a build entry Tested: extension stays connected after >2h with Chrome idle / SW suspended on Linux. --- extension/dist/offscreen.js | 100 ++++++++++++++ extension/manifest.json | 3 +- extension/offscreen.html | 4 + extension/src/background.ts | 259 +++++++++++++++--------------------- extension/src/offscreen.ts | 142 ++++++++++++++++++++ extension/vite.config.ts | 7 +- 6 files changed, 357 insertions(+), 158 deletions(-) create mode 100644 extension/dist/offscreen.js create mode 100644 extension/offscreen.html create mode 100644 extension/src/offscreen.ts diff --git a/extension/dist/offscreen.js b/extension/dist/offscreen.js new file mode 100644 index 00000000..16be666b --- /dev/null +++ b/extension/dist/offscreen.js @@ -0,0 +1,100 @@ +const DAEMON_PORT = 19825; +const DAEMON_HOST = "localhost"; +const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; +const WS_RECONNECT_BASE_DELAY = 2e3; +const WS_RECONNECT_MAX_DELAY = 6e4; + +let ws = null; +let reconnectTimer = null; +let reconnectAttempts = 0; +const MAX_EAGER_ATTEMPTS = 6; +function sendLog(level, msg) { + chrome.runtime.sendMessage({ type: "log", level, msg, ts: Date.now() }).catch(() => { + }); +} +const _origLog = console.log.bind(console); +const _origWarn = console.warn.bind(console); +const _origError = console.error.bind(console); +console.log = (...args) => { + _origLog(...args); + sendLog("info", args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")); +}; +console.warn = (...args) => { + _origWarn(...args); + sendLog("warn", args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")); +}; +console.error = (...args) => { + _origError(...args); + sendLog("error", args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")); +}; +async function connect() { + if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + try { + ws = new WebSocket(DAEMON_WS_URL); + } catch { + scheduleReconnect(); + return; + } + ws.onopen = () => { + console.log("[opencli/offscreen] Connected to daemon"); + reconnectAttempts = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + ws?.send(JSON.stringify({ type: "hello", version: "__offscreen__" })); + chrome.runtime.sendMessage({ type: "ws-connected" }).catch(() => { + }); + }; + ws.onmessage = (event) => { + chrome.runtime.sendMessage({ type: "ws-message", data: event.data }).catch(() => { + }); + }; + ws.onclose = () => { + console.log("[opencli/offscreen] Disconnected from daemon"); + ws = null; + scheduleReconnect(); + }; + ws.onerror = () => { + ws?.close(); + }; +} +function scheduleReconnect() { + if (reconnectTimer) return; + reconnectAttempts++; + if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; + const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + void connect(); + }, delay); +} +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type === "ws-connect") { + reconnectTimer = null; + reconnectAttempts = 0; + void connect(); + sendResponse({ ok: true }); + return false; + } + if (msg?.type === "ws-send") { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(msg.payload); + sendResponse({ ok: true }); + } else { + sendResponse({ ok: false, error: "WebSocket not open" }); + } + return false; + } + if (msg?.type === "ws-status") { + sendResponse({ + type: "ws-status-reply", + connected: ws?.readyState === WebSocket.OPEN, + reconnecting: reconnectTimer !== null + }); + return false; + } + return false; +}); +void connect(); +console.log("[opencli/offscreen] Offscreen document ready"); diff --git a/extension/manifest.json b/extension/manifest.json index f4feaa49..45f9fc50 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -9,7 +9,8 @@ "tabs", "cookies", "activeTab", - "alarms" + "alarms", + "offscreen" ], "host_permissions": [ "" diff --git a/extension/offscreen.html b/extension/offscreen.html new file mode 100644 index 00000000..1bfbab9a --- /dev/null +++ b/extension/offscreen.html @@ -0,0 +1,4 @@ + +OpenCLI Offscreen + + diff --git a/extension/src/background.ts b/extension/src/background.ts index 36e3861b..1b3ecf68 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -3,115 +3,84 @@ * * Connects to the opencli daemon via WebSocket, receives commands, * dispatches them to Chrome APIs (debugger/tabs/cookies), returns results. + * + * WebSocket lives in an Offscreen document (offscreen.ts) so it is never + * killed when the Service Worker is suspended by Chrome MV3. The SW only + * forwards messages to/from the offscreen document. */ import type { Command, Result } from './protocol'; -import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; import * as executor from './cdp'; -let ws: WebSocket | null = null; -let reconnectTimer: ReturnType | null = null; -let reconnectAttempts = 0; +// ─── Offscreen document management ────────────────────────────────── -// ─── Console log forwarding ────────────────────────────────────────── -// Hook console.log/warn/error to forward logs to daemon via WebSocket. +const OFFSCREEN_URL = chrome.runtime.getURL('offscreen.html'); -const _origLog = console.log.bind(console); -const _origWarn = console.warn.bind(console); -const _origError = console.error.bind(console); +async function ensureOffscreen(): Promise { + // @ts-ignore — chrome.offscreen is typed in newer @types/chrome but may not + // be present in older versions; we guard with existence check at runtime. + if (!chrome.offscreen) return; // unsupported Chrome version, fall back silently + const existing = await (chrome as any).offscreen.hasDocument(); + if (!existing) { + await (chrome as any).offscreen.createDocument({ + url: OFFSCREEN_URL, + reasons: ['DOM_SCRAPING'], + justification: 'Maintain persistent WebSocket connection to opencli daemon', + }); + } +} -function forwardLog(level: 'info' | 'warn' | 'error', args: unknown[]): void { - if (!ws || ws.readyState !== WebSocket.OPEN) return; +/** Tell the offscreen doc to (re-)connect. */ +async function offscreenConnect(): Promise { + await ensureOffscreen(); try { - const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '); - ws.send(JSON.stringify({ type: 'log', level, msg, ts: Date.now() })); - } catch { /* don't recurse */ } + await chrome.runtime.sendMessage({ type: 'ws-connect' }); + } catch { + // offscreen not ready yet — it will auto-connect on boot anyway + } } -console.log = (...args: unknown[]) => { _origLog(...args); forwardLog('info', args); }; -console.warn = (...args: unknown[]) => { _origWarn(...args); forwardLog('warn', args); }; -console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error', args); }; - -// ─── WebSocket connection ──────────────────────────────────────────── - -/** - * Probe the daemon via its /ping HTTP endpoint before attempting a WebSocket - * connection. fetch() failures are silently catchable; new WebSocket() is not - * — Chrome logs ERR_CONNECTION_REFUSED to the extension error page before any - * JS handler can intercept it. By keeping the probe inside connect() every - * call site remains unchanged and the guard can never be accidentally skipped. - */ -async function connect(): Promise { - if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; - +/** Send a serialised result/hello string over the WebSocket. */ +async function wsSend(payload: string): Promise { try { - const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) }); - if (!res.ok) return; // unexpected response — not our daemon + const resp = await chrome.runtime.sendMessage({ type: 'ws-send', payload }) as { ok: boolean }; + if (!resp?.ok) { + // Offscreen WS is down — trigger reconnect + void offscreenConnect(); + } } catch { - return; // daemon not running — skip WebSocket to avoid console noise + void offscreenConnect(); } +} +/** Query live connection status from offscreen. */ +async function wsStatus(): Promise<{ connected: boolean; reconnecting: boolean }> { try { - ws = new WebSocket(DAEMON_WS_URL); + const resp = await chrome.runtime.sendMessage({ type: 'ws-status' }) as any; + return { connected: resp?.connected ?? false, reconnecting: resp?.reconnecting ?? false }; } catch { - scheduleReconnect(); - return; + return { connected: false, reconnecting: false }; } +} - ws.onopen = () => { - console.log('[opencli] Connected to daemon'); - reconnectAttempts = 0; // Reset on successful connection - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - // Send version so the daemon can report mismatches to the CLI - ws?.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); - }; - - ws.onmessage = async (event) => { - try { - const command = JSON.parse(event.data as string) as Command; - const result = await handleCommand(command); - ws?.send(JSON.stringify(result)); - } catch (err) { - console.error('[opencli] Message handling error:', err); - } - }; +// ─── Console log forwarding ────────────────────────────────────────── +// Logs from offscreen arrive as { type:'log', level, msg, ts } messages. +// SW-side logs are forwarded directly via wsSend. - ws.onclose = () => { - console.log('[opencli] Disconnected from daemon'); - ws = null; - scheduleReconnect(); - }; +const _origLog = console.log.bind(console); +const _origWarn = console.warn.bind(console); +const _origError = console.error.bind(console); - ws.onerror = () => { - ws?.close(); - }; +function forwardLog(level: 'info' | 'warn' | 'error', args: unknown[]): void { + const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' '); + void wsSend(JSON.stringify({ type: 'log', level, msg, ts: Date.now() })); } -/** - * After MAX_EAGER_ATTEMPTS (reaching 60s backoff), stop scheduling reconnects. - * The keepalive alarm (~24s) will still call connect() periodically, but at a - * much lower frequency — reducing console noise when the daemon is not running. - */ -const MAX_EAGER_ATTEMPTS = 6; // 2s, 4s, 8s, 16s, 32s, 60s — then stop - -function scheduleReconnect(): void { - if (reconnectTimer) return; - reconnectAttempts++; - if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; // let keepalive alarm handle it - const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - void connect(); - }, delay); -} +console.log = (...args: unknown[]) => { _origLog(...args); forwardLog('info', args); }; +console.warn = (...args: unknown[]) => { _origWarn(...args); forwardLog('warn', args); }; +console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error', args); }; // ─── Automation window isolation ───────────────────────────────────── -// All opencli operations happen in a dedicated Chrome window so the -// user's active browsing session is never touched. -// The window auto-closes after 120s of idle (no commands). type AutomationSession = { windowId: number; @@ -120,7 +89,7 @@ type AutomationSession = { }; const automationSessions = new Map(); -const WINDOW_IDLE_TIMEOUT = 30000; // 30s — quick cleanup after command finishes +const WINDOW_IDLE_TIMEOUT = 30000; function getWorkspaceKey(workspace?: string): string { return workspace?.trim() || 'default'; @@ -137,31 +106,22 @@ function resetWindowIdleTimer(workspace: string): void { try { await chrome.windows.remove(current.windowId); console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`); - } catch { - // Already gone - } + } catch { /* Already gone */ } automationSessions.delete(workspace); }, WINDOW_IDLE_TIMEOUT); } -/** Get or create the dedicated automation window. */ async function getAutomationWindow(workspace: string): Promise { - // Check if our window is still alive const existing = automationSessions.get(workspace); if (existing) { try { await chrome.windows.get(existing.windowId); return existing.windowId; } catch { - // Window was closed by user automationSessions.delete(workspace); } } - // Create a new window with a data: URI that New Tab Override extensions cannot intercept. - // Using about:blank would be hijacked by extensions like "New Tab Override". - // Note: Do NOT set `state` parameter here. Chrome 146+ rejects 'normal' as an invalid - // state value for windows.create(). The window defaults to 'normal' state anyway. const win = await chrome.windows.create({ url: BLANK_PAGE, focused: false, @@ -177,12 +137,10 @@ async function getAutomationWindow(workspace: string): Promise { automationSessions.set(workspace, session); console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`); resetWindowIdleTimer(workspace); - // Brief delay to let Chrome load the initial data: URI tab await new Promise(resolve => setTimeout(resolve, 200)); return session.windowId; } -// Clean up when the automation window is closed chrome.windows.onRemoved.addListener((windowId) => { for (const [workspace, session] of automationSessions.entries()) { if (session.windowId === windowId) { @@ -200,9 +158,9 @@ let initialized = false; function initialize(): void { if (initialized) return; initialized = true; - chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds + chrome.alarms.create('keepalive', { periodInMinutes: 0.25 }); // ~15 seconds — faster recovery after SW suspend executor.registerListeners(); - void connect(); + void offscreenConnect(); console.log('[opencli] OpenCLI extension initialized'); } @@ -215,26 +173,54 @@ chrome.runtime.onStartup.addListener(() => { }); chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === 'keepalive') void connect(); + if (alarm.name === 'keepalive') { + // Ensure offscreen doc is alive and WS is connected after any SW suspend/resume. + void offscreenConnect(); + } }); -// ─── Popup status API ─────────────────────────────────────────────── +// ─── Message router ────────────────────────────────────────────────── chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + // ── Popup status query ── if (msg?.type === 'getStatus') { - sendResponse({ - connected: ws?.readyState === WebSocket.OPEN, - reconnecting: reconnectTimer !== null, - }); + wsStatus().then(s => sendResponse(s)); + return true; // async } + + // ── Incoming WS frame from offscreen ── + if (msg?.type === 'ws-message') { + void (async () => { + try { + const command = JSON.parse(msg.data as string) as Command; + const result = await handleCommand(command); + await wsSend(JSON.stringify(result)); + } catch (err) { + console.error('[opencli] Message handling error:', err); + } + })(); + return false; + } + + // ── WS connected — send hello with real extension version ── + if (msg?.type === 'ws-connected') { + void wsSend(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); + return false; + } + + // ── Log forwarding from offscreen (pass through to WS) ── + if (msg?.type === 'log') { + void wsSend(JSON.stringify(msg)); + return false; + } + return false; }); -// ─── Command dispatcher ───────────────────────────────────────────── +// ─── Command dispatcher ────────────────────────────────────────────── async function handleCommand(cmd: Command): Promise { const workspace = getWorkspaceKey(cmd.workspace); - // Reset idle timer on every command (window stays alive while active) resetWindowIdleTimer(workspace); try { switch (cmd.action) { @@ -279,12 +265,10 @@ function isDebuggableUrl(url?: string): boolean { return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:'); } -/** Check if a URL is safe for user-facing navigation (http/https only). */ function isSafeNavigationUrl(url: string): boolean { return url.startsWith('http://') || url.startsWith('https://'); } -/** Minimal URL normalization for same-page comparison: root slash + default port only. */ function normalizeUrlForComparison(url?: string): string { if (!url) return ''; try { @@ -319,9 +303,6 @@ function setWorkspaceSession(workspace: string, session: Pick { - // Even when an explicit tabId is provided, validate it is still debuggable. - // This prevents issues when extensions hijack the tab URL to chrome-extension:// - // or when the tab has been closed by the user. if (tabId !== undefined) { try { const tab = await chrome.tabs.get(tabId); @@ -331,25 +312,18 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi if (session && !matchesSession) { console.warn(`[opencli] Tab ${tabId} is not bound to workspace ${workspace}, re-resolving`); } else if (!isDebuggableUrl(tab.url)) { - // Tab exists but URL is not debuggable — fall through to auto-resolve console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); } } catch { - // Tab was closed — fall through to auto-resolve console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); } } - // Get (or create) the automation window const windowId = await getAutomationWindow(workspace); - - // Prefer an existing debuggable tab const tabs = await chrome.tabs.query({ windowId }); const debuggableTab = tabs.find(t => t.id && isDebuggableUrl(t.url)); if (debuggableTab?.id) return debuggableTab.id; - // No debuggable tab — another extension may have hijacked the tab URL. - // Try to reuse by navigating to a data: URI (not interceptable by New Tab Override). const reuseTab = tabs.find(t => t.id); if (reuseTab?.id) { await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); @@ -358,12 +332,9 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi const updated = await chrome.tabs.get(reuseTab.id); if (isDebuggableUrl(updated.url)) return reuseTab.id; console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); - } catch { - // Tab was closed during navigation - } + } catch { /* Tab was closed */ } } - // Fallback: create a new tab const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true }); if (!newTab.id) throw new Error('Failed to create tab in automation window'); return newTab.id; @@ -408,7 +379,6 @@ async function handleNavigate(cmd: Command, workspace: string): Promise const beforeNormalized = normalizeUrlForComparison(beforeTab.url); const targetUrl = cmd.url; - // Fast-path: tab is already at the target URL and fully loaded. if (beforeTab.status === 'complete' && isTargetUrl(beforeTab.url, targetUrl)) { return { id: cmd.id, @@ -417,19 +387,9 @@ async function handleNavigate(cmd: Command, workspace: string): Promise }; } - // Detach any existing debugger before top-level navigation. - // Some sites (observed on creator.xiaohongshu.com flows) can invalidate the - // current inspected target during navigation, which leaves a stale CDP attach - // state and causes the next Runtime.evaluate to fail with - // "Inspected target navigated or closed". Resetting here forces a clean - // re-attach after navigation. await executor.detach(tabId); - await chrome.tabs.update(tabId, { url: targetUrl }); - // Wait until navigation completes. Resolve when status is 'complete' AND either: - // - the URL matches the target (handles same-URL / canonicalized navigations), OR - // - the URL differs from the pre-navigation URL (handles redirects). let timedOut = false; await new Promise((resolve) => { let settled = false; @@ -451,23 +411,17 @@ async function handleNavigate(cmd: Command, workspace: string): Promise const listener = (id: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => { if (id !== tabId) return; - if (info.status === 'complete' && isNavigationDone(tab.url ?? info.url)) { - finish(); - } + if (info.status === 'complete' && isNavigationDone(tab.url ?? info.url)) finish(); }; chrome.tabs.onUpdated.addListener(listener); - // Also check if the tab already navigated (e.g. instant cache hit) checkTimer = setTimeout(async () => { try { const currentTab = await chrome.tabs.get(tabId); - if (currentTab.status === 'complete' && isNavigationDone(currentTab.url)) { - finish(); - } + if (currentTab.status === 'complete' && isNavigationDone(currentTab.url)) finish(); } catch { /* tab gone */ } }, 100); - // Timeout fallback with warning timeoutTimer = setTimeout(() => { timedOut = true; console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`); @@ -487,14 +441,13 @@ async function handleTabs(cmd: Command, workspace: string): Promise { switch (cmd.op) { case 'list': { const tabs = await listAutomationWebTabs(workspace); - const data = tabs - .map((t, i) => ({ - index: i, - tabId: t.id, - url: t.url, - title: t.title, - active: t.active, - })); + const data = tabs.map((t, i) => ({ + index: i, + tabId: t.id, + url: t.url, + title: t.title, + active: t.active, + })); return { id: cmd.id, ok: true, data }; } case 'new': { @@ -628,11 +581,7 @@ async function handleCdp(cmd: Command, workspace: string): Promise { async function handleCloseWindow(cmd: Command, workspace: string): Promise { const session = automationSessions.get(workspace); if (session) { - try { - await chrome.windows.remove(session.windowId); - } catch { - // Window may already be closed - } + try { await chrome.windows.remove(session.windowId); } catch { /* already closed */ } if (session.idleTimer) clearTimeout(session.idleTimer); automationSessions.delete(workspace); } diff --git a/extension/src/offscreen.ts b/extension/src/offscreen.ts new file mode 100644 index 00000000..affa1eee --- /dev/null +++ b/extension/src/offscreen.ts @@ -0,0 +1,142 @@ +/** + * OpenCLI — Offscreen Document (WebSocket host). + * + * Lives in an Offscreen document which Chrome never suspends, so the + * WebSocket connection survives across Service Worker sleep/wake cycles. + * + * Message protocol with background.ts: + * + * background → offscreen: + * { type: 'ws-connect' } — (re-)establish WS connection + * { type: 'ws-send', payload: str} — send a raw string over WS + * { type: 'ws-status' } — query connection state + * + * offscreen → background: + * { type: 'ws-message', data: str } — incoming WS frame + * { type: 'ws-status-reply', connected: bool, reconnecting: bool } + * { type: 'log', level, msg, ts } — forward console output + */ + +import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; + +let ws: WebSocket | null = null; +let reconnectTimer: ReturnType | null = null; +let reconnectAttempts = 0; + +const MAX_EAGER_ATTEMPTS = 6; + +// ─── Logging ───────────────────────────────────────────────────────── + +function sendLog(level: 'info' | 'warn' | 'error', msg: string): void { + chrome.runtime.sendMessage({ type: 'log', level, msg, ts: Date.now() }).catch(() => {/* SW may be asleep */}); +} + +const _origLog = console.log.bind(console); +const _origWarn = console.warn.bind(console); +const _origError = console.error.bind(console); + +console.log = (...args: unknown[]) => { + _origLog(...args); + sendLog('info', args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')); +}; +console.warn = (...args: unknown[]) => { + _origWarn(...args); + sendLog('warn', args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')); +}; +console.error = (...args: unknown[]) => { + _origError(...args); + sendLog('error', args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')); +}; + +// ─── WebSocket ─────────────────────────────────────────────────────── + +async function connect(): Promise { + if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + + // Skip HTTP ping — fetch to localhost is blocked in offscreen context. + // WebSocket onerror will handle daemon-not-running gracefully. + try { + ws = new WebSocket(DAEMON_WS_URL); + } catch { + scheduleReconnect(); + return; + } + + ws.onopen = () => { + console.log('[opencli/offscreen] Connected to daemon'); + reconnectAttempts = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + // Send hello — version comes from background, use a static marker here + ws?.send(JSON.stringify({ type: 'hello', version: '__offscreen__' })); + // Tell background we're up so it can send a proper hello with the real version + chrome.runtime.sendMessage({ type: 'ws-connected' }).catch(() => {}); + }; + + ws.onmessage = (event) => { + chrome.runtime.sendMessage({ type: 'ws-message', data: event.data as string }).catch(() => { + // SW may be sleeping — it will wake via alarm and re-check + }); + }; + + ws.onclose = () => { + console.log('[opencli/offscreen] Disconnected from daemon'); + ws = null; + scheduleReconnect(); + }; + + ws.onerror = () => { + ws?.close(); + }; +} + +function scheduleReconnect(): void { + if (reconnectTimer) return; + reconnectAttempts++; + if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return; + const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + void connect(); + }, delay); +} + +// ─── Message listener (from background) ───────────────────────────── + +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type === 'ws-connect') { + reconnectTimer = null; + reconnectAttempts = 0; + void connect(); + sendResponse({ ok: true }); + return false; + } + + if (msg?.type === 'ws-send') { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(msg.payload as string); + sendResponse({ ok: true }); + } else { + sendResponse({ ok: false, error: 'WebSocket not open' }); + } + return false; + } + + if (msg?.type === 'ws-status') { + sendResponse({ + type: 'ws-status-reply', + connected: ws?.readyState === WebSocket.OPEN, + reconnecting: reconnectTimer !== null, + }); + return false; + } + + return false; +}); + +// ─── Boot ──────────────────────────────────────────────────────────── + +void connect(); +console.log('[opencli/offscreen] Offscreen document ready'); diff --git a/extension/vite.config.ts b/extension/vite.config.ts index f7cd0ecc..da0855a6 100644 --- a/extension/vite.config.ts +++ b/extension/vite.config.ts @@ -6,9 +6,12 @@ export default defineConfig({ outDir: 'dist', emptyOutDir: true, rollupOptions: { - input: resolve(__dirname, 'src/background.ts'), + input: { + background: resolve(__dirname, 'src/background.ts'), + offscreen: resolve(__dirname, 'src/offscreen.ts'), + }, output: { - entryFileNames: 'background.js', + entryFileNames: '[name].js', format: 'es', }, }, From a84fd66cf9b67bdae4f4b697239a0794516f49aa Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 29 Mar 2026 17:36:09 +0800 Subject: [PATCH 2/4] fix(extension): harden offscreen websocket transport --- extension/dist/assets/protocol-Z52ThYIj.js | 8 ++ extension/dist/offscreen.js | 64 +++++++-- extension/scripts/package-release.mjs | 46 +++++++ extension/src/background.ts | 147 ++++++++++++++++++--- extension/src/offscreen.ts | 72 ++++++++-- extension/tsconfig.json | 3 +- 6 files changed, 301 insertions(+), 39 deletions(-) create mode 100644 extension/dist/assets/protocol-Z52ThYIj.js diff --git a/extension/dist/assets/protocol-Z52ThYIj.js b/extension/dist/assets/protocol-Z52ThYIj.js new file mode 100644 index 00000000..1af50c65 --- /dev/null +++ b/extension/dist/assets/protocol-Z52ThYIj.js @@ -0,0 +1,8 @@ +const DAEMON_PORT = 19825; +const DAEMON_HOST = "localhost"; +const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; +const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`; +const WS_RECONNECT_BASE_DELAY = 2e3; +const WS_RECONNECT_MAX_DELAY = 6e4; + +export { DAEMON_PING_URL as D, WS_RECONNECT_BASE_DELAY as W, DAEMON_WS_URL as a, WS_RECONNECT_MAX_DELAY as b }; diff --git a/extension/dist/offscreen.js b/extension/dist/offscreen.js index 16be666b..8914daa2 100644 --- a/extension/dist/offscreen.js +++ b/extension/dist/offscreen.js @@ -1,13 +1,13 @@ -const DAEMON_PORT = 19825; -const DAEMON_HOST = "localhost"; -const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`; -const WS_RECONNECT_BASE_DELAY = 2e3; -const WS_RECONNECT_MAX_DELAY = 6e4; +import { a as DAEMON_WS_URL, W as WS_RECONNECT_BASE_DELAY, b as WS_RECONNECT_MAX_DELAY } from './assets/protocol-Z52ThYIj.js'; let ws = null; let reconnectTimer = null; let reconnectAttempts = 0; +let pendingFrames = []; +let flushTimer = null; +let flushingFrames = false; const MAX_EAGER_ATTEMPTS = 6; +const FRAME_RETRY_DELAY = 1e3; function sendLog(level, msg) { chrome.runtime.sendMessage({ type: "log", level, msg, ts: Date.now() }).catch(() => { }); @@ -27,8 +27,20 @@ console.error = (...args) => { _origError(...args); sendLog("error", args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")); }; +async function probeDaemon() { + try { + const resp = await chrome.runtime.sendMessage({ type: "ws-probe" }); + return resp?.ok === true; + } catch { + return false; + } +} async function connect() { if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; + if (!await probeDaemon()) { + scheduleReconnect(); + return; + } try { ws = new WebSocket(DAEMON_WS_URL); } catch { @@ -42,13 +54,12 @@ async function connect() { clearTimeout(reconnectTimer); reconnectTimer = null; } - ws?.send(JSON.stringify({ type: "hello", version: "__offscreen__" })); - chrome.runtime.sendMessage({ type: "ws-connected" }).catch(() => { - }); + ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version })); + void flushPendingFrames(); }; ws.onmessage = (event) => { - chrome.runtime.sendMessage({ type: "ws-message", data: event.data }).catch(() => { - }); + pendingFrames.push(event.data); + void flushPendingFrames(); }; ws.onclose = () => { console.log("[opencli/offscreen] Disconnected from daemon"); @@ -59,6 +70,39 @@ async function connect() { ws?.close(); }; } +function scheduleFlush() { + if (flushTimer) return; + flushTimer = setTimeout(() => { + flushTimer = null; + void flushPendingFrames(); + }, FRAME_RETRY_DELAY); +} +async function flushPendingFrames() { + if (flushingFrames || pendingFrames.length === 0) return; + flushingFrames = true; + try { + while (pendingFrames.length > 0) { + let delivered = false; + try { + const resp = await chrome.runtime.sendMessage({ + type: "ws-message", + data: pendingFrames[0] + }); + delivered = resp?.ok === true; + } catch { + delivered = false; + } + if (!delivered) { + scheduleFlush(); + break; + } + pendingFrames.shift(); + } + } finally { + flushingFrames = false; + if (pendingFrames.length > 0 && !flushTimer) scheduleFlush(); + } +} function scheduleReconnect() { if (reconnectTimer) return; reconnectAttempts++; diff --git a/extension/scripts/package-release.mjs b/extension/scripts/package-release.mjs index 1a57dca9..34823676 100644 --- a/extension/scripts/package-release.mjs +++ b/extension/scripts/package-release.mjs @@ -94,9 +94,35 @@ async function collectHtmlDependencies(relativeHtmlPath, files, visited) { } } +async function collectScriptDependencies(relativeScriptPath, files, visited) { + if (visited.has(relativeScriptPath)) return; + visited.add(relativeScriptPath); + + const scriptPath = path.join(extensionDir, relativeScriptPath); + const source = await fs.readFile(scriptPath, 'utf8'); + const importRe = /\bimport\s+(?:[^"'()]+?\s+from\s+)?["']([^"']+)["']|\bimport\(\s*["']([^"']+)["']\s*\)/g; + + for (const match of source.matchAll(importRe)) { + const rawRef = match[1] ?? match[2]; + const cleanRef = rawRef?.split('?')[0]; + if (!isLocalAsset(cleanRef)) continue; + + const resolvedRelativePath = cleanRef.startsWith('/') + ? cleanRef.slice(1) + : path.posix.normalize(path.posix.join(path.posix.dirname(relativeScriptPath), cleanRef)); + + addLocalAsset(files, resolvedRelativePath); + + if (resolvedRelativePath.endsWith('.js') || resolvedRelativePath.endsWith('.mjs')) { + await collectScriptDependencies(resolvedRelativePath, files, visited); + } + } +} + async function collectManifestAssets(manifest) { const files = new Set(collectManifestEntrypoints(manifest)); const htmlPages = []; + const scriptEntries = []; if (manifest.action?.default_popup) { htmlPages.push(manifest.action.default_popup); @@ -114,6 +140,26 @@ async function collectManifestAssets(manifest) { } } + if (manifest.background?.service_worker && isLocalAsset(manifest.background.service_worker)) { + scriptEntries.push(manifest.background.service_worker); + } + for (const contentScript of manifest.content_scripts ?? []) { + for (const jsFile of contentScript.js ?? []) { + if (isLocalAsset(jsFile)) scriptEntries.push(jsFile); + } + } + + for (const file of files) { + if (typeof file === 'string' && (file.endsWith('.js') || file.endsWith('.mjs'))) { + scriptEntries.push(file); + } + } + + const scriptVisited = new Set(); + for (const scriptEntry of new Set(scriptEntries)) { + await collectScriptDependencies(scriptEntry, files, scriptVisited); + } + return [...files]; } diff --git a/extension/src/background.ts b/extension/src/background.ts index 1b3ecf68..5d6b288f 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -10,29 +10,59 @@ */ import type { Command, Result } from './protocol'; +import { DAEMON_PING_URL, DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; import * as executor from './cdp'; // ─── Offscreen document management ────────────────────────────────── const OFFSCREEN_URL = chrome.runtime.getURL('offscreen.html'); +let forceLegacyTransport = false; +let legacyWs: WebSocket | null = null; +let legacyReconnectTimer: ReturnType | null = null; +let legacyReconnectAttempts = 0; +const MAX_EAGER_ATTEMPTS = 6; + +function prefersOffscreenTransport(): boolean { + return !forceLegacyTransport && !!(chrome as any).offscreen; +} + +async function probeDaemon(): Promise { + try { + const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1000) }); + return res.ok; + } catch { + return false; + } +} -async function ensureOffscreen(): Promise { +async function ensureOffscreen(): Promise { // @ts-ignore — chrome.offscreen is typed in newer @types/chrome but may not // be present in older versions; we guard with existence check at runtime. - if (!chrome.offscreen) return; // unsupported Chrome version, fall back silently - const existing = await (chrome as any).offscreen.hasDocument(); - if (!existing) { - await (chrome as any).offscreen.createDocument({ - url: OFFSCREEN_URL, - reasons: ['DOM_SCRAPING'], - justification: 'Maintain persistent WebSocket connection to opencli daemon', - }); + if (!chrome.offscreen) return false; + try { + const existing = await (chrome as any).offscreen.hasDocument(); + if (!existing) { + await (chrome as any).offscreen.createDocument({ + url: OFFSCREEN_URL, + reasons: ['DOM_SCRAPING'], + justification: 'Maintain persistent WebSocket connection to opencli daemon', + }); + } + return true; + } catch (err) { + forceLegacyTransport = true; + console.warn('[opencli] Failed to initialize offscreen transport, falling back to Service Worker transport:', err); + return false; } } /** Tell the offscreen doc to (re-)connect. */ async function offscreenConnect(): Promise { - await ensureOffscreen(); + const ready = await ensureOffscreen(); + if (!ready) { + await legacyConnect(); + return; + } try { await chrome.runtime.sendMessage({ type: 'ws-connect' }); } catch { @@ -40,8 +70,81 @@ async function offscreenConnect(): Promise { } } +async function legacyConnect(): Promise { + if (legacyWs?.readyState === WebSocket.OPEN || legacyWs?.readyState === WebSocket.CONNECTING) return; + if (!(await probeDaemon())) return; + + try { + legacyWs = new WebSocket(DAEMON_WS_URL); + } catch { + scheduleLegacyReconnect(); + return; + } + + legacyWs.onopen = () => { + console.log('[opencli] Connected to daemon'); + legacyReconnectAttempts = 0; + if (legacyReconnectTimer) { + clearTimeout(legacyReconnectTimer); + legacyReconnectTimer = null; + } + legacyWs?.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); + }; + + legacyWs.onmessage = async (event) => { + try { + const command = JSON.parse(event.data as string) as Command; + const result = await handleCommand(command); + await wsSend(JSON.stringify(result)); + } catch (err) { + console.error('[opencli] Message handling error:', err); + } + }; + + legacyWs.onclose = () => { + console.log('[opencli] Disconnected from daemon'); + legacyWs = null; + scheduleLegacyReconnect(); + }; + + legacyWs.onerror = () => { + legacyWs?.close(); + }; +} + +function scheduleLegacyReconnect(): void { + if (legacyReconnectTimer) return; + legacyReconnectAttempts++; + if (legacyReconnectAttempts > MAX_EAGER_ATTEMPTS) return; + const delay = Math.min( + WS_RECONNECT_BASE_DELAY * Math.pow(2, legacyReconnectAttempts - 1), + WS_RECONNECT_MAX_DELAY, + ); + legacyReconnectTimer = setTimeout(() => { + legacyReconnectTimer = null; + void legacyConnect(); + }, delay); +} + +async function connectTransport(): Promise { + if (prefersOffscreenTransport()) { + await offscreenConnect(); + } else { + await legacyConnect(); + } +} + /** Send a serialised result/hello string over the WebSocket. */ async function wsSend(payload: string): Promise { + if (!prefersOffscreenTransport()) { + if (legacyWs?.readyState === WebSocket.OPEN) { + legacyWs.send(payload); + } else { + void legacyConnect(); + } + return; + } + try { const resp = await chrome.runtime.sendMessage({ type: 'ws-send', payload }) as { ok: boolean }; if (!resp?.ok) { @@ -55,6 +158,13 @@ async function wsSend(payload: string): Promise { /** Query live connection status from offscreen. */ async function wsStatus(): Promise<{ connected: boolean; reconnecting: boolean }> { + if (!prefersOffscreenTransport()) { + return { + connected: legacyWs?.readyState === WebSocket.OPEN, + reconnecting: legacyReconnectTimer !== null, + }; + } + try { const resp = await chrome.runtime.sendMessage({ type: 'ws-status' }) as any; return { connected: resp?.connected ?? false, reconnecting: resp?.reconnecting ?? false }; @@ -160,7 +270,7 @@ function initialize(): void { initialized = true; chrome.alarms.create('keepalive', { periodInMinutes: 0.25 }); // ~15 seconds — faster recovery after SW suspend executor.registerListeners(); - void offscreenConnect(); + void connectTransport(); console.log('[opencli] OpenCLI extension initialized'); } @@ -175,7 +285,7 @@ chrome.runtime.onStartup.addListener(() => { chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === 'keepalive') { // Ensure offscreen doc is alive and WS is connected after any SW suspend/resume. - void offscreenConnect(); + void connectTransport(); } }); @@ -188,8 +298,15 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return true; // async } + // ── Offscreen asks background to probe daemon reachability ── + if (msg?.type === 'ws-probe') { + probeDaemon().then((ok) => sendResponse({ ok })); + return true; // async + } + // ── Incoming WS frame from offscreen ── if (msg?.type === 'ws-message') { + sendResponse({ ok: true }); void (async () => { try { const command = JSON.parse(msg.data as string) as Command; @@ -202,12 +319,6 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return false; } - // ── WS connected — send hello with real extension version ── - if (msg?.type === 'ws-connected') { - void wsSend(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); - return false; - } - // ── Log forwarding from offscreen (pass through to WS) ── if (msg?.type === 'log') { void wsSend(JSON.stringify(msg)); diff --git a/extension/src/offscreen.ts b/extension/src/offscreen.ts index affa1eee..b6983ba4 100644 --- a/extension/src/offscreen.ts +++ b/extension/src/offscreen.ts @@ -17,13 +17,17 @@ * { type: 'log', level, msg, ts } — forward console output */ -import { DAEMON_WS_URL, DAEMON_PING_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; +import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol'; let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; let reconnectAttempts = 0; +let pendingFrames: string[] = []; +let flushTimer: ReturnType | null = null; +let flushingFrames = false; const MAX_EAGER_ATTEMPTS = 6; +const FRAME_RETRY_DELAY = 1000; // ─── Logging ───────────────────────────────────────────────────────── @@ -50,11 +54,25 @@ console.error = (...args: unknown[]) => { // ─── WebSocket ─────────────────────────────────────────────────────── +async function probeDaemon(): Promise { + try { + const resp = await chrome.runtime.sendMessage({ type: 'ws-probe' }) as { ok?: boolean }; + return resp?.ok === true; + } catch { + return false; + } +} + async function connect(): Promise { if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return; - // Skip HTTP ping — fetch to localhost is blocked in offscreen context. - // WebSocket onerror will handle daemon-not-running gracefully. + // Offscreen cannot probe localhost directly, so ask the background SW to do it. + // This preserves the previous "don't spam console with refused WS connects" guard. + if (!(await probeDaemon())) { + scheduleReconnect(); + return; + } + try { ws = new WebSocket(DAEMON_WS_URL); } catch { @@ -69,16 +87,13 @@ async function connect(): Promise { clearTimeout(reconnectTimer); reconnectTimer = null; } - // Send hello — version comes from background, use a static marker here - ws?.send(JSON.stringify({ type: 'hello', version: '__offscreen__' })); - // Tell background we're up so it can send a proper hello with the real version - chrome.runtime.sendMessage({ type: 'ws-connected' }).catch(() => {}); + ws?.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); + void flushPendingFrames(); }; ws.onmessage = (event) => { - chrome.runtime.sendMessage({ type: 'ws-message', data: event.data as string }).catch(() => { - // SW may be sleeping — it will wake via alarm and re-check - }); + pendingFrames.push(event.data as string); + void flushPendingFrames(); }; ws.onclose = () => { @@ -92,6 +107,43 @@ async function connect(): Promise { }; } +function scheduleFlush(): void { + if (flushTimer) return; + flushTimer = setTimeout(() => { + flushTimer = null; + void flushPendingFrames(); + }, FRAME_RETRY_DELAY); +} + +async function flushPendingFrames(): Promise { + if (flushingFrames || pendingFrames.length === 0) return; + flushingFrames = true; + try { + while (pendingFrames.length > 0) { + let delivered = false; + try { + const resp = await chrome.runtime.sendMessage({ + type: 'ws-message', + data: pendingFrames[0], + }) as { ok?: boolean }; + delivered = resp?.ok === true; + } catch { + delivered = false; + } + + if (!delivered) { + scheduleFlush(); + break; + } + + pendingFrames.shift(); + } + } finally { + flushingFrames = false; + if (pendingFrames.length > 0 && !flushTimer) scheduleFlush(); + } +} + function scheduleReconnect(): void { if (reconnectTimer) return; reconnectAttempts++; diff --git a/extension/tsconfig.json b/extension/tsconfig.json index 93294a53..c2c2762c 100644 --- a/extension/tsconfig.json +++ b/extension/tsconfig.json @@ -11,5 +11,6 @@ "declaration": false, "types": ["chrome"] }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] } From 9170219c9de20959070aafda0083dee9d9bfe307 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 29 Mar 2026 17:38:13 +0800 Subject: [PATCH 3/4] fix(extension): package offscreen document assets --- extension/scripts/package-release.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/extension/scripts/package-release.mjs b/extension/scripts/package-release.mjs index 34823676..a368c9e4 100644 --- a/extension/scripts/package-release.mjs +++ b/extension/scripts/package-release.mjs @@ -65,6 +65,10 @@ function collectManifestEntrypoints(manifest) { for (const entry of manifest.web_accessible_resources ?? []) { for (const resource of entry.resources ?? []) addLocalAsset(files, resource); } + // MV3 offscreen documents are created at runtime via chrome.offscreen.createDocument() + // and are not referenced directly from manifest entry fields, so include the + // conventional offscreen page explicitly when the permission is present. + if ((manifest.permissions ?? []).includes('offscreen')) files.add('offscreen.html'); if (manifest.default_locale) files.add('_locales'); return [...files]; @@ -130,6 +134,7 @@ async function collectManifestAssets(manifest) { if (manifest.options_page) htmlPages.push(manifest.options_page); if (manifest.devtools_page) htmlPages.push(manifest.devtools_page); if (manifest.side_panel?.default_path) htmlPages.push(manifest.side_panel.default_path); + if ((manifest.permissions ?? []).includes('offscreen')) htmlPages.push('offscreen.html'); for (const page of manifest.sandbox?.pages ?? []) htmlPages.push(page); for (const overridePage of Object.values(manifest.chrome_url_overrides ?? {})) htmlPages.push(overridePage); From 0aceb2cc1531df519ec9fc49f1028550a6dda69c Mon Sep 17 00:00:00 2001 From: Sean Sun <1194458432@qq.com> Date: Thu, 2 Apr 2026 22:11:59 +0800 Subject: [PATCH 4/4] fix(extension): reliable command delivery and result buffering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address reviewer feedback on offscreen WebSocket transport reliability: 1. Forward path: ws-message handler now acks only AFTER handleCommand() completes and the result is sent/buffered. Previously the immediate sendResponse({ok:true}) caused offscreen to drop the frame from pendingFrames before the SW had finished processing — a suspended SW would silently lose the command. 2. Reverse path: wsSend() now buffers failed payloads in pendingResults[] instead of dropping them. Buffered results are flushed when the transport reconnects (offscreen or legacy). 3. pendingFrames cap: MAX_PENDING_FRAMES=100 with oldest-first eviction prevents unbounded memory growth when the SW is unresponsive. 4. forceLegacyTransport reset: cleared on every keepalive alarm (~15s) so a transient offscreen creation failure doesn't permanently lock out the preferred transport. Co-Authored-By: Claude Opus 4.6 (1M context) --- extension/src/background.ts | 75 ++++++++++++++++++++++++++++++------- extension/src/offscreen.ts | 5 +++ 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/extension/src/background.ts b/extension/src/background.ts index 5d6b288f..19f9ce5f 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -22,6 +22,11 @@ let legacyReconnectTimer: ReturnType | null = null; let legacyReconnectAttempts = 0; const MAX_EAGER_ATTEMPTS = 6; +// Outbound result frames that failed to send via offscreen/legacy WS. +// Flushed when the transport reconnects. +const pendingResults: string[] = []; +const MAX_PENDING_RESULTS = 100; + function prefersOffscreenTransport(): boolean { return !forceLegacyTransport && !!(chrome as any).offscreen; } @@ -89,6 +94,7 @@ async function legacyConnect(): Promise { legacyReconnectTimer = null; } legacyWs?.send(JSON.stringify({ type: 'hello', version: chrome.runtime.getManifest().version })); + void flushPendingResults(); }; legacyWs.onmessage = async (event) => { @@ -134,25 +140,63 @@ async function connectTransport(): Promise { } } -/** Send a serialised result/hello string over the WebSocket. */ -async function wsSend(payload: string): Promise { +/** Send a serialised result/hello string over the WebSocket. Returns true if sent. */ +async function wsSend(payload: string): Promise { if (!prefersOffscreenTransport()) { if (legacyWs?.readyState === WebSocket.OPEN) { legacyWs.send(payload); - } else { - void legacyConnect(); + return true; } - return; + // Buffer the payload so it can be retried once the connection is back. + bufferResult(payload); + void legacyConnect(); + return false; } try { const resp = await chrome.runtime.sendMessage({ type: 'ws-send', payload }) as { ok: boolean }; - if (!resp?.ok) { - // Offscreen WS is down — trigger reconnect - void offscreenConnect(); - } + if (resp?.ok) return true; + bufferResult(payload); + void offscreenConnect(); + return false; } catch { + bufferResult(payload); void offscreenConnect(); + return false; + } +} + +function bufferResult(payload: string): void { + if (pendingResults.length >= MAX_PENDING_RESULTS) { + pendingResults.shift(); // oldest-first eviction + } + pendingResults.push(payload); +} + +/** Drain buffered result frames after transport reconnects. */ +async function flushPendingResults(): Promise { + while (pendingResults.length > 0) { + const payload = pendingResults[0]; + const sent = await wsSendDirect(payload); + if (!sent) break; // transport still down, stop flushing + pendingResults.shift(); + } +} + +/** Low-level send without buffering — used by flushPendingResults to avoid recursion. */ +async function wsSendDirect(payload: string): Promise { + if (!prefersOffscreenTransport()) { + if (legacyWs?.readyState === WebSocket.OPEN) { + legacyWs.send(payload); + return true; + } + return false; + } + try { + const resp = await chrome.runtime.sendMessage({ type: 'ws-send', payload }) as { ok: boolean }; + return resp?.ok === true; + } catch { + return false; } } @@ -284,8 +328,10 @@ chrome.runtime.onStartup.addListener(() => { chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === 'keepalive') { + // Allow offscreen transport to be retried periodically instead of permanent lockout. + forceLegacyTransport = false; // Ensure offscreen doc is alive and WS is connected after any SW suspend/resume. - void connectTransport(); + void connectTransport().then(() => flushPendingResults()); } }); @@ -305,18 +351,21 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } // ── Incoming WS frame from offscreen ── + // Ack only AFTER handleCommand() completes and the result is sent (or buffered). + // This keeps the frame in offscreen's pendingFrames until we've truly handled it. if (msg?.type === 'ws-message') { - sendResponse({ ok: true }); - void (async () => { + (async () => { try { const command = JSON.parse(msg.data as string) as Command; const result = await handleCommand(command); await wsSend(JSON.stringify(result)); + sendResponse({ ok: true }); } catch (err) { console.error('[opencli] Message handling error:', err); + sendResponse({ ok: false, error: String(err) }); } })(); - return false; + return true; // async response } // ── Log forwarding from offscreen (pass through to WS) ── diff --git a/extension/src/offscreen.ts b/extension/src/offscreen.ts index b6983ba4..36d64065 100644 --- a/extension/src/offscreen.ts +++ b/extension/src/offscreen.ts @@ -28,6 +28,7 @@ let flushingFrames = false; const MAX_EAGER_ATTEMPTS = 6; const FRAME_RETRY_DELAY = 1000; +const MAX_PENDING_FRAMES = 100; // ─── Logging ───────────────────────────────────────────────────────── @@ -92,6 +93,10 @@ async function connect(): Promise { }; ws.onmessage = (event) => { + if (pendingFrames.length >= MAX_PENDING_FRAMES) { + console.warn('[opencli/offscreen] pendingFrames at capacity, dropping oldest frame'); + pendingFrames.shift(); + } pendingFrames.push(event.data as string); void flushPendingFrames(); };