Skip to content

Commit fae8de9

Browse files
committed
fix(browser): land PR #27617 relay reconnect resilience
1 parent aa17bdb commit fae8de9

File tree

3 files changed

+223
-21
lines changed

3 files changed

+223
-21
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai
4949
- Browser/Chrome extension handshake: bind relay WS message handling before `onopen` and add non-blocking `connect.challenge` response handling for gateway-style handshake frames, avoiding stuck `` badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553)
5050
- Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688)
5151
- Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay `stop()` before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng.
52+
- Browser/Extension relay reconnect resilience: keep CDP clients alive across brief MV3 extension disconnect windows, wait briefly for extension reconnect before failing in-flight CDP commands, and only tear down relay target/client state after reconnect grace expires. Landed from contributor PR #27617 by @davidemanuelDEV.
5253
- Browser/Route decode hardening: guard malformed percent-encoding in relay target action routes and browser route-param decoding so crafted `%` paths return `400` instead of crashing/unhandled URI decode failures. Landed from contributor PR #11880 by @Yida-Dev.
5354
- Feishu/Permission error dispatch: merge sender-name permission notices into the main inbound dispatch so one user message produces one agent turn/reply (instead of a duplicate permission-notice turn), with regression coverage. (#27381) thanks @byungsker.
5455
- Feishu/Inbound message metadata: include inbound `message_id` in `BodyForAgent` on a dedicated metadata line so agents can reliably correlate and act on media/message operations that require message IDs, with regression coverage. (#27253) thanks @xss925175263.

src/browser/extension-relay.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,22 @@ function waitForError(ws: WebSocket) {
2727
});
2828
}
2929

30+
function waitForClose(ws: WebSocket, timeoutMs = RELAY_MESSAGE_TIMEOUT_MS) {
31+
return new Promise<void>((resolve, reject) => {
32+
const timer = setTimeout(() => {
33+
reject(new Error("timeout"));
34+
}, timeoutMs);
35+
ws.once("close", () => {
36+
clearTimeout(timer);
37+
resolve();
38+
});
39+
ws.once("error", (err) => {
40+
clearTimeout(timer);
41+
reject(err instanceof Error ? err : new Error(String(err)));
42+
});
43+
});
44+
}
45+
3046
function relayAuthHeaders(url: string) {
3147
return getChromeExtensionRelayAuthHeaders(url);
3248
}
@@ -132,8 +148,14 @@ describe("chrome extension relay server", () => {
132148
let envSnapshot: ReturnType<typeof captureEnv>;
133149

134150
beforeEach(() => {
135-
envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN"]);
151+
envSnapshot = captureEnv([
152+
"OPENCLAW_GATEWAY_TOKEN",
153+
"OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS",
154+
"OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS",
155+
]);
136156
process.env.OPENCLAW_GATEWAY_TOKEN = TEST_GATEWAY_TOKEN;
157+
delete process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS;
158+
delete process.env.OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS;
137159
});
138160

139161
afterEach(async () => {
@@ -341,6 +363,97 @@ describe("chrome extension relay server", () => {
341363
ext2.close();
342364
});
343365

366+
it("keeps CDP clients alive across a brief extension reconnect", async () => {
367+
const { port, ext: ext1 } = await startRelayWithExtension();
368+
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
369+
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
370+
});
371+
await waitForOpen(cdp);
372+
373+
let cdpClosed = false;
374+
cdp.once("close", () => {
375+
cdpClosed = true;
376+
});
377+
378+
const ext1Closed = waitForClose(ext1, 2_000);
379+
ext1.close();
380+
await ext1Closed;
381+
382+
await new Promise((r) => setTimeout(r, 200));
383+
const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
384+
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
385+
});
386+
await waitForOpen(ext2);
387+
388+
await new Promise((r) => setTimeout(r, 200));
389+
expect(cdpClosed).toBe(false);
390+
391+
cdp.close();
392+
ext2.close();
393+
});
394+
395+
it("waits briefly for extension reconnect before failing CDP commands", async () => {
396+
const { port, ext: ext1 } = await startRelayWithExtension();
397+
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
398+
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
399+
});
400+
await waitForOpen(cdp);
401+
const cdpQueue = createMessageQueue(cdp);
402+
403+
const ext1Closed = waitForClose(ext1, 2_000);
404+
ext1.close();
405+
await ext1Closed;
406+
407+
cdp.send(JSON.stringify({ id: 41, method: "Runtime.enable" }));
408+
await new Promise((r) => setTimeout(r, 150));
409+
410+
const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
411+
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
412+
});
413+
const ext2Queue = createMessageQueue(ext2);
414+
await waitForOpen(ext2);
415+
416+
while (true) {
417+
const msg = JSON.parse(await ext2Queue.next(4_000)) as {
418+
id?: number;
419+
method?: string;
420+
};
421+
if (msg.method === "ping") {
422+
ext2.send(JSON.stringify({ method: "pong" }));
423+
continue;
424+
}
425+
if (msg.method === "forwardCDPCommand" && typeof msg.id === "number") {
426+
ext2.send(JSON.stringify({ id: msg.id, result: { ok: true } }));
427+
break;
428+
}
429+
}
430+
431+
const response = JSON.parse(await cdpQueue.next(6_000)) as {
432+
id?: number;
433+
result?: { ok?: boolean };
434+
error?: { message?: string };
435+
};
436+
expect(response.id).toBe(41);
437+
expect(response.error).toBeUndefined();
438+
expect(response.result?.ok).toBe(true);
439+
440+
cdp.close();
441+
ext2.close();
442+
});
443+
444+
it("closes CDP clients after reconnect grace when extension stays disconnected", async () => {
445+
process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "150";
446+
447+
const { port, ext } = await startRelayWithExtension();
448+
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
449+
headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
450+
});
451+
await waitForOpen(cdp);
452+
453+
ext.close();
454+
await waitForClose(cdp, 2_000);
455+
});
456+
344457
it("accepts extension websocket access with relay token query param", async () => {
345458
const port = await getFreePort();
346459
cdpUrl = `http://127.0.0.1:${port}`;

src/browser/extension-relay.ts

Lines changed: 108 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ type ConnectedTarget = {
8282
};
8383

8484
const RELAY_AUTH_HEADER = "x-openclaw-relay-token";
85+
const DEFAULT_EXTENSION_RECONNECT_GRACE_MS = 5_000;
86+
const DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS = 3_000;
8587

8688
function headerValue(value: string | string[] | undefined): string | undefined {
8789
if (!value) {
@@ -171,6 +173,18 @@ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) {
171173
}
172174
}
173175

176+
function envMsOrDefault(name: string, fallback: number): number {
177+
const raw = process.env[name];
178+
if (!raw || raw.trim() === "") {
179+
return fallback;
180+
}
181+
const parsed = Number.parseInt(raw, 10);
182+
if (!Number.isFinite(parsed) || parsed <= 0) {
183+
return fallback;
184+
}
185+
return parsed;
186+
}
187+
174188
const relayRuntimeByPort = new Map<number, RelayRuntime>();
175189
const relayInitByPort = new Map<number, Promise<ChromeExtensionRelayServer>>();
176190

@@ -225,6 +239,15 @@ export async function ensureChromeExtensionRelayServer(opts: {
225239
return await inFlight;
226240
}
227241

242+
const extensionReconnectGraceMs = envMsOrDefault(
243+
"OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS",
244+
DEFAULT_EXTENSION_RECONNECT_GRACE_MS,
245+
);
246+
const extensionCommandReconnectWaitMs = envMsOrDefault(
247+
"OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS",
248+
DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS,
249+
);
250+
228251
const initPromise = (async (): Promise<ChromeExtensionRelayServer> => {
229252
const relayAuthToken = resolveRelayAuthTokenForPort(info.port);
230253
const relayAuthTokens = new Set(resolveRelayAcceptedTokensForPort(info.port));
@@ -233,6 +256,73 @@ export async function ensureChromeExtensionRelayServer(opts: {
233256
const cdpClients = new Set<WebSocket>();
234257
const connectedTargets = new Map<string, ConnectedTarget>();
235258
const extensionConnected = () => extensionWs?.readyState === WebSocket.OPEN;
259+
let extensionDisconnectCleanupTimer: NodeJS.Timeout | null = null;
260+
const extensionReconnectWaiters = new Set<(connected: boolean) => void>();
261+
262+
const flushExtensionReconnectWaiters = (connected: boolean) => {
263+
if (extensionReconnectWaiters.size === 0) {
264+
return;
265+
}
266+
const waiters = Array.from(extensionReconnectWaiters);
267+
extensionReconnectWaiters.clear();
268+
for (const waiter of waiters) {
269+
waiter(connected);
270+
}
271+
};
272+
273+
const clearExtensionDisconnectCleanupTimer = () => {
274+
if (!extensionDisconnectCleanupTimer) {
275+
return;
276+
}
277+
clearTimeout(extensionDisconnectCleanupTimer);
278+
extensionDisconnectCleanupTimer = null;
279+
};
280+
281+
const closeCdpClientsAfterExtensionDisconnect = () => {
282+
connectedTargets.clear();
283+
for (const client of cdpClients) {
284+
try {
285+
client.close(1011, "extension disconnected");
286+
} catch {
287+
// ignore
288+
}
289+
}
290+
cdpClients.clear();
291+
flushExtensionReconnectWaiters(false);
292+
};
293+
294+
const scheduleExtensionDisconnectCleanup = () => {
295+
clearExtensionDisconnectCleanupTimer();
296+
extensionDisconnectCleanupTimer = setTimeout(() => {
297+
extensionDisconnectCleanupTimer = null;
298+
if (extensionConnected()) {
299+
return;
300+
}
301+
closeCdpClientsAfterExtensionDisconnect();
302+
}, extensionReconnectGraceMs);
303+
};
304+
305+
const waitForExtensionReconnect = async (timeoutMs: number): Promise<boolean> => {
306+
if (extensionConnected()) {
307+
return true;
308+
}
309+
return await new Promise<boolean>((resolve) => {
310+
let settled = false;
311+
const waiter = (connected: boolean) => {
312+
if (settled) {
313+
return;
314+
}
315+
settled = true;
316+
clearTimeout(timer);
317+
extensionReconnectWaiters.delete(waiter);
318+
resolve(connected);
319+
};
320+
const timer = setTimeout(() => {
321+
waiter(false);
322+
}, timeoutMs);
323+
extensionReconnectWaiters.add(waiter);
324+
});
325+
};
236326

237327
const pendingExtension = new Map<
238328
number,
@@ -543,10 +633,6 @@ export async function ensureChromeExtensionRelayServer(opts: {
543633
rejectUpgrade(socket, 401, "Unauthorized");
544634
return;
545635
}
546-
if (extensionConnected()) {
547-
rejectUpgrade(socket, 409, "Extension already connected");
548-
return;
549-
}
550636
// MV3 worker reconnect races can leave a stale non-OPEN socket reference.
551637
if (extensionWs && extensionWs.readyState !== WebSocket.OPEN) {
552638
try {
@@ -556,6 +642,10 @@ export async function ensureChromeExtensionRelayServer(opts: {
556642
}
557643
extensionWs = null;
558644
}
645+
if (extensionConnected()) {
646+
rejectUpgrade(socket, 409, "Extension already connected");
647+
return;
648+
}
559649
wssExtension.handleUpgrade(req, socket, head, (ws) => {
560650
wssExtension.emit("connection", ws, req);
561651
});
@@ -583,6 +673,8 @@ export async function ensureChromeExtensionRelayServer(opts: {
583673

584674
wssExtension.on("connection", (ws) => {
585675
extensionWs = ws;
676+
clearExtensionDisconnectCleanupTimer();
677+
flushExtensionReconnectWaiters(true);
586678

587679
const ping = setInterval(() => {
588680
if (ws.readyState !== WebSocket.OPEN) {
@@ -710,16 +802,7 @@ export async function ensureChromeExtensionRelayServer(opts: {
710802
pending.reject(new Error("extension disconnected"));
711803
}
712804
pendingExtension.clear();
713-
connectedTargets.clear();
714-
715-
for (const client of cdpClients) {
716-
try {
717-
client.close(1011, "extension disconnected");
718-
} catch {
719-
// ignore
720-
}
721-
}
722-
cdpClients.clear();
805+
scheduleExtensionDisconnectCleanup();
723806
});
724807
});
725808

@@ -741,12 +824,15 @@ export async function ensureChromeExtensionRelayServer(opts: {
741824
}
742825

743826
if (!extensionConnected()) {
744-
sendResponseToCdp(ws, {
745-
id: cmd.id,
746-
sessionId: cmd.sessionId,
747-
error: { message: "Extension not connected" },
748-
});
749-
return;
827+
const reconnected = await waitForExtensionReconnect(extensionCommandReconnectWaitMs);
828+
if (!reconnected || !extensionConnected()) {
829+
sendResponseToCdp(ws, {
830+
id: cmd.id,
831+
sessionId: cmd.sessionId,
832+
error: { message: "Extension not connected" },
833+
});
834+
return;
835+
}
750836
}
751837

752838
try {
@@ -841,6 +927,8 @@ export async function ensureChromeExtensionRelayServer(opts: {
841927
extensionConnected,
842928
stop: async () => {
843929
relayRuntimeByPort.delete(port);
930+
clearExtensionDisconnectCleanupTimer();
931+
flushExtensionReconnectWaiters(false);
844932
for (const [, pending] of pendingExtension) {
845933
clearTimeout(pending.timer);
846934
pending.reject(new Error("server stopping"));

0 commit comments

Comments
 (0)