mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
Peter Steinberger
Peter Steinberger
parent
cc7bd4d95a
commit
bc77ef3398
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user