From 234e9101ed17d59e982e63926040730418044e95 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Wed, 22 Jul 2026 12:17:06 +0800 Subject: [PATCH] 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 --- apps/extension/PRIVACY.md | 1 + apps/extension/src/entrypoints/background.ts | 40 ++++++ .../src/lib/__tests__/heartbeat.test.ts | 130 ++++++++++++++++++ apps/extension/src/lib/heartbeat.ts | 110 +++++++++++++++ apps/extension/wxt.config.ts | 6 +- 5 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 apps/extension/src/lib/__tests__/heartbeat.test.ts create mode 100644 apps/extension/src/lib/heartbeat.ts diff --git a/apps/extension/PRIVACY.md b/apps/extension/PRIVACY.md index 29527a4..9517a3c 100644 --- a/apps/extension/PRIVACY.md +++ b/apps/extension/PRIVACY.md @@ -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 ``** — 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. diff --git a/apps/extension/src/entrypoints/background.ts b/apps/extension/src/entrypoints/background.ts index c1bafb5..ac81ab7 100644 --- a/apps/extension/src/entrypoints/background.ts +++ b/apps/extension/src/entrypoints/background.ts @@ -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); diff --git a/apps/extension/src/lib/__tests__/heartbeat.test.ts b/apps/extension/src/lib/__tests__/heartbeat.test.ts new file mode 100644 index 0000000..12ddf74 --- /dev/null +++ b/apps/extension/src/lib/__tests__/heartbeat.test.ts @@ -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 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: {} }); + }); +}); diff --git a/apps/extension/src/lib/heartbeat.ts b/apps/extension/src/lib/heartbeat.ts new file mode 100644 index 0000000..7869291 --- /dev/null +++ b/apps/extension/src/lib/heartbeat.ts @@ -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, + }; +} diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts index 9eee8da..bca4399 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -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: [""], icons: { 16: "icon/logo.png",