DeepSeek Harness 卡在「载入历史…」:DSH plugin 四条根因与恢复

故障排查发布于 2026-10-03作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSH载入历史Loading historyopenStateGeckoWebKitturn=null
DSH 打开会话永久卡在「载入历史…」,重复点击不重试,只有刷新能恢复。同一界面下至少四条根因:socket waiter 无超时、openState 状态机漏格、Gecko/WebKit 的 toString 判据恒假,以及日志里一个 turn=null 的 step 块。

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):

  1. 永久卡住:界面一直停在加载态,重复点击同一个会话不会重试,只有刷新页面才恢复。
  2. 迟到数秒:同样的会话,有时只是慢数秒才出来,此时刷新同样能立刻恢复。

第二档很有价值:它说明这不是「数据坏了」,而是等待路径本身不可靠——刷新会重建整条连接,于是等待被绕开。

链①:waitForSocket() 没有超时,socket 停在 CONNECTING 就永久 pending

机制三句话:waiter 没有超时,唤醒只发生在连接失败时,而 socket 停在 CONNECTING 时既不算成功也不算失败。 具体是(#7802):

  1. waitForSocket() 没有超时;
  2. maintain() 只在 connect 失败时唤醒 waiter;
  3. keepAlive 只在结算之后清空。

⇒ socket 卡在 CONNECTING 时,waiter 永久 pending,界面停在加载态。影响面是全部浏览器(与引擎无关);该讨论中此链的离线复现为 ✅,但真机上卡住那一刻的 socket.readyState 直证仍缺失(作者自己标为未决,见文末注意事项)。

链②:doOpen / resync 三处状态机漏格

同一个 UI 症状还有第二条完全独立的来路:异常在写完状态之前就抛出去了。 三处漏格分别位于(#7802、#6921):

  1. 异常在写入 openState 之前抛出 ⇒ 状态从未被改写;
  2. dispose() 不碰状态;
  3. resync() 的 dispose 没有 try/finally。

在 @deepseek-ai/dsh-api-session-controller@0.1.5-rc.2(lib/client.js:1979-2006)里可以读到这个「没有出口」的形状:

js
: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)。

编号自测:确认是不是①②,以及能不能自救

  1. 确认「刷新能恢复」:回到会话列表,刷新页面,再点同一个会话。能立刻打开 ⇒ 落在客户端链(①②)或兼容链③的概率很高。
  2. 确认「重复点击不重试」:卡住时连点同一个会话行几次。若毫无变化,说明等待者没有被重建,符合链①的「waiter 永久 pending」特征(#7802)。
  3. 看远端 session/follow 的首帧到没到:打开浏览器 DevTools 的网络面板,观察卡住那一刻远端会话流的第一帧。没到 ⇒ 偏链①②;到了但仍然 loading ⇒ 偏链③(见下一节判据表)。
  4. 优先用「换浏览器」做一次对照:换到 Chrome/Edge 打开同一会话。若立刻正常,直接锁定链③(兼容问题);若依旧卡,回到链①②(#7802)。

可用的修复:社区补丁集 + 只读探针

社区把三条链做成了可安装产物(均非官方):

  1. 补丁集 dsh-waitforsocket-timeout(MIT):覆盖 4 个包、三层问题;脚本自带幂等、自动备份、锚点不匹配即拒绝(exit 2)、语法检查失败自动回滚,其锚点对官方 0.1.7-rc.2 逐字节核对过(#7802)。
  2. 只读诊断探针 dsh-open-watchdog(MIT,v0.3.0):不修任何东西,只把「点击会话行」那一刻的时间线记成 JSON(加载文案何时出现/消失、openState 快照、网络请求、浏览器节流判据、open() 最终 resolved/rejected)。宿主侧路由自带 loopback / Host / Origin / content-type 四道闸(#7802)。

探针有个容易踩的坑:浏览器不在宿主那台机器上时必须放开跨机开关,否则四道闸全 403,而浏览器侧是静默吞掉的——表现为「装好了、路由也注册了,但一条记录都没有」(#7802):

bash
# 方式一:显式放开允许来源(默认都为空 ⇒ 只允许 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 的结果与硬编码的单行形式比对。问题在于:

  1. Gecko / WebKit 对内置函数返回多行形式;
  2. 于是比对恒假;
  3. ⇒ 一切普通对象都被判「不是无损 JSON」,会话加载因此走不下去(#7802)。

界面症状与链①②完全同形(都停在「载入历史…」),因为上游 doOpen 是先 rethrow、再写 error 态,所以即便命中兼容链,UI 也只显示加载文案(#7802)。

一行自测:确认你的引擎中不中招

在出问题的浏览器控制台跑这一行,结果含换行就是中招。 命令行等价写法:

bash
node -e "console.log(/\\n/.test(Function.prototype.toString.call(Object)))"

把 node 换成浏览器控制台就是:

js
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 步处理链③:

  1. 先绕开:用 V8 内核浏览器(Chrome / Edge)打开会话,确认能否正常加载。
  2. 确认范围:只有正在生成中的回复失败、已完成的对话能打开 ⇒ 就是链③的 rc.2 行为。
  3. 等或打:等上游合入带回归测试的修复;急于自救可用社区补丁集,但注意它锚定官方版本,升级前要重新核对。

数据形状链:日志里一个 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)。

编号步骤:定位并修复这个块

  1. 先备份:把该会话的日志文件整份复制一份到别处(改写前必须留退路)。
  2. 扫描 turn: null:对日志做一次结构扫描,找 step/start → assistant/message → step/end 且 data.turn 未设的连续块。思路示意(只读,不写回):
bash
# 只读扫描:列出所有 data.turn 为 null 的事件(按你的日志文件格式调整字段路径)
jq -c 'select(.data.turn == null) | {seq: .seq, type: .type, turn: .data.turn}' session.jsonl
  1. 确认「当时打开的 turn 是哪一个」:在该块前后找最近一次 turn/start(本例是 turn 95)——这是它本应归属的 turn。
  2. 把这三个事件的 turn 从 null 改为该 turn,其它字段一律不动。
  3. 验证:重跑契约检查器应为 0 违规、官方加载仍是 724 条消息、turn: null 计数归零;然后打开会话,应正常渲染。

作者的原话是:「把这 3 个事件从 turn: null 改为当时实际打开的 turn(turn 95)——没有改别的任何东西」,客户端随后正常打开(#6921)。

三种修复方向与各自代价(社区给维护者的建议)

社区没有声称这是官方补丁,而是给出了三个方向(#6921):

  1. 给 open 状态机「每个失败」都留出口,而不只是远端失败:对每个失败都置 "error"(远端错误保留类型化载荷),或新增第三个终态表示「已打开但渲染不了」。代价是当前有调用方依赖非远端错误向外传播,用独立终态可避开冲突。作者认为这是三条里最该先做的——它决定了「一次失败」还是「一次挂死」。
  2. 让无法归属的 step 在渲染层不致命:把无 turn 的 step/marker 视为不可渲染,跳过它、其余对话继续渲染。代价要说清:跳过会藏内容,读者分不清「什么都没发生」和「有东西被跳过」;因此社区主张给一个可见提示(例如对话里一行「1 个事件无法归属,已跳过」),而不是静默跳过。
  3. 离线检测(只检测、不修复):在打开会话前做一次结构扫描,把「应用打不开我的会话」提前变成「这个会话有一个畸形块」。

一个重要的横向对照:同一批报告里还有 #6942 / #6949 / #6952 / #6953 / #6954 五条,共同形状是**「对意料之外的输入零容忍 + 错误边界吞掉异常 ⇒ 用户看到沉默」**(空白屏、无提示)。排查时如果「数据健康但界面空白」,可以往这个模式上靠(#6921)。

排查注意事项

同一个「载入历史…」至少四条根因,先用判据分诊、再动手,最忌讳直接去改会话数据。 九条要点:

  1. 刷新能不能恢复是最省事的第一刀:能恢复偏客户端链;不能恢复要看首帧体积或数据形状(#7802)。
  2. 换浏览器做对照:换 V8 内核就好 ⇒ 链③兼容问题;都一样卡 ⇒ 链①②(#7802)。
  3. 一行为引擎体检:Function.prototype.toString.call(Object) 含换行即命中兼容判据(#7802)。
  4. 链①的 CONNECTING 仍是反推:社区自己承认还缺卡住那一刻 socket.readyState 的直证,别把它当已定论(#7802)。
  5. 链②的「触发者」未定:一处归到 dispose()、另一处归到 connection generation,两者实测 connection/reset 都不换代,但缺直连日志(#7802)。
  6. 别把体量当唯一判据:约 69 万、36 万事件的会话能正常打开,约 33 万的反而不行(#6921)。
  7. 同症状不等于同因:#4513 / #4416 是规模/性能型(host 事件循环与浏览器主线程双阻塞,实测 535,316 事件),与本篇的数据形状型不同;#7754、#6966(首帧 4~9 MB 全量下发)、#6978(解析成本)又是另外的链(#7802、#6921)。
  8. 诚实标注 prior art:链②最早由 #7527 报(2026-09-22)、链③最早由 #5919(2026-09-08)与 #5677(2026-09-04)定位、链①近似报告见 #5056(2026-08-29)——社区自己也强调「三条链没有一条是我们首发的」(#7802)。
  9. 改动会话日志前必须备份:turn: null 的修复是直接改写事件,写错会制造新的畸形数据;先备份、再扫描、后改写、最后用契约检查器验证。

排查这类「界面卡住」问题时,如果你把插件安装、更新确认、系统日志与诊断集中在 DSH Plugin Hub 里管理,至少能先在已安装列表确认是不是某个插件版本引发的加载异常,再去动会话数据。

DSH Plugin Hub · 已安装插件列表

来源:Discussion #7802、Discussion #6921。

常见问题

DSH 打开会话卡在「载入历史…」不动,重复点同一个会话也没反应,怎么办?

先试刷新页面——如果刷新能立刻恢复,基本落在这篇文章的客户端三条链里(socket waiter 无超时 / openState 状态机漏格 / Gecko、WebKit 的 toString 判据恒假),可用社区补丁集或换浏览器验证。如果刷新也无效,考虑首帧体积过大或数据形状问题(日志里存在 turn=null 的 step 块)。

怎么快速判断卡住是浏览器兼容问题(Gecko / WebKit)还是 DSH 客户端缺陷?

在出问题的浏览器控制台执行 Function.prototype.toString.call(Object),如果结果里含换行,说明该引擎命中「硬编码单行比对恒假」这条兼容 bug,典型出现在 Firefox、Safari 与 iOS/iPadOS 上,Chrome/Edge 等 V8 引擎免疫。另一种判据是看卡住那一刻远端 session/follow 的首帧到没到:没到偏 socket/状态机,到了且仍 loading 偏兼容问题。

会话日志里出现 turn=null 的 step 块会导致打不开会话吗?

会。有生产环境实测:一个约 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

来源