DeepSeek Harness 卡在「载入历史…」:DSH plugin 四条根因与恢复
DSH 打开某个会话时永久停在「载入历史…」、重复点击同一个会话不会重试、只有刷新页面才能恢复——这个界面症状下至少叠着四条互不相同的根因:waitForSocket() 没有超时兜底、openState 状态机有三处漏格、Gecko/WebKit 上 Function.prototype.toString 判据恒假,以及会话日志里一个 turn: null 的 step 块。 四条的症状完全同形,所以光看屏幕分不出来,必须用「首帧到没到」「浏览器引擎是谁」「日志里有没有 turn: null」这三条判据逐条分诊。本文按「客户端两条链 → 浏览器兼容链 → 数据形状链」拆开,每条都给判据、可粘贴的自测命令与对应解法。
客户端两处缺陷:socket waiter 无超时 + openState 状态机漏格
这两条是 DSH 客户端自身的健壮性缺口,与浏览器引擎无关,任何平台都可能撞上。
症状:两种刻度,一种表现
社区报告里的现场是 0.1.7-rc.2、profile web、Windows 11、Node v24.20.0,症状分两档(#7802):
- 永久卡住:界面一直停在加载态,重复点击同一个会话不会重试,只有刷新页面才恢复。
- 迟到数秒:同样的会话,有时只是慢数秒才出来,此时刷新同样能立刻恢复。
第二档很有价值:它说明这不是「数据坏了」,而是等待路径本身不可靠——刷新会重建整条连接,于是等待被绕开。
链①:waitForSocket() 没有超时,socket 停在 CONNECTING 就永久 pending
机制三句话:waiter 没有超时,唤醒只发生在连接失败时,而 socket 停在 CONNECTING 时既不算成功也不算失败。 具体是(#7802):
waitForSocket()没有超时;maintain()只在 connect 失败时唤醒 waiter;keepAlive只在结算之后清空。
⇒ socket 卡在 CONNECTING 时,waiter 永久 pending,界面停在加载态。影响面是全部浏览器(与引擎无关);该讨论中此链的离线复现为 ✅,但真机上卡住那一刻的 socket.readyState 直证仍缺失(作者自己标为未决,见文末注意事项)。
链②:doOpen / resync 三处状态机漏格
同一个 UI 症状还有第二条完全独立的来路:异常在写完状态之前就抛出去了。 三处漏格分别位于(#7802、#6921):
- 异常在写入
openState之前抛出 ⇒ 状态从未被改写; dispose()不碰状态;resync()的 dispose 没有 try/finally。
在 @deepseek-ai/dsh-api-session-controller@0.1.5-rc.2(lib/client.js:1979-2006)里可以读到这个「没有出口」的形状:
:1994 await events.open({ maxMessages: 50 });
:1996 this.openState = "open"; // 出口 1:成功
:1999 if (!isRemoteFailure(error)) throw error; // 非远端错误 → 重新抛出,状态仍是 "loading"
:2001 this.openState = "error"; // 出口 2:仅远端失败
只有两个出口,且第二个专门给远端失败。 任何非远端异常都被重新抛出、openState 仍是 "loading"——UI 没有任何状态可去,只能永久显示加载文案(社区逐行核对,#6921)。
编号自测:确认是不是①②,以及能不能自救
- 确认「刷新能恢复」:回到会话列表,刷新页面,再点同一个会话。能立刻打开 ⇒ 落在客户端链(①②)或兼容链③的概率很高。
- 确认「重复点击不重试」:卡住时连点同一个会话行几次。若毫无变化,说明等待者没有被重建,符合链①的「waiter 永久 pending」特征(#7802)。
- 看远端
session/follow的首帧到没到:打开浏览器 DevTools 的网络面板,观察卡住那一刻远端会话流的第一帧。没到 ⇒ 偏链①②;到了但仍然 loading ⇒ 偏链③(见下一节判据表)。 - 优先用「换浏览器」做一次对照:换到 Chrome/Edge 打开同一会话。若立刻正常,直接锁定链③(兼容问题);若依旧卡,回到链①②(#7802)。
可用的修复:社区补丁集 + 只读探针
社区把三条链做成了可安装产物(均非官方):
- 补丁集
dsh-waitforsocket-timeout(MIT):覆盖 4 个包、三层问题;脚本自带幂等、自动备份、锚点不匹配即拒绝(exit 2)、语法检查失败自动回滚,其锚点对官方0.1.7-rc.2逐字节核对过(#7802)。 - 只读诊断探针
dsh-open-watchdog(MIT,v0.3.0):不修任何东西,只把「点击会话行」那一刻的时间线记成 JSON(加载文案何时出现/消失、openState快照、网络请求、浏览器节流判据、open()最终 resolved/rejected)。宿主侧路由自带 loopback / Host / Origin / content-type 四道闸(#7802)。
探针有个容易踩的坑:浏览器不在宿主那台机器上时必须放开跨机开关,否则四道闸全 403,而浏览器侧是静默吞掉的——表现为「装好了、路由也注册了,但一条记录都没有」(#7802):
# 方式一:显式放开允许来源(默认都为空 ⇒ 只允许 loopback)
export DSH_OPEN_WATCHDOG_ALLOW_HOSTS="<你的浏览器所在主机>"
export DSH_OPEN_WATCHDOG_ALLOW_PEERS="<允许的对端>"
# 方式二(推荐,一个闸门都不用放宽):本地端口转发
ssh -N -L 3080:127.0.0.1:3080 <宿主机器>
浏览器兼容链:Gecko / WebKit 上 toString 判据恒假
这条只在 Firefox、Safari 以及 iOS/iPadOS 上的所有浏览器成立,Chrome/Edge 等 V8 引擎完全免疫——「换个浏览器就好了」正是它的指纹。
机制:硬编码单行比对遇上多行返回
客户端用 hasIntrinsicConstructor 判断一个值能否被无损 JSON 序列化,做法是把 Function.prototype.toString 的结果与硬编码的单行形式比对。问题在于:
- Gecko / WebKit 对内置函数返回多行形式;
- 于是比对恒假;
- ⇒ 一切普通对象都被判「不是无损 JSON」,会话加载因此走不下去(#7802)。
界面症状与链①②完全同形(都停在「载入历史…」),因为上游 doOpen 是先 rethrow、再写 error 态,所以即便命中兼容链,UI 也只显示加载文案(#7802)。
一行自测:确认你的引擎中不中招
在出问题的浏览器控制台跑这一行,结果含换行就是中招。 命令行等价写法:
node -e "console.log(/\\n/.test(Function.prototype.toString.call(Object)))"
把 node 换成浏览器控制台就是:
Function.prototype.toString.call(Object)
// 结果里含换行 ⇒ 你的引擎命中该判据(Gecko / WebKit)
判据表:三条链怎么分
| 判据 | 链① / 链② | 链③ |
|---|---|---|
卡住那一刻远端 session/follow 首帧到没到 | 没到 | 到了(且仍然 loading) |
| 会话当时在不在运行 | 运行中(或正在重连)才触发 | 需要有活跃 attempt;冷会话走不到 |
| 你的浏览器 | 任意 | 只有 Gecko / WebKit |
| 一行自测 | — | Function.prototype.toString.call(Object) 含换行 |
(判据表来自 社区总结。)
注意链③的范围:社区在 0.1.7-rc.2 上澄清为只有「正在生成中的回复」会失败(客户端只校验当前 attempt 的原始 chunk,api/session-controller/src/client/sessions/assistant-stream.ts:79-82),已完成的对话可以正常加载(#7802)。
上游方向的社区修复已带测试
一位贡献者提交了分支 fix/values-native-source-whitespace:回归测试(修复前失败 / 修复后通过)+ 17 个包 238 文件 5,827 测试全过 + typecheck 干净 + V8 / JavaScriptCore 45 用例对比;修法与社区补丁同路——比较前规范化空白、保留 toString 检查。社区作者明确表示:上游若采纳,他们的补丁就该退休(#7802)。
按下面 1/2/3 步处理链③:
- 先绕开:用 V8 内核浏览器(Chrome / Edge)打开会话,确认能否正常加载。
- 确认范围:只有正在生成中的回复失败、已完成的对话能打开 ⇒ 就是链③的 rc.2 行为。
- 等或打:等上游合入带回归测试的修复;急于自救可用社区补丁集,但注意它锚定官方版本,升级前要重新核对。
数据形状链:日志里一个 turn: null 的 step 块
这条与前面三条的本质区别是「数据形状型」——不管会话多大,只要日志里有一个无法归属的 step 块,客户端渲染状态机就会打转。 它来自另一份生产环境实测(#6921)。
关键前提:数据层是健康的
受影响会话约 33 万事件、724 条消息、2 帧(macOS 26.3 arm64,DSH 0.1.1-rc.2 线)。动任何东西之前,作者用只读工具把整份日志查了一遍(#6921):
| 检查 | 结果 |
|---|---|
| 离线契约检查器 | 0 error / 0 warning |
官方 Session 构造 + deriveMessages | ✅ 724 条消息(1.8 s) |
| 未闭合的 turn / step | 无 |
| seq 连续性 | 0 跳跃 |
| 时间顺序 | 0 倒退 |
官方 paginate 模拟(50 条窗口) | 5 ms |
更关键的是体量对照:同环境另有约 69 万事件和约 36 万事件的会话都能正常打开,出问题的这个约 33 万事件反而不行 ⇒ 单看体量决定不了(#6921)。
唯一异常:连续三条 turn: null
全量扫描只找到一处结构异常——连续三个事件带着 turn: null:
seq 580037 step/start turn: null
seq 580038 assistant/message turn: null ← 替换 marker
seq 580039 step/end turn: null
这三条是消息被编辑时写入的 surface-replace marker:marker 记下了被编辑的节点,却没有填 turn(#6921)。
编号步骤:定位并修复这个块
- 先备份:把该会话的日志文件整份复制一份到别处(改写前必须留退路)。
- 扫描
turn: null:对日志做一次结构扫描,找step/start → assistant/message → step/end且data.turn未设的连续块。思路示意(只读,不写回):
# 只读扫描:列出所有 data.turn 为 null 的事件(按你的日志文件格式调整字段路径)
jq -c 'select(.data.turn == null) | {seq: .seq, type: .type, turn: .data.turn}' session.jsonl
- 确认「当时打开的 turn 是哪一个」:在该块前后找最近一次
turn/start(本例是 turn 95)——这是它本应归属的 turn。 - 把这三个事件的
turn从null改为该 turn,其它字段一律不动。 - 验证:重跑契约检查器应为 0 违规、官方加载仍是 724 条消息、
turn: null计数归零;然后打开会话,应正常渲染。
作者的原话是:「把这 3 个事件从 turn: null 改为当时实际打开的 turn(turn 95)——没有改别的任何东西」,客户端随后正常打开(#6921)。
三种修复方向与各自代价(社区给维护者的建议)
社区没有声称这是官方补丁,而是给出了三个方向(#6921):
- 给 open 状态机「每个失败」都留出口,而不只是远端失败:对每个失败都置
"error"(远端错误保留类型化载荷),或新增第三个终态表示「已打开但渲染不了」。代价是当前有调用方依赖非远端错误向外传播,用独立终态可避开冲突。作者认为这是三条里最该先做的——它决定了「一次失败」还是「一次挂死」。 - 让无法归属的 step 在渲染层不致命:把无
turn的 step/marker 视为不可渲染,跳过它、其余对话继续渲染。代价要说清:跳过会藏内容,读者分不清「什么都没发生」和「有东西被跳过」;因此社区主张给一个可见提示(例如对话里一行「1 个事件无法归属,已跳过」),而不是静默跳过。 - 离线检测(只检测、不修复):在打开会话前做一次结构扫描,把「应用打不开我的会话」提前变成「这个会话有一个畸形块」。
一个重要的横向对照:同一批报告里还有 #6942 / #6949 / #6952 / #6953 / #6954 五条,共同形状是**「对意料之外的输入零容忍 + 错误边界吞掉异常 ⇒ 用户看到沉默」**(空白屏、无提示)。排查时如果「数据健康但界面空白」,可以往这个模式上靠(#6921)。
排查注意事项
同一个「载入历史…」至少四条根因,先用判据分诊、再动手,最忌讳直接去改会话数据。 九条要点:
- 刷新能不能恢复是最省事的第一刀:能恢复偏客户端链;不能恢复要看首帧体积或数据形状(#7802)。
- 换浏览器做对照:换 V8 内核就好 ⇒ 链③兼容问题;都一样卡 ⇒ 链①②(#7802)。
- 一行为引擎体检:
Function.prototype.toString.call(Object)含换行即命中兼容判据(#7802)。 - 链①的
CONNECTING仍是反推:社区自己承认还缺卡住那一刻socket.readyState的直证,别把它当已定论(#7802)。 - 链②的「触发者」未定:一处归到
dispose()、另一处归到 connection generation,两者实测connection/reset都不换代,但缺直连日志(#7802)。 - 别把体量当唯一判据:约 69 万、36 万事件的会话能正常打开,约 33 万的反而不行(#6921)。
- 同症状不等于同因:
#4513/#4416是规模/性能型(host 事件循环与浏览器主线程双阻塞,实测 535,316 事件),与本篇的数据形状型不同;#7754、#6966(首帧 4~9 MB 全量下发)、#6978(解析成本)又是另外的链(#7802、#6921)。 - 诚实标注 prior art:链②最早由
#7527报(2026-09-22)、链③最早由#5919(2026-09-08)与#5677(2026-09-04)定位、链①近似报告见#5056(2026-08-29)——社区自己也强调「三条链没有一条是我们首发的」(#7802)。 - 改动会话日志前必须备份:
turn: null的修复是直接改写事件,写错会制造新的畸形数据;先备份、再扫描、后改写、最后用契约检查器验证。
排查这类「界面卡住」问题时,如果你把插件安装、更新确认、系统日志与诊断集中在 DSH Plugin Hub 里管理,至少能先在已安装列表确认是不是某个插件版本引发的加载异常,再去动会话数据。

常见问题
先试刷新页面——如果刷新能立刻恢复,基本落在这篇文章的客户端三条链里(socket waiter 无超时 / openState 状态机漏格 / Gecko、WebKit 的 toString 判据恒假),可用社区补丁集或换浏览器验证。如果刷新也无效,考虑首帧体积过大或数据形状问题(日志里存在 turn=null 的 step 块)。
在出问题的浏览器控制台执行 Function.prototype.toString.call(Object),如果结果里含换行,说明该引擎命中「硬编码单行比对恒假」这条兼容 bug,典型出现在 Firefox、Safari 与 iOS/iPadOS 上,Chrome/Edge 等 V8 引擎免疫。另一种判据是看卡住那一刻远端 session/follow 的首帧到没到:没到偏 socket/状态机,到了且仍 loading 偏兼容问题。
会。有生产环境实测:一个约 33 万事件、724 条消息的会话打开即白屏卡在「载入历史…」,全量扫描只发现一处异常——连续三条 seq 580037~580039 的 step/start、assistant/message、step/end 都带 turn: null。把这三个事件的 turn 改写为当时实际打开的 turn 95 后,会话立刻恢复正常,其他什么都没改。
社区已有开源补丁集 dsh-waitforsocket-timeout(覆盖 4 个包、三条链,自带幂等、自动备份、锚点不匹配即拒绝与语法失败回滚,锚点对官方 0.1.7-rc.2 逐字节核对),另有只读诊断探针 dsh-open-watchdog。此外 derSato 提交了带回归测试的分支,若上游采纳,社区补丁就该退休。
因为三条链的界面症状完全同形但影响面不同:socket 无超时和状态机漏格与浏览器引擎无关,Gecko/WebKit 的 toString 判据则只在 Firefox、Safari 及 iOS/iPadOS 上成立,V8 与 Hermes 免疫。所以「换 Chrome 就好」恰好是兼容链的典型特征,需要按判据逐条分。
相关术语
- openState(会话打开状态)
- openState 是客户端会话控制器里表示会话打开进度的状态字段,取值包括 loading / open / error。它只在两个出口被写入:events.open() 成功后的 open,以及仅当 isRemoteFailure(error) 为真时的 error;其它异常会被重新抛出而不写 error,于是界面永久停在 loading。— https://github.com/deepseek-ai/deepseek-harness/discussions/6921
- socket waiter
- socket waiter 是客户端等待远端会话流 socket 就绪的等待者。waitForSocket() 没有超时兜底,maintain() 只在连接失败时唤醒 waiter,keepAlive 又只在结算后清空;当 socket 停在 CONNECTING 状态时,waiter 会永久 pending,「载入历史」因此永不推进。— https://github.com/deepseek-ai/deepseek-harness/discussions/7802
- hasIntrinsicConstructor 判据
- DSH 客户端用 hasIntrinsicConstructor 判断一个值能否被无损 JSON 序列化:它把 Function.prototype.toString 的结果与硬编码的单行形式比对。Gecko 与 WebKit 对内置函数返回多行形式,于是判据恒假、一切普通对象都被判为「不是无损 JSON」,会话加载因此卡在 loading。— https://github.com/deepseek-ai/deepseek-harness/discussions/7802
- turn=null step 块
- turn=null step 块指会话日志里 step/start、assistant/message、step/end 三条事件都带 data.turn: null。它通常由消息编辑操作在已打开 turn 的 step 间隙写入 surface-replace marker 时产生——marker 记下了被编辑节点却没有填 turn,导致客户端无法把它归属到任何 turn。— https://github.com/deepseek-ai/deepseek-harness/discussions/6921
来源
- #7802 — 「加载历史」偶发永久卡住:等待 socket 的 waiter 永不 settle(含根因与社区补丁)· deepseek-ai(GitHub Discussions)
- #6921 — Session opens blank stuck on "Loading history…" — client render state machine loops forever on a session containing a turn=null step block· deepseek-ai(GitHub Discussions)