mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
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>
111 lines
3.5 KiB
TypeScript
111 lines
3.5 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|