Skip to content

Commit 67bac62

Browse files
NKsteipete
authored andcommitted
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 openclaw#19744
1 parent 721d8b2 commit 67bac62

File tree

1 file changed

+104
-32
lines changed

1 file changed

+104
-32
lines changed

assets/chrome-extension/background.js

Lines changed: 104 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ const pending = new Map()
3030
/** @type {Set<number>} */
3131
const tabOperationLocks = new Set()
3232

33+
// Tabs currently in a detach/re-attach cycle after navigation.
34+
/** @type {Set<number>} */
35+
const reattachPending = new Set()
36+
3337
// Reconnect state for exponential backoff.
3438
let reconnectAttempt = 0
3539
let reconnectTimer = null
@@ -190,6 +194,8 @@ function onRelayClosed(reason) {
190194
p.reject(new Error(`Relay disconnected (${reason})`))
191195
}
192196

197+
reattachPending.clear()
198+
193199
for (const [tabId, tab] of tabs.entries()) {
194200
if (tab.state === 'connected') {
195201
setBadge(tabId, 'connecting')
@@ -493,6 +499,16 @@ async function connectOrToggleForActiveTab() {
493499
tabOperationLocks.add(tabId)
494500

495501
try {
502+
if (reattachPending.has(tabId)) {
503+
reattachPending.delete(tabId)
504+
setBadge(tabId, 'off')
505+
void chrome.action.setTitle({
506+
tabId,
507+
title: 'OpenClaw Browser Relay (click to attach/detach)',
508+
})
509+
return
510+
}
511+
496512
const existing = tabs.get(tabId)
497513
if (existing?.state === 'connected') {
498514
await detachTab(tabId, 'toggle')
@@ -632,50 +648,106 @@ function onDebuggerEvent(source, method, params) {
632648
}
633649
}
634650

635-
// Navigation/reload fires target_closed but the tab is still alive — Chrome
636-
// just swaps the renderer process. Suppress the detach event to the relay and
637-
// seamlessly re-attach after a short grace period.
638-
function onDebuggerDetach(source, reason) {
651+
async function onDebuggerDetach(source, reason) {
639652
const tabId = source.tabId
640653
if (!tabId) return
641654
if (!tabs.has(tabId)) return
642655

643-
if (reason === 'target_closed') {
644-
const oldState = tabs.get(tabId)
645-
setBadge(tabId, 'connecting')
646-
void chrome.action.setTitle({
647-
tabId,
648-
title: 'OpenClaw Browser Relay: re-attaching after navigation…',
649-
})
656+
if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') {
657+
void detachTab(tabId, reason)
658+
return
659+
}
650660

651-
setTimeout(async () => {
652-
try {
653-
// If user manually detached during the grace period, bail out.
654-
if (!tabs.has(tabId)) return
655-
const tab = await chrome.tabs.get(tabId)
656-
if (tab && relayWs?.readyState === WebSocket.OPEN) {
657-
console.log(`Re-attaching tab ${tabId} after navigation`)
658-
if (oldState?.sessionId) tabBySession.delete(oldState.sessionId)
659-
tabs.delete(tabId)
660-
await attachTab(tabId, { skipAttachedEvent: false })
661-
} else {
662-
// Tab gone or relay down — full cleanup.
663-
void detachTab(tabId, reason)
664-
}
665-
} catch (err) {
666-
console.warn(`Failed to re-attach tab ${tabId} after navigation:`, err.message)
667-
void detachTab(tabId, reason)
668-
}
669-
}, 500)
661+
let tabInfo
662+
try {
663+
tabInfo = await chrome.tabs.get(tabId)
664+
} catch {
665+
void detachTab(tabId, reason)
670666
return
671667
}
672668

673-
// Non-navigation detach (user action, crash, etc.) — full cleanup.
674-
void detachTab(tabId, reason)
669+
if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) {
670+
void detachTab(tabId, reason)
671+
return
672+
}
673+
674+
if (reattachPending.has(tabId)) return
675+
676+
const oldTab = tabs.get(tabId)
677+
const oldSessionId = oldTab?.sessionId
678+
const oldTargetId = oldTab?.targetId
679+
680+
if (oldSessionId) tabBySession.delete(oldSessionId)
681+
tabs.delete(tabId)
682+
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
683+
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
684+
}
685+
686+
if (oldSessionId && oldTargetId) {
687+
try {
688+
sendToRelay({
689+
method: 'forwardCDPEvent',
690+
params: {
691+
method: 'Target.detachedFromTarget',
692+
params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' },
693+
},
694+
})
695+
} catch {
696+
// Relay may be down.
697+
}
698+
}
699+
700+
reattachPending.add(tabId)
701+
setBadge(tabId, 'connecting')
702+
void chrome.action.setTitle({
703+
tabId,
704+
title: 'OpenClaw Browser Relay: re-attaching after navigation…',
705+
})
706+
707+
const delays = [300, 700, 1500]
708+
for (let attempt = 0; attempt < delays.length; attempt++) {
709+
await new Promise((r) => setTimeout(r, delays[attempt]))
710+
711+
if (!reattachPending.has(tabId)) return
712+
713+
try {
714+
await chrome.tabs.get(tabId)
715+
} catch {
716+
reattachPending.delete(tabId)
717+
setBadge(tabId, 'off')
718+
return
719+
}
720+
721+
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
722+
reattachPending.delete(tabId)
723+
setBadge(tabId, 'error')
724+
void chrome.action.setTitle({
725+
tabId,
726+
title: 'OpenClaw Browser Relay: relay disconnected during re-attach',
727+
})
728+
return
729+
}
730+
731+
try {
732+
await attachTab(tabId)
733+
reattachPending.delete(tabId)
734+
return
735+
} catch {
736+
// continue retries
737+
}
738+
}
739+
740+
reattachPending.delete(tabId)
741+
setBadge(tabId, 'off')
742+
void chrome.action.setTitle({
743+
tabId,
744+
title: 'OpenClaw Browser Relay: re-attach failed (click to retry)',
745+
})
675746
}
676747

677748
// Tab lifecycle listeners — clean up stale entries.
678749
chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => {
750+
reattachPending.delete(tabId)
679751
if (!tabs.has(tabId)) return
680752
const tab = tabs.get(tabId)
681753
if (tab?.sessionId) tabBySession.delete(tab.sessionId)

0 commit comments

Comments
 (0)