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:
drakezhang
2026-07-22 12:17:06 +08:00
co-authored by Cursor
parent 5e91178e03
commit 234e9101ed
5 changed files with 286 additions and 1 deletions
+1
View File
@@ -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: {} });
});
});
+110
View File
@@ -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,
};
}
+5 -1
View File
@@ -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",