mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Keep service worker alive with WS heartbeat and wake reconnect
Harden the extension's connection so the daemon link survives normal
idle periods and OS sleep instead of silently dropping.
- Send a `system.heartbeat` event every 20s while the post-handshake
link is live. Since Chrome 116 WebSocket activity resets the MV3
service-worker idle timer, so this keeps the worker (and the socket)
alive during use rather than relying on the 30s keepalive alarm that
sits right on the eviction boundary. Declare `minimum_chrome_version:
"116"` accordingly.
- Reconnect on `runtime.onStartup` and `idle.onStateChanged` ("active"),
which revive the worker after a browser restart or machine wake and
let it reconnect immediately instead of waiting for the next alarm
tick. Add the `idle` permission and document it.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -48,6 +48,7 @@ The Extension requests the following Chrome permissions. Each is used solely for
|
||||
- **`tabs`** — Inspect, create, and close tabs in the Agent Window; query tab metadata.
|
||||
- **`windows`** — Create and manage the dedicated Agent Window that isolates agent activity from the user's normal browsing.
|
||||
- **`alarms`** — Periodically wake the service worker to keep the local WebSocket connection alive.
|
||||
- **`idle`** — Detect when the device returns from idle/locked so the Extension can promptly re-establish the local WebSocket connection after the machine wakes. No idle data is stored or transmitted.
|
||||
- **`notifications`** — Show a system notification to obtain user approval before the agent borrows a user-owned tab.
|
||||
- **`storage`** — Persist a random instance ID and optional label in `chrome.storage.local`.
|
||||
- **Host permission `<all_urls>`** — Inject a small status overlay (showing "Agent Active") on pages controlled by the agent, and enable automation across whatever sites the user directs the agent to. The Extension does **not** read or transmit page content from sites the agent is not actively driving.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { i18n } from "@browser-skill/i18n";
|
||||
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
||||
import { ConnectionController } from "@/lib/connection-controller";
|
||||
import { startHeartbeat } from "@/lib/heartbeat";
|
||||
import {
|
||||
getConnectionEnabled,
|
||||
setConnectionEnabled as persistConnectionEnabled,
|
||||
@@ -112,6 +113,45 @@ export default defineBackground(() => {
|
||||
shouldConnect: () => controller.isConnectionEnabled,
|
||||
});
|
||||
|
||||
// Application-level heartbeat (Chrome 116+): while the post-handshake
|
||||
// link is live, beat every 20s so WebSocket activity keeps the service
|
||||
// worker — and thus the daemon connection — alive during use, rather
|
||||
// than depending on the boundary-hugging 30s keepalive alarm. The
|
||||
// daemon also uses these beats to reap a silently-dead browser.
|
||||
startHeartbeat({
|
||||
send: (frame) => transport.send(frame),
|
||||
onActiveChange: (cb) =>
|
||||
controller.subscribe((snap) =>
|
||||
cb(snap.state === "connected" || snap.state === "version_skew"),
|
||||
),
|
||||
});
|
||||
|
||||
// Wake-driven reconnect (best effort). An MV3 service worker is killed
|
||||
// across OS sleep regardless of any keepalive, and the setTimeout-based
|
||||
// transport backoff dies with it. These Chrome lifecycle events revive
|
||||
// the worker and let us reconnect immediately instead of waiting for
|
||||
// the next 30s alarm tick. They only help when the daemon is actually
|
||||
// running; a cold daemon is (re)spawned by the next `bsk` command.
|
||||
const reconnectIfNeeded = () => {
|
||||
if (!controller.isConnectionEnabled) return;
|
||||
if (transport.state === "connected") return;
|
||||
void transport.connect().catch((err) => {
|
||||
console.debug("[browser-skill] wake reconnect attempt failed", err);
|
||||
});
|
||||
};
|
||||
if (typeof chrome.runtime?.onStartup?.addListener === "function") {
|
||||
chrome.runtime.onStartup.addListener(reconnectIfNeeded);
|
||||
}
|
||||
if (typeof chrome.idle?.onStateChanged?.addListener === "function") {
|
||||
chrome.idle.onStateChanged.addListener((state) => {
|
||||
// "active" fires when the user returns from idle/locked — the most
|
||||
// reliable "machine just woke" signal we get.
|
||||
if (state === "active") reconnectIfNeeded();
|
||||
});
|
||||
// Treat >60s of no input as idle so the active transition is timely.
|
||||
chrome.idle.setDetectionInterval?.(60);
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const connectionEnabled = await getConnectionEnabled();
|
||||
await controller.attach(transport, detectBrowserMeta(), connectionEnabled);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ProtocolFrame } from "@/transport/types";
|
||||
import { HEARTBEAT_EVENT, HEARTBEAT_INTERVAL_MS, startHeartbeat } from "../heartbeat";
|
||||
|
||||
function makeTimers() {
|
||||
let nextId = 1;
|
||||
const intervals = new Map<number, { cb: () => void; ms: number }>();
|
||||
return {
|
||||
intervals,
|
||||
api: {
|
||||
setInterval: (cb: () => void, ms: number) => {
|
||||
const id = nextId++;
|
||||
intervals.set(id, { cb, ms });
|
||||
return id;
|
||||
},
|
||||
clearInterval: (id: number) => {
|
||||
intervals.delete(id);
|
||||
},
|
||||
},
|
||||
tick: (id: number) => intervals.get(id)?.cb(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Drives `onActiveChange` subscribers so a test can flip the link state. */
|
||||
function makeActiveSource() {
|
||||
const listeners = new Set<(active: boolean) => void>();
|
||||
return {
|
||||
subscribe: (cb: (active: boolean) => void) => {
|
||||
// Mirror the controller contract: fire immediately with the current
|
||||
// value (starts inactive), then on every change.
|
||||
cb(false);
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
},
|
||||
set: (active: boolean) => {
|
||||
for (const cb of listeners) cb(active);
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
};
|
||||
}
|
||||
|
||||
describe("startHeartbeat", () => {
|
||||
it("does not beat while the link is inactive", () => {
|
||||
const timers = makeTimers();
|
||||
const active = makeActiveSource();
|
||||
const send = vi.fn();
|
||||
startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
|
||||
|
||||
expect(timers.intervals.size).toBe(0);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts a 20s interval on activation and sends heartbeat events", () => {
|
||||
const timers = makeTimers();
|
||||
const active = makeActiveSource();
|
||||
const send = vi.fn();
|
||||
startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
|
||||
|
||||
active.set(true);
|
||||
expect(timers.intervals.size).toBe(1);
|
||||
const [id, info] = [...timers.intervals.entries()][0];
|
||||
expect(info.ms).toBe(HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
timers.tick(id);
|
||||
expect(send).toHaveBeenCalledWith({ event: HEARTBEAT_EVENT, payload: {} });
|
||||
});
|
||||
|
||||
it("stops beating when the link goes inactive", () => {
|
||||
const timers = makeTimers();
|
||||
const active = makeActiveSource();
|
||||
const send = vi.fn();
|
||||
startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
|
||||
|
||||
active.set(true);
|
||||
expect(timers.intervals.size).toBe(1);
|
||||
active.set(false);
|
||||
expect(timers.intervals.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not stack intervals on repeated activation", () => {
|
||||
const timers = makeTimers();
|
||||
const active = makeActiveSource();
|
||||
startHeartbeat({ send: vi.fn(), onActiveChange: active.subscribe, timers: timers.api });
|
||||
|
||||
active.set(true);
|
||||
active.set(true);
|
||||
expect(timers.intervals.size).toBe(1);
|
||||
});
|
||||
|
||||
it("swallows send errors so the interval keeps running", () => {
|
||||
const timers = makeTimers();
|
||||
const active = makeActiveSource();
|
||||
const send = vi.fn(() => {
|
||||
throw new Error("socket closed");
|
||||
});
|
||||
const handle = startHeartbeat({ send, onActiveChange: active.subscribe, timers: timers.api });
|
||||
expect(() => handle.beatForTest()).not.toThrow();
|
||||
});
|
||||
|
||||
it("dispose clears the interval and unsubscribes", () => {
|
||||
const timers = makeTimers();
|
||||
const active = makeActiveSource();
|
||||
const handle = startHeartbeat({
|
||||
send: vi.fn(),
|
||||
onActiveChange: active.subscribe,
|
||||
timers: timers.api,
|
||||
});
|
||||
active.set(true);
|
||||
expect(timers.intervals.size).toBe(1);
|
||||
|
||||
handle.dispose();
|
||||
expect(timers.intervals.size).toBe(0);
|
||||
expect(active.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("sends a well-formed protocol frame", () => {
|
||||
const timers = makeTimers();
|
||||
const active = makeActiveSource();
|
||||
let captured: ProtocolFrame | null = null;
|
||||
const handle = startHeartbeat({
|
||||
send: (f) => {
|
||||
captured = f;
|
||||
},
|
||||
onActiveChange: active.subscribe,
|
||||
timers: timers.api,
|
||||
});
|
||||
handle.beatForTest();
|
||||
expect(captured).toEqual({ event: HEARTBEAT_EVENT, payload: {} });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Application-level WebSocket heartbeat.
|
||||
*
|
||||
* Since Chrome 116, sending or receiving a message over a WebSocket
|
||||
* resets the MV3 service-worker idle timer (see
|
||||
* https://developer.chrome.com/docs/extensions/how-to/web-platform/websockets).
|
||||
* By emitting a tiny `system.heartbeat` event every 20s — comfortably
|
||||
* inside the 30s idle window — we keep the worker (and therefore the
|
||||
* daemon link) alive for as long as the connection is up, instead of
|
||||
* relying solely on the 30s keepalive alarm that sits right on the
|
||||
* eviction boundary.
|
||||
*
|
||||
* The daemon also treats the heartbeat as a liveness signal, so a
|
||||
* silently-dead browser can be reaped on its side.
|
||||
*
|
||||
* The heartbeat only runs while the *post-handshake* link is live: the
|
||||
* daemon rejects any first frame that is not `system.handshake`, so
|
||||
* beating before the handshake completes would get the connection
|
||||
* kicked. Callers wire {@link HeartbeatOptions.onActiveChange} to the
|
||||
* connection controller, which only reports `connected` / `version_skew`
|
||||
* after the handshake has landed.
|
||||
*/
|
||||
|
||||
import type { ProtocolFrame } from "@/transport/types";
|
||||
|
||||
export const HEARTBEAT_EVENT = "system.heartbeat";
|
||||
/** 20s: the interval Chrome's own WebSocket keepalive sample recommends. */
|
||||
export const HEARTBEAT_INTERVAL_MS = 20_000;
|
||||
|
||||
/** Subset of the timer API we use; lets tests inject fakes. */
|
||||
export interface HeartbeatTimers {
|
||||
setInterval(cb: () => void, ms: number): number;
|
||||
clearInterval(handle: number): void;
|
||||
}
|
||||
|
||||
export interface HeartbeatOptions {
|
||||
/** Send a frame over the live transport. */
|
||||
send: (frame: ProtocolFrame) => void;
|
||||
/**
|
||||
* Subscribe to "is the post-handshake link live" changes. Must invoke
|
||||
* the callback with the current value immediately and again on every
|
||||
* transition, and return an unsubscribe function.
|
||||
*/
|
||||
onActiveChange: (cb: (active: boolean) => void) => () => void;
|
||||
/** Override the beat interval (tests). */
|
||||
intervalMs?: number;
|
||||
/** Inject fake timers (tests). */
|
||||
timers?: HeartbeatTimers;
|
||||
}
|
||||
|
||||
export interface HeartbeatHandle {
|
||||
/** Stop beating and detach the active-state listener. */
|
||||
dispose(): void;
|
||||
/** Test hook: emit one heartbeat frame right now. */
|
||||
beatForTest(): void;
|
||||
}
|
||||
|
||||
function defaultTimers(): HeartbeatTimers {
|
||||
return {
|
||||
setInterval: (cb, ms) => setInterval(cb, ms) as unknown as number,
|
||||
clearInterval: (handle) => clearInterval(handle),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a heartbeat that beats every `intervalMs` while the link is
|
||||
* active and pauses entirely while it is not.
|
||||
*/
|
||||
export function startHeartbeat(options: HeartbeatOptions): HeartbeatHandle {
|
||||
const intervalMs = options.intervalMs ?? HEARTBEAT_INTERVAL_MS;
|
||||
const timers = options.timers ?? defaultTimers();
|
||||
let timer: number | null = null;
|
||||
|
||||
const beat = () => {
|
||||
try {
|
||||
options.send({ event: HEARTBEAT_EVENT, payload: {} });
|
||||
} catch (err) {
|
||||
// The socket raced a close between the state change and this
|
||||
// tick. The transport's own reconnect path recovers; swallow so
|
||||
// the interval keeps running (it is cleared on the disconnect
|
||||
// notification anyway).
|
||||
console.debug("[bsk heartbeat] send failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (timer !== null) {
|
||||
timers.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (timer !== null) return;
|
||||
timer = timers.setInterval(beat, intervalMs);
|
||||
};
|
||||
|
||||
const unsubscribe = options.onActiveChange((active) => {
|
||||
if (active) start();
|
||||
else stop();
|
||||
});
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
unsubscribe();
|
||||
stop();
|
||||
},
|
||||
beatForTest: beat,
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,11 @@ export default defineConfig({
|
||||
name: "BrowserSkill",
|
||||
description:
|
||||
"Let AI agents use your logged-in browser in a separate Agent Window—without interrupting your work. Powered by the bsk CLI.",
|
||||
permissions: ["alarms", "debugger", "notifications", "tabs", "storage", "windows"],
|
||||
// Chrome 116 is the first version where WebSocket activity resets the
|
||||
// service-worker idle timer, which the 20s heartbeat relies on to
|
||||
// keep the daemon link alive.
|
||||
minimum_chrome_version: "116",
|
||||
permissions: ["alarms", "debugger", "idle", "notifications", "tabs", "storage", "windows"],
|
||||
host_permissions: ["<all_urls>"],
|
||||
icons: {
|
||||
16: "icon/logo.png",
|
||||
|
||||
Reference in New Issue
Block a user