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,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);