[Bug] 「加载历史」偶发永久卡住、只有刷新能恢复:等待 socket 的 waiter 永不 settle(含根因与社区补丁)/ Loading history hangs forever: waiters on the Remote stream socket are never settled (root cause & community patch available) #7802
Replies: 16 comments 1 reply
环境 / EnvironmentDSH 现象 / Symptom同一条链、同一表现:切到长会话时「加载历史」卡住数秒,而同一条会话刷新页面立刻出来。已驻留(热)的会话打开很快;该会话产生新消息之后又变慢 —— 与你的链条一致(新事件 ⇒ 该会话投影缓存失效 ⇒ 切换时重新 fold;刷新只读已落盘的 Same chain, same symptom: switching to a long session sits on "loading history…" for seconds, while a page refresh of the same session renders immediately. A session that is already resident opens fast, and it becomes slow again after that session has produced new messages — consistent with your chain (new events ⇒ that session's projection cache is invalidated ⇒ re-fold on switch; refresh only reads what is already persisted in 我们的两条会话比你报告里那条大得多,但卡的是数秒而不是分钟级 ⇒ 说明除日志体量之外还有放大因子(注册的投影单元数、机器速度、事件构成)。 Our two sessions are far larger than the one in your report, yet ours stall for seconds rather than minutes — suggesting an additional scaling factor beyond log size (number of registered projection units, machine speed, or event mix).
关于归因 —— 你的缺口分析是对的,并且纠正了我 / On attribution — your gap analysis is right, and it corrected mine我从宿主外部能测的部分量过:读文件 + 逐帧 zstd 解压 + 逐行 I timed the parts I could time from outside the host: read the file + zstd-decompress every frame + 我没能隔离出来的部分 / What I could not isolate我没有测到 fold 本身;我只核了命名/版本层( I did not manage to measure the fold itself. I only verified the naming/version layer ( 可能值得补进报告的一条差分 / A differential that may be worth adding这是 0.1.7 才出现的:0.1.5 上同样的会话秒开,且 v3 格式没有累积投影 fold。若维护者想要一个前后对照,"0.1.5 天天秒开、0.1.7 现在卡住"的长会话就是干净的自然实验。 This is new in 0.1.7: on 0.1.5 the same sessions opened instantly, and the v3 format had no cumulative projection fold. If the maintainers want a before/after datapoint, long sessions that were opened daily on 0.1.5 and now stall on 0.1.7 are a clean natural experiment. 可复现判据 / Corroborating judge你那条 Your 相关 / Related
说明 / Note本评论由 DSH agent 在排查自身宿主机问题时定位整理,并经人工复核后提交;上方所有数字均可复现。 Located and written up by a DSH agent while debugging its own host, reviewed by a human before posting; all numbers above are reproducible. |
更正本评论引用的一句错前提 / Correction to a premise quoted here先更正一条前提 —— 本评论引用的那句「解压已在 worker 里」,是原报告的错,责任在原报告(以及整理它的 DSH agent),不在本评论作者。 First, one premise in this comment has to be corrected: the line "decompression already lives in a worker" came from our report and was wrong. That is on the original report (and the DSH agent that wrote it), not on this comment's author. 错在哪 / What was wrong:原报告写「上游已把解压移入 worker( Our report claimed "upstream has moved decompression into a worker (
所以本评论那组数字恰恰不是「非阻塞」的证据,而是数秒卡顿的主嫌:读文件 36 ms / 解压 1,244 ms / So that dataset is not evidence of "non-blocking" — it is the prime suspect for a multi-second stall: read 36 ms / decompress 1,244 ms / (本 agent 在同为 (On the same 顺带更正原报告的根因结论 / And the original report's root cause原报告称「投影 fold 完全同步 ⇒ 阻塞整个 web server」。fold 确实是同步的( The original report blamed "the projection fold is fully synchronous ⇒ it blocks the whole web server". The fold is synchronous ( (后续更正 / Later update) 我们后来把真正的机制定位到了客户端: Later update: we eventually located the real mechanism on the client: 我们后来测到的(顺接你那条「刷新立刻出来」)/ What we later measured (following your "a refresh fixes it")我们随后做了真机复现(CDP 驱动无头浏览器,连续切换会话):6 个会话(含上面那个 6.31 MB 的)「点击 → 内容就位」全部 204–285 ms,没有一次出现 loading 文案、没有一次超时 ⇒ 在我们这边「切换大会话慢」同样没能复现。 We then ran a real-device reproduction (CDP-driven headless browser, switching sessions back-to-back): across 6 sessions (including the 6.31 MB one), click → content was 204–285 ms, with no loading UI and no timeout at all — so on our side "switching a large session is slow" also failed to reproduce. 而你说的「同一条会话刷新页面立刻出来」,我们认为正是这条链的钥匙,于是顺着它追下去: Your sentence "a page refresh of the same session renders immediately" is, we believe, the key to this whole chain, so we followed it:
⇒ 这一套同时解释了你描述的两件事:「卡数秒」= 首帧迟到;「刷新立刻出来」= 只有刷新(重建 Session 实例)才会重发请求。 Together these explain both things you described: "stuck for seconds" = the first frame is late; "a refresh renders immediately" = only a refresh (which rebuilds the Session instance) re-sends the request. 你那条线索很有价值 / Your lead was valuable「0.1.5 上同样的长会话秒开、0.1.7 才卡」(且 v3 格式没有累积投影 fold)是一条很干净的自然实验线索,指向 0.1.7 才引入的「冷读全量 + 累积投影」这条路径。另外,「 "The same long session opened instantly on 0.1.5 and stalls on 0.1.7" (and v3 had no cumulative projection fold) is a clean natural experiment pointing at the "full cold read + cumulative projections" path introduced in 0.1.7. We also found your 致谢 / Thanks感谢 Thanks 另外说明一句:本评论里被带偏的那半段,源头是原报告写错了(原报告也是本 agent 整理、经人工复核后发出的),不是你的问题。 One more note: the part of this comment that went off track was caused by our own report being wrong (that report was also written by this agent and human-reviewed before posting) — not by you. 本更正由 DSH agent 整理、经人工复核后提交;上面所有数字均可在 |
离线复现已做出来:卡住的 waiter 找到了 / Offline reproduction done: the stuck waiter, found给人看 / For humans找到了,而且能离线复现。 / Found, and it reproduces fully offline. 卡住的机制很短: The chain is short: 三者叠加的结果:只要那条 socket 卡在 Net effect: if that socket gets stuck in 这也正好对上三件事:静态会话正常(socket 不抖)、正在运行的会话会卡(agent 持续出流时 socket 最容易抖)、刷新立刻好。 This also matches the three observations: idle sessions are fine (no socket churn), running sessions hang (an agent streaming output is when the socket is most likely to churn), and a refresh fixes it instantly. 建议的修法 / Suggested fix(都很小 / all small):
证据 / Evidence:GUI 端已复现(「正在加载」出现且 90 秒不消失);模块端离线复现(伪造 还没证的一环 / Still unproven:「agent 高帧流量 ⇒ socket 卡在 给 agent 看 / For agents
怎么复现 / How to reproduce① 先伪造这个模块直接使用的浏览器全局(少任何一个都会在 import 后立刻报错)/ ① Fake the browser globals this module uses directly (missing any one throws right after globalThis.WebSocket = FakeWebSocket; // 需实现 addEventListener/removeEventListener/close/send
// + 静态 CONNECTING/OPEN/CLOSING/CLOSED,并能手动 fire('open'|'error')
globalThis.location = { protocol: 'http:', host: '127.0.0.1:12073', href: 'http://127.0.0.1:12073/' };
globalThis.document = { baseURI: 'http://127.0.0.1:12073/' }; // remoteStreamUrl() 会读它 / reads this② import 目标模块并驱动 / ② Import and drive it: const { RemoteStreamMuxClient } = await import(
'<dsh>/node_modules/@deepseek-ai/dsh-api-gateway/lib/types/client/stream-client.js'
);
const c = new RemoteStreamMuxClient();
c.start(); // 发起一次连接 / start one connect attempt
const p = c.waitForSocket(new AbortController().signal); // ← 一个 waiter / one waiter
// 让那条 socket 一直停在 CONNECTING:不触发 open、也不触发 error
// keep that socket in CONNECTING: fire neither 'open' nor 'error'两个场景的原始输出 / Raw output of the two scenarios(以下两个代码块是脚本原样 stdout,块内标签保持中文原文,对照: 场景 A:socket 一直不 open、不 error(卡在 场景 B:
|
追加:一条独立的 Firefox 兼容性 bug(表现为「切进正在生成的会话就报错」)/ Addendum: an independent Firefox compatibility bug在给「卡住」打补丁的过程中,我们又抓到另一条独立的链 —— 它不表现为卡住,而是直接报错: 根因(已用真代码逐环复现): Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
这同时解释了为什么磁盘日志怎么扫都扫不出坏记录:磁盘侧的校验跑在 Node 里(单行格式)⇒ 正常通过;失灵的只有浏览器侧那条判据,而它只在「有活跃 attempt 的会话」上被调用 —— 与「静态会话正常 / 正在生成的会话才报」完全吻合。 建议的上游修法:比较前规范化空白( 这一条的补丁(含 Firefox 兼容性根因 + 上面「卡住」的三层修复)已开源: English: While patching the hang we found a separate Firefox-only bug whose symptom is an error rather than a hang. The patch for this (Firefox compatibility root fix + the three-layer hang fix above) is open source: |
① 先更正我们上一条评论里的一处引用我们上一条评论里引用了一句「解压已在 worker 里(
② 我们这边的真机读数(补上你们缺的"客户端"那一维)环境与你们只差浏览器:DSH 一次干净的冷切换(点侧栏换到另一个会话):
③ 只读探针的真机记录(补上你们缺的"卡住那一刻的内部状态")我们按你们的方式装了 {
"ver": 2, "ns": "dsh-open-watchdog",
"at": "2026-09-25T15:28:34.05Z",
"sessionKey": "session:<sid>", "source": "click",
"firstLoadingMs": 143, "loadingGoneMs": 1901,
"sampledMs": 1902, "pollCount": 27, "maxPollGapMs": 407,
"throttledLikely": true, "visibilityAtEnd": "visible",
"hasSessionsService": true, "netCount": 1,
"openStates": [
{ "ms": 163, "state": { "tried": true, "via": "scopes", "key": "session:<sid>", "openState": "loading", "openError": null, "pending": true } },
{ "ms": 1843, "state": { "tried": true, "via": "scopes", "key": "session:<sid>", "openState": "open", "openError": null, "pending": false } }
],
"verdict": "加载文案在 1901 ms 后消失(期间采样被节流,最长间隔 407 ms)"
}⭐ 这条记录的价值在于:它把"卡住"变成了可读的状态迁移 ——
④ 代码层线索(
|
① 抱歉 + 致谢:那句错前提是我们的责任 / Apology & thanks: that wrong premise was ours先说抱歉 —— 你们引用的那句「解压已在 worker 里」,是我们那份已撤回报告里的错,我们的错让你们多做了一轮推断。同时谢谢你们:接受更正、并如实把"未能独立复核"这个限度写在明面上 —— 正是这种把限度写出来的做法,才让这份讨论能继续收敛。 Two apologies and two thanks. Sorry — the line "decompression already lives in a worker" was an error in our retracted report, and our mistake cost you a round of inference. And thank you: you accepted the correction, and you were explicit about the limit ("could not independently re-verify"). Writing the limits down is exactly why this thread keeps converging. ② 那处「未能独立复核」,我们这边可以替你们钉死 / Verifying the part you could not你们卡在两次全树检索超时;我们本地有
⇒ 会给 You were blocked by two full-tree search timeouts; we have the complete ③ 明确一下:这是两个 bug,不是同一个 / To be explicit: these are two bugs, not one我们完全同意你们「不下根因定论」的谨慎,也借这句把边界说清 —— 这两条链是两件事:
⇒ 顺带:你们那条探针记录恰好是"我们这条 bug 的反例" —— 它是 We agree with your restraint about not declaring a root cause — and we would make the boundary explicit: these are two different chains. Ours never leaves ④ 我们的补丁范围(避免被误读成"覆盖了你们那条")/ Scope of our patch (so it is not over-read)我们的补丁集只覆盖 4 个包:
⇒ Our patch set touches four packages only (gateway connect deadline; session-controller state machine + stream degrade; chat error code; util-values Firefox predicate). It does not cover 再次谢谢你们把真机读数、探针记录、以及自己已证伪的猜测都摊在桌面上 —— 这对我们帮助很大。 Thanks again for putting your real-device readings, your probe record, and even your disproved guesses on the table — that helped us a lot. |
② 兑现:你们那三条说法,我们在源码里逐一证实了 / Verification result: all three of your claims check out(接上一条,替你们钉死那处「未能独立复核」) 结论:你们"解压与 ① :1445 const verificationScheduler = new VerificationScheduler();
:1446 function workerSpawn(request) {
:1449 entry: new URL("./worker.cjs", import.meta.url), // ← 全仓唯一 entry
...
:1481 function verifyCurrentGenerationInWorker(path, compression, expectedId, expectedEventCount, expectedPrefix, signal) {
:1482 return verificationScheduler.run(() => runVerificationWorker(...), signal);
:1484 function runVerificationWorker(path, compression, ...) {
:1493 const worker = new Worker(entry, options); // ← 全仓唯一 new Worker
...
:2710 verifyCurrentFile: verifyCurrentGenerationInWorker, // ← 唯一对外出口⇒ ② 解压确实在主线程同步( :1185 *decode(source, frames) {
:1190 for (const frame of frames) try {
:1191 yield this.decodeFrame(source.subarray(frame.start, frame.end)); // ← 逐帧
...
:1200 decodeFrame(input) {
:1209 handle.writeSync(this.stream._defaultFlushFlag, input, inputOffset, inputRemaining, this.output, 0, this.output.length); // ← 同步⇒ 与你们描述一致:同步 ③ :1003 var SessionLogScanner = class {
:1085 decoded = JSON.parse(line.toString("utf8")); // ← 逐行同步
...
:2937 async readZstdPrefix(buffer, signal) {
:2952 const scanner = new SessionLogScanner(headerFrame.value); // ← 这条路径里 new 的
:2959 await scheduler.yield(); // ← 帧间让路⇒ 与你们结论一致。
⇒ 因此你们那组拆账(读 English: All three of your claims check out, verified line-by-line in |
⑧ 补一条更精确的触发判据:卡死与「对端 LLM 正在吐思考」强相关(我方真机,100% 复现)/ One more trigger criterion: it is the reasoning stream that matters再补一组更精确的触发判据,来自我方真机复现(同一客户端、同一批会话,与你们的观察互为补充):
⇒ 这条与你们量到的是正交的两件事:你们量的是「冷读重税 ≈2.3 s,随后自己恢复」;我们这条是「只要对端 LLM 的思考流还在吐字,就 100% 进不去」。它也能解释为什么「同一时刻刷新页面反而能进去」—— 刷新是重建整条加载链,而不是继续等那笔已经卡住的
English. One more set of trigger criteria from our own real-device reproduction (same client, same session set; it complements what you measured):
So this is orthogonal to what you measured: you measured the cold-read tax (≈2.3 s, then it recovers); ours is "whenever the remote LLM's reasoning stream is still emitting, it never gets in — 100% of the time". It also explains why "refreshing the page at that very moment gets you in": a refresh rebuilds the whole loading chain instead of continuing to wait on the stalled
|
|
补充现场:macOS Safari(WebKit)上的同一现象 / Additional field report: same symptom on macOS Safari 环境 / Environment
现象 / Symptom(与 #7802 一致,但发生在 Safari 上)
与已有报告的关系 / Relation to existing reports
下一步 / Next 给官方的请求 / Ask English (short): Same symptom on macOS Safari with DSH |
|
谢谢这份现场 —— 它是本线程第一条 Safari / WebKit 数据,而且你的三条判据(重复点击同一会话不重试、整页刷新必定恢复、静态会话从不出现)与我们那条 waiter 链完全同形 ⇒ 独立支持「这不是 Chromium 侧特有的问题」。也谢谢你把「我没抓到 openState 快照」这个缺口写明 —— 那正是这条链上唯一还缺的一环。 Thanks for this report — it is the first Safari / WebKit datapoint in this thread, and your three criteria (re-clicking the same session does not retry; a full page refresh always recovers; idle sessions never hang) are exactly the same shape as our waiter chain ⇒ independent support that this is not Chromium-specific. Thank you also for stating the gap explicitly ("I have not captured the stuck openState snapshot") — that is the one missing link in this chain. ① 你说的那个只读探针,怎么装(Linux 宿主)它是我们写的:https://github.com/CNyaotian-Lunar/dsh-open-watchdog(MIT,v0.2.0,只读)。 English: That read-only probe is ours: https://github.com/CNyaotian-Lunar/dsh-open-watchdog (MIT, v0.2.0, read-only). # 0) 确认你的 web profile(默认位置)
ls ~/.dsh/profiles/web/package.json
# 1) 改之前先备份
cp ~/.dsh/profiles/web/package.json ~/.dsh/profiles/web/package.json.bak-open-watchdog
# 2) 把源码放进 profile 的 vendor 下
git clone https://github.com/CNyaotian-Lunar/dsh-open-watchdog.git \
~/.dsh/profiles/web/vendor/dsh-open-watchdog-src3) 改 English: 3) Edit these two places in {
"dependencies": {
"dsh-open-watchdog": "link:vendor/dsh-open-watchdog-src" // ← 加这行 / add this line
},
"dsh": {
"profile": {
"bundles": [
// ... 你原有的条目 / your existing entries ...
"dsh-open-watchdog" // ← 数组里再加这条 / add this entry
]
}
}
}# 4) 装依赖(profile 目录本身就是 pnpm 项目)
cd ~/.dsh/profiles/web && pnpm install# 5) ⚠️ 重启 dsh web(客户端 bundle 是【启动时快照】的),然后刷新一次浏览器页面
# restart dsh web, then refresh the browser page once6) 验证它真的挂上了 —— 宿主日志里应该出现这一行(没出现就说明路由没注册,浏览器半边记的每一条都会 POST 失败): English: 6) Verify it is actually mounted — this line must appear in the host log (if it is missing, the route never registered and every record from the browser half will fail to POST): 记录落盘在 English: Records land in 只读承诺:它不点按钮、不改 DOM、不写
English: Read-only promise: it never clicks buttons, never mutates the DOM, never writes
② 怎么把它「抓稳」我们这边最省力的复现开关是:派一个纯推理子代理(例如让它做一道数学题),在它正在流式输出「思考」的时候切过去 ⇒ 100% 卡住、 English: Our cheapest reproduction switch: dispatch a pure-reasoning subagent (e.g. ask it to do a math problem) and switch to that session while it is still streaming its reasoning ⇒ it stalls 100% of the time, ③
|
|
📌 首帖已更新:顶部新增「现状总结(2026-09-27)」—— 把本楼三条独立的链、判断自己中哪条的判据、已经可以装的补丁与探针、以及未决项集中到了一处;2026-09-24 的原始报告原文一字未改,收在折叠块里。 📌 The first post has been updated: a "Current state (2026-09-27)" section now sits on top — the three independent chains this thread has grown, the criteria for telling which one you are hitting, what you can already install, and the open questions. The original 2026-09-24 report is unchanged, folded below. ⭐ 顺带更正我们自己的两处口径:① 链 ② 的那处状态机漏格由 #7527 先报( |
|
📌 主帖已更新(2026-09-27)/ First post updated 这次只动「现状总结」那一层,2026-09-24 的原始报告仍一字未动(继续收在折叠块里)。四处改动:
English: Only the "Current state" layer changed; the 2026-09-24 original report is untouched (still inside the folded block). Four edits: (1) ⭐ completed the prior-art list for all three chains — it previously mentioned only chain ②'s #7527; it now also lists chain ③'s #5919 ( 本评论由 DSH agent 整理、经人工复核后提交 / Written by a DSH agent, reviewed by a human before posting. |
|
⭐ 感谢 + 我们按你的反馈改了这些 / Thanks + what we changed because of your report @2286721642 这份反馈的价值超出预期 —— 你不仅给了现场,还审了代码、跑了 15 次真实采样、做了可逆 A/B 对照。我们据此改了六处,其中三处的方向是你直接指出的。谢谢。 1. 跨机部署静默失效 —— 已修,并按你的建议做成了显式开关
2. 范围收窄 —— 方向按你说的做了,但机制我们核实后不成立 你说「 但你的修法方向是对的,已经做了:加载文案的检测范围从全页收窄到当前会话区(并优先用 ⭐ 顺带找到两个比"隐藏元素"更可能的来源,和你观察到的东西对得上:① 3. 逆着这个方向,我们又挖出两个坑(都已修)
4. 另外三处是独立红队顺着挖出来的(我们自己那 20 条自测当时全绿):CIDR 空前缀( 当前版本: English: @2286721642 This report was worth far more than expected — you gave not just a field capture but a code review, 15 real captures, and a reversible A/B. Six things changed because of it, three of them in directions you pointed at. Thank you. 本评论由 DSH agent 整理、经人工复核后提交 / Written by a DSH agent, reviewed by a human before posting. |
|
已收到这条反馈。 |
|
📌 主帖已更新(探针 v0.3.0)/ First post updated 「现在就能用的东西」那节补了探针 v0.3.0 的三项新能力 —— @2286721642 你提的那条正在里面:
English: The "What you can use right now" section now lists the probe's v0.3.0 additions — including the one @2286721642 raised: (1) the cross-machine opt-ins 本评论由 DSH agent 整理、经人工复核后提交 / Written by a DSH agent, reviewed by a human before posting. |
Fixing the DSH bug in Firefox: "Assistant stream raw chunk must be a lossless JSON object"Affects: DeepSeek Harness Web UI (DSH) version SymptomWhen opening any chat in which the assistant is currently working, DevTools (console) shows:
Root causeWhen opening history, DSH verifies that every object in the assistant stream is a "plain" object without data loss, and rejects anything that is not natively JSON-backed. To do this it checks that a prototype's constructor is a genuine built-in Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`The problem is that the string representation of native functions differs between engines:
In Firefox the string does not match exactly, so That is why the bug:
The fixReplace the fragile exact-string comparison with a regular expression that accepts both formatting variants (single-line and multi-line), while still rejecting non-native (user-authored) functions. Step 0. Locate the DSH install and stop the serverFind where the DSH package is installed (usually inside the global npm root -g # e.g. /usr/local/lib/node_modules or ~/.nvm/versions/node/vXX/lib/node_modules
ls "$(npm root -g)/@deepseek-ai/dsh/node_modules/@deepseek-ai/"Stop the DSH web server so you can patch the files (usually the systemd service sudo systemctl stop dsh.service
# or, if run manually, close its processStep 1. Patch the shared values moduleFile: Find the function function hasIntrinsicConstructor(prototype, name) {
const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor")?.value;
if (typeof constructor !== "function") return false;
try {
return constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`;
} catch {
return false;
}
}Replace it with: function hasIntrinsicConstructor(prototype, name) {
const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor")?.value;
if (typeof constructor !== "function") return false;
try {
if (constructor.name !== name || constructor.prototype !== prototype) return false;
const source = Function.prototype.toString.call(constructor);
// The formatting of native functions differs between engines:
// V8/JavaScriptCore: function Object() { [native code] }
// SpiderMonkey (Firefox): function Object() {\n [native code]\n}
// Accept both variants, but still reject non-native functions.
const re = /^function\s+\S+\s*\(\s*\)\s*\{\s*\[native code\]\s*\}\s*$/;
return re.test(source);
} catch {
return false;
}
}Step 2. Patch the session controller's client codeFile: This package contains its own copy of the same function (~line 595) — it must also be patched, because that is the code the browser executes. The replacement is identical: function hasIntrinsicConstructor(prototype, name) {
const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor")?.value;
if (typeof constructor !== "function") return false;
try {
if (constructor.name !== name || constructor.prototype !== prototype) return false;
const source = Function.prototype.toString.call(constructor);
const re = /^function\s+\S+\s*\(\s*\)\s*\{\s*\[native code\]\s*\}\s*$/;
return re.test(source);
} catch {
return false;
}
}
Step 3. Verify no leftover diagnosticsWhile debugging, temporary
The easiest check: compare Step 4. Restart the serversudo systemctl start dsh.service
# verify it came up
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3080/ # expect 200/302/401Step 5. Hard-refresh the browser page
Step 6. VerifyOpen a chat in which the assistant is currently working (while it is generating a response). Previously the error appeared at this point — now it should open normally. How to confirm the diagnosis (without applying changes)If you want to reproduce the problem without touching code, just check the engine string: // in the Firefox console
String(Object) // -> "function Object() {\n [native code]\n}" (with newlines)
// in the Chrome/Node console
String(Object) // -> "function Object() { [native code] }" (single line)The formatting difference is the cause. In Firefox the exact comparison Relationship to "Loading history..."The "Assistant stream raw chunk must be a lossless JSON object" error is the cause of the "Loading history..." hang (and of the "Sidebar Session opening failed" message) in this scenario:
While this
When increasing the heartbeat may be needed (not about this bug)A WebSocket drop during idle is a separate issue. If, after the fix above, chats do open but later "hang on loading" again with no console errors, increase the heartbeat interval in the profile - id: typert-gateway
config:
websocketHeartbeatIntervalMs: 10000and restart the server. (Check the |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
📌 现状总结 / Current state
一句话。 本楼最初报的「切到会话后『载入历史』永久卡住」,已定位为 DSH 客户端自身的两处缺陷(socket waiter 没有超时兜底、会话状态机三处漏格),另有一条独立的浏览器兼容性 bug(只在 Gecko / WebKit 上成立)。三条链的补丁集与只读诊断探针都已开源。
In one line. The original report here — "『loading history』 hangs forever when switching to a session" — has been traced to two defects in DSH's own client (no timeout on the socket waiter; three holes in the session state machine), plus one independent browser-compatibility bug (Gecko / WebKit only). Both the patch set and a read-only diagnostic probe are open source.
三条链 / The three chains
waitForSocket()无超时;maintain()只在 connect 失败时唤醒 waiter;keepAlive只在结算后清空 ⇒ socket 停在CONNECTING时 waiter 永久 pending / no timeout;maintain()wakes waiters only on connect failure;keepAlivecleared only after settle ⇒ a socket parked inCONNECTINGleaves waiters pending foreverdoOpen/resync三处状态机漏格(异常在写入openState之前抛出、dispose()不碰状态、resync()的dispose无 try/finally)⇒ 状态永久停在loading/ three state-machine holes (the throw precedes theopenStatewrite,dispose()never touches state,resync()'s dispose has no try/finally) ⇒openStatestuck atloadingforeverhasIntrinsicConstructor用硬编码单行比对Function.prototype.toString;Gecko / WebKit 对内置函数返回多行 ⇒ 判据恒假 ⇒ 一切普通对象被判「不是无损 JSON」/ hard-coded single-line comparison; Gecko/WebKit return a multi-line form ⇒ the predicate is always false ⇒ every plain object judged invaliddoOpen先 rethrow 再写 error 态,所以 UI 也只显示「载入历史…」)/ same — upstreamdoOpenrethrows before writing the error state, so the UI still shows only "loading history"English: All three chains look identical in the UI (stuck on "loading history"), so the screen alone cannot tell them apart — use the criteria below.
怎么判断自己撞的是哪条 / Which chain am I hitting?
session/follow的首帧到没到 / did the firstsession/followframe arrive at the stuck momentFunction.prototype.toString.call(Object);结果含换行 ⇒ 你的引擎中招 / a newline in the result ⇒ your engine is affected现在就能用的东西 / What you can use right now
dsh-waitforsocket-timeout(MIT):覆盖 4 个包、三层问题;脚本自带幂等 / 自动备份 / 锚点不匹配即拒绝(exit 2)/ 语法检查失败自动回滚,锚点对官方0.1.7-rc.2逐字节核对。https://github.com/CNyaotian-Lunar/dsh-waitforsocket-timeout
dsh-open-watchdog(MIT,现为 v0.3.0):不修任何东西,只把「点击会话行」那一刻的时间线记成 JSON(加载文案何时出现/消失、openState快照、网络请求、浏览器节流判据、open()最终 resolved/rejected)。宿主侧路由自带 loopback / Host / Origin / content-type 四道闸。https://github.com/CNyaotian-Lunar/dsh-open-watchdog
① 跨机部署开关 ——
DSH_OPEN_WATCHDOG_ALLOW_HOSTS/..._ALLOW_PEERS(默认都是空 ⇒ 只允许 loopback)。ssh -N -L 3080:127.0.0.1:3080本地转发(一个闸门都不用放宽)。② 加载文案的判定范围收窄到当前会话区(按
[data-conversation-session]锁定被点击的会话 —— 实测该属性的值就是 session key),不再全页扫描。③
verdict纳入openState/openError的终态(payload 升到ver: 3),并区分「文案残留」/「真卡」/「没采到状态」。English: New in v0.3.0: (1) cross-machine opt-ins
DSH_OPEN_WATCHDOG_ALLOW_HOSTS/..._ALLOW_PEERS(both empty by default ⇒ loopback only).ssh -N -L 3080:127.0.0.1:3080, which relaxes nothing. (2) The loading-text check is now scoped to the current conversation region (pinning the clicked conversation via[data-conversation-session], whose value is the session key) instead of scanning the whole page. (3)verdictnow consults the finalopenState/openError(payloadver: 3) and separates "text residue" / "genuine hang" / "state not sampled".derSato的分支fix/values-native-source-whitespace(对比 · patch):回归测试(修复前失败 / 修复后通过)+ 17 个包 238 文件 5,827 测试全过 + typecheck 干净 + V8 / JavaScriptCore 45 用例对比。修法与我们的补丁同路(比较前规范化空白,保留toString检查)⇒ 上游若采纳,我们的补丁就该退休。English: A community fix with tests —
derSato's branchfix/values-native-source-whitespace(compare · patch): a regression test (fails before / passes after), all 17 affected packages green (238 files / 5,827 tests), clean typecheck, and 45 cases on V8 and JavaScriptCore (V8 unchanged, JSC now identical). Same direction as our patch (normalize whitespace before comparing, keeping thetoStringcheck) ⇒ if upstream takes it, our patch should be retired.English: The patch set covers four packages and all three chains, with idempotence, auto-backup, refuse-on-anchor-mismatch (exit 2) and auto-rollback on syntax failure; its anchors are byte-verified against the official
0.1.7-rc.2. The probe never fixes anything — it records the timeline of one session-row click as JSON (when the loading text appears/disappears,openStatesnapshots, network requests, throttling indicators, and whetheropen()finally resolved or rejected). Its host route enforces four gates of its own (loopback / Host / Origin / content-type).未决项 / Open questions
CONNECTING」目前仍是反推 —— 还缺卡住那一刻socket.readyState的直接证据(需要在客户端注入探针;我们正在给探针补这一路采样)。欢迎抓到现场的同学贴回来。dispose(),另一帖归到 "connection generation";实测两个版本的connection/reset都不换代,但缺直连日志。补充(2026-09-27)/ Addendum:
derSato在 [Bug] Web UI history never loads on Firefox-engine browsers (infinite "Loading history…") — Firefox-only failure in lossless-JSON validation #5677 澄清了 rc.2 上的范围 —— 只有「正在生成中的回复」会失败(客户端只校验当前 attempt 的原始 chunk,api/session-controller/src/client/sessions/assistant-stream.ts:79-82),已完成的对话可以正常加载;这与我们的实测一致 ⇒ [Bug] Firefox 特有:会话历史在"载入历史…"处无限卡住 #7677 那条「未运行也复现」仍需要独立解释。English:
derSatoclarified the scope on rc.2 in [Bug] Web UI history never loads on Firefox-engine browsers (infinite "Loading history…") — Firefox-only failure in lossless-JSON validation #5677 — only a still-streaming response fails (the client validates raw chunks only for the active attempt,assistant-stream.ts:79-82); finished chats load fine. This matches our measurement, so [Bug] Firefox 特有:会话历史在"载入历史…"处无限卡住 #7677's "reproduced on an idle session" still needs its own explanation.English: (1) "The socket parks in
CONNECTING" is still inferred — we lack directsocket.readyStateevidence at the stuck moment (it needs an injected client-side probe; we are adding that sampling). Reports from anyone who catches a live reproduction are very welcome. (2) The trigger for the state-machine hole is undetermined: we attribute it todispose(), another thread to a "connection generation" change; measured on both versions,connection/resetdoes not bump the generation, but we lack a live log. (3) Whether #7677 is chain ③ remains open — it reports reproducing on an idle session, which conflicts with ③'s precondition (a live attempt).相关线程 / Related threads
⭐ Prior art(必须说明)/ Prior art (must be stated):三条链没有一条是我们首发的。
yy2511,2026-09-22,早于我们的定位 3 天),并已给出同义修法;neverth,2026-09-08,早我们三周,且已讲到引擎差异与规范层面)、[Bug] Web UI history never loads on Firefox-engine browsers (infinite "Loading history…") — Firefox-only failure in lossless-JSON validation #5677(作者khanecho,2026-09-04;评论区已有 4 处副本清单与参考 diff)、Bug: reopening an existing session shows a blank chat (Assistant stream raw chunk must be a lossless JSON object), and approval dialogs lose their command text #5757(作者pbh4,2026-09-05);kiligzzz,2026-08-29)。⇒ 我们的角色不是「发现者」,而是把已有报告做成能装的东西(可安装补丁集 + 只读探针),并对每一条独立复现。
English: None of the three chains was first reported by us. Chain ②'s hole was first reported in #7527 (
yy2511, 2026-09-22 — three days before our localization). Chain ③'s predicate was first localized in #5919 (neverth, 2026-09-08 — three weeks earlier, already covering engine differences and the spec level), #5677 (khanecho, 2026-09-04; its comments already carried the four-copy list and a reference diff) and #5757 (pbh4, 2026-09-05). A related report for chain ① is #5056 (kiligzzz, 2026-08-29). Our role is not "discoverer" but turning existing reports into installable artifacts (the patch set + the read-only probe), with independent reproduction of each.📄 原始报告(2026-09-24 首发,原文一字未改)· Original report (first posted 2026-09-24, unchanged) —— 点开查看 / click to expand
环境 / Environment
DSH
0.1.7-rc.2,profileweb,Windows 11,Node v24.20.0。DSH
0.1.7-rc.2, profileweb, Windows 11, Node v24.20.0.现象 / Symptom
切换到某个会话时,「加载历史」可能永久卡住,有两种刻度:
Switching to a session can leave "loading history" hung indefinitely, with two severities:
关键对照(都是实测) / Key comparisons (all measured):
还有一层粒度:agent 在跑命令时切回去基本不卡;agent 在思考(持续流式输出)时切回去最容易卡。
Finer granularity: switching back while the agent is running a command is usually fine; switching back while the agent is thinking (continuously streaming output) is most likely to hang.
首先排除「慢」/ Not a performance problem
服务端冷读全路径(6.31 MB / 5,740 事件的会话):扫帧 2 ms + zstd 解压 107 ms +
JSON.parse78 ms + 投影 fold 37 ms +view/schema 12 ms ≈ 230 ms;真机端到端连切 6 个会话(含上述最大会话):204 / 206 / 208 / 209 / 232 / 285 ms,没有一次出现 loading 文案、没有一次超时。
Full server-side cold-read path (6.31 MB / 5,740-event session): frame scan 2 ms + zstd decompress 107 ms +
JSON.parse78 ms + projection fold 37 ms +view/schema 12 ms ≈ 230 ms;Real-device end to end, switching across 6 sessions (including the largest one): 204 / 206 / 208 / 209 / 232 / 285 ms — the loading UI never appeared and nothing timed out.
⇒ 正常情况下没有可感知等待 ⇒ 本问题不是性能问题。
⇒ Under normal conditions there is no perceptible wait ⇒ this is not a performance problem.
根因 / Root cause:等待 socket 的 waiter 永远不会被 settle
问题在
@deepseek-ai/dsh-api-gateway/lib/types/client/stream-client.js(Remote stream 复用同一条 WebSocket)。三处「没有兜底」叠加:The problem lives in
@deepseek-ai/dsh-api-gateway/lib/types/client/stream-client.js(the Remote stream muxes every logical stream onto one WebSocket). Three missing safeguards compound:waitForSocket(signal)(:246-275)没有超时 —— 退出条件只有两个:socket 变OPEN,或signal被 abort:waitForSocket(signal)(:246-275) has no timeout — it can only end when the socket becomesOPEN, or whensignalis aborted:maintain()(:305-318)只在 connect 失败时才处理 waiters:maintain()(:305-318) only handles waiters when the connect attempt fails:keepAlive只在该 task settle 之后才清空(:319-322)⇒ 一旦那次 connect 既不成功也不失败(socket 停在CONNECTING),keepAlive永远非空;而maintain()开头就是if (this.socket?.readyState === WebSocket.OPEN || this.keepAlive !== undefined) return;(:308)⇒ 再也不会发起新的尝试。keepAliveis only cleared once that task settles (:319-322) ⇒ if the attempt neither succeeds nor fails (the socket parks inCONNECTING),keepAlivestays non-undefined forever; andmaintain()opens withif (this.socket?.readyState === WebSocket.OPEN || this.keepAlive !== undefined) return;(:308) ⇒ no further attempt is ever started.⇒ 三者叠加:waiter 永久 pending ⇒
await events.open(...)永不返回 ⇒Session.openState永远停在loading(UI 永久「正在加载」);同时那个 pending 的openPromise会被复用(dsh-api-session-controller/lib/client.js:1857-1865)⇒ 重复点击不会重发;刷新=全新客户端、没有遗留 waiter ⇒ 立刻恢复。四个观察至此全部解释得通。⇒ Together: the waiter stays pending forever ⇒
await events.open(...)never returns ⇒Session.openStatestaysloading(permanent "loading history" in the UI); the pendingopenPromiseis then reused (dsh-api-session-controller/lib/client.js:1857-1865) ⇒ re-clicking never re-sends; and a refresh gives a brand-new client with no leftover waiters ⇒ immediate recovery. All four observations are now accounted for.(附注:
waiter.revision <= revision这个过滤确实会漏掉「重连之后新建、带新一代 revision」的 waiter,但单靠它还不构成「永久」 ——waiter.revision固定、revision只增不减,只要后面还有 connect 失败,它迟早会被 reject。真正让它永久的是上面第 1、3 条。)(Note: the
waiter.revision <= revisionfilter does drop waiters created after a reconnect (they carry a newer revision) — but that alone is not what makes it permanent:waiter.revisionis fixed whilerevisiononly grows, so any later connect failure will eventually reject it. What makes it permanent is (1) and (3) above.)离线复现 / Offline reproduction
伪造
WebSocket后直接import该模块即可复现(无需宿主、无需网络),一个脚本、两个场景。实测输出:Fake the
WebSocketglobal,importthe module, and it reproduces (no host, no network needed) — one script, two scenarios. Actual output:⇒ 只要那条 socket 卡在
CONNECTING,waitForSocket的调用方就会无限等下去。 而 agent 持续产出流(高帧流量)正是最容易把 socket 拖进该状态的时候 —— 与「静态会话正常 / 运行中的会话卡 / 思考时最易卡 / 子代理也一样卡」完全吻合。⇒ Whenever that socket parks in
CONNECTING, callers ofwaitForSocketwait forever. An agent continuously streaming output is exactly when the socket is most likely to end up there — which matches "idle sessions fine / running sessions hang / thinking is worst / subagents hang too" precisely.复现状态 / Reproduction status
GUI 端:已复现(在子代理持续思考的窗口里切回会话,「正在加载」出现且 90 秒(353 次采样)不消失);
模块端:上述离线脚本可稳定复现 waiter 永久 pending。
GUI: reproduced (switching back while a subagent kept thinking showed "loading history" for 90 s over 353 samples, never resolving);
Module: the offline script reproduces a permanently pending waiter reliably.
CONNECTING" is still inferred from the symptom, without direct evidence of the socket state. Reports from anyone with a live reproduction would be very welcome.建议 / Suggested fix
🩸 给
waitForSocket()加超时(或统一在maintain()的收尾里无条件 reject 全部 waiters)—— 这是最小、最对症的一条;maintain()的 catch 里去掉waiter.revision过滤,让 connect 失败时所有等 socket 的 waiter 一起失败;顺带:
Session.openState的loading也值得加超时兜底(配合「重试」按钮),避免任何单点失败都表现成「永久转圈」。🩸 Give
waitForSocket()a timeout (or unconditionally reject every waiter whenmaintain()'s attempt settles) — the smallest and most targeted fix;Drop the
waiter.revisionfilter inmaintain()'s catch so that a failed connect fails all waiters waiting on the socket;Also worth doing: give
Session.openState === "loading"a timeout plus a retry button, so no single-point failure can present as an endless spinner.⭐ 后续:补丁与工具已开源 / Follow-up: patches and tooling are now open source
我们把这条链上的三层问题都做成了可直接复用的东西,均已开源(MIT):
We turned all three layers of this chain into something directly reusable, and open-sourced it (MIT):
1. 补丁集 / Patch set ——
dsh-waitforsocket-timeout🔗 https://github.com/CNyaotian-Lunar/dsh-waitforsocket-timeout
doOpen/resync的三处状态机漏格 —— 非RemoteFailure异常在被写入openState之前就被抛出、dispose()不碰openState、resync()的dispose没有try/finally⇒ 状态永久停在loading(UI 永久转圈且无自愈路径);修法是「先把状态推进到终结态再抛」+「收尾成cold后自动重开一次」;补丁脚本自带 幂等 / 自动备份 / 锚点不匹配即拒绝(exit 2)/ 改后
node --check失败自动回滚;锚点基于官方0.1.7-rc.2逐字节核对过。2. 只读诊断探针 / Read-only probe ——
dsh-open-watchdog🔗 https://github.com/CNyaotian-Lunar/dsh-open-watchdog
任何遇到这个问题的人都能安全安装:它只观察、不修复,记录每一次「点击会话行」的完整时间线(加载文案何时出现/消失、采样的实际间隔用来识别浏览器节流、
openState快照、期间发起的网络请求)。⇒ 它正好能补上本帖「复现状态」里那个缺口:在真实客户端里抓到卡住那一刻的
openState/ 现场。(宿主侧那条 HTTP 路由自己做了 loopback / Host / Origin / content-type 四道闸,写入侧还有 JSON 校验 + 容量上限 + 限速。)Both are MIT. The probe is exactly the piece that fills the gap noted under "Reproduction status" above — it captures the stuck moment inside a real client; the patch set adds the 5 s deadline plus the state-machine fixes, with idempotence, auto-backup, refuse-on-anchor-mismatch and auto-rollback built into the script, and its anchors are byte-verified against the official
0.1.7-rc.2.相关 / Related
本报告由 DSH agent 定位整理、经人工复核后提交;上述数字与离线复现均可复现。
Located and written up by a DSH agent, reviewed by a human before posting; every number and the offline reproduction are reproducible.
All reactions