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
This commit is contained in:
NK
2026-02-17 20:29:07 -08:00
committed by Peter Steinberger
parent 721d8b2278
commit 67bac62c2c

View File

@@ -30,6 +30,10 @@ const pending = new Map()
/** @type {Set<number>} */ /** @type {Set<number>} */
const tabOperationLocks = new Set() const tabOperationLocks = new Set()
// Tabs currently in a detach/re-attach cycle after navigation.
/** @type {Set<number>} */
const reattachPending = new Set()
// Reconnect state for exponential backoff. // Reconnect state for exponential backoff.
let reconnectAttempt = 0 let reconnectAttempt = 0
let reconnectTimer = null let reconnectTimer = null
@@ -190,6 +194,8 @@ function onRelayClosed(reason) {
p.reject(new Error(`Relay disconnected (${reason})`)) p.reject(new Error(`Relay disconnected (${reason})`))
} }
reattachPending.clear()
for (const [tabId, tab] of tabs.entries()) { for (const [tabId, tab] of tabs.entries()) {
if (tab.state === 'connected') { if (tab.state === 'connected') {
setBadge(tabId, 'connecting') setBadge(tabId, 'connecting')
@@ -493,6 +499,16 @@ async function connectOrToggleForActiveTab() {
tabOperationLocks.add(tabId) tabOperationLocks.add(tabId)
try { 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) const existing = tabs.get(tabId)
if (existing?.state === 'connected') { if (existing?.state === 'connected') {
await detachTab(tabId, 'toggle') 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 async function onDebuggerDetach(source, reason) {
// 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) {
const tabId = source.tabId const tabId = source.tabId
if (!tabId) return if (!tabId) return
if (!tabs.has(tabId)) return if (!tabs.has(tabId)) return
if (reason === 'target_closed') { if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') {
const oldState = tabs.get(tabId) void detachTab(tabId, reason)
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)
return return
} }
// Non-navigation detach (user action, crash, etc.) — full cleanup. let tabInfo
void detachTab(tabId, reason) 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. // Tab lifecycle listeners — clean up stale entries.
chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => { chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => {
reattachPending.delete(tabId)
if (!tabs.has(tabId)) return if (!tabs.has(tabId)) return
const tab = tabs.get(tabId) const tab = tabs.get(tabId)
if (tab?.sessionId) tabBySession.delete(tab.sessionId) if (tab?.sessionId) tabBySession.delete(tab.sessionId)