历史加载卡死修复 yuyuyUuuuuuuuuuuu/deepseek-harness-dsh-loading-history-hang-fix
DeepSeek Harness修补 Safari 与 WebKit 会话卡在加载历史的故障
29.9/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/10/8 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add yuyuyUuuuuuuuuuuu/deepseek-harness-dsh-loading-history-hang-fix该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:需留意
图例:✓ 通过! 需注意— 未判定 / 无数据· 「未判定」= 本站尚无数据,不代表插件有问题
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
功能亮点
- 修复加载历史卡死
- 修补JSON校验器bug
展开完整介绍(149 字)↓
修复 DeepSeek Harness(DSH)网页 UI 卡在「Loading history…」:JavaScriptCore/WebKit 无损 JSON 校验器 bug(Safari、iPhone、iPad)、WebSocket 心跳掉线,13 个补丁,带安装器和测试套件。非官方,MIT。
README
由 DeepSeek 最新模型翻译生成DeepSeek Harness (DSH)「Loading history…」卡死:原因与补丁
针对 DeepSeek Harness(DSH,npm 包 @deepseek-ai/dsh)Web UI 一个 bug 的分析、补丁和测试套件。这个 bug 会让会话视图一直停在 「Loading history…」,永远不显示对话内容。
这是独立的社区项目,与 DeepSeek 无关。DSH 采用 MIT 协议;这里的补丁会修改 DSH 代码(见 NOTICE)。
可在浏览器控制台搜索的症状文本(Safari / WebKit):
TypeError: Assistant stream raw chunk must be a lossless JSON object
| 事实 | 值 |
| 受影响 | macOS 上的 Safari,以及 iPhone / iPad 上的所有浏览器(WebKit);Firefox 155 有同样的检查(仅在无头 Firefox 中验证过字符串输出) |
| 不受 D1 影响 | Chrome、Edge、Node(V8) |
| 触发条件 | 在一轮对话运行期间打开或重新加载会话;这一轮结束后视图会恢复 |
展开完整文档(README 剩余 1 节)
DeepSeek Harness (DSH)「Loading history…」卡死:原因与补丁
针对 DeepSeek Harness(DSH,npm 包 @deepseek-ai/dsh)Web UI 一个 bug 的分析、补丁和测试套件。这个 bug 会让会话视图一直停在 「Loading history…」,永远不显示对话内容。
这是独立的社区项目,与 DeepSeek 无关。DSH 采用 MIT 协议;这里的补丁会修改 DSH 代码(见 NOTICE)。
可在浏览器控制台搜索的症状文本(Safari / WebKit):
TypeError: Assistant stream raw chunk must be a lossless JSON object
| 事实 | 值 |
| 受影响 | macOS 上的 Safari,以及 iPhone / iPad 上的所有浏览器(WebKit);Firefox 155 有同样的检查(仅在无头 Firefox 中验证过字符串输出) |
| 不受 D1 影响 | Chrome、Edge、Node(V8) |
| 触发条件 | 在一轮对话运行期间打开或重新加载会话;这一轮结束后视图会恢复 |
| 原因(D1) | 一个 lossless-JSON 校验器把 Function.prototype.toString.call(Object) 与 V8 的精确输出做比较;JavaScriptCore 会在这个字符串里放入换行符 |
| 其他原因 | WebSocket 心跳掉线(D3-D6),以及同一路径上的更多缺陷(D7-D21) |
| 修复 | 13 个补丁;./install.sh 把它们应用到运行时的副本上,./install.sh --rollback 切回原状 |
| 版本 | 在 0.1.6-alpha.1 上分析和打补丁(0.1.6-alpha.2 中字节完全一致);在 0.1.7-rc.2 中 D1 校验器未变 |
| 测试 | test/suite.sh、test/safari-jsc-check.sh、test/webkit-e2e.mjs |
概览
出了什么问题。 几个互相独立的缺陷最终都停在同一个界面上:
- Safari / WebKit:找到根因(D1)。 在 macOS 的 Safari 和 iPhone/iPad 的浏览器(都用 WebKit)里,在一轮对话运行期间打开或重新加载会话,每次都会卡死,而且数据完全合法。浏览器控制台显示 TypeError: Assistant stream raw chunk must be a lossless JSON object。只有这一轮结束后视图才会恢复。原因是 lossless-JSON 校验器把 Function.prototype.toString.call(Object) 与 V8 的精确输出做比较。JavaScriptCore 会在这个字符串里放入换行符,于是校验器拒绝每一个普通对象。Firefox 受同一个检查影响:Firefox 155 也会在这个字符串里放入换行符(在无头 Firefox 中验证;没有在那里运行完整的 DSH UI)。Chrome、Edge 和 Node 使用 V8,从不出现这个 bug。
- 所有浏览器,尤其是远程和移动端使用场景(D3 到 D6)。 服务器的 WebSocket 心跳在约 4 到 6 秒收不到 pong 后就会断开客户端(后台标签页、锁屏的手机、拥塞的链路)。重连路径上的竞态随后可能让历史请求永远等待。这种卡死往往在重新加载后依然存在,只有重启 DSH 才能清除。
- 同一路径上的更多缺陷(D7 到 D21)。 在两个服务端位置(D7、D8),一个抛异常的事件监听器会静默停止向所有已连接的浏览器投递事件。
在 11 处(D9 到 D19),视图设置了 loading 或 saving,但遇到错误或请求被取代而提前返回时,从未将其复位。搜索在一次失败的打开之后可能一直处于禁用状态(D20),而一次失败的 fork 会被静默吞掉(D21)。
最快的缓解办法。
| 你看到它的地方 | 该怎么做 |
| Safari、iPhone、iPad | 应用 patches/dsh-api-session-controller.client.js.patch 和 patches/dsh-util-values.index.js.patch(它们包含一行修复 D1),然后重启 DSH。见应用与回退。不打补丁的话,等这一轮结束再重新加载。 |
| 远程访问、手机、后台标签页 | 加上心跳设置。它只是配置,升级后依然保留。见 3.1。 |
| 任何环境 | ./install.sh 会把全部 13 个补丁应用到运行时的副本上,并切换到该副本。./install.sh --rollback 切回去。 |
版本。 分析和打补丁基于 0.1.6-alpha.1。受影响的代码在 0.1.6-alpha.2 中逐字节相同。在 0.1.7-rc.2(发布于 2026-09-24)中,D1 背后的校验器没有变化。其余补丁尚未针对 0.1.7 重新检查;在那里使用之前先运行 ./install.sh --check <version>。
目录
| 路径 | 用途 |
| patches/.patch | 13 个补丁,每个文件一个。<package>.<file>.patch 在 node_modules/@deepseek-ai/<package>/lib/ 内用 patch -p0 应用。 |
| install.sh | 复制一份运行时版本,给副本打补丁,检查语法并切换到它。支持 --check 和 --rollback。 |
| patch-status.sh | 报告正在运行的运行时是否仍带有这些补丁和设置。 |
| UPGRADE.md | 升级 DSH 之后该做什么。 |
| test/ | 验证脚本。它们从已安装的运行时加载随附的 DSH 代码。 |
| tools/ | 针对这两种缺陷模式的启发式扫描器。 |
| results/ | 测试套件和压力测试的原始输出。 |
1. 症状
- 会话打开后停在「Loading history…」,历史记录始终不出现。
- 活动指示器(例如「Deep diving…」)可能一直转:这一轮仍在服务器上运行。
- Safari/WebKit:只要有一轮在运行,每次打开或重新加载都会出现,这一轮结束后就消失。
- 其他浏览器:时有时无,在远程访问和手机上频繁得多。重新加载基本没用(据我们观察,大约二十次里成功一次)。重启 DSH 有用。
「Loading history…」是在会话的 openState === "loading" 时渲染的(dsh-client-ui-chat/lib/client.js,0.1.6-alpha.1 中第 2529 行)。下面每个缺陷都是一条路径,在这条路径上该状态永远不会被离开,或者视图等待的数据永远不会到达。
2. 缺陷与修复
| ID | 缺陷 | 包 | 修复 |
| D1 | 校验器在 JavaScriptCore 上拒绝所有普通对象 | dsh-api-session-controller(浏览器)、dsh-util-values | 补丁 |
| D2 | 历史记录校验失败后,openState 停留在 loading | dsh-api-session-controller | 补丁 |
| D3 | 心跳在约 4 到 6 秒内收不到 pong 就终止客户端 | dsh-api-gateway(默认) | 设置 |
| D4 | 流代际被替换后,待处理的首个条目被搁置 | dsh-api-gateway | 补丁 |
| D5 | 被取代的 doOpen() 在 loading 状态下静默返回 | dsh-api-session-controller | 补丁 |
| D6 | followCurrent() 从不重新打开已在监听的会话 | dsh-api-session-controller | 补丁 |
| D7 | 投影监听器抛错后,事件停止投递到所有浏览器 | dsh-session-projection | 补丁 |
| D8 | assistant-stream 累加器中有同样的问题 | dsh-api-session-controller(服务端) | 补丁 |
| D9 到 D19 | 重新抛出或提前返回前,忙碌状态未落定(11 处) | 若干 UI 包 | 补丁 |
| D20 | 搜索把被拒绝的打开结果缓存到重启 | dsh-session-query-sqlite | 补丁 |
| D21 | fork 失败被吞掉(按钮无反应) | dsh-client-ui-chat | 补丁 |
| H1、H2 | 针对非无损数据的加固(不是所报告卡死的成因) | dsh-llm-pi-ai、dsh-session | 补丁 |
补丁文件及其包含的缺陷:
| 补丁 | 缺陷 |
| dsh-api-session-controller.client.js.patch | D1(浏览器副本)、D2、D5、D6、D9、D10、D19 |
| dsh-util-values.index.js.patch | D1(服务端副本) |
| dsh-api-gateway.client.js.patch | D4 |
| dsh-session-projection.index.js.patch | D7 |
| dsh-api-session-controller.index.js.patch | D8 |
| dsh-api-workspace-controller.client.js.patch | D11 |
| dsh-client-ui-settings-plugins.client.js.patch | D12、D13、D14 |
| dsh-client-ui-permission-presets.client.js.patch | D15、D16 |
| dsh-client-ui-settings-models.client.js.patch | D17、D18 |
| dsh-session-query-sqlite.index.js.patch | D20 |
| dsh-client-ui-chat.client.js.patch | D21 |
| dsh-llm-pi-ai.index.js.patch | H1 |
| dsh-session.index.js.patch | H2 |
D1. Safari/WebKit:无损 JSON 校验器拒绝所有普通对象
hasIntrinsicConstructor() 位于 dsh-util-values,并被内联进浏览器 bundle dsh-api-session-controller/lib/client.js。它通过精确字符串比较,判断某个原型是不是某个 realm 真正的 Object.prototype:
Function.prototype.toString.call(constructor) === function ${name}() { [native code] }
这个字符串是 V8 的格式。JavaScriptCore 返回的文本相同,但带有换行(用 WebKitGTK 2.52.3 实测):
V8 : "function Object() { [native code] }"
JSC: "function Object() "
在 Safari 上,这会导致:
1. hasIntrinsicConstructor(Object.prototype, "Object") 返回 false。
2. 于是 hasPlainObjectPrototype() 拒绝所有普通对象。
3. snapshotJsonValue({...}) 返回 undefined。
4. snapshotChunk() 抛出 Assistant stream chunk must be losslessly JSON-serializable,validateRecord() 再把它作为 Assistant stream raw chunk must be a lossless JSON object。
5. doOpen() 重新抛出错误(D2)。这个 rejection 没人处理,openState 停在 "loading"。
为什么只在回合运行期间出现,以及为什么重新加载也没用:
- 客户端只在一处调用 expandAssistantStream():在 replace() 里,作用于 baseline.activeAttempt.stream。这是回合进行中时服务端附带的实时 attempt。存在磁盘上的 attempt 客户端不会展开。
- 只有 chunk 记录(block-start、usage、finish 等)会经过
snapshotChunk()。text-chunks 和 reasoning-chunks 记录不会。助手一旦开始第一个 block,实时 attempt 里就包含一条 chunk 记录。
- 工具调用很多的长回合会让这个窗口开很久,所以这个 bug 看起来像是负载相关。
- 回合结束前,每次重新加载拿到的都是同一份 baseline。
- 基于 Chromium 的浏览器和 Node 都跑 V8,所以在那里测试复现不了这个 bug。
修复(一行,两份副本都改):比较前先归一化空白。这个检查的目的是拒绝伪造的 prototype,这一点不变。
Function.prototype.toString.call(constructor).replace(/\s+/g, " ") === function ${name}() { [native code] }
- dsh-api-session-controller/lib/client.js 是浏览器副本,也是关键的那份。
- dsh-util-values/lib/index.js 是服务端副本。V8 上本来就能通过;改它是为了让两份定义保持一致。
浏览器缓存不会碍事。bundle URL 带内容哈希(rev=,用 framedHash("combo", sourceBytes) 算出),并以 cache-control: public, max-age=31536000, immutable 提供。所以打过补丁的 bundle 会拿到新 URL,Safari 不会继续用旧的缓存。
同样的精确匹配检查也存在于仅 Node 的包里(dsh-cordis-host-runner、 dsh-tools、dsh-ptc-runtime-node、dsh-workflow-ptc)。它们跑在 V8 上,这里不改。
证据 1:JavaScriptCore 里实际发布的 validator。 test/safari-jsc-check.sh 从运行时的浏览器 bundle 里原样切出 validator,不做改动,放到 JavaScriptCore(WebKitGTK 2.52.3)里跑。usage chunk 从真实回合里复制而来,是合法数据。输出(有删节):
JavaScriptCore, pristine 0.1.6-alpha.1
hasIntrinsicConstructor(Object.prototype,'Object') = false
snapshotJsonValue({a:1}) = undefined
text-chunks record : OK
chunk record (usage): THROWS Assistant stream raw chunk must be a lossless JSON object
| cause: Assistant stream chunk must be losslessly JSON-serializable
JavaScriptCore, patched
hasIntrinsicConstructor(Object.prototype,'Object') = true
chunk record (usage): OK
Node / V8 (control): OK on both trees
证据 2:真实 WebKit 浏览器里的端到端验证。 test/webkit-e2e.mjs 用 Playwright 的 WebKit 构建(user agent Safari/605.1.15 Version/26.6,headless)。它启动在一个标签页里跑一个长回合,然后在回合还在跑的时候,在第二个标签页里打开同一个会话。同样的流程也在 Chromium 里跑了一遍作为对照,连的是同一个服务器。
| 服务器 | 浏览器 | 结果 | 页面错误 |
| pristine 0.1.6-alpha.1 | WebKit | 卡住 4/4(25 秒后仍显示 "Loading history…") | TypeError: Assistant stream raw chunk must be a lossless JSON object ×4 |
| pristine 0.1.6-alpha.1 | Chromium | 历史加载 2/2(1.4 秒) | 0 |
| patched | WebKit | 历史加载 4/4(2.2 秒) | 0 |
同一个服务器、同样的步骤,只有引擎决定这个 bug 会不会出现。数据和服务器都没关系。
D2. 一条校验失败的历史记录会让视图永远停在 "loading"
doOpen() 会捕获 events.open() 抛出的错误,但会把任何不是远程失败的错误重新抛出,而且是在设置状态之前就抛。这个 rejection 没人处理,finally 块只把视图标记为 dirty,openState 就一直停在 "loading"。
修复: 发布 openState = "error",带上 { code: "session/history-unreadable", message },并把错误打到控制台。
D2 修不了 D1,但它能把以后任何校验失败变成看得见的错误,而不是永远的 "Loading history…"。两个修复都需要:没有 D1,Safari 永远失败;没有 D2,任何这类失败都会变成卡死。
D3. 心跳大约 4 到 6 秒后就把客户端踢掉
const MAX_MISSED_HEARTBEATS = 2;
const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 2e3;
一个共享的 interval 定时器给每个 socket 发 ping。一个 socket 错过两次 ping,就会在下一个 tick 被终止。用一个从不回应 ping 的客户端测(test/nopong.mjs):
| 路径 | 第一次 ping | 第二次 ping | 被服务器关闭 |
| 直连,本地 | | | 4.0 秒后 |
| 走远程访问路径(浏览器用的那条) | 0.8 秒 | 2.8 秒 | 4.8 秒后 |
| 同一条路径,websocketHeartbeatIntervalMs: 30000 | 30.0 秒 | | 40 秒时仍然开着 |
后台标签页、锁屏的手机或者拥堵的链路,很容易错过两次 ping。断开本身没害处。真正让视图卡住的是后面跟着的重连,D4 到 D6 就卡在这里。
现场观察:三台会话在一台远程笔记本上卡在 "Loading history…"(对运行中的服务器做只读观察):
| 观察项 | 值 |
| 那台笔记本到 DSH 的连接数 | 0 |
| 8 秒内远程访问接口上的流量 | 两个方向都是 0 字节 |
| 每个 DSH socket 上的 Send-Q / Recv-Q | 全是 0(没有背压) |
| 服务器侧 | 三个回合都还在跑,事件还在推进 |
| 模型后端 | 健康,还在产出 token |
服务器和模型一直在工作。客户端的连接已经没了,而 UI 没说出来。
修复: 只改配置(见 3.1)。
D4. 被替换的 generation 让 RemoteStream 里待处理的第一个条目搁浅
RemoteStream.read()(dsh-api-gateway/lib/client.js)处理代际切换(重连或 restart())时先 break 再 continue,于是直接跳到下一代,什么也不产出。open() 正等待的 iterator.next() 永远不会 resolve 或 reject。doOpen() 的 catch 从不执行,所以也不报错。此外,连接健康时 waitForRemoteStreamRetry() 只允许重试一次(if (attempt === 1) return; throw error;),总共只有两代。
修复: 给每一代的第一个条目设一个截止时间,且只给第一个条目设。如果某代的第一个条目没有按时到达,就中止这一代,并把它算作一次代际切换(revision++),而不是算作一次已用掉的重试。这样流会不断重新打开,直到服务端应答。
| 情况 | 第一个条目的截止时间 |
| 首次打开,无中断 | 20 s |
| 中断之后(重连、restart() 或更早的卡顿) | 2.5 s,每次卡顿翻倍,最多 20 s;有条目到达时重置 |
第一个条目到达后截止时间即清除,所以空闲的实时 tail 永远不会超时。这一点用第一个条目之后静默 25 s 做过测试。
设计说明。下面每种方案都实现并测量过:
- 固定截止时间会累加。 固定 20 s 截止时间下,连续三次卡顿耗时 60 s,对用户来说就是卡死。
- 首次打开不能给短截止时间。 2.5 s 截止时间会重启一次健康的 6 s 打开,结果耗时 18 s。首次打开给 10 s 截止时间时,一次健康的 12 s 打开耗时 48 s,重启 4 次。按最终设计,同样的 12 s 打开在单代内 12.0 s 完成。
- 量级参考: 在可用的最大真实会话中(18 MB,约 6,400 个事件),服务端产出第一个条目耗时 127 到 248 ms,具体取决于测量方式。20 s 至少留出 80 倍余量。
- 实现细节: 判断某一代是否「中断」,要跟 read() 启动时捕获的 revision 比较。用 this.revision 初始化的变量分辨不出来。
- 结果: 中断后恢复耗时 7.5 s。固定 20 s 截止时间下耗时 60 s。
用 100 场景压力测试对比各截止时间设计(见 4):
| 设计 | HANG | 慢(> 10 s) | 最差 |
| pristine | 45/100 | n/a | n/a |
| 固定 20 s | 19/100 | n/a | 60.0 s |
| 固定 8 s | 0/100 | 19 | 24.1 s |
| 最终:感知中断、自适应 | 0/100 | 0 | 7.6 s |
D5. 被取代的 doOpen() 在「loading」中静默返回
如果 events.open() 挂起期间打开代际发生变化,成功路径和错误路径都会 return,不碰 openState。open() 的 finally 清掉 openPromise,之后没有任何地方再调用 open(),于是视图一直停在「loading」,直到整页刷新。
修复: abandonSupersededOpen() 把 loading 移回 cold,这样下一次 open() 会重新启动它。test/model-superseded-open.mjs 模拟了这个模式。
D6. followCurrent() 从不重新打开已在监视的会话
当 current === this.watched 时,followCurrent() 提前返回。D5 已经把某个会话移回 cold,而它仍处于选中状态,此时没有任何东西会再打开它。
修复: 对已在监视的会话,调用 session.open()(它是幂等的)然后返回。
D7. 一个抛异常的 session/event 监听器会切断发往每个浏览器的事件
cordis 的 emit() 不隔离监听器:
emit(...args) { this.dispatch("emit", args).map((cb) => cb(...args)); } // no try
dsh-session-projection 里的 session/event 处理器调用 apply() 和 viewSchema.parse(),没有任何防护。当某个投影抛异常,该事件后续的每个监听器都会被跳过。其中就包括 follow() 里那个监听器,它负责把事件流式发送给已连接的客户端。socket 保持打开,却什么都收不到:活动指示器一直在转,下面的视图永远填不满。
修复: 在投影处理器内部捕获并记录日志。
D8. SessionAssistantStreamAccumulator.accept() 里同样的问题
accept()(dsh-api-session-controller/lib/index.js)运行在 agent/assistant-stream 监听器内部,调用 attempt.stream.push(),遇到非无损 chunk 时会抛异常。这个抛出会跳过后续每个监听器,包括发往浏览器的投递。
修复: 捕获错误,记录一条警告,并丢弃该 attempt 的实时基线(activeAttempt = undefined)。持久会话日志由另一条路径写入,不受影响。在 V8 上,只有真正非无损的 chunk 才会触发它(见 H1),所以这个修复属于兜底隔离。
D9 到 D19. 其他视图里的同一反模式
下面每一处,视图先设置一个忙碌状态(loading、saving 或某个 pending 标志),然后在把它落定之前返回或重新抛出。finally 块只清除了在途防护,没有清除 UI 渲染所依赖的状态。
| ID | 位置 | 发生了什么 |
| D9 | dsh-api-session-controller refreshList() | 在 listState = "error" 之前重新抛出,侧边栏一直停在 loading |
| D10 | dsh-api-session-controller refreshSubagents() | 同上,针对子代理目录 |
| D11 | dsh-api-workspace-controller handleStreamFailure() | 在发布 error 之前重新抛出;视图已经被 handleCarrierFailure() 停在 loading |
| D12 | dsh-client-ui-settings-plugins loadCatalog() | 被取代的加载以 loading 状态返回,而该方法自身的重入防护随后拒绝每一次后续加载(死锁) |
| D13 | dsh-client-ui-settings-plugins save() | 抛出时 saving = true,于是后续每次保存都被拒绝 |
| D14 | dsh-client-ui-settings-plugins,被取代的保存 | 返回时 saving = true;效果相同 |
| D15 | dsh-client-ui-permission-presets load() | 等待 describeFace.ensure() 时没有加保护;一旦抛错就会跳过 derive(),面板一直停在加载中 |
| D16 | dsh-client-ui-permission-presets select() | finally 会重置保护,但抛错之后界面上 status = "saving" 仍然留着 |
| D17 | dsh-client-ui-settings-models load() | Promise.all 没有 catch;一旦 reject 就会跳过 failLoad(),面板一直停在加载中 |
| D18 | dsh-client-ui-settings-models,欢迎提示确认 | scope.set() 抛错,derive() 被跳过,视图一直停在保存中 |
| D19 | dsh-api-session-controller,提示词提交 | 提示词失败后 firstPromptPendingTurn 一直是 true;只有轮次事件才会重置它,而这时不会有任何轮次事件到来 |
修复:以上每种情况都要先落定可见状态(error 或 idle),再做任何 rethrow 或提前返回。
D20. 搜索会把一次被拒绝的 open 永久缓存下来
dsh-session-query-sqlite 里的 this._ready ??= this._open() 会缓存被拒绝的 promise。
一次偶发失败就会让搜索失效,直到 DSH 重启。
修复: 该次尝试被拒绝时清掉 _ready。
D21. fork 失败被吞掉了
dsh-client-ui-chat 里的 forkAt(...).catch(() => {}):fork 失败时什么也不做,也不上报,
按钮看起来就是死的。
修复: 记录错误([chat] fork failed:)。此时 UI 里还没有接任何错误展示入口。
加固:H1 和 H2(不是所报告卡死的成因)
- H1. dsh-llm-pi-ai 里的 mapUsage() 原样复制服务商的 token 计数。
pi-ai 已经把 undefined 和 NaN 转成 0(|| 0),但 Infinity 这类非有限值
(比如来自 JSON 的 1e400)会直接透传,让 usage chunk 变成非无损。
修复: 把每个计数都归一化为有限整数,默认 0。
打上补丁后,在真实服务器上跑了三个真实轮次,记录的 usage 保持不变(例如 inputTokens: 2720、totalTokens: 7331、cacheReadTokens: 4608)。
这些会话日志没有附在这里。
- H2. dsh-session 里的 Session.fromRestore() 会直接采纳恢复的事件,不做无损校验。
这是有意为之;它的 docstring 写着:"Embedded Assistant streams remain opaque until a stream consumer or storage verifier reads them." 因此恢复事件里的非无损数据会挺过恢复过程,让之后的
expandAssistantStream() 抛错。修复: 恢复的事件不是无损时,把无法表示的值替换成
null,用一个永不抛错的 walker 来做。这里 JSON.parse(JSON.stringify()) 往返不行:遇到 BigInt、循环引用和会抛错的 toJSON 都会抛错。测试里,这让 100 个用例中的 19 个从「读不了」变成「根本打不开」。合法事件保持原样(200 个里 200 个完全一致)。
磁盘上没有发现这类数据(268 个真实会话、7,040 条流里 0 个非无损 chunk),浏览器也不会展开存在磁盘上的尝试。H1 和 H2 属于纵深防御。
已排查并排除
- 共享 mux 写队列中的队头阻塞。 积压增大 24 倍,延迟增加 0 ms,现场每个 Send-Q 和 Recv-Q 都是 0。
- 服务端 follow() 慢。 对已发布代码插桩后,18 MB / 6,431 个事件的会话,第一项在 248 ms 后到达。
- checkRootEncoding。 耗时 7 ms,且结果已缓存。
- toPiReplayState() 对未知块返回 undefined。 push() 里的条目校验会拒绝这类值,所以它们永远不会被存储。
- v0 到 v3 的会话迁移。 它们不会创建 chunk 记录。
- 服务端和客户端用了不同的校验器。 snapshotChunk 两边是同一份实现。
有意不改
- 在 cordis emit() 里隔离监听器。 这样能从根上修掉 D7 和 D8。但 emit() 有 133 个调用点,其中 5 个包在 try 里,我们无法证明没有东西依赖监听器异常向外传播。这个改动应该放到上游。
- 暴露面类似但影响范围窄的处理器。 dsh-client-file-upload、dsh-file-reference-local、dsh-goal、dsh-permission-presets 和 dsh-session-title 都按事件类型提前返回。invariant.js 模块不会被 web 配置档加载。
- 扫描器命中但实际无害的地方(读代码确认):
- dsh-client-ui-model-selection:invalidate() 总是重置为 idle。
- follow() 里疑似丢失唤醒:popFront() 和 wake = resolve 之间没有 await。
- loadThrough():每个 return 都在 try 里,所以 finally 总会执行。
3. 应用与回滚
3.1 配置(D3,不改代码)
追加到 $DSH_HOME/profiles/web/cordis.patch.yml(DSH_HOME 默认为 ~/.dsh):
- id: typert-gateway
config:
websocketHeartbeatIntervalMs: 30000
- id 是 typert-gateway,不是 api-gateway(见 dsh-base/cordis.patch.yml)。
- 这个设置在 settings.yaml 里不生效。
- id 写错会被静默忽略。用 test/nopong.mjs 检查设置是否生效:socket 应该能撑过 20 s。
- 文件在运行时目录之外,所以设置能挺过 DSH 升级。
有了这个设置,pong 停止的客户端会在 60 到 90 s 后被断开,而不是 4 到 6 s。这个设置能减少断连,补丁(D4 到 D6)让视图在断连真的发生时也能恢复。两个都要用。
3.2 install.sh
install.sh 假设每个 DSH 版本装一次 npm,并且 DSH 从 current 符号链接启动:
$DSH_RUNTIME_DIR/0.1.6-alpha.1/node_modules/@deepseek-ai/... pristine, never modified
$DSH_RUNTIME_DIR/0.1.6-alpha.1-patched/... created by install.sh
$DSH_RUNTIME_DIR/current -> 0.1.6-alpha.1-patched
./install.sh --check # dry-run every patch against the version current points tobash
./install.sh # 把该版本复制为 <version>-patched,给副本打补丁,
做语法检查,加入心跳设置,切换 current
./patch-status.sh # 输出 "DSH patch: ACTIVE (<version>-patched)" ./install.sh --rollback # 把 current 指回原始版本
| 变量 | 默认值 | 含义 |
| DSH_RUNTIME_DIR | ~/.local/dsh-runtime | 存放各版本目录和 current 的目录 |
| DSH_HOME | ~/.dsh | DSH 主目录;心跳设置写入 profiles/web/cordis.patch.yml |
| DSH_SERVICE | (未设置) | 切换后要重启的 systemd 用户单元。未设置时,请自行重启 DSH。 |
| NODE_BIN | node | 语法检查所用的 Node |
只要有补丁打不上,install.sh 就会删掉副本,不做切换。追加心跳设置之前,它会先给 cordis.patch.yml 存一份带时间戳的备份。复制出来的运行时里约 23,000 个文件,只有 13 个与原始版本不同。
3.3 手动应用
其他安装布局(例如全局 npm 安装)请就地打补丁。先备份受影响的包。
bash
NM="$(npm root -g)/@deepseek-ai" # adjust to where DSH is installed
P="$PWD/patches"
for f in "$P"/.patch; do
n="$(basename "$f" .patch)" # <package>.<file>, e.g. dsh-api-gateway.client.js
(cd "$NM/${n%%.}/lib" && patch -p0 --dry-run < "$f")
done
If every dry run succeeds, run the same loop without --dry-run, then check syntax:
browser bundles: node -e "new Function(require('fs').readFileSync('client.js','utf8'))"
ESM modules: node --input-type=module -e "await import('/absolute/path/index.js')"
client.js 一旦损坏,界面就会一片空白,所以语法检查不能跳过。之后重启 DSH。要还原,就用 patch -p0 -R 跑同样的循环,或者重装 DSH。
DSH 升级后补丁就没了,因为新版本放在新目录里。参见 UPGRADE.md。
4. 验证
测试跑的是已安装运行时里实际发布的代码,不是重新敲一遍的副本。dsh-api-gateway 的浏览器 bundle 通过小垫片 test/loadreal.mjs 在 Node 里求值。runtime* 参数指的是包含 node_modules/@deepseek-ai 的目录。
测试套件 bash test/suite.sh <patched-runtime> [<pristine-runtime>] # must end with "SUITE PASS"
1. 回归:4 个用例,行为必须与原始代码完全一致。
2. 对抗:3 个用例,其中包括一个 25 s 的空闲实时 tail,不能超时。
3. 健康的慢速打开(1、3、6 和 7.5 s)必须无需重启即可送达。
4. 监听器隔离(D7):静态检查投影处理器是否加了保护。
5. H2:100 种非无损形态 × 4 组种子,走真实的 restore → expand 路径。
6. H2:200 个有效会话经两棵树恢复后必须完全一致(需要纯净运行时;不带 -patched 时默认使用 <patched-runtime>)。
7. 压力测试:100 个随机故障场景,要求 HANG = 0。
各个脚本
| 脚本 | 作用 |
| test/stress.mjs [runs] [budget-ms] [seed-offset] | 针对 D4 的随机故障场景:首个条目卡住、心跳被杀、在随机时刻调用 restart()、替换代际卡住。报告 HANG、slow(>10s) 和 worst。 |
| test/repro-stranded-open.mjs | 最小化的 D4 复现。在纯净树上,挂起的 next() 永远不会被敲定。 |
| test/heal-at-generation.mjs [n] [wait-ms] | D4: the server heals at generation n. |
| test/blip-after-open.mjs | D4: a disruption after the stream is already open. |
| test/replay-seeds.mjs | Re-runs single stress scenarios by seed. |
| test/trace-generations.mjs | Prints the generation timeline of a recovery. |
| test/regress.mjs, test/adversarial.mjs, test/slowopen.mjs <ms> | Suite sections 1 to 3. |
| test/model-superseded-open.mjs, test/model-rethrow-before-state.mjs | Stand-alone models of the D5 and D9 patterns. They do not need DSH. |
| test/nopong.mjs <log-with-token> [s] | D3 against a running server; the log is the one that contains the ?token= URL DSH prints. |
| test/safari-jsc-check.sh | D1 in JavaScriptCore. Needs python3-gi and gir1.2-javascriptcoregtk-6.0. |
| test/webkit-e2e.mjs <port> <log-with-token> [label] | D1 end to end in WebKit. Needs playwright-core and its WebKit build. BROWSER=chromium runs the V8 control. |
| tools/scan-busy-flags.mjs | Finds candidates of the D5/D12/D14 kind. It independently re-found those three. It has false positives, so read every hit. |
| tools/scan-hot-listeners.mjs | Finds unguarded listeners on hot events (the D7/D8 kind). |
About the stress budget: on the pristine tree a stranded open never settles, so a short
per-run budget (for example 1200 ms) is enough to count it. The suite gives the patched
tree 60 s per run. It records how long delivery took, as slow(>10s) and worst, so a
recovery that is slow cannot pass silently.
For webkit-e2e.mjs: Playwright's WebKit build runs headless on Linux but may need extra
system libraries. playwright install may garbage-collect browser builds in a shared
cache that it considers unused. If other tools depend on a specific build there, install
into a separate PLAYWRIGHT_BROWSERS_PATH.
Results
Stress test, 300 scenarios in three seed ranges:
patched seeds 1-100 runs=100 delivered=100 error=0 HANG=0 slow(>10s)=0 worst=7.6s
patched seeds 101-200 runs=100 delivered=100 error=0 HANG=0 slow(>10s)=0 worst=7.6s
patched seeds 201-300 runs=100 delivered=100 error=0 HANG=0 slow(>10s)=0 worst=7.6s
pristine seeds 1-100 HANG=45/100
pristine seeds 101-200 HANG=44/100
pristine seeds 201-300 HANG=38/100
Targeted scenarios on the shipped gateway code:
| Scenario | pristine | patched |
| Server heals at generation 3 | hangs | history delivered (generation 3) |
| Server heals at generation 5 | hangs | history delivered (generation 5) |
| Disruption after the stream is open, 3 runs | 3/3 hang | 3/3 resume |
| Client never answers pings | closed after 4.0 s | still open after 20 s (with the setting) |
Regression and adversarial cases: all pass, with behaviour identical to the pristine code.
PASS happy path: two items delivered
PASS terminal error propagates
PASS dispose() completes while awaiting opening item
PASS carrier retry within budget still works
PASS fast yield unaffected by deadline
PASS dispose during pending deadline is clean
PASS normal live tail keeps flowing (25 s idle, no spurious timeout)
Healthy slow opens were delivered in one generation, taking 1.0, 3.0, 6.0 and 7.5 s.
Non-lossless data on the restore path (H2): 400 cases. On the pristine tree the client
threw in 400 of 400. On the patched tree the client threw in 0, and 0 were rejected at
restore. For 200 valid sessions, the results were identical.
D1: see the JavaScriptCore output and the WebKit table in D1.
Smoke test on a real server with all patches applied: DSH starts, login returns 200, the
page title is DeepSeek Harness, and the WebSocket mux upgrade returns 101. The served
browser bundle (about 11 MB) contains the patched code: opening item stalled ×1,
abandonSupersededOpen ×3, fork failed ×2. These counts are for the built bundle, where
some patched modules are inlined more than once, so they can differ from the counts in
patches/. No new errors appear in the server log.
Raw outputs:
- results/suite-patched.out is the full suite on the final patch set. It was captured
with an earlier revision of the scripts. That revision's section headings said "defect
12 guard" (section 3) and "defect 14 guard" (section 4), and its section 6 printed its
summary line in Japanese ("valid data is left unchanged"). The tests are the same.
0.1.6-alpha.1-fixtest in that file is the name of the patched test tree.
- results/stress-installer-vs-pristine.out is the 100-scenario stress test on a tree
built by install.sh and on the pristine tree, with the same seeds.
5. Limitations and open questions
- For the disconnect path (D3 to D6), a natural hang in a real browser could not be
triggered on demand, because it depends on timing. What was shown: the shipped code
hangs under the reproduced conditions, the patches resolve those conditions, and the
heartbeat disconnect was observed in the field.
- It is not known whether the server can fail to send a first item for reasons other than
a dropped connection. D4 makes the client retry in any case.
- The list is probably incomplete. Several of these defects were found only after the
earlier fixes were believed to be complete.
- Tests against the shipped code cover D1 (JavaScriptCore and WebKit), D3 (nopong.mjs),
D4 (stress and targeted scenarios), D7 (a static check), H1 (three real turns) and H2
(the restore path). D2, D5, D6, D8 and D9 to D21 were found by reading the code and
with the scanners. Apart from syntax checks, the smoke test and the stand-alone models
of D5 and D9, they have no dedicated tests.
- Firefox: only the validator condition was checked, in headless Firefox 155. It returns
"function Object() ", so the exact-match check fails and the
normalised check passes, as in Safari. The full DSH UI was not run in Firefox.
- failEventStream() in dsh-api-session-controller/lib/client.js also rethrows
non-remote errors before publishing an error state. It runs after a session is already
open, so it cannot cause "Loading history…", but it could leave an open view on a dead stream. It is not patched or tested.
- The patches were written against 0.1.6-alpha.1.
License
MIT, see LICENSE. The patches contain and modify DSH code
(MIT, Copyright (c) 2026 DeepSeek); see NOTICE.
日本語の要約
DSH(@deepseek-ai/dsh)の Web UI で、セッションが「Loading history…」のまま履歴を表示しなくなる不具合について、原因の分析、パッチ、検証一式をまとめたものです。DeepSeek とは無関係の非公式プロジェクトです。
- Safari / WebKit(原因を特定、D1): macOS の Safari と iPhone・iPad のブラウザでは、ターンの進行中にセッションを開くか再読み込みすると、正常なデータでも必ず固まります。可逆 JSON 検証器 hasIntrinsicConstructor() が Function.prototype.toString.call(Object) の結果を V8 の書式と完全一致で比べています。JavaScriptCore はこの文字列に改行を入れるため、全てのプレーンオブジェクトが拒否され、TypeError: Assistant stream raw chunk must be a lossless JSON object が出ます。修正は、空白を正規化してから比べる 1 行です。0.1.7-rc.2 でも未修正です。
- 全ブラウザ、特に外出先やスマホ(D3〜D6): サーバのハートビートは、pong が 2 回返らないクライアントを約 4〜6 秒で切断します。再接続の競合で、履歴の最初のフレームを待つ処理が取り残されます。設定 websocketHeartbeatIntervalMs: 30000(id は typert-gateway)と、パッチ(最初の 1 フレームだけに期限を付け、中断後は 2.5 秒から倍々で最大 20 秒)で対処します。
- 同じ形の欠陥(D7〜D21): 状態を確定させる前に return や throw するため、loading や saving が残る箇所が 11 か所あります。ほかに、例外を投げたリスナーが後続の配信リスナーを止めてしまう箇所がサーバ側に 2 か所あります。H1 と H2 は念のための防御で、今回の症状の原因ではありません。
- 適用: ./install.sh --check → ./install.sh。元の版は書き換えず、コピーにパッチを当てて current を切り替えます。戻すときは ./install.sh --rollback。
- 検証結果: 100 通りのランダムな障害で、未パッチは 38〜45% が固着し、パッチ版は 300 シナリオで固着 0、最悪でも 7.6 秒でした。本物の WebKit では、未パッチが 4/4 固着し、パッチ版は 4/4 表示できました。
- 分かっていないこと: 実ブラウザで自然発生の固着を狙って起こすことはできていません。一覧が網羅的だという保証もありません。Firefox では試していません。预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
yuyuyUuuuuuuuuuuu/deepseek-harness-dsh-loading-history-hang-fix
修补 Safari 与 WebKit 会话卡在加载历史的故障
相关插件 · 市场 / 管理
插件精选列表awesome-dsh-plugin/awesome-dsh-plugin
DeepSeek Harness(dsh)插件精选列表
★ 22,399未汉化DSH
73.6
新词雷达zhuyansen/new-term-radar
new-term-radar新词雷达
★ 2,049已汉化MCP
72.8
插件生态导航0xsline/awesome-deepseek-harness
安装 贡献指南 DeepSeek Docs 公开插件目录 Issues
★ 1,188已汉化DSH
72.6
技能注册中心iflytek/skillhub
企业级开源智能体技能注册中心——发布、发现并管理组织内可复用的技能包。
★ 5,384已汉化DSH
71.9
插件市场目录imsai-sh/awesome-deepseek-harness-plugins
面向 DeepSeek Harnessdsh生态的社区插件目录,共收录 13950 个插件含 PR 收录与…
★ 360已汉化DSH
71.9
插件精选清单Anil-matcha/awesome-dsh-plugin
DeepSeek Harnessdsh精选指南——DeepSeek…
★ 1,013已汉化DSH
71.7
这个插件是你维护的吗?
作者本人可认领并核对本条目;认领通过后,详情页会显示「作者已认领」标记。