fix(browser): time out relay WebSocket opening (#109114)

* fix(browser): time out relay WebSocket opening

* test(browser): resolve worker import at runtime

* test(browser): refresh relay worker mocks

Co-authored-by: Alix-007 <li.long15@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Peter Steinberger <peter@steipete.me>
This commit is contained in:
Alix-007
2026-07-18 09:36:46 +01:00
committed by GitHub
co-authored by Peter Steinberger Peter Steinberger
parent cc7bd4d95a
commit bc77ef3398
2 changed files with 250 additions and 2 deletions
@@ -27,6 +27,9 @@ const COPILOT_RELAY_LABEL = {
on: "Browser relay connected",
error: "Browser relay reconnecting",
};
const RELAY_WATCHDOG_ALARM = "openclaw-relay-watchdog";
const RELAY_OPENING_DEADLINE_ALARM = "openclaw-relay-opening-deadline";
const RELAY_OPENING_TIMEOUT_MS = 30_000;
/** @type {WebSocket|null} */
let relayWs = null;
@@ -34,6 +37,7 @@ let relayState = "off"; // off | connecting | on | error
let copilot = null;
let reconnectAttempt = 0;
let reconnectTimer = null;
let relayOpeningDeadlineAt = 0;
/** Tab ids with an active chrome.debugger attachment. */
const attachedTabs = new Set();
/** Tabs denied to every relay attach while copilot run cleanup is pending. */
@@ -305,6 +309,16 @@ function send(message) {
}
}
function clearRelayOpeningDeadline() {
relayOpeningDeadlineAt = 0;
void chrome.alarms.clear(RELAY_OPENING_DEADLINE_ALARM);
}
function armRelayOpeningDeadline() {
relayOpeningDeadlineAt = Date.now() + RELAY_OPENING_TIMEOUT_MS;
chrome.alarms.create(RELAY_OPENING_DEADLINE_ALARM, { when: relayOpeningDeadlineAt });
}
async function handleRelayCommand(msg) {
const { seq } = msg;
try {
@@ -379,6 +393,7 @@ async function sendHello() {
async function connectRelay() {
const { relayUrl, token } = await getConfig();
if (!relayUrl || !token) {
clearRelayOpeningDeadline();
setBadge("off");
return;
}
@@ -398,7 +413,13 @@ async function connectRelay() {
return;
}
relayWs = ws;
armRelayOpeningDeadline();
ws.addEventListener("open", () => {
if (relayWs !== ws) {
ws.close();
return;
}
clearRelayOpeningDeadline();
reconnectAttempt = 0;
setBadge("on");
void sendHello();
@@ -414,6 +435,7 @@ async function connectRelay() {
});
ws.addEventListener("close", () => {
if (relayWs === ws) {
clearRelayOpeningDeadline();
relayWs = null;
setBadge("error");
scheduleReconnect();
@@ -435,6 +457,38 @@ copilot = createCopilotController({
const copilotCustodyReady = copilot.initializeCustody();
const copilotReady = copilot.initialize();
function handleRelayOpeningDeadline() {
const ws = relayWs;
if (!ws) {
clearRelayOpeningDeadline();
void connectRelay();
return;
}
if (ws.readyState === WebSocket.OPEN) {
clearRelayOpeningDeadline();
return;
}
if (
ws.readyState !== WebSocket.CONNECTING ||
relayOpeningDeadlineAt === 0 ||
Date.now() < relayOpeningDeadlineAt
) {
return;
}
// Clear ownership before close so a delayed close/open event from this
// socket cannot mutate the replacement connection's badge or deadline.
relayWs = null;
clearRelayOpeningDeadline();
try {
ws.close();
} catch {
// The socket may have changed state while the alarm event was queued.
}
setBadge("error");
scheduleReconnect();
}
function scheduleReconnect() {
if (reconnectTimer) {
return;
@@ -476,6 +530,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
groupColor: nearestGroupColor(msg.groupColor),
});
reconnectAttempt = 0;
clearRelayOpeningDeadline();
relayWs?.close();
relayWs = null;
await chrome.storage.local.set({ gatewayUrl: parsed.gatewayUrl ?? "" });
@@ -486,6 +541,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
}
case "unpair": {
await chrome.storage.local.remove(["relayUrl", "gatewayUrl", "token"]);
clearRelayOpeningDeadline();
relayWs?.close();
relayWs = null;
setBadge("off");
@@ -557,13 +613,15 @@ chrome.tabGroups.onRemoved.addListener(() => {
});
// Watchdog: MV3 can stop this worker; the alarm revives it and re-connects.
chrome.alarms.create("openclaw-relay-watchdog", { periodInMinutes: 0.5 });
chrome.alarms.create(RELAY_WATCHDOG_ALARM, { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "openclaw-relay-watchdog") {
if (alarm.name === RELAY_WATCHDOG_ALARM) {
void connectRelay();
void copilot.drainAborts();
void copilot.drainArchives();
void copilot.drainStaleScopes();
} else if (alarm.name === RELAY_OPENING_DEADLINE_ALARM) {
handleRelayOpeningDeadline();
}
});
chrome.runtime.onStartup.addListener(() => void connectRelay());
@@ -0,0 +1,190 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const RELAY_WATCHDOG_ALARM = "openclaw-relay-watchdog";
const RELAY_OPENING_DEADLINE_ALARM = "openclaw-relay-opening-deadline";
const START_TIME_MS = Date.parse("2026-07-16T08:00:00.000Z");
type SocketEvent = { data?: unknown };
type SocketListener = (event: SocketEvent) => void;
async function loadBackground() {
const sockets: FakeWebSocket[] = [];
let alarmListener: ((alarm: { name: string }) => void) | undefined;
class FakeWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
readyState = FakeWebSocket.CONNECTING;
readonly send = vi.fn();
readonly close = vi.fn(() => {
this.readyState = FakeWebSocket.CLOSED;
this.emit("close");
});
private readonly listeners = new Map<string, SocketListener[]>();
constructor(
readonly url: string,
readonly protocols: string[],
) {
sockets.push(this);
}
addEventListener(type: string, listener: SocketListener) {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
open() {
this.readyState = FakeWebSocket.OPEN;
this.emit("open");
}
private emit(type: string, event: SocketEvent = {}) {
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}
const addListener = vi.fn();
const createAlarm = vi.fn();
const clearAlarm = vi.fn(async () => true);
const setBadgeText = vi.fn(async () => undefined);
const setBadgeBackgroundColor = vi.fn(async () => undefined);
const chromeMock = {
action: { setBadgeText, setBadgeBackgroundColor },
alarms: {
create: createAlarm,
clear: clearAlarm,
onAlarm: {
addListener: vi.fn((listener: (alarm: { name: string }) => void) => {
alarmListener = listener;
}),
},
},
debugger: {
onEvent: { addListener },
onDetach: { addListener },
attach: vi.fn(async () => undefined),
detach: vi.fn(async () => undefined),
getTargets: vi.fn(async () => []),
sendCommand: vi.fn(async () => ({})),
},
runtime: {
getManifest: vi.fn(() => ({ version: "1.0.0" })),
onConnect: { addListener },
onMessage: { addListener },
onStartup: { addListener },
onInstalled: { addListener },
},
storage: {
local: {
get: vi.fn(async () => ({
relayUrl: "ws://127.0.0.1:18797/extension",
token: "test-token-placeholder",
groupColor: "orange",
})),
set: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
},
session: {
get: vi.fn(async () => ({})),
set: vi.fn(async () => undefined),
},
},
tabGroups: {
query: vi.fn(async () => []),
update: vi.fn(async () => undefined),
onUpdated: { addListener },
onRemoved: { addListener },
},
tabs: {
query: vi.fn(async () => []),
get: vi.fn(async () => ({ id: 1, windowId: 1 })),
group: vi.fn(async () => 1),
ungroup: vi.fn(async () => undefined),
create: vi.fn(async () => ({ id: 1 })),
remove: vi.fn(async () => undefined),
update: vi.fn(async () => undefined),
onRemoved: { addListener },
onUpdated: { addListener },
},
windows: { update: vi.fn(async () => undefined) },
};
vi.stubGlobal("chrome", chromeMock);
vi.stubGlobal("navigator", { userAgent: "Chromium/125.0.0.0" });
vi.stubGlobal("WebSocket", FakeWebSocket);
// The shipped MV3 worker is plain JS, so keep this a runtime-resolved import.
const backgroundModulePath = "./background.js";
await import(backgroundModulePath);
await Promise.resolve();
await Promise.resolve();
if (!alarmListener) {
throw new Error("expected background worker to register an alarm listener");
}
return {
alarmListener,
clearAlarm,
createAlarm,
setBadgeText,
sockets,
};
}
describe("relay opening deadline", () => {
beforeEach(() => {
vi.resetModules();
vi.useFakeTimers();
vi.setSystemTime(START_TIME_MS);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("closes a stuck connecting socket and retries", async () => {
const harness = await loadBackground();
expect(harness.sockets).toHaveLength(1);
expect(harness.createAlarm).toHaveBeenCalledWith(RELAY_WATCHDOG_ALARM, {
periodInMinutes: 0.5,
});
expect(harness.createAlarm).toHaveBeenCalledWith(RELAY_OPENING_DEADLINE_ALARM, {
when: START_TIME_MS + 30_000,
});
vi.setSystemTime(START_TIME_MS + 30_000);
harness.alarmListener({ name: RELAY_OPENING_DEADLINE_ALARM });
expect(harness.sockets[0]?.close).toHaveBeenCalledOnce();
expect(harness.clearAlarm).toHaveBeenCalledWith(RELAY_OPENING_DEADLINE_ALARM);
expect(harness.setBadgeText).toHaveBeenLastCalledWith({ text: "!" });
await vi.advanceTimersByTimeAsync(1_000);
expect(harness.sockets).toHaveLength(2);
expect(harness.createAlarm).toHaveBeenLastCalledWith(RELAY_OPENING_DEADLINE_ALARM, {
when: START_TIME_MS + 61_000,
});
});
it("clears the deadline after the socket opens", async () => {
const harness = await loadBackground();
const socket = harness.sockets[0];
expect(socket).toBeDefined();
socket?.open();
expect(harness.clearAlarm).toHaveBeenCalledWith(RELAY_OPENING_DEADLINE_ALARM);
expect(harness.setBadgeText).toHaveBeenLastCalledWith({ text: "ON" });
vi.setSystemTime(START_TIME_MS + 60_000);
harness.alarmListener({ name: RELAY_OPENING_DEADLINE_ALARM });
expect(socket?.close).not.toHaveBeenCalled();
});
});