mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user