DeepSeek Harness · Upstream · Issue #9 草案
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 正文。
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。前两条是本次实测,第三条是随包源码可指到行、但尚未取得生产现场证据的机制(按本仓约定标「未定论」)。
默认 websocketHeartbeatIntervalMs = 2000;MAX_MISSED_HEARTBEATS = 2。多轮独立实测(同一个零依赖原生 socket 探针,另有一轮 ws 客户端交叉复现,判词格式不同未收录原文)都落在 6000–6005 ms(验证轮 6002 / 6001 / 6001 / 6002 ms),即「最后一次被回答的 Pong → socket 被拆」为三跳 2 秒;一轮负载抖动实测 6279 ms(+4.6%)——窗口是「≥ 三跳」,tick 迟到会等比拉长。
判死分支里只有 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,不是我们的动作时刻。
心跳判死不是孤立的小毛病——它的代价由重连后的客户端行为放大。
| 环节 | 发生了什么 |
|---|---|
| 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. 正反馈 | 这批重拉本身就是「多会话并行」下最重的后台工作;它让下一次窗口更容易被吃满 → 又一次判死。用户的感受就是「越忙越掉线、越掉线越忙」 |
行号按随包发布的 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 |
这一节是 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 先拆、客户端再重连 |
材料在本仓 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 | 退出码 | 含义 |
|---|---|---|
KILLED | 0 | 复现成功:最后被回答的 Pong 之后约 6 s 被拆,且没有 Close 帧 |
SURVIVED_MUTED | 1 | 窗口内没判死——该实例已调大 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 共同的根:交互路径与批量流没有隔离。 |
下面是可直接粘贴的正文;行号按 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")