Fix browser keepalive reconnect#37
Merged
Merged
Conversation
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>
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>
After a long idle the service worker is only revived on its next keepalive-alarm tick (up to 30s away), so the previous 5s wait made the first command after idle fail with `no_browser_connected` even though the extension was about to reconnect. - Raise the daemon's `EXTENSION_CONNECT_WAIT` (used by `session.start`) to 35s so it comfortably covers one alarm period plus handshake slack. - Bump the CLI `session start` IPC timeout to that window plus slack so it never gives up before the daemon answers, and print a one-line "waiting for browser extension to connect…" hint when it actually waits. - Keep `bsk status` / `browsers` on their own short 5s wait so quick informational commands are not slowed down. Co-authored-by: Cursor <cursoragent@cursor.com>
The extension emits the literal event string "system.heartbeat"; assert the daemon-side serde name serialises to the same value and round-trips from the exact frame shape the extension sends, so a future rename cannot silently break keepalive and liveness. Co-authored-by: Cursor <cursoragent@cursor.com>
The manifest now declares a minimum Chrome version and requests the `idle` permission, and the runtime gains the WebSocket heartbeat and wake-driven reconnect. Publishing that update requires a new version. Co-authored-by: Cursor <cursoragent@cursor.com>
Only reap browsers that have sent at least one `system.heartbeat`. A pre-heartbeat extension never opts in, so it is never dropped on silence and keeps relying on socket-close detection exactly as before. This removes a backward-compat regression where a new daemon paired with an old extension would wrongly reap a live-but-idle connection and then report `no_browser_connected` on the next command. - Add a `heartbeat_seen` flag to `BrowserClient`, set when a heartbeat event arrives, and exclude never-heartbeated clients from `stale_browsers`. - Cover both the opt-in and the legacy-survivor cases in unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
Move the reaper's silence timeout and scan cadence into DaemonConfig (defaulting to the existing 60s/15s constants) with a `with_browser_liveness` builder, so tests can drive the task in sub-second windows. Add an integration test that runs a real daemon and asserts a silent heartbeat-capable browser — and its sessions — get reaped, while a legacy browser that never heartbeated is left untouched. Co-authored-by: Cursor <cursoragent@cursor.com>
…-reconnect Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # apps/extension/wxt.config.ts # crates/bsk-cli/src/cli/session.rs
shnpd
approved these changes
Jul 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
## Problem
With Chrome and the extension both running, after finishing one task and leaving it idle for a long time, the next command reports "extension not connected" and
bsk doctorshows 0 browsers. Root cause is the Chrome MV3 service-worker lifecycle: the worker is evicted after ~30s idle, the only keepalive (a 30s alarm) sits right on the eviction boundary and is delayed or skipped during sleep, the daemon then self-exits after ~10min, and the CLI's 5s connect wait is too short for the worker to revive on its next alarm — so the command fails even though the extension is about to reconnect.Solution
Three changes, as discussed on the issue:
system.heartbeatevery 20s while connected; the daemon uses it as a liveness signal and reaps a silently-dead browser (and its sessions). Declaresminimum_chrome_version: "116".runtime.onStartupandidle.onStateChanged("active") so the worker reconnects immediately after a browser restart or machine wake instead of waiting for the next alarm (adds theidlepermission).session.startbrowser-connect wait to ~35s (with the CLI IPC timeout and a "waiting…" hint to match), so the first command after idle waits for the reviving worker instead of failing fast.bsk status/browserskeep their short 5s wait.Why this way
To stay safe for mixed versions, liveness reaping only applies to browsers
that have actually sent a heartbeat, so a new daemon paired with an older
extension never wrongly drops a live-but-idle connection.
Testing
system.heartbeatwire name, daemon unit tests for the liveness gate, and an integration test that runs a real daemon and asserts a silent heartbeat-capable browser (and its sessions) get reaped while a legacy no-heartbeat browser is left untouched.cargo test,cargo clippy,cargo fmt, and the extensiontsc+vitestsuites all pass.Extension bumped to
0.1.5.问题
Chrome 和浏览器都开着,插件也正常, 执行完一次任务后长时间放置,再次下达指令时报「插件未连接」,
bsk doctor显示浏览器数为 0。根因在于 Chrome MV3 的 Service Worker 生命周期: SW 空闲约 30s 被回收,而唯一的保活(30s 闹钟)正好卡在回收边界、休眠时还会被延迟或跳过; SW 断开后 daemon 约 10 分钟无连接便自杀退出;此时 CLI 等待浏览器连接的窗口只有 5s, 远不够 SW 靠下一次闹钟复活——于是即便插件马上就要重连,命令也直接失败。解决方案
做了三点改动:
system.heartbeat;daemon 以此作为存活信号,回收僵死的浏览器(连同其 session)。 同时声明minimum_chrome_version: "116"。runtime.onStartup与idle.onStateChanged("active"), 浏览器重启或机器唤醒后立即重连,而不必等下一次闹钟(新增idle权限)。session.start的浏览器连接等待放宽到 ~35s (CLI 的 IPC 超时同步抬高,并在等待时打印「waiting…」提示),使空闲后的第一条指令会等待正在复活的 SW,而不是快速失败。bsk status/browsers仍保持 5s 短等待。思考了下,还是做了3个改动,因为第三项可能会有改善,但是大部分情况还是会中断,测试了下1 和2 消耗还好,另外也还是当前问题的scope 没有扩大问题。
为什么这么做
测试
我自己验证了连续 12 小时以上稳定连接,以及机器休眠、重启后仍能正常重连,体验提升明显。
所有相关改动均补充了测试:插件侧心跳调度的单元测试、
system.heartbeatwire 名的协议契约测试、daemon 侧探活门控的单元测试,以及一个跑真实 daemon 的集成测试——断言静默的、支持心跳的浏览器
(及其 session)会被回收,而从未发过心跳的旧版浏览器不受影响。
cargo test、cargo clippy、cargo fmt以及插件的tsc+vitest全部通过。插件版本升至
0.1.5。