From 67bac62c2c7065bdbe4a51beb9ac3f6596c40aca Mon Sep 17 00:00:00 2001 From: NK Date: Tue, 17 Feb 2026 20:29:07 -0800 Subject: [PATCH] fix: Chrome relay extension auto-reattach after SPA navigation When Chrome's debugger detaches during page navigation (common in SPAs like Gmail, Google Calendar), the extension now automatically re-attaches instead of permanently losing the connection. Changes: - onDebuggerDetach: detect navigation vs tab close, attempt re-attach with 3 retries and exponential backoff (300ms, 700ms, 1500ms) - Add reattachPending guard to prevent concurrent re-attach races - connectOrToggleForActiveTab: handle pending re-attach state - onRelayClosed: clear reattachPending on relay disconnect - Add chrome.tabs.onRemoved listener for proper cleanup Fixes #19744 --- assets/chrome-extension/background.js | 138 ++++++++++++++++++++------ 1 file changed, 105 insertions(+), 33 deletions(-) diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index b149f8745dc..294d9f87b2b 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -30,6 +30,10 @@ const pending = new Map() /** @type {Set} */ const tabOperationLocks = new Set() +// Tabs currently in a detach/re-attach cycle after navigation. +/** @type {Set} */ +const reattachPending = new Set() + // Reconnect state for exponential backoff. let reconnectAttempt = 0 let reconnectTimer = null @@ -190,6 +194,8 @@ function onRelayClosed(reason) { p.reject(new Error(`Relay disconnected (${reason})`)) } + reattachPending.clear() + for (const [tabId, tab] of tabs.entries()) { if (tab.state === 'connected') { setBadge(tabId, 'connecting') @@ -493,6 +499,16 @@ async function connectOrToggleForActiveTab() { tabOperationLocks.add(tabId) try { + if (reattachPending.has(tabId)) { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay (click to attach/detach)', + }) + return + } + const existing = tabs.get(tabId) if (existing?.state === 'connected') { await detachTab(tabId, 'toggle') @@ -632,50 +648,106 @@ function onDebuggerEvent(source, method, params) { } } -// Navigation/reload fires target_closed but the tab is still alive — Chrome -// just swaps the renderer process. Suppress the detach event to the relay and -// seamlessly re-attach after a short grace period. -function onDebuggerDetach(source, reason) { +async function onDebuggerDetach(source, reason) { const tabId = source.tabId if (!tabId) return if (!tabs.has(tabId)) return - if (reason === 'target_closed') { - const oldState = tabs.get(tabId) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: re-attaching after navigation…', - }) - - setTimeout(async () => { - try { - // If user manually detached during the grace period, bail out. - if (!tabs.has(tabId)) return - const tab = await chrome.tabs.get(tabId) - if (tab && relayWs?.readyState === WebSocket.OPEN) { - console.log(`Re-attaching tab ${tabId} after navigation`) - if (oldState?.sessionId) tabBySession.delete(oldState.sessionId) - tabs.delete(tabId) - await attachTab(tabId, { skipAttachedEvent: false }) - } else { - // Tab gone or relay down — full cleanup. - void detachTab(tabId, reason) - } - } catch (err) { - console.warn(`Failed to re-attach tab ${tabId} after navigation:`, err.message) - void detachTab(tabId, reason) - } - }, 500) + if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') { + void detachTab(tabId, reason) return } - // Non-navigation detach (user action, crash, etc.) — full cleanup. - void detachTab(tabId, reason) + let tabInfo + try { + tabInfo = await chrome.tabs.get(tabId) + } catch { + void detachTab(tabId, reason) + return + } + + if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) { + void detachTab(tabId, reason) + return + } + + if (reattachPending.has(tabId)) return + + const oldTab = tabs.get(tabId) + const oldSessionId = oldTab?.sessionId + const oldTargetId = oldTab?.targetId + + if (oldSessionId) tabBySession.delete(oldSessionId) + tabs.delete(tabId) + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) childSessionToTab.delete(childSessionId) + } + + if (oldSessionId && oldTargetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' }, + }, + }) + } catch { + // Relay may be down. + } + } + + reattachPending.add(tabId) + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attaching after navigation…', + }) + + const delays = [300, 700, 1500] + for (let attempt = 0; attempt < delays.length; attempt++) { + await new Promise((r) => setTimeout(r, delays[attempt])) + + if (!reattachPending.has(tabId)) return + + try { + await chrome.tabs.get(tabId) + } catch { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + return + } + + if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { + reattachPending.delete(tabId) + setBadge(tabId, 'error') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay disconnected during re-attach', + }) + return + } + + try { + await attachTab(tabId) + reattachPending.delete(tabId) + return + } catch { + // continue retries + } + } + + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attach failed (click to retry)', + }) } // Tab lifecycle listeners — clean up stale entries. chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => { + reattachPending.delete(tabId) if (!tabs.has(tabId)) return const tab = tabs.get(tabId) if (tab?.sessionId) tabBySession.delete(tab.sessionId)