Add heartbeat event and daemon browser-liveness reaper

Introduce a `system.heartbeat` protocol event and use it as a
liveness signal on the daemon side.

- Track `last_seen` per connected browser, bumped on every inbound
  WS frame (tool response, event, ping/pong, or heartbeat).
- Add a reaper task that drops any browser silent past a 60s window
  and purges its sessions, mirroring the WS disconnect cleanup. This
  releases half-open connections left behind when the OS resumes from
  sleep and kills the service worker without delivering a close frame,
  so the daemon no longer pins a phantom "connected" browser and can
  idle-exit normally.
- Wire the reaper into both the foreground daemon and the test/embed
  entry point, aborting it on shutdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
drakezhang
2026-07-22 12:16:56 +08:00
co-authored by Cursor
parent ccf3c32140
commit 5e91178e03
8 changed files with 161 additions and 2 deletions
+82 -1
View File
@@ -22,10 +22,29 @@ use bsk_protocol::system::BrowserStatusEntry;
/// seconds to wake, discover the WS port, and finish `system.handshake`.
/// `session.start` polls the registry for up to this long before
/// returning `no_browser_connected`.
pub const EXTENSION_CONNECT_WAIT: Duration = Duration::from_secs(5);
///
/// Sized to comfortably cover one MV3 keepalive-alarm period (30s, the
/// smallest Chrome allows) plus handshake slack: after a long idle the
/// service worker is only revived on its next alarm tick, so a shorter
/// window would make the first command after idle fail with
/// `no_browser_connected` even though the extension is about to
/// reconnect. Callers that want a snapshot (`bsk status`/`browsers`)
/// pass their own, shorter wait instead of this constant.
pub const EXTENSION_CONNECT_WAIT: Duration = Duration::from_secs(35);
const EXTENSION_CONNECT_POLL: Duration = Duration::from_millis(50);
/// A connected browser is considered dead if no frame (including the
/// ~20s `system.heartbeat`) has arrived within this window. Set to three
/// missed heartbeats so a briefly-busy service worker (GC, heavy CDP) is
/// not reaped on a single late beat. Used by the liveness reaper to drop
/// half-open connections that never delivered a socket close — e.g. after
/// the OS resumed from sleep and killed the worker.
pub const BROWSER_LIVENESS_TIMEOUT: Duration = Duration::from_secs(60);
/// How often the liveness reaper scans the registry for stale browsers.
pub const BROWSER_LIVENESS_TICK: Duration = Duration::from_secs(15);
/// Process-wide monotonic counter for [`BrowserClient::generation`]. Used
/// by the reconnect-race guard: when an old WS task tears down it only
/// removes itself from the registry if the registry's current
@@ -125,9 +144,29 @@ pub struct BrowserClient {
/// `true` when the peer's `protocol_version` differs from ours
/// (same major, minor drift). Set during WS handshake (M10.4).
pub version_skew: bool,
/// Monotonic timestamp of the most recent inbound frame from this
/// browser (any response/event, including `system.heartbeat`).
/// Bumped by [`BrowserClient::touch`] from the WS read loop and read
/// by the liveness reaper to detect a silently-dead connection.
pub last_seen: Mutex<Instant>,
}
impl BrowserClient {
/// Record that a frame just arrived from this browser.
pub fn touch(&self) {
if let Ok(mut last) = self.last_seen.lock() {
*last = Instant::now();
}
}
/// How long since the last inbound frame from this browser.
pub fn idle_for(&self) -> Duration {
match self.last_seen.lock() {
Ok(last) => last.elapsed(),
Err(poisoned) => poisoned.into_inner().elapsed(),
}
}
pub fn status_entry(&self, session_count: u32) -> BrowserStatusEntry {
BrowserStatusEntry {
instance_id: self.id.0.clone(),
@@ -206,6 +245,20 @@ impl BrowserRegistry {
guard.values().cloned().collect()
}
/// Return every currently-registered browser whose last inbound
/// frame is older than `threshold`. The caller is responsible for
/// dropping them (via [`Self::remove_if_generation_matches`]) and
/// purging their sessions — done outside the lock so session
/// teardown never blocks the registry.
pub fn stale_browsers(&self, threshold: Duration) -> Vec<std::sync::Arc<BrowserClient>> {
let guard = self.inner.lock().expect("browser registry poisoned");
guard
.values()
.filter(|c| c.idle_for() >= threshold)
.cloned()
.collect()
}
pub fn len(&self) -> usize {
self.inner.lock().expect("browser registry poisoned").len()
}
@@ -348,6 +401,7 @@ mod tests {
generation: next_browser_generation(),
connected_at_ms: 0,
version_skew: false,
last_seen: Mutex::new(Instant::now()),
})
}
@@ -543,6 +597,33 @@ mod tests {
assert_eq!(reg.len(), 1);
}
#[test]
fn touch_resets_idle_for() {
let client = fake_client("a", "");
// Force the last_seen well into the past, then touch.
*client.last_seen.lock().unwrap() = Instant::now() - Duration::from_secs(120);
assert!(client.idle_for() >= Duration::from_secs(120));
client.touch();
assert!(client.idle_for() < Duration::from_secs(1));
}
#[test]
fn stale_browsers_reports_only_silent_connections() {
let reg = BrowserRegistry::new();
let fresh = fake_client("fresh", "");
let stale = fake_client("stale", "");
*stale.last_seen.lock().unwrap() = Instant::now() - Duration::from_secs(90);
reg.insert(fresh);
reg.insert(stale);
let stale_ids: Vec<String> = reg
.stale_browsers(Duration::from_secs(60))
.into_iter()
.map(|c| c.id.0.clone())
.collect();
assert_eq!(stale_ids, vec!["stale".to_string()]);
}
#[tokio::test]
async fn wait_for_any_connected_skips_when_already_populated() {
let reg = BrowserRegistry::new();