Bug: mux heartbeat kills a healthy browser WebSocket (endless ~10s reconnect loop) #6092
Replies: 1 comment
|
Thanks for the forensic write-up — the destroy-stack samples plus the "same client, only the interval changed" control make this one of the more useful mux reports so far. I re-derived the mechanism from source on Confirmed from source
1. I would not ship fix A.1 as written. In This is falsifiable with your existing instrumentation: log 2. Your data already contains a better liveness signal
So the robust gate is positive evidence, not a buffer threshold: treat a missed pong as no evidence rather than bad evidence, and only kill when the socket is silent in both directions (no inbound bytes since the last heartbeat, no pong) and the send path is healthy. Backlog stays as a guard, but as a second condition after the liveness check, not the primary one. That also scales the deadline for free: a peer that is still talking should never be timed out at 4 s no matter how large the burst is. 3. The client replay is structural — the cheaper lever is in the journal layer Because the reopening request is the retained opening window, "follow only visible sessions" plus a byte budget (B.1/B.2) reduces the size but not the shape: any transport reset still re-opens every live journal with a full snapshot. Worth checking whether a transport-driven generation should instead resume from the retained cursor with One note on the amplification bound: On diagnostics (C) — I agree, and I'd add that this is not currently reachable from a plugin: Finally: the 中文补充(三条)
|
Uh oh!
There was an error while loading. Please reload this page.
Component:
packages/api/gateway(RemoteStreamMuxServer/TypertGatewayService) — client symptom lands inpackages/client/connectionandpackages/api/session-controller/src/client/sessions/session.tsVersion:
dsh@0.1.2-rc.1(commita66e4702,@deepseek-ai/dsh-api-gateway@0.1.2-rc.1)Platform: Windows 10 Pro 19045, Node v24.14.0, Edge 152.0.4191.66,
dsh webon127.0.0.1:3080(loopback, no NAT/VPN/proxy in the path)Related: #3030 and #2842 report the mirror image (WebSocket paths that lack a heartbeat); #3102 is a reconnect-resync race that this loop makes far more likely.
Checked against master:
origin/masterd347e703(0.1.3-alpha.1) has no diff forpackages/api/gateway/src/stream-server.tsrelative to the release, so the defect is still present.中文摘要
宿主端
RemoteStreamMuxServer的心跳用「2 次没收到 pong(约 4–6 秒)就认定对端已死」来判定存活,却完全不看这条 socket 上还积压着多少没写完的数据。而浏览器端每次(重)连接都会为它持有的每一个会话重开一路session/follow并回放完整快照(实测 12+ 个会话 ≈ 28 MB)。于是形成闭环:重连 → 28 MB 洪峰 → 浏览器还在消化,ping 帧排在数据后面,pong 来不及回来 → 服务端terminate()→ 再次重连。用户看到的就是连接指示灯每十几秒闪一次「连接中 / 连接异常 / 连接成功」,而主机、网络、会话本身全都正常。把
websocketHeartbeatIntervalMs从 2000 改成 30000(profile 补丁层,loader 热生效)后,同一个浏览器、同一份数据洪峰,连接稳定 10 分钟以上 —— 说明客户端从未真的掉线,是看门狗的判据有问题。建议:服务端在判定
missedHeartbeats之前先看socket.bufferedAmount(有积压就不能判死),并以 ping 的 flush 回调作为计时起点;客户端则避免在一次重连中回放 N 份全量快照(按可见性懒开、给首屏快照加字节预算、把重开串行化)。Symptom
The browser's connection indicator flickers 连接中 → 连接异常 → 连接成功 roughly every 10 seconds, forever. It auto-recovers without any user action, so it reads like a flaky network or a slow host, but the host process is healthy (16 h uptime, no errors logged), all HTTP APIs answer instantly, and the machine shows no connectivity change at the time.
Summary
The mux watchdog treats "no pong within 2 missed heartbeats (~4–6 s)" as a dead peer. But the same socket may be carrying a multi-megabyte burst, and a pong can only be produced by the peer after it has read past that burst. So a perfectly healthy — merely busy — browser is terminated, and because the reconnect replays the burst, the disconnect becomes self-sustaining.
Two defects compound:
startHeartbeat()counts a heartbeat before/independently of the ping frame actually being flushed, and never consults the carrier's write backlog, so backpressure is indistinguishable from death.session/followstream for every session the page holds, each replaying a full snapshot; with a workspace of a dozen sessions that is ~28 MB in one burst.Evidence
Measured by wrapping the host's
/api/remote.muxupgrade route and the gateway'sopenRemoteEvents, logging to a JSONL file (in-process instrumentation of a livedsh web; no source changes).1. Every disconnect is the server terminating the carrier
Destroy stack on the raw socket (40/40 samples):
i.e. it is
RemoteStreamMuxServer.startHeartbeatand not the client, not TCP, and not the OS.peerFinMs: null, readableEnded: falseon those sockets: no FIN was received from the browser first.2. The socket was never dead — it was behind
One representative connection (lifetime 9052 ms):
dataInBytes: 5378shows the browser was still sending its own frames — the client was alive and reading the whole time.3. What the burst is
Outbound text frames sampled during the first ~1.5 s of one connection (12 distinct streams, one per followed session):
Total per connection: 27.7–32.7 MB (
totalWritten: 27733865 / 27842938 / 28167293 / 32743992over 9–164 s lifetimes). The snapshot replay is byte-blind:packages/api/session-controller/src/client/sessions/session.ts:601opens with{ maxMessages: PAGE_MESSAGES }(PAGE_MESSAGES = 50, line 47) while the payload is dominated by large tool records.4. The loop is self-sustaining
38 sockets in 35 minutes (02:06:47Z–02:41:55Z). Each cycle: terminate → client backoff 5–16 s → new socket → 28 MB replay → pong starved → terminate.
5. Confirmation: the same client, only the heartbeat interval changed
Setting
websocketHeartbeatIntervalMs: 30000for thetypert-gatewayloader entry (profile patch layer; the loader hot-applied it and re-created the mux):Same browser, same workspace, same 34 MB burst: no disconnect, connection stable for 10+ minutes. The client was never unhealthy — only the watchdog was wrong.
Root cause
A. Server: a liveness deadline that a busy carrier cannot meet
packages/api/gateway/src/stream-server.ts:With the default interval the peer has ~4–6 s to answer a ping. In that window this session's carrier legitimately had tens of megabytes in flight:
send()(line 180) serializes frames throughthis.writesand resolves on thewswrite callback, which means "handed to the kernel", never "consumed by the peer". On loopback the kernel happily absorbs megabytes, so the ping frame — a normal frame in the same FIFO TCP stream — is queued behind them, and the pong cannot be produced until the peer reads that far. The watchdog then kills a socket that is merely behind, and it does so at exactly the moment the client is working hardest.B. Client transport: one reconnect ⇒ N full snapshot replays
The page holds a client
Sessionper followed session (packages/api/session-controller/src/client/sessions/session.ts:601→events.open({ maxMessages: PAGE_MESSAGES })→session/follow). A transport reset makes every one of them re-open at once and each replay is a full snapshot (0.4–2.2 MB here, 12+ sessions). The result is a ~28 MB burst on a socket that the watchdog allows 4 s of silence.Note this also means a single genuine blip is enough to enter the loop, so the user-visible failure mode is "the GUI never stops reconnecting" rather than "one reconnect".
Suggested fixes
A. Make the watchdog backpressure-aware (small, server-only)
socket.ping(undefined, undefined, () => {…})(or reset the counter when the frame is written) so the peer's budget starts when the ping leaves the host.max(3 × interval, 30_000)of silence, and consider raisingDEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS(2 000 currently).bufferedAmountinstead of only awaiting the write callback; then a ping can never queue behind 28 MB of session replay.B. Stop the reconnect amplification (client)
maxMessages) and backfill the rest on demand.C. Diagnostics
A terminated-by-watchdog event (
missedHeartbeats,bufferedAmount, lifetime, bytes written) would have made this a five-minute diagnosis instead of a morning of packet forensics.Reproduction
WebSocket.terminatefromstartHeartbeatevery 10–30 s, the client reconnects with 5–16 s of backoff, and the connection indicator flickers forever.Workaround
The loader applies this live (it re-creates the mux); no host restart needed. It removes the symptom but not the underlying burst — fix A is still the right place for it.
All reactions