fix(browser): preserve debugger attachment across relay disconnects during navigation reattach

This commit is contained in:
stone-jin
2026-02-27 21:12:45 +08:00
committed by Peter Steinberger
parent 18cd77c8ce
commit 04b3a51d3a
2 changed files with 182 additions and 10 deletions

View File

@@ -807,19 +807,21 @@ async function onDebuggerDetach(source, reason) {
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
}
const relayUp = relayWs && relayWs.readyState === WebSocket.OPEN
try {
await attachTab(tabId)
// When relay is down, still attach the debugger but skip sending the
// relay event. reannounceAttachedTabs() will notify the relay once it
// reconnects, so the tab stays tracked across transient relay drops.
await attachTab(tabId, { skipAttachedEvent: !relayUp })
reattachPending.delete(tabId)
if (!relayUp) {
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'OpenClaw Browser Relay: attached, waiting for relay reconnect…',
})
}
return
} catch {
// continue retries

View File

@@ -838,6 +838,176 @@ describe("chrome extension relay server", () => {
}
});
it(
"restores tabs after extension reconnects and re-announces",
async () => {
process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "200";
const { port, ext: ext1 } = await startRelayWithExtension();
ext1.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-10",
targetInfo: {
targetId: "t10",
type: "page",
title: "My Page",
url: "https://example.com",
},
waitingForDebugger: false,
},
},
}),
);
const list1 = await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string }>,
(list) => list.some((t) => t.id === "t10"),
);
expect(list1.some((t) => t.id === "t10")).toBe(true);
// Disconnect extension and wait for grace period cleanup.
const ext1Closed = waitForClose(ext1, 2_000);
ext1.close();
await ext1Closed;
await new Promise((r) => setTimeout(r, 400));
const listEmpty = (await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string }>;
expect(listEmpty.length).toBe(0);
// Reconnect and re-announce the same tab (simulates reannounceAttachedTabs).
const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext2);
ext2.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-10",
targetInfo: {
targetId: "t10",
type: "page",
title: "My Page",
url: "https://example.com",
},
waitingForDebugger: false,
},
},
}),
);
const list2 = await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string; title?: string }>,
(list) => list.some((t) => t.id === "t10"),
);
expect(list2.some((t) => t.id === "t10" && t.title === "My Page")).toBe(true);
ext2.close();
},
RELAY_TEST_TIMEOUT_MS,
);
it(
"preserves tab across a fast extension reconnect within grace period",
async () => {
process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "2000";
const { port, ext: ext1 } = await startRelayWithExtension();
ext1.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-20",
targetInfo: {
targetId: "t20",
type: "page",
title: "Persistent",
url: "https://example.org",
},
waitingForDebugger: false,
},
},
}),
);
await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string }>,
(list) => list.some((t) => t.id === "t20"),
);
// Disconnect briefly (within grace period).
const ext1Closed = waitForClose(ext1, 2_000);
ext1.close();
await ext1Closed;
await new Promise((r) => setTimeout(r, 100));
// Tab should still be listed during grace period.
const listDuringGrace = (await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string }>;
expect(listDuringGrace.some((t) => t.id === "t20")).toBe(true);
// Reconnect within grace and re-announce with updated info.
const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
});
await waitForOpen(ext2);
ext2.send(
JSON.stringify({
method: "forwardCDPEvent",
params: {
method: "Target.attachedToTarget",
params: {
sessionId: "cb-tab-20",
targetInfo: {
targetId: "t20",
type: "page",
title: "Persistent Updated",
url: "https://example.org/new",
},
waitingForDebugger: false,
},
},
}),
);
const list2 = await waitForListMatch(
async () =>
(await fetch(`${cdpUrl}/json/list`, {
headers: relayAuthHeaders(cdpUrl),
}).then((r) => r.json())) as Array<{ id?: string; title?: string; url?: string }>,
(list) => list.some((t) => t.id === "t20" && t.title === "Persistent Updated"),
);
expect(list2.some((t) => t.id === "t20" && t.url === "https://example.org/new")).toBe(true);
ext2.close();
},
RELAY_TEST_TIMEOUT_MS,
);
it("does not swallow EADDRINUSE when occupied port is not an openclaw relay", async () => {
const port = await getFreePort();
const blocker = createServer((_, res) => {