← 回到 infra 主页

DeepSeek Harness · Upstream · Issue #9 草案

mux WebSocket 的心跳判死是静默的:漏 2 次 Pong 即 terminate()(无日志、无指标、无 Close 帧),而判死判据与它要检测的拥塞共用同一条连接

先说结论:用户报的「DSH 老是显示自动重连中、并行会话一多就更频繁」,在排除了网络、浏览器、事件循环停摆与鉴权之后,落到载体自己的心跳上——/api/remote.mux 默认每 2 秒 Ping,连续 2 次没收到 Pong 就在第 3 跳调用 socket.terminate()。实测容忍窗口 = 6.0 秒(三跳 2 秒),且这次判死不发 Close 帧、不写日志、不打指标:浏览器只拿到 1006 空 reason,于是把 generation 判失效、显示「自动重连中」,我们的监控与 Caddy 日志全程全绿。更要紧的是判据本身:Ping 与所有会话流的数据帧走同一条 TCP,而 Host 的写路径是单条串行链、从不看 bufferedAmount——拥塞会把 Pong 挤出它自己的判死窗口。本页给出零依赖的复现探针、代码锚点(指到行)、建议修法与可直接粘贴的英文 issue 正文。

一句话判据(30 秒复现):连上 mux 后停止回 Pong 但继续读,Host 会在最后一个被回答的 Pong 之后约 6.0 秒拆掉连接,且没有 Close 帧:HEARTBEAT_TOLERANCE … last_pong_to_close_ms=6005 close_code=none result=KILLED。探针:node ops/dsh/upstream-issue-ws-heartbeat/heartbeat-tolerance-probe.mjs --port 4006。
优先级:中高。①用户可感知且日常化(每次判死都弹一次「自动重连中」,客户端文案在 connection.connecting);②每次重连成功都会触发客户端 connection/reset,把整批数据重拉一遍——其中就包含 #7/#8 那条实测 9.7–11.9 秒的 session/list,多会话并行时构成正反馈;③本缺陷当前在监控上不可见:不修观测性,我们只能靠用户报。

一句话结论与三条事实

前两条是本次实测,第三条是随包源码可指到行、但尚未取得生产现场证据的机制(按本仓约定标「未定论」)。

① 容忍窗口只有 6.0 秒:漏 2 次 Pong,第 3 跳判死

默认 websocketHeartbeatIntervalMs = 2000;MAX_MISSED_HEARTBEATS = 2。多轮独立实测(同一个零依赖原生 socket 探针,另有一轮 ws 客户端交叉复现,判词格式不同未收录原文)都落在 6000–6005 ms(验证轮 6002 / 6001 / 6001 / 6002 ms),即「最后一次被回答的 Pong → socket 被拆」为三跳 2 秒;一轮负载抖动实测 6279 ms(+4.6%)——窗口是「≥ 三跳」,tick 迟到会等比拉长。

② 判死是静默的:不发 Close 帧、不写日志、不打指标

判死分支里只有 socket.terminate()(直接销毁 TCP)。实测 close_code=none:连 Close 帧都没有,客户端只能看到 1006 空 reason,无从区分「Host 心跳判死」与「网线掉了」。Host 侧同样没有任何一行日志或指标——这直接解释了为什么用户在看「自动重连中」时,我们的看板是全绿的。

③ 判据与被判对象共用同一条连接(机制清楚,现场未证)

RemoteStreamMuxConnection.send() 把所有逻辑流的每一帧串到同一条 this.writes 链上,只等 write 回调,从不读 bufferedAmount;而 socket.ping() 写的是同一个 socket。TCP 有序:Ping 会排在已排队的数据帧之后。另一侧,浏览器在背压下会暂停读 socket——连 Ping 都读不到,自然不回 Pong。也就是说:会话越多 → 事件越多 → 发送缓冲越积压 → 越容易吃满 6 秒窗口 → 被判死 → 重连后又整批重拉。这一条我们只有代码级证据与机制推理,没有抓到「生产实例拥塞期间被杀」的现场(缺 Host 侧计数)——正好是建议 (a) 要补的观测。

实测锚点(含原始输出)

2026-09-13 22:0x(+08:00)在 user-a 实例(127.0.0.1:4006,loopback 直连、不经过 Caddy 与本机 gateway)上测得;命令可复跑。

观测值
Ping 间隔median_gap_ms=2000(逐次 1999 / 2000 / 2000 / 2004 / 2000)——与默认值一致
健康期(正常回 Pong)连接稳定:8 秒内 4 个 Ping 全部回答,socket 不断
静音后(继续读、不回 Pong)Ping #5 missed=1 → Ping #6 missed=2 → 下一跳 terminate
最后一次 Pong → 被拆6000–6005 ms(验证轮 6002 / 6001 / 6001 / 6002 ms)——三跳 2 秒;负载抖动轮 6279 ms,故为「≥ 三跳」而非恒定 6.000 s
Close 帧 / 关闭码close_code=none:没有 Close 帧;浏览器侧因此是 1006 空 reason
客户端表现connection.state=connecting → 文案「自动重连中」(key connection.connecting);控制台 [connection] connection lost, retry #N;退避 500ms/1s/2s/4s/8s/10s 封顶(默认);恢复后 connection/reset
主循环是否停摆否:同刻心跳计时器准点(idle 间隔 2000.0 ms;16 路 session/list 并发负载下 2085 ms)——排除「整个事件循环被钉死」
版本核对行号按随包 @deepseek-ai/dsh 0.1.5-rc.1(Node v24.16.0)核对;本页只在 rc.1 上逐行核过

原始输出(零依赖探针,两次独立运行)

     1 upgraded (HTTP/1.1 101 Switching Protocols)
   272 <- Ping #1 -> Pong
  2271 <- Ping #2 (gap 1999ms) -> Pong
  4271 <- Ping #3 (gap 2000ms) -> Pong
  6271 <- Ping #4 (gap 2000ms) -> Pong
  8004 --- muting Pong (socket keeps reading) ---
  8275 <- Ping #5 (gap 2004ms) [muted: no Pong sent]
 10275 <- Ping #6 (gap 2000ms) [muted: no Pong sent]
 12277 socket closed (no Close frame)
HEARTBEAT_TOLERANCE pings=6 median_gap_ms=2000 last_pong_to_close_ms=6005 since_mute_ms=4273 close_code=none text_frames=0 result=KILLED

     1 upgraded (HTTP/1.1 101 Switching Protocols)
  1084 <- Ping #1 -> Pong
  3084 <- Ping #2 (gap 2000ms) -> Pong
  5002 --- muting Pong (socket keeps reading) ---
  5084 <- Ping #3 (gap 2000ms) [muted: no Pong sent]
  7084 <- Ping #4 (gap 2000ms) [muted: no Pong sent]
  9085 socket closed (no Close frame)
HEARTBEAT_TOLERANCE pings=4 median_gap_ms=2000 last_pong_to_close_ms=6000 since_mute_ms=4083 close_code=none text_frames=0 result=KILLED

第二次的静音点恰好落在 Ping 前 82 ms(5002 vs 5084),仍得到 6000 ms——说明窗口锚在最后一次真正的 Pong,不是我们的动作时刻。

影响链:它为什么和 #7 / #8 是同一条链

心跳判死不是孤立的小毛病——它的代价由重连后的客户端行为放大。

环节发生了什么
1. 判死6 秒窗口被吃满 → terminate() → 客户端 1006 → generation 失效
2. UI状态 connecting:侧边栏/设置区显示「自动重连中」(connection.connecting),点一下可立即重连
3. 重连风暴重连成功后客户端 emit("connection/reset"):重拉 session/list(#8 实测 2957 行 / 1.28 MB / 9.7–11.9 s)、每个打开会话的 page/follow 追赶,以及 models / settings / tools / skills / workspace 等一批 RPC
4. 正反馈这批重拉本身就是「多会话并行」下最重的后台工作;它让下一次窗口更容易被吃满 → 又一次判死。用户的感受就是「越忙越掉线、越掉线越忙」
与 #7 / #8 的分工:那两页说的是 session/list 本身(挂起不返回 / fail-closed 清空列表);本页说的是触发它们被反复重拉的那个上游事件。三条都指向同一个结构:把「人此刻的交互」和「所有会话的批量流」放在同一条没有优先级、没有背压、也没有观测的管道上。

代码级证据(指到行)

行号按随包发布的 lib/(0.1.5-rc.1)。

#事实锚点
①漏 2 次即判死dsh-api-gateway/lib/types/stream-server.js:4 const MAX_MISSED_HEARTBEATS = 2;;心跳循环 :61-77:if (missed >= MAX_MISSED_HEARTBEATS) { setImmediate(() => { … socket.terminate(); }); continue; },否则 missed+1 并 socket.ping()(:74-75)
②判死无观测、无 Close 帧同一分支只有 socket.terminate():无 logger、无 metric、无 close(code, reason)。对比:另一条兜底路径(连 error 帧都发不出去时)会 close(1011, 'Remote stream failure could not be delivered')(:152);普通流错误走 :147 的 {type:'error'} 帧——说明「带原因关闭」在本文件里已有先例,心跳路径只是没做
③默认 2 秒dsh-api-gateway/lib/index.js:398 DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 2e3;;Config 在 :433:websocketHeartbeatIntervalMs: z.number().step(1).min(1)…default(…);传给 mux 在 :458(types/index.js:76)
④Ping 与数据同管道、写路径无背压stream-server.js:157-179 send():const delivery = this.writes.then(() => new Promise(… socket.send(text, cb))),this.writes = delivery.catch(…)——全连接一条 FIFO 链,从不检查 socket.bufferedAmount;:75 的 socket.ping() 写同一 socket(TCP 有序 ⇒ Ping 排在数据后)
⑤该风险包 README 已知,但只在「已知限制」里dsh-api-gateway/README.zh.md:「websocketHeartbeatIntervalMs 同时是 Ping 周期和 Pong 截止时间。对端未在下一周期前回复时,Host 会终止连接;如果部署的事件循环或网络可能停顿超过该间隔,必须调大此配置。」——运维不会翻到包 README
⑥客户端的可观测性是够的(Server 侧不是)dsh-client-connection/lib/client.js:943 emitState("connecting");:954 console.warn('[connection] connection lost, retry #N');退避默认 500 / ×2 / 10s 封顶 / 50–100% 抖动(:802-807, :907-909);恢复路径 onConnected → emit("connection/reset")(dsh-api-gateway/lib/client.js:1433);文案 key 在 dsh-client-ui-settings-general/lib/client.js:491

期望 vs 实际

这一节是 issue 正文的核心:不要把它读成「2 秒太短」这一条。

期望实际
存活判据应能区分「对端死了」与「我们在拥塞」:socket 仍在成功推进写时,不应凭 Pong 迟到判死只看「这一跳有没有 Pong」;而 Ping 恰恰排在同一 socket 的数据积压之后
Host 主动关闭连接应可观测:日志 + 指标(missed / interval / bufferedAmount / 上次 Pong 距今)无日志、无指标、无 Close 帧;客户端与服务端两侧都无法归因
主动关闭应带原因(如 close(1011, 'heartbeat timeout')),让客户端与运维区分于网络掉线terminate() ⇒ 1006 空 reason ⇒ 只能靠猜
作为该 socket 的默认值,2 秒应经得起「这条连接承载全部会话流」这个事实默认 2 秒;调大它的入口只在包 README 的已知限制里,config catalog 没有

已排除的原因(都实测过)

这些是我们在自家部署上先逐条排除的,写在这里是为了让 issue 不必从「是不是你们的网」开始。

候选排除依据
网络中间设备 / 反代超时实测走 127.0.0.1:4006 loopback 直连,不经过 Caddy 与本机 gateway,现象一致
浏览器实现 / 页面脚本零依赖原生 socket(node:net + 手写 WebSocket 帧)复现同一窗口,与浏览器无关
宿主 CPU / 事件循环停摆判死同刻心跳计时器仍是准点的(idle 2000.0 ms;16 路 session/list 并发下 2085 ms)——被拆的是某一条 socket,不是整个循环
鉴权 / Cookie / Host 信任栅栏探针用真实启动令牌换到的 Cookie;无效时是握手失败(退出码 1),与本次判死现象不同
客户端主动断开静音期间我们只在读,没发 Close、没 destroy;是被对端拆掉的
另有第三态:disconnected浏览器 offline 事件走的是另一个状态(文案「连接异常,点击立即重连」)。用户看到的是「自动重连中」= connecting,即 Host 先拆、客户端再重连
仍未定论(禁止当结论):生产实例在真实多会话拥塞下,被心跳误杀占全部重连的比例。机制具备(④)、窗口已实测(①),但要把「机制」升级成「现场」,需要在判死分支上加一行日志或一个计数——这正是建议 (a)。

复现:零依赖探针与判据行

材料在本仓 ops/dsh/upstream-issue-ws-heartbeat/。探针只用 node:net,手写 WebSocket 握手与帧解析——因为它要做浏览器做不到的事:继续读、但停止回 Pong(这正是背压下的浏览器与拥塞 socket 在 Host 眼里的样子)。

# 本机某一户:读 $DSH_HOME/web-url.txt 的启动令牌自动换 Cookie
DSH_HOME=<home>/.dsh node ops/dsh/upstream-issue-ws-heartbeat/heartbeat-tolerance-probe.mjs --port <dsh-web-port>

# 判据行(期望 KILLED,窗口 4–6.5 s,close_code=none)
# HEARTBEAT_TOLERANCE pings=6 median_gap_ms=2000 last_pong_to_close_ms=6005 since_mute_ms=4273 close_code=none text_frames=0 result=KILLED
result退出码含义
KILLED0复现成功:最后被回答的 Pong 之后约 6 s 被拆,且没有 Close 帧
SURVIVED_MUTED1窗口内没判死——该实例已调大 websocketHeartbeatIntervalMs,或版本已改行为
握手/鉴权失败1信任栅栏或 Cookie 不对,判据无效(不要当负结果用)

部署侧现有绕法(不是修复,是止痛):把 websocketHeartbeatIntervalMs 调大。该键在 base bundle 的 typert-gateway 行(@deepseek-ai/dsh-api-gateway)的 config 上,写进该户 $DSH_HOME/cordis.patch.yml 后需重启该户服务(会打断在跑会话)。调大 = 容忍窗口变长,代价是真死连接发现更晚——这也是建议 (b) 比 (c) 更好的原因。

建议修法(分级)

(a) 是最小且必做的一块:没有它,这类故障永远只能靠用户报。

级别内容
(a) 观测性(必做,最小)判死时写一条结构化日志 + 指标:socketId、missed、intervalMs、bufferedAmount、lastPongAgoMs;并把 terminate() 换成 close(1011, 'heartbeat timeout'),让客户端与运维能把「心跳判死」与「网络掉线」分开。
(b) 判据(推荐)不要只凭「这一跳没 Pong」判死:若同一周期内该 socket 仍在成功 flush(write 回调持续推进,或 bufferedAmount 在下降),说明对端活着、Pong 只是在排队 → 跳过本次判死并重置计数;至少也应改成按时间窗(≥3×interval 未回)而不是「漏 2 次」。
(c) 默认值与文档对「承载全部会话流」的连接,2 秒过于激进:建议默认 10–15 秒,并把 websocketHeartbeatIntervalMs 登记进 config catalog(现在只在包 README 的「已知限制」里)。
(d) 结构(长期)控制面(心跳/取消/RPC 小帧)与批量数据面分离,或至少给控制帧优先级、给写路径加背压(bufferedAmount 上限 + 合批)。否则存活判据会一直被它要检测的拥塞污染——这也是本页与 #7/#8 共同的根:交互路径与批量流没有隔离。

可提交的 issue #9 正文(英文)

下面是可直接粘贴的正文;行号按 0.1.5-rc.1 随包 lib/ 核对。

Title: WebSocket heartbeat kills a healthy socket silently: 2 s Ping, two
       missed Pongs, terminate() with no log / metric / Close frame

Summary
-------
The Web mux carrier (/api/remote.mux) pings every `websocketHeartbeatIntervalMs`
(default 2000 ms) and destroys the socket once two consecutive Pings went
unanswered. Measured tolerance is about 6.0 s from the last answered Pong
(6000-6005 ms across four runs; one loaded round stretched to 6279 ms, since
latent ticks lengthen the window), and the
termination is completely silent: `socket.terminate()` sends no Close frame and
emits no log line or metric, so the browser only sees 1006 with an empty reason.
The client then invalidates its connection generation, shows the "Reconnecting"
indicator, and refetches everything on recovery (`connection/reset`).

The same socket carries every multiplexed session stream. The mux write path
serialises all streams onto one promise chain and never inspects
`socket.bufferedAmount`, while `socket.ping()` writes to that same TCP
connection. TCP ordering means a Ping queues behind whatever data is already
buffered, and a browser under backpressure stops reading the socket altogether —
so congestion can delay the very Pong that the liveness check is waiting for.

Environment
-----------
@deepseek-ai/dsh 0.1.5-rc.1, Node v24.16.0, Linux. Reproduced on
ws://127.0.0.1:4006/api/remote.mux (loopback, no reverse proxy in the path) with
a valid browser-session cookie, on 2026-09-13 ~22:00 +0800.

Steps to reproduce
------------------
  1. Open the mux WebSocket (any authenticated client) and answer Pings with
     Pongs. Confirm Ping interval is 2000 ms and the socket stays open.
  2. Keep reading the socket but stop answering Pings.
  3. Observe: after two missed Pings the Host destroys the TCP connection.

Reproduction is dependency-free (raw WebSocket over node:net) in
`heartbeat-tolerance-probe.mjs`:

  $ node heartbeat-tolerance-probe.mjs --port <dsh-web-port>
  HEARTBEAT_TOLERANCE pings=6 median_gap_ms=2000 last_pong_to_close_ms=6005
    since_mute_ms=4273 close_code=none text_frames=0 result=KILLED

Four independent runs: last_pong_to_close_ms = 6005 / 6000 / 6002 / 6001 ms
(three 2 s ticks; a loaded round measured 6279 ms). `close_code=none` means no
Close frame was sent.

Expected behaviour
------------------
  * A liveness check should distinguish "the peer is gone" from "we are
    congested". While the socket is demonstrably making progress (write
    callbacks completing, `bufferedAmount` decreasing), a late Pong should not
    be treated as death.
  * A Host-initiated close should be observable: one log line plus a metric
    carrying missed count, interval, `bufferedAmount` and time since the last
    Pong.
  * A Host-initiated close should carry a reason, e.g.
    `close(1011, 'heartbeat timeout')`, so client and operators can tell it
    apart from a dropped connection. Note the same file already closes with a
    reason on the error path (`close(1011, 'Remote stream failure could not be
    delivered')`).
  * 2 s should not be the default for a connection that carries every session
    stream; and the knob should be documented in the config catalog instead of
    only in the package README's "known limitations".

Actual behaviour
----------------
  * Two missed Pongs (third tick) = terminate, measured 6.0 s after the last
    answered Pong.
  * `socket.terminate()`: no log, no metric, no Close frame. The client sees
    1006 with an empty reason and cannot distinguish heartbeat-kill from network
    loss. Our dashboards stayed green while users watched "Reconnecting" flaps.
  * Client-side consequences: `connection.state = connecting` (indicator
    `connection.connecting` = "自动重连中" / "Reconnecting"), console warning
    `[connection] connection lost, retry #N`, then backoff 500 ms … 10 s; on
    success `emit("connection/reset")` triggers a full refetch (session list,
    per-session page/follow catch-up, models/settings/tools/skills/workspace).
    On a deployment where `session/list` already costs 9.7-11.9 s for ~2 957
    rows, each heartbeat kill is expensive, and more concurrent sessions make
    the next kill more likely — a self-reinforcing loop.

Ruled out (all tested)
----------------------
  * Network middleboxes / proxy timeouts: reproduced on loopback with no proxy.
  * Browser behaviour: reproduced with a raw socket client.
  * Host CPU / blocked event loop: the mux heartbeat timer stayed punctual
    during the kill (2000.0 ms idle; 2085 ms under 16 concurrent session/list
    calls). One socket dies, not the loop.
  * Auth / Host-Origin trust: a valid cookie was used; failures there look like
    a failed handshake, not this.
  * Client-initiated close: the probe only read; it never sent Close.
  * The `disconnected` state is a different path (browser `offline` event).

Not yet proven
--------------
What share of real-world reconnects on a busy multi-session deployment are
heartbeat kills rather than network loss. The mechanism and the window are
measured; the production rate needs the log/metric from the first suggested
fix.

Suggested fixes
---------------
  1. Observability (smallest, highest value): log + metric on heartbeat
     termination (socketId, missed, intervalMs, bufferedAmount, lastPongAgoMs),
     and `close(1011, 'heartbeat timeout')` instead of a bare `terminate()`.
  2. Predicate: treat a socket that is still flushing as alive — skip the kill
     and reset the counter while writes keep completing / `bufferedAmount`
     decreases; or at minimum require an elapsed-time window (≥3×interval)
     rather than "two missed".
  3. Defaults/docs: raise the default to 10-15 s for this carrier and register
     `websocketHeartbeatIntervalMs` in the config catalog.
  4. Longer term: separate the control plane (heartbeat, cancels, small RPC
     frames) from the bulk data plane, or at least prioritise control frames
     and bound the write queue (`bufferedAmount` cap + coalescing), so the
     liveness signal is not polluted by the congestion it guards.

Code references (0.1.5-rc.1, shipped lib/)
------------------------------------------
  * dsh-api-gateway/lib/types/stream-server.js:4   MAX_MISSED_HEARTBEATS = 2
  * dsh-api-gateway/lib/types/stream-server.js:61-77  heartbeat loop; missed
    >= 2 => setImmediate(() => socket.terminate()); :75 socket.ping()
  * dsh-api-gateway/lib/types/stream-server.js:152  a reasoned close already
exists on the last-resort path (close(1011, ...) when even the error frame
cannot be delivered) — precedent for doing the same on a heartbeat timeout
    (ordinary stream errors use the {type:'error'} frame at :147)
  * dsh-api-gateway/lib/types/stream-server.js:157-179  single serialised write
    chain, no bufferedAmount check
  * dsh-api-gateway/lib/index.js:398, :433, :458  default 2000 ms, Config
    schema, wiring into RemoteStreamMuxServer
  * dsh-api-gateway/lib/types/index.js:76  wiring into RemoteStreamMuxServer
  * dsh-client-connection/lib/client.js:943, :954, :802-804, :907-909
    connecting state, warning, backoff defaults
  * dsh-client-ui-settings-general/lib/client.js:491  the "自动重连中" label key
  * dsh-api-gateway/lib/client.js:1433  onConnected => emit("connection/reset")