mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
fix(browser): harden extension relay worker recovery
Co-authored-by: codexGW <9350182+codexGW@users.noreply.github.com>
This commit is contained in:
@@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703)
|
||||
- Slack/Upload: resolve bare user IDs (U-prefix) to DM channel IDs via `conversations.open` before calling `files.uploadV2`, which rejects non-channel IDs. `chat.postMessage` tolerates user IDs directly, but `files.uploadV2` → `completeUploadExternal` validates `channel_id` against `^[CGDZ][A-Z0-9]{8,}$`, causing `invalid_arguments` when agents reply with media to DM conversations.
|
||||
- Browser/Relay: treat extension websocket as connected only when `OPEN`, allow reconnect when a stale `CLOSING/CLOSED` extension socket lingers, and guard stale socket message/close handlers so late events cannot clear active relay state; includes regression coverage for live-duplicate `409` rejection and immediate reconnect-after-close races. (#15099, #18698, #20688)
|
||||
- Browser/Extension Relay: refactor the MV3 worker to preserve debugger attachments across relay drops, auto-reconnect with bounded backoff+jitter, persist and rehydrate attached tab state via `chrome.storage.session`, recover from `target_closed` navigation detaches, guard stale socket handlers, enforce per-tab operation locks and per-request timeouts, and add lifecycle keepalive/badge refresh hooks (`alarms`, `webNavigation`). (#15099, #6175, #8468, #9807)
|
||||
- Signal/RPC: guard malformed Signal RPC JSON responses with a clear status-scoped error and add regression coverage for invalid JSON responses. (#22995) Thanks @adhitShet.
|
||||
- Gateway/Subagents: guard gateway and subagent session-key/message trim paths against undefined inputs to prevent early `Cannot read properties of undefined (reading 'trim')` crashes during subagent spawn and wait flows.
|
||||
- Agents/Workspace: guard `resolveUserPath` against undefined/null input to prevent `Cannot read properties of undefined (reading 'trim')` crashes when workspace paths are missing in embedded runner flows.
|
||||
|
||||
30
assets/chrome-extension/background-utils.js
Normal file
30
assets/chrome-extension/background-utils.js
Normal file
@@ -0,0 +1,30 @@
|
||||
export function reconnectDelayMs(
|
||||
attempt,
|
||||
opts = { baseMs: 1000, maxMs: 30000, jitterMs: 1000, random: Math.random },
|
||||
) {
|
||||
const baseMs = Number.isFinite(opts.baseMs) ? opts.baseMs : 1000;
|
||||
const maxMs = Number.isFinite(opts.maxMs) ? opts.maxMs : 30000;
|
||||
const jitterMs = Number.isFinite(opts.jitterMs) ? opts.jitterMs : 1000;
|
||||
const random = typeof opts.random === "function" ? opts.random : Math.random;
|
||||
const safeAttempt = Math.max(0, Number.isFinite(attempt) ? attempt : 0);
|
||||
const backoff = Math.min(baseMs * 2 ** safeAttempt, maxMs);
|
||||
return backoff + Math.max(0, jitterMs) * random();
|
||||
}
|
||||
|
||||
export function buildRelayWsUrl(port, gatewayToken) {
|
||||
const token = String(gatewayToken || "").trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)",
|
||||
);
|
||||
}
|
||||
return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export function isRetryableReconnectError(err) {
|
||||
const message = err instanceof Error ? err.message : String(err || "");
|
||||
if (message.includes("Missing gatewayToken")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
|
||||
|
||||
const DEFAULT_PORT = 18792
|
||||
|
||||
const BADGE = {
|
||||
@@ -12,8 +14,6 @@ let relayWs = null
|
||||
/** @type {Promise<void>|null} */
|
||||
let relayConnectPromise = null
|
||||
|
||||
let debuggerListenersInstalled = false
|
||||
|
||||
let nextSession = 1
|
||||
|
||||
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number}>} */
|
||||
@@ -26,6 +26,14 @@ const childSessionToTab = new Map()
|
||||
/** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
|
||||
const pending = new Map()
|
||||
|
||||
// Per-tab operation locks prevent double-attach races.
|
||||
/** @type {Set<number>} */
|
||||
const tabOperationLocks = new Set()
|
||||
|
||||
// Reconnect state for exponential backoff.
|
||||
let reconnectAttempt = 0
|
||||
let reconnectTimer = null
|
||||
|
||||
function nowStack() {
|
||||
try {
|
||||
return new Error().stack || ''
|
||||
@@ -55,6 +63,63 @@ function setBadge(tabId, kind) {
|
||||
void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {})
|
||||
}
|
||||
|
||||
// Persist attached tab state to survive MV3 service worker restarts.
|
||||
async function persistState() {
|
||||
try {
|
||||
const tabEntries = []
|
||||
for (const [tabId, tab] of tabs.entries()) {
|
||||
if (tab.state === 'connected' && tab.sessionId && tab.targetId) {
|
||||
tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId, attachOrder: tab.attachOrder })
|
||||
}
|
||||
}
|
||||
await chrome.storage.session.set({
|
||||
persistedTabs: tabEntries,
|
||||
nextSession,
|
||||
})
|
||||
} catch {
|
||||
// chrome.storage.session may not be available in all contexts.
|
||||
}
|
||||
}
|
||||
|
||||
// Rehydrate tab state on service worker startup. Fast path — just restores
|
||||
// maps and badges. Relay reconnect happens separately in background.
|
||||
async function rehydrateState() {
|
||||
try {
|
||||
const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession'])
|
||||
if (stored.nextSession) {
|
||||
nextSession = Math.max(nextSession, stored.nextSession)
|
||||
}
|
||||
const entries = stored.persistedTabs || []
|
||||
// Phase 1: optimistically restore state and badges.
|
||||
for (const entry of entries) {
|
||||
tabs.set(entry.tabId, {
|
||||
state: 'connected',
|
||||
sessionId: entry.sessionId,
|
||||
targetId: entry.targetId,
|
||||
attachOrder: entry.attachOrder,
|
||||
})
|
||||
tabBySession.set(entry.sessionId, entry.tabId)
|
||||
setBadge(entry.tabId, 'on')
|
||||
}
|
||||
// Phase 2: validate asynchronously, remove dead tabs.
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
await chrome.tabs.get(entry.tabId)
|
||||
await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', {
|
||||
expression: '1',
|
||||
returnByValue: true,
|
||||
})
|
||||
} catch {
|
||||
tabs.delete(entry.tabId)
|
||||
tabBySession.delete(entry.sessionId)
|
||||
setBadge(entry.tabId, 'off')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore rehydration errors.
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRelayConnection() {
|
||||
if (relayWs && relayWs.readyState === WebSocket.OPEN) return
|
||||
if (relayConnectPromise) return await relayConnectPromise
|
||||
@@ -63,9 +128,7 @@ async function ensureRelayConnection() {
|
||||
const port = await getRelayPort()
|
||||
const gatewayToken = await getGatewayToken()
|
||||
const httpBase = `http://127.0.0.1:${port}`
|
||||
const wsUrl = gatewayToken
|
||||
? `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(gatewayToken)}`
|
||||
: `ws://127.0.0.1:${port}/extension`
|
||||
const wsUrl = buildRelayWsUrl(port, gatewayToken)
|
||||
|
||||
// Fast preflight: is the relay server up?
|
||||
try {
|
||||
@@ -74,12 +137,6 @@ async function ensureRelayConnection() {
|
||||
throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
|
||||
}
|
||||
|
||||
if (!gatewayToken) {
|
||||
throw new Error(
|
||||
'Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)',
|
||||
)
|
||||
}
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
relayWs = ws
|
||||
|
||||
@@ -99,42 +156,142 @@ async function ensureRelayConnection() {
|
||||
}
|
||||
})
|
||||
|
||||
ws.onmessage = (event) => void onRelayMessage(String(event.data || ''))
|
||||
ws.onclose = () => onRelayClosed('closed')
|
||||
ws.onerror = () => onRelayClosed('error')
|
||||
|
||||
if (!debuggerListenersInstalled) {
|
||||
debuggerListenersInstalled = true
|
||||
chrome.debugger.onEvent.addListener(onDebuggerEvent)
|
||||
chrome.debugger.onDetach.addListener(onDebuggerDetach)
|
||||
// Bind permanent handlers. Guard against stale socket: if this WS was
|
||||
// replaced before its close fires, the handler is a no-op.
|
||||
ws.onmessage = (event) => {
|
||||
if (ws !== relayWs) return
|
||||
void whenReady(() => onRelayMessage(String(event.data || '')))
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (ws !== relayWs) return
|
||||
onRelayClosed('closed')
|
||||
}
|
||||
ws.onerror = () => {
|
||||
if (ws !== relayWs) return
|
||||
onRelayClosed('error')
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
await relayConnectPromise
|
||||
reconnectAttempt = 0
|
||||
} finally {
|
||||
relayConnectPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
// Relay closed — update badges, reject pending requests, auto-reconnect.
|
||||
// Debugger sessions are kept alive so they survive transient WS drops.
|
||||
function onRelayClosed(reason) {
|
||||
relayWs = null
|
||||
|
||||
for (const [id, p] of pending.entries()) {
|
||||
pending.delete(id)
|
||||
p.reject(new Error(`Relay disconnected (${reason})`))
|
||||
}
|
||||
|
||||
for (const tabId of tabs.keys()) {
|
||||
void chrome.debugger.detach({ tabId }).catch(() => {})
|
||||
setBadge(tabId, 'connecting')
|
||||
void chrome.action.setTitle({
|
||||
tabId,
|
||||
title: 'OpenClaw Browser Relay: disconnected (click to re-attach)',
|
||||
})
|
||||
for (const [tabId, tab] of tabs.entries()) {
|
||||
if (tab.state === 'connected') {
|
||||
setBadge(tabId, 'connecting')
|
||||
void chrome.action.setTitle({
|
||||
tabId,
|
||||
title: 'OpenClaw Browser Relay: relay reconnecting…',
|
||||
})
|
||||
}
|
||||
}
|
||||
tabs.clear()
|
||||
tabBySession.clear()
|
||||
childSessionToTab.clear()
|
||||
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
const delay = reconnectDelayMs(reconnectAttempt)
|
||||
reconnectAttempt++
|
||||
|
||||
console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`)
|
||||
|
||||
reconnectTimer = setTimeout(async () => {
|
||||
reconnectTimer = null
|
||||
try {
|
||||
await ensureRelayConnection()
|
||||
reconnectAttempt = 0
|
||||
console.log('Reconnected successfully')
|
||||
await reannounceAttachedTabs()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`)
|
||||
if (!isRetryableReconnectError(err)) {
|
||||
return
|
||||
}
|
||||
scheduleReconnect()
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function cancelReconnect() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
reconnectAttempt = 0
|
||||
}
|
||||
|
||||
// Re-announce all attached tabs to the relay after reconnect.
|
||||
async function reannounceAttachedTabs() {
|
||||
for (const [tabId, tab] of tabs.entries()) {
|
||||
if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
|
||||
|
||||
// Verify debugger is still attached.
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
||||
expression: '1',
|
||||
returnByValue: true,
|
||||
})
|
||||
} catch {
|
||||
tabs.delete(tabId)
|
||||
if (tab.sessionId) tabBySession.delete(tab.sessionId)
|
||||
setBadge(tabId, 'off')
|
||||
void chrome.action.setTitle({
|
||||
tabId,
|
||||
title: 'OpenClaw Browser Relay (click to attach/detach)',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Send fresh attach event to relay.
|
||||
try {
|
||||
const info = /** @type {any} */ (
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo')
|
||||
)
|
||||
const targetInfo = info?.targetInfo
|
||||
|
||||
sendToRelay({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
method: 'Target.attachedToTarget',
|
||||
params: {
|
||||
sessionId: tab.sessionId,
|
||||
targetInfo: { ...targetInfo, attached: true },
|
||||
waitingForDebugger: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
setBadge(tabId, 'on')
|
||||
void chrome.action.setTitle({
|
||||
tabId,
|
||||
title: 'OpenClaw Browser Relay: attached (click to detach)',
|
||||
})
|
||||
} catch {
|
||||
setBadge(tabId, 'on')
|
||||
}
|
||||
}
|
||||
|
||||
await persistState()
|
||||
}
|
||||
|
||||
function sendToRelay(payload) {
|
||||
@@ -159,10 +316,18 @@ async function maybeOpenHelpOnce() {
|
||||
function requestFromRelay(command) {
|
||||
const id = command.id
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject })
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id)
|
||||
reject(new Error('Relay request timeout (30s)'))
|
||||
}, 30000)
|
||||
pending.set(id, {
|
||||
resolve: (v) => { clearTimeout(timer); resolve(v) },
|
||||
reject: (e) => { clearTimeout(timer); reject(e) },
|
||||
})
|
||||
try {
|
||||
sendToRelay(command)
|
||||
} catch (err) {
|
||||
clearTimeout(timer)
|
||||
pending.delete(id)
|
||||
reject(err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
@@ -233,8 +398,9 @@ async function attachTab(tabId, opts = {}) {
|
||||
throw new Error('Target.getTargetInfo returned no targetId')
|
||||
}
|
||||
|
||||
const sessionId = `cb-tab-${nextSession++}`
|
||||
const attachOrder = nextSession
|
||||
const sid = nextSession++
|
||||
const sessionId = `cb-tab-${sid}`
|
||||
const attachOrder = sid
|
||||
|
||||
tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder })
|
||||
tabBySession.set(sessionId, tabId)
|
||||
@@ -258,11 +424,33 @@ async function attachTab(tabId, opts = {}) {
|
||||
}
|
||||
|
||||
setBadge(tabId, 'on')
|
||||
await persistState()
|
||||
|
||||
return { sessionId, targetId }
|
||||
}
|
||||
|
||||
async function detachTab(tabId, reason) {
|
||||
const tab = tabs.get(tabId)
|
||||
|
||||
// Send detach events for child sessions first.
|
||||
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
|
||||
if (parentTabId === tabId) {
|
||||
try {
|
||||
sendToRelay({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
method: 'Target.detachedFromTarget',
|
||||
params: { sessionId: childSessionId, reason: 'parent_detached' },
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Relay may be down.
|
||||
}
|
||||
childSessionToTab.delete(childSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// Send detach event for main session.
|
||||
if (tab?.sessionId && tab?.targetId) {
|
||||
try {
|
||||
sendToRelay({
|
||||
@@ -273,21 +461,17 @@ async function detachTab(tabId, reason) {
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
// Relay may be down.
|
||||
}
|
||||
}
|
||||
|
||||
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
|
||||
tabs.delete(tabId)
|
||||
|
||||
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
|
||||
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.debugger.detach({ tabId })
|
||||
} catch {
|
||||
// ignore
|
||||
// May already be detached.
|
||||
}
|
||||
|
||||
setBadge(tabId, 'off')
|
||||
@@ -295,6 +479,8 @@ async function detachTab(tabId, reason) {
|
||||
tabId,
|
||||
title: 'OpenClaw Browser Relay (click to attach/detach)',
|
||||
})
|
||||
|
||||
await persistState()
|
||||
}
|
||||
|
||||
async function connectOrToggleForActiveTab() {
|
||||
@@ -302,33 +488,43 @@ async function connectOrToggleForActiveTab() {
|
||||
const tabId = active?.id
|
||||
if (!tabId) return
|
||||
|
||||
const existing = tabs.get(tabId)
|
||||
if (existing?.state === 'connected') {
|
||||
await detachTab(tabId, 'toggle')
|
||||
return
|
||||
}
|
||||
|
||||
tabs.set(tabId, { state: 'connecting' })
|
||||
setBadge(tabId, 'connecting')
|
||||
void chrome.action.setTitle({
|
||||
tabId,
|
||||
title: 'OpenClaw Browser Relay: connecting to local relay…',
|
||||
})
|
||||
// Prevent concurrent operations on the same tab.
|
||||
if (tabOperationLocks.has(tabId)) return
|
||||
tabOperationLocks.add(tabId)
|
||||
|
||||
try {
|
||||
await ensureRelayConnection()
|
||||
await attachTab(tabId)
|
||||
} catch (err) {
|
||||
tabs.delete(tabId)
|
||||
setBadge(tabId, 'error')
|
||||
const existing = tabs.get(tabId)
|
||||
if (existing?.state === 'connected') {
|
||||
await detachTab(tabId, 'toggle')
|
||||
return
|
||||
}
|
||||
|
||||
// User is manually connecting — cancel any pending reconnect.
|
||||
cancelReconnect()
|
||||
|
||||
tabs.set(tabId, { state: 'connecting' })
|
||||
setBadge(tabId, 'connecting')
|
||||
void chrome.action.setTitle({
|
||||
tabId,
|
||||
title: 'OpenClaw Browser Relay: relay not running (open options for setup)',
|
||||
title: 'OpenClaw Browser Relay: connecting to local relay…',
|
||||
})
|
||||
void maybeOpenHelpOnce()
|
||||
// Extra breadcrumbs in chrome://extensions service worker logs.
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.warn('attach failed', message, nowStack())
|
||||
|
||||
try {
|
||||
await ensureRelayConnection()
|
||||
await attachTab(tabId)
|
||||
} catch (err) {
|
||||
tabs.delete(tabId)
|
||||
setBadge(tabId, 'error')
|
||||
void chrome.action.setTitle({
|
||||
tabId,
|
||||
title: 'OpenClaw Browser Relay: relay not running (open options for setup)',
|
||||
})
|
||||
void maybeOpenHelpOnce()
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.warn('attach failed', message, nowStack())
|
||||
}
|
||||
} finally {
|
||||
tabOperationLocks.delete(tabId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,14 +533,12 @@ async function handleForwardCdpCommand(msg) {
|
||||
const params = msg?.params?.params || undefined
|
||||
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
|
||||
|
||||
// Map command to tab
|
||||
const bySession = sessionId ? getTabBySessionId(sessionId) : null
|
||||
const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined
|
||||
const tabId =
|
||||
bySession?.tabId ||
|
||||
(targetId ? getTabByTargetId(targetId) : null) ||
|
||||
(() => {
|
||||
// No sessionId: pick the first connected tab (stable-ish).
|
||||
for (const [id, tab] of tabs.entries()) {
|
||||
if (tab.state === 'connected') return id
|
||||
}
|
||||
@@ -434,20 +628,173 @@ function onDebuggerEvent(source, method, params) {
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
// Relay may be down.
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// Non-navigation detach (user action, crash, etc.) — full cleanup.
|
||||
void detachTab(tabId, reason)
|
||||
}
|
||||
|
||||
chrome.action.onClicked.addListener(() => void connectOrToggleForActiveTab())
|
||||
// Tab lifecycle listeners — clean up stale entries.
|
||||
chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => {
|
||||
if (!tabs.has(tabId)) return
|
||||
const tab = tabs.get(tabId)
|
||||
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
|
||||
tabs.delete(tabId)
|
||||
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
|
||||
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
|
||||
}
|
||||
if (tab?.sessionId && tab?.targetId) {
|
||||
try {
|
||||
sendToRelay({
|
||||
method: 'forwardCDPEvent',
|
||||
params: {
|
||||
method: 'Target.detachedFromTarget',
|
||||
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason: 'tab_closed' },
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Relay may be down.
|
||||
}
|
||||
}
|
||||
void persistState()
|
||||
}))
|
||||
|
||||
chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => void whenReady(() => {
|
||||
const tab = tabs.get(removedTabId)
|
||||
if (!tab) return
|
||||
tabs.delete(removedTabId)
|
||||
tabs.set(addedTabId, tab)
|
||||
if (tab.sessionId) {
|
||||
tabBySession.set(tab.sessionId, addedTabId)
|
||||
}
|
||||
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
|
||||
if (parentTabId === removedTabId) {
|
||||
childSessionToTab.set(childSessionId, addedTabId)
|
||||
}
|
||||
}
|
||||
setBadge(addedTabId, 'on')
|
||||
void persistState()
|
||||
}))
|
||||
|
||||
// Register debugger listeners at module scope so detach/event handling works
|
||||
// even when the relay WebSocket is down.
|
||||
chrome.debugger.onEvent.addListener((...args) => void whenReady(() => onDebuggerEvent(...args)))
|
||||
chrome.debugger.onDetach.addListener((...args) => void whenReady(() => onDebuggerDetach(...args)))
|
||||
|
||||
chrome.action.onClicked.addListener(() => void whenReady(() => connectOrToggleForActiveTab()))
|
||||
|
||||
// Refresh badge after navigation completes — service worker may have restarted
|
||||
// during navigation, losing ephemeral badge state.
|
||||
chrome.webNavigation.onCompleted.addListener(({ tabId, frameId }) => void whenReady(() => {
|
||||
if (frameId !== 0) return
|
||||
const tab = tabs.get(tabId)
|
||||
if (tab?.state === 'connected') {
|
||||
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
|
||||
}
|
||||
}))
|
||||
|
||||
// Refresh badge when user switches to an attached tab.
|
||||
chrome.tabs.onActivated.addListener(({ tabId }) => void whenReady(() => {
|
||||
const tab = tabs.get(tabId)
|
||||
if (tab?.state === 'connected') {
|
||||
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
|
||||
}
|
||||
}))
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
// Useful: first-time instructions.
|
||||
void chrome.runtime.openOptionsPage()
|
||||
})
|
||||
|
||||
// MV3 keepalive via chrome.alarms — more reliable than setInterval across
|
||||
// service worker restarts. Checks relay health and refreshes badges.
|
||||
chrome.alarms.create('relay-keepalive', { periodInMinutes: 0.5 })
|
||||
|
||||
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
if (alarm.name !== 'relay-keepalive') return
|
||||
await initPromise
|
||||
|
||||
if (tabs.size === 0) return
|
||||
|
||||
// Refresh badges (ephemeral in MV3).
|
||||
for (const [tabId, tab] of tabs.entries()) {
|
||||
if (tab.state === 'connected') {
|
||||
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
|
||||
}
|
||||
}
|
||||
|
||||
// If relay is down and no reconnect is in progress, trigger one.
|
||||
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
|
||||
if (!relayConnectPromise && !reconnectTimer) {
|
||||
console.log('Keepalive: WebSocket unhealthy, triggering reconnect')
|
||||
await ensureRelayConnection().catch(() => {
|
||||
// ensureRelayConnection may throw without triggering onRelayClosed
|
||||
// (e.g. preflight fetch fails before WS is created), so ensure
|
||||
// reconnect is always scheduled on failure.
|
||||
if (!reconnectTimer) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Rehydrate state on service worker startup. Split: rehydration is the gate
|
||||
// (fast), relay reconnect runs in background (slow, non-blocking).
|
||||
const initPromise = rehydrateState()
|
||||
|
||||
initPromise.then(() => {
|
||||
if (tabs.size > 0) {
|
||||
ensureRelayConnection().then(() => {
|
||||
reconnectAttempt = 0
|
||||
return reannounceAttachedTabs()
|
||||
}).catch(() => {
|
||||
scheduleReconnect()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Shared gate: all state-dependent handlers await this before accessing maps.
|
||||
async function whenReady(fn) {
|
||||
await initPromise
|
||||
return fn()
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"permissions": ["debugger", "tabs", "activeTab", "storage"],
|
||||
"permissions": ["debugger", "tabs", "activeTab", "storage", "alarms", "webNavigation"],
|
||||
"host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
|
||||
"background": { "service_worker": "background.js", "type": "module" },
|
||||
"action": {
|
||||
|
||||
77
src/browser/chrome-extension-background-utils.test.ts
Normal file
77
src/browser/chrome-extension-background-utils.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRelayWsUrl,
|
||||
isRetryableReconnectError,
|
||||
reconnectDelayMs,
|
||||
} from "../../assets/chrome-extension/background-utils.js";
|
||||
|
||||
describe("chrome extension background utils", () => {
|
||||
it("builds websocket url with encoded gateway token", () => {
|
||||
const url = buildRelayWsUrl(18792, "abc/+= token");
|
||||
expect(url).toBe("ws://127.0.0.1:18792/extension?token=abc%2F%2B%3D%20token");
|
||||
});
|
||||
|
||||
it("throws when gateway token is missing", () => {
|
||||
expect(() => buildRelayWsUrl(18792, "")).toThrow(/Missing gatewayToken/);
|
||||
expect(() => buildRelayWsUrl(18792, " ")).toThrow(/Missing gatewayToken/);
|
||||
});
|
||||
|
||||
it("uses exponential backoff from attempt index", () => {
|
||||
expect(reconnectDelayMs(0, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
|
||||
1000,
|
||||
);
|
||||
expect(reconnectDelayMs(1, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
|
||||
2000,
|
||||
);
|
||||
expect(reconnectDelayMs(4, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
|
||||
16000,
|
||||
);
|
||||
});
|
||||
|
||||
it("caps reconnect delay at max", () => {
|
||||
const delay = reconnectDelayMs(20, {
|
||||
baseMs: 1000,
|
||||
maxMs: 30000,
|
||||
jitterMs: 0,
|
||||
random: () => 0,
|
||||
});
|
||||
expect(delay).toBe(30000);
|
||||
});
|
||||
|
||||
it("adds jitter using injected random source", () => {
|
||||
const delay = reconnectDelayMs(3, {
|
||||
baseMs: 1000,
|
||||
maxMs: 30000,
|
||||
jitterMs: 1000,
|
||||
random: () => 0.25,
|
||||
});
|
||||
expect(delay).toBe(8250);
|
||||
});
|
||||
|
||||
it("sanitizes invalid attempts and options", () => {
|
||||
expect(reconnectDelayMs(-2, { baseMs: 1000, maxMs: 30000, jitterMs: 0, random: () => 0 })).toBe(
|
||||
1000,
|
||||
);
|
||||
expect(
|
||||
reconnectDelayMs(Number.NaN, {
|
||||
baseMs: Number.NaN,
|
||||
maxMs: Number.NaN,
|
||||
jitterMs: Number.NaN,
|
||||
random: () => 0,
|
||||
}),
|
||||
).toBe(1000);
|
||||
});
|
||||
|
||||
it("marks missing token errors as non-retryable", () => {
|
||||
expect(
|
||||
isRetryableReconnectError(
|
||||
new Error("Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps transient network errors retryable", () => {
|
||||
expect(isRetryableReconnectError(new Error("WebSocket connect timeout"))).toBe(true);
|
||||
expect(isRetryableReconnectError(new Error("Relay server not reachable"))).toBe(true);
|
||||
});
|
||||
});
|
||||
29
src/browser/chrome-extension-manifest.test.ts
Normal file
29
src/browser/chrome-extension-manifest.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type ExtensionManifest = {
|
||||
background?: { service_worker?: string; type?: string };
|
||||
permissions?: string[];
|
||||
};
|
||||
|
||||
function readManifest(): ExtensionManifest {
|
||||
const path = resolve(process.cwd(), "assets/chrome-extension/manifest.json");
|
||||
return JSON.parse(readFileSync(path, "utf8")) as ExtensionManifest;
|
||||
}
|
||||
|
||||
describe("chrome extension manifest", () => {
|
||||
it("keeps background worker configured as module", () => {
|
||||
const manifest = readManifest();
|
||||
expect(manifest.background?.service_worker).toBe("background.js");
|
||||
expect(manifest.background?.type).toBe("module");
|
||||
});
|
||||
|
||||
it("includes resilience permissions", () => {
|
||||
const permissions = readManifest().permissions ?? [];
|
||||
expect(permissions).toContain("alarms");
|
||||
expect(permissions).toContain("webNavigation");
|
||||
expect(permissions).toContain("storage");
|
||||
expect(permissions).toContain("debugger");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user