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
+13 -1
View File
@@ -304,6 +304,7 @@ async fn drive_connection(
generation,
connected_at_ms,
version_skew,
last_seen: std::sync::Mutex::new(std::time::Instant::now()),
});
state.browsers.insert(Arc::clone(&client));
info!(
@@ -352,15 +353,21 @@ async fn drive_connection(
msg = reader.next() => {
match msg {
Some(Ok(Message::Text(t))) => {
// Any inbound frame — tool response, event, or the
// ~20s heartbeat — counts as proof of life.
pump_browser.touch();
handle_inbound_text(&pump_state, &pump_browser, &t).await;
}
Some(Ok(Message::Binary(_))) => {
warn!("ignoring binary message");
}
Some(Ok(Message::Ping(p))) => {
pump_browser.touch();
let _ = writer.send(Message::Pong(p)).await;
}
Some(Ok(Message::Pong(_))) => {}
Some(Ok(Message::Pong(_))) => {
pump_browser.touch();
}
Some(Ok(Message::Frame(_))) => {}
Some(Ok(Message::Close(_))) | None => break,
Some(Err(err)) => {
@@ -416,6 +423,11 @@ async fn handle_inbound_text(state: &Arc<DaemonState>, client: &Arc<BrowserClien
}
}
Frame::Event(ev) => match ev.event {
bsk_protocol::EventKind::SystemHeartbeat => {
// Liveness only — `touch()` already ran for this frame in
// the read loop, so there is nothing else to do.
debug!(id = %client.id, "heartbeat");
}
bsk_protocol::EventKind::SessionWindowClosed => {
handle_session_window_closed(state, &client.id, &ev.payload);
}