Skip to content

Fix browser keepalive reconnect#37

Merged
iuyo5678 merged 8 commits into
mainfrom
fix-browser-keepalive-reconnect
Jul 23, 2026
Merged

Fix browser keepalive reconnect#37
iuyo5678 merged 8 commits into
mainfrom
fix-browser-keepalive-reconnect

Conversation

@iuyo5678

Copy link
Copy Markdown
Collaborator

## 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 doctor shows 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:

  1. Application-level WebSocket heartbeat + daemon liveness. The extension sends a system.heartbeat every 20s while connected; the daemon uses it as a liveness signal and reaps a silently-dead browser (and its sessions). Declares minimum_chrome_version: "116".
  2. Wake-driven reconnect. Reconnect on runtime.onStartup and idle.onStateChanged ("active") so the worker reconnects immediately after a browser restart or machine wake instead of waiting for the next alarm (adds the idle permission).
  3. Wider connect window. Raise the daemon's session.start browser-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 / browsers keep their short 5s wait.

Why this way

  1. The 20s heartbeat follows Chrome's recommended WebSocket keepalive pattern — since Chrome 116, WebSocket activity resets the service-worker idle timer, which is the intended mechanism to keep an in-use worker alive.
  2. The failure is inherent to the MV3 lifecycle and cannot be fully eliminated without a local native-messaging host. These changes are a deliberate tradeoff: they sharply reduce how often the problem happens and make the connection far more robust, while the extra daemon memory from staying resident is negligible.
    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

  • Verified a stable connection for 12+ hours, and correct reconnection after the machine slept and restarted — noticeably better experience.
  • All new behavior is covered by tests: extension unit tests for the heartbeat scheduler, protocol contract tests for the system.heartbeat wire 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 extension tsc + vitest suites 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 靠下一次闹钟复活——于是即便插件马上就要重连,命令也直接失败。

解决方案

做了三点改动:

  1. 应用层 WebSocket 心跳 + daemon 探活。 连接期间插件每 20s 发一个 system.heartbeat;daemon 以此作为存活信号,回收僵死的浏览器(连同其 session)。 同时声明 minimum_chrome_version: "116"
  2. 唤醒主动重连。 监听 runtime.onStartupidle.onStateChanged("active"), 浏览器重启或机器唤醒后立即重连,而不必等下一次闹钟(新增 idle 权限)。
  3. 放宽连接等待窗口。 将 daemon 侧 session.start 的浏览器连接等待放宽到 ~35s (CLI 的 IPC 超时同步抬高,并在等待时打印「waiting…」提示),使空闲后的第一条指令会等待正在复活的 SW,而不是快速失败。bsk status / browsers 仍保持 5s 短等待。
    思考了下,还是做了3个改动,因为第三项可能会有改善,但是大部分情况还是会中断,测试了下1 和2 消耗还好,另外也还是当前问题的scope 没有扩大问题。

为什么这么做

  1. 20s 心跳参考了 Chrome 官方推荐的 WebSocket 保活方案——自 Chrome 116 起, WebSocket 收发活动会重置 SW 空闲计时器,这正是保持「使用中的 worker」存活的既定机制。
  2. 该问题源于 MV3 生命周期本身,除非引入本地 native-messaging 方案,否则无法彻底消除。 因此这里是有意为之的 tradeoff:显著降低问题发生的概率、大幅提升连接健壮性,而 daemon 因常驻带来的额外内存开销几乎可以忽略。
  3. 为兼顾多版本共存, 探活回收只作用于已发过心跳的浏览器,因此新版 daemon 搭配旧版chrome的插件, 不会删一个正常但是空闲的连接。

测试

  • 我自己验证了连续 12 小时以上稳定连接,以及机器休眠、重启后仍能正常重连,体验提升明显。

  • 所有相关改动均补充了测试:插件侧心跳调度的单元测试、system.heartbeat wire 名的协议契约测试、
    daemon 侧探活门控的单元测试,以及一个跑真实 daemon 的集成测试——断言静默的、支持心跳的浏览器
    (及其 session)会被回收,而从未发过心跳的旧版浏览器不受影响。

  • cargo testcargo clippycargo fmt 以及插件的 tsc + vitest 全部通过。

插件版本升至 0.1.5

TencentXiaowei and others added 8 commits July 22, 2026 12:16
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
@iuyo5678
iuyo5678 requested review from Ljy-0827 and shnpd July 22, 2026 08:56
@iuyo5678
iuyo5678 merged commit c8963e3 into main Jul 23, 2026
4 checks passed
@iuyo5678
iuyo5678 deleted the fix-browser-keepalive-reconnect branch July 23, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants