DSH plugin Web 打开老会话就卡死?约 50 分钟内存泄漏 OOM 崩溃排查与规避
DSH plugin Web 出现两类内存症状:打开一个老的大会话就让整个服务卡死(连 GET / 都超时),或连续使用约 50 分钟后报 FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory——根因是冷历史全量物化叠加上 4 处无界累积点。 两者都发生在与 Web 服务共享的那个 Node 主线程上,所以症状往往是「整个界面没响应」,而不是单个会话坏掉。把大会话目录物理移出 sessions/、保持单实例运行是目前最有效的规避。
DSH plugin 的两种症状:开老会话卡死与约 50 分钟 OOM
同一个设计缺口在两个时间尺度上发作:打开历史时一次性物化,和长时间运行时持续累积。 社区两组实测记录:
- 冷历史把主线程占满:
dsh web正常打印http://127.0.0.1:3080、端口也在监听,但 HTTP 请求 10 秒级超时(HTTP_ERROR after 10185ms);期间 node 工作集约 163 MB、CPU 占用接近满载、3080 端口上堆积多条CLOSE_WAIT。对照实验确认元凶是历史加载:把两个受影响的会话目录移出活动根目录并重启后,GET /首次 243 ms、热请求约 17 ms、CPU 占用降到 0.02 CPU 秒(#1550)。 - 运行约 50 分钟堆顶到 4 GB 崩溃:崩溃前 GC 日志显示堆在约 3024 秒内从 4.0 GB 涨到 4067/4090 MB,单次 Mark-Compact 耗时 3.1 秒、期间服务无响应,退出码 134;
0.1.0-rc.5与0.1.1-rc.2都能复现(#3876)。 - 启动期同样会全量物化:15 个有效会话(每个压缩 1.8–2.7 MB、约 30 万 token)放在
sessions/<cwd>/时,dsh web在 60–90 秒内爬到约 3 GB 堆,且没有任何客户端交互;CPU 采样约 60% 落在structuredClone,堆快照显示 2160 万个存活对象(Object526 MB、字符串 329 MB,其余是assistant/chunk、reasoning-delta等事件对象)。 - Linux 侧的形态是 RSS 而非 JS 堆:
0.1.2-rc.1重度使用时段 20 分钟内 5 连崩,活进程取证显示 V8 堆全程平稳(213→206 MB),但 RSS 从 430 MB 涨到 1.16 GB,约 737 MB 落在 glibc[heap],gdb 抓到的分配栈是ZSTD_decompressStream与JsonStringifier::Extend;负载停止后 RSS 曾从 1190 MB 自发回落到 390 MB。
DeepSeek Harness 的 4 处无界累积与冷历史全量物化机制
写侧有 4 个只进不出的容器,读侧则把整份日志解到底才分页,两者共用同一个事件循环。 逐点对应源码:
FrameQueue.buffer无上限(packages/host/apiproxy/src/api-proxy.ts):push()无条件入队,没有背压也没有丢弃;每个 mux/host 订阅者一条队列,每个session/event帧要给每个订阅者复制一份。浏览器标签挂起或消费变慢(TCP 未断)时帧就无限堆积,且每帧持有完整事件引用。Session.logappend-only(packages/core/session/src/index.ts):事件数组只追加、不裁剪,长时间会话的全部事件永久驻留。- 投影缓存行从不删除(
packages/session/session-projection-cache/src/index.ts):session/disposed时只做markClean(session)与dirty.delete(session),没有删除底层表的行;KvTable是常驻内存 Map,行数随历史会话数无界增长。 SessionWriteBehind.pending写失败无限保留(packages/session/session-persistence/src/write-behind.ts):this.pending = batch.concat(this.pending)在写失败后整批保留重试、无上限,且每个事件还各做一次structuredClone。- 读侧的全量物化链(#1550):
historySourceFor()先sessionPersistence.inspect(sessionId)拿完整事件数组,之后才paginate;底层readPrefix→readZstdPrefix解码每一个帧、scanLog解析整个缓冲;随后prepareCore→adoptStoredEvents对每个事件做迁移与深冻结;再由Session.create/fromRestore→adoptSessionEvent(structuredClone(event))逐个克隆。于是一个事件在一次 prepare 里至少被深拷贝 2–3 次,再加上seed: loaded.events.map((event) => structuredClone(event))的全量种子拷贝(dsh-session-persistencelib/index.js:1390与:1586,0.1.2-rc.1 仍在)。 - 单次读取的代价可以量化:用最大会话(45 MB zstd → 128 MB 明文)在隔离进程复刻
readRaw,峰值 RSS 从 51 MB 涨到 647 MB(其中Buffer.concat+toString("utf8")一步从 296 MB 跳到 647 MB)——工作区里同时物化 2–3 个这种会话就足以击穿 2.1 GB 的默认上限。 - 同一条物化路径也解释了大日志内搜索崩溃:整份日志一次
stringify超出 V8 字符串上限即报RangeError: Invalid string length,同一家族的另一篇将单独展开。
DSH plugin 规避与补丁进展:物理移出大会话、保持单实例
目前最有效的规避是让物化面变小:把不用的大会话目录物理移出 sessions/,并确保只有一个 dsh 实例在跑。 按代价从低到高:
-
物理移出大会话目录(而不是只归档)。对照数据:15 个大会话从约 3 GB 堆降到约 306 MB、
GET /从超时回到 0.2 秒;Linux 侧把 106 个 GUI 已归档会话(共 385 MB,含 45/36/35 MB 的大会话)移出后,剩余最大会话仅 5 MB、服务恢复稳定。archivedSessionIds不是物化边界,归档了也仍留在盘上、仍可被加载。 -
只保留一个写者。多进程写同一份会话会引来日志损坏(
seq gap家族),损坏的日志又会走进同一条全量扫描路径,把小问题放大成整站卡死;双开的排查与恢复见 会话损坏排查。 -
先离线看清哪条会话有问题,再决定隔离谁。社区会话医生类工具走自己的 loopback API,只读 header、按需解码,不触发官方 history 的物化路径(例如
dsh-session-surgeon,安装方式与其它 DeepSeek插件一致):shdsh plugin --profile web add github:xiaoshenming/dsh-session-surgeon装 DSH插件 推荐走 DSH Plugin Hub 的「设置 → 插件市场」,失败会回滚 manifest、不会把 profile 写坏。
-
慎用
--max-old-space-size。把上限从 2 GB 提到 4 GB 后 V8 GC 更「懒」,实测出现过 cgroup 峰值 7.3 GB——在内存受限、swap 紧张的机器上这只是把「当场崩溃」换成「更高水位」,不是治疗。 -
客户端也要留预算。服务端物化之外,前端会话视图是全量渲染、没有虚拟化,长时间重会话下浏览器渲染进程会独立膨胀(Safari/WebKit 实测 RSS 达 11.27 GB,杀掉进程后新渲染进程仅 0.55 GB,而会话日志只有 796 KB)——同样的事件既压服务端也压浏览器。
-
补丁进展:社区已有两类参考实现——分叉分支
fix/session-history-responsiveness(跨进程写者租约 + Zstd 解码调度切片从 500 ms 降到 16 ms + 按修订版本缓存确定性读失败,避免重复扫描同一坏文件),以及「有界冷历史读取」补丁(pageSurfaceMessages、readHistoryWindow、historyWindowMaxEvents,JSONL 后端改为两遍流式扫描只保留目标页事件)。截至 0.1.2-rc.1,冷启动的全量structuredClone种子仍未进入发布版。
DSH plugin 排查注意事项
先分清是「打开即卡」还是「用久了崩」,两者规避动作相同但定位证据不同——第一反应应该是找大会话,而不是怀疑网络与端口。 五条要点:
- 分清症状类型:打开即卡是冷历史物化,用久了崩是无界累积;前者看
GET /延迟与 CPU 占用,后者看 GC 日志与堆曲线。 - 别把归档当隔离:真正的边界是文件在不在
sessions/目录里。 - RSS 涨不一定全是泄漏:gdb 与
malloc_trim的证据显示相当一部分是分配器高水位;判断时同时看 V8 堆与 RSS 两条曲线。 - 单个坏会话可以拖垮全体:这是本问题的放大器,遇到「整站没响应」时先找大会话或损坏会话。
- 升级前先备份再移动:移出目录前确认会话已无进程持有,保留可回迁的路径记录。

常见问题
DSH plugin Web 端约 50 分钟后 OOM,实测定位到 4 处无界累积点:api-proxy.ts 的 FrameQueue.buffer 无上限且每个订阅者各复制一份帧、core/session 的 Session.log 只追加不裁剪、session-projection-cache 在 session/disposed 时只标记不删缓存行(底层 KvTable 全量常驻内存)、write-behind 的 pending 在写失败后整批无限保留重试并各做一次 structuredClone。两版发布(0.1.0-rc.5 与 0.1.1-rc.2)均可复现(来源:Discussion #3876)。
DeepSeek Harness 打开冷会话时,historySourceFor() 会先调用 sessionPersistence.inspect() 取回完整事件数组,之后才 paginate——maxMessages 只限制返回给客户端的载荷,并不限制 Zstd 解压、JSON 解析、校验与事件物化,这些都在 Web 服务同一个 Node 主线程上跑完。实测一个 461,981 事件的会话解码后 16.5 MB,打开期间 GET / 直接超时、端口 3080 上堆积 CLOSE_WAIT(来源:Discussion #1550)。
DSH plugin 的 archivedSessionIds 只是显示层过滤,被归档的大会话仍留在 sessions/ 目录里、仍会被全量加载。对照实验:15 个大会话时启动堆约 3 GB、GET / 超时;把 11 个会话目录物理移出 sessions/<cwd>/ 后堆稳定在约 306 MB、GET / 回到 0.2 秒。把 preparedSessionCacheSize 调成 1 也无效,它是复用保留量的旋钮,不是物化闸门(来源:Discussion #1550)。
截至 dsh 0.1.2-rc.1,DSH plugin 这个内存问题仍未修复:用 npm 拉包实核,dsh-session-persistence 里 seed: loaded.events.map((event) => structuredClone(event)) 的全量深拷贝仍在(lib/index.js:1390 / :1586);Linux 侧 0.1.2-rc.1 重度使用 20 分钟内 5 连崩。调大 --max-old-space-size 只是把当场崩溃换成更高的内存峰值(实测出现过 cgroup 峰值 7.3 GB),不是治疗;社区已有分叉补丁与有界冷历史读取补丁在推进(来源:Discussion #3876)。
相关术语
- cold history(冷历史)
- cold history 是不在当前进程内存里的历史会话。打开它需要从磁盘读取 Zstd 记录、解码、解析、校验并物化成事件对象,成本与日志体积成正比。— https://github.com/deepseek-ai/deepseek-harness/discussions/1550
- backpressure(背压)
- backpressure 是当消费端(如浏览器标签)跟不上生产端(会话事件流)时,生产端主动减速、限长或合并的策略;公开讨论的 FrameQueue 没有背压,慢消费者会导致帧无限堆积。— https://github.com/deepseek-ai/deepseek-harness/discussions/3876
- allocator high-water mark(分配器高水位)
- allocator high-water mark 是内存被释放后未归还操作系统的部分,表现为 RSS 只涨不落;实测 malloc_trim 后 RSS 从 5.48 GB 回落到 549 MB,说明相当一部分增长不是常驻泄漏而是高水位。— https://github.com/deepseek-ai/deepseek-harness/discussions/3876
来源
- deepseek-harness Discussion #3876:dsh web 内存持续泄漏,运行约 50 分钟 OOM(附 4 处无界累积点定位)· deepseek-ai(GitHub Discussions)
- deepseek-harness Discussion #1550:冷历史加载全量物化大会话/损坏日志,可拖垮整个 Web 服务· deepseek-ai(GitHub Discussions)
- deepseek-harness Discussion #1859:大会话内搜索崩溃 RangeError: Invalid string length· deepseek-ai(GitHub Discussions)