← Back to home@maozhuoshushu

dsh-local-host-guard

DSH 宿主内哨兵:同步调用打点 + 主线程卡死取证 + 内存三闸(写闸 / 冷回填闸 / 闲置会话下放)+ 队列哨兵。零依赖,全中文。

Stars
0
Language
JavaScript
Created
Oct 7, 2026
Updated
Oct 7, 2026
GitHub repo

Introduction

dsh-local-host-guard · DSH 宿主内哨兵

30 秒版

DSH 的宿主进程有两条命门:一次卡死的同步调用,和一个撞上 4 GB 堆顶的进程。 这个插件就是给宿主配的随行医生 + 三层限流阀:卡死时它指名道姓说"卡在哪一行调用"; 内存快撞顶时它先把派生缓存、冷回填、闲置会话一层一层放掉。 零依赖(只用 node 内建)、全中文界面、默认只报告不动作。

三句话讲完它的价值:

  • 宿主**"莫名其妙断了"**(窗口还在、活儿全停、没有任何报错)→ 卡死报告 里有最后那次"进入了、没返回"的同步调用,带完整命令行摘要;
  • 宿主**"一开大会话就 OOM 退出"(FATAL ERROR: CALL_AND_RETRY_LAST,退出码 134)→ 下放让闲置会话退出宿主 SessionStore,把活对象先降下来;崩前那些对象连全量 GC 都收不掉**;
  • 宿主**"越用越慢、磁盘狂转"**→ 写闸 / 冷回填闸 / 队列哨兵各自把住一条"一个动作放大成几十 KB"的链路。

安装

# 从 npm(包名已预留;发布后可用)
dsh plugin --profile desktop add dsh-local-host-guard

# 或直接从 Git 仓库装
dsh plugin --profile desktop add https://github.com/maozhuoshushu/dsh-local-host-guard

装好后在宿主里挂载;配置写在 %USERPROFILE%\.dsh\host-guard.json(没有这个文件就用内置默认值,默认 启用下放=true 但 下放动作="只报告",即"只观测、不动手")。

下文里的 D:\数据库\dsh闲杂工作区\...、D:\dsh\_audit\... 是作者本机的示例路径: 插件所有路径都能在配置里改,默认值会退回 %USERPROFILE%\.dsh\host-guard。


它解决什么问题

2026-10-01 的"DSH 莫名其妙断了"事故复盘结论是:宿主进程的事件循环被某次同步调用卡死—— 没有任何崩溃日志、没有报错,窗口还在,但活儿全停了。要根治,第一步不是"防",而是能说出卡在哪一次调用上。

这个插件就干这件事:

功能(中文)干什么卡死时能给你什么
同步调用打点每次 spawnSync / execFileSync / execSync 调用前后各写一行流水("进入" / "返回")最后一次"进入了没返回"的调用,就是凶手(带完整命令行摘要)
同步调用限时调用方没写 timeout 时补一个默认限时(默认 120 秒;taskkill 这类杀进程调用 10 秒)让"无限期挂在事件循环上"变成"最多挂 10 秒/2 分钟"
慢调用日志超过阈值(默认 3 秒)就记一条,哪怕最终成功了事后回看"哪里一直很慢",找趋势
主线程哨兵独立 worker 线程盯着主线程心跳,停摆超阈值(默认 8 秒)立刻落盘一份卡死报告:检测时间、停了多久、当前卡住的同步调用、最近流水、进程内存与 CPU,并附 node 进程报告
恢复补记主线程活过来后往同一份报告里补"恢复时间 / 总停摆毫秒"知道每次卡死持续了多久

一句话:下次再断,不用猜,直接看 卡死报告 说卡在哪。

2026-10-05 追加了第二个本事:内存下放(见下文「内存下放」一节)。宿主堆上限固定 4192 MB, 撞顶就 FATAL ERROR 退出(退出码 134),而崩前那些活对象连全量 GC 都收不掉—— 所以让"闲置但活着"的会话从宿主的 SessionStore 里退出去,是目前唯一有效的减活对象手段。 它默认只报告不动作。


为什么写成插件(而不是之前那两条路)

走过的路结果
启动器注入 NODE_OPTIONS=--require ... 预加载❌ 物理上走不通:打包版 Electron 在 GUI 主进程就把 NODE_OPTIONS 从环境里删掉了(2026-10-01 用 PEB 读运行中进程的真实环境块实测:主进程与宿主进程里都查不到它,日志里有 Most NODE_OPTIONs are not supported in packaged apps.)
翻转 Electron fuse❌ 没有可翻的:EnableNodeOptionsEnvironmentVariable 出厂就是开着的(而且即便关的,Electron 依然会删)
直接改 DSH 的 asar / 源码❌ 升级就失效,而且动的是 DSH 自己的文件
写成 DSH 插件✅ 插件由宿主自己加载、直接在宿主进程里执行;不改 DSH 任何文件;升级照样在

它到底能管到多少(如实说明,别指望过头)

  • ✅ 打点是全覆盖的:不管哪个模块、哪种写法,只要真在同步等子进程,就有"进入/返回"流水。
  • ✅ 限时覆盖"调用时才取 child_process.xxx"的调用方(CommonJS require、以及插件装好之后才加载的模块)。
  • ❌ 限时管不到"已经绑定好"的 ESM 命名导入:宿主里很多模块写的是 import { spawnSync } from "node:child_process",这种绑定在模块加载时就固定了; 2026-10-01 实测(_diag\esm-binding-test):事后替换 child_process.spawnSync,这类模块拿到的仍是原函数。 但这些调用照样会被记进流水——所以哪怕限时管不到,你依然能知道是哪一次卡住的。
  • ❌ 不抓内核态堆栈,也不自动杀进程:只取证,不动手(这是你定的规矩:卡死时先留证据)。

装在哪、怎么看

  • 源码:D:\数据库\dsh闲杂工作区\dsh-local-host-guard
  • 数据目录(日志/标记/报告都在这里):D:\dsh\_audit\guard\dsh-local-host-guard\
    • 日志.log:插件自己的日志(中文)
    • 当前同步调用.log:打点流水(一行"进入"、一行"返回",滚动清空,最大 256 KB)
    • 卡死报告\卡死-<时间戳>.json:卡死证据(主线程停摆时由哨兵线程写出)
    • node-reports\node-report-<时间戳>.json:同时抓的 node 进程报告(各线程 JS 栈 / libuv 活动)

      这个目录名故意用纯 ASCII:2026-10-02 实测,node 自带的 process.report.writeReport() 在 Windows 上 写不进含中文的路径(Failed to open Node.js report file: ... (errno: 2),哪怕目录存在)。 插件自己用 fs 写的那些中文文件名不受影响。

    • 状态.json:哨兵最近一次心跳状态(主线程是否正常、当前停摆多少毫秒)
    • 最近卡死报告.txt:最新那份报告的路径,外部看门狗也能一眼找到
  • 只读状态路由(仅本机回环可读,返回中文 JSON): http://127.0.0.1:19387/__dsh/host-guard/status

命令行

# 看状态:哨兵在不在岗、打点统计、最近流水、最近卡死报告
node D:\数据库\dsh闲杂工作区\dsh-local-host-guard\lib\cli.mjs --状态

# 只看打点流水尾部,以及"最后那次进了没回的调用"
node D:\数据库\dsh闲杂工作区\dsh-local-host-guard\lib\cli.mjs --标记

# 列出卡死报告并打印最新那份
node D:\数据库\dsh闲杂工作区\dsh-local-host-guard\lib\cli.mjs --报告

# 自测:在独立进程里故意让主线程死等几秒,看哨兵能不能落证据(默认 4000 毫秒)
node D:\数据库\dsh闲杂工作区\dsh-local-host-guard\lib\cli.mjs --自测卡死 4000

内存下放:让"活着但不活跃"的会话从宿主堆里退出(2026-10-05 新增)

背景见 D:\数据库\dsh闲杂工作区\报告\DSH宿主内存崩溃-全量报告-20261005.md: 宿主堆上限固定 4192 MB,撞顶就 FATAL ERROR 退出(退出码 134)。实测清楚了两件事—— 常驻内存由"事件流总量"决定,瞬时尖峰由"单个会话大小"决定;崩前 2.7~3.2 GB 的活对象 四枪全量 GC 只回收 7/9/11/20 MB,也就是 GC 无效,只能减活对象。

lib\下放.mjs 干的就是这件事:把一个闲置、没人在看、内容已经完整落盘的活会话, 从宿主的 SessionStore 里摘出去(不是删数据,只是不再占内存);客户端下次进入这个会话时, DSH 会照常从磁盘把它读回来。

红线(第 12 章):不删任何数据、永不写会话文件、不改内核 asar、三大会话默认在保护名单里。 任何一步失败就中止,绝不做半途操作。

它怎么判断"可以下放"(每一步都是硬门槛)

定位会话文件 → await sessions.flush(会话) → 校验磁盘 → 强校验 → detachEntered(entry) → 复核 → 记流水
步骤判据不通过会怎样
找会话文件<会话根>\<项目目录>\<会话id>\session.v4.jsonl.zstd中止
flush(会话)唯一持久化屏障;返回值=有没有 session/flush 监听者参与抛错 ⇒ 中止
校验磁盘-字节账只是线索(字节没变不代表磁盘旧:可能早就落过了)文件读不到/为空 ⇒ 中止
强校验流式解压+数行,要求 磁盘事件数 ≥ 内存事件数(内存里每一条磁盘上都要有)不够 ⇒ 中止;文件 > 下放强校验MB(默认 64 MB)直接拒绝下放,不冒丢数据的风险
detachEntered(entry)实证签名:入参是 entry 不是 id没有 entry / 抛错 ⇒ 中止
复核再取一次 entry,报"已不在内存表"才算生效仍在表里 ⇒ 如实记"下放可能没生效"

候选条件(全中才动手)

① 不是保护名单里的会话 ② 不在"最近最活跃"的那 N 个里(下放排除最近活动,默认 1) ③ 闲置 ≥ 下放闲置分钟(默认 10)④ 事件数 ≥ 下放事件下限(默认 500) ⑤ 在内存表里有活 entry,且 appending / announcing / detachRequested 都不是 true ⑥ 没在冷却期、没超"每次/每分钟"上限。

触发:定时扫描(默认 60 秒)|内存压力(heapUsed ≥ 下放阈值MB,默认 2600 MB)|手动 GET http://127.0.0.1:19387/__dsh/host-guard/下放。

怎么开(默认是关的,只报告不动作)

// %USERPROFILE%\.dsh\host-guard.json
{
  "启用下放": true,
  "下放动作": "detach",
  "下放闲置分钟": 10,
  "下放事件下限": 500,
  "下放阈值MB": 2600
}

杀开关:"启用下放": false(立刻回到只报告);GET /__dsh/host-guard/arm(带缓存破坏重装三个闸)。 回滚:删掉插件即可,原始方法本来就没被改(下放只调公开服务,不包装任何内核方法)。

可改的项(默认值)

键默认说明
启用下放false总开关。false ⇒ 只报告,一个都不动
下放动作"只报告"只有显式改成 detach 才真动手(dispose 已停用:它不移出内存却会关掉写句柄)
下放闲置分钟10多久没动过才算闲置
下放事件下限500太小的会话不值得动
下放阈值MB2600heapUsed 到这儿就算"有压力",立即扫一轮
下放压力最小间隔毫秒15000两次压力扫描之间至少隔这么久
下放每次上限1一轮最多下放几个
下放每分钟上限2每分钟最多下放几个(防抖动)
下放扫描间隔毫秒60000定时扫描间隔
下放冷却毫秒600000同一个会话多久内不再动
下放强校验MB64超过就拒绝下放(不是跳过校验)
下放探针允许flushfalse探针默认只读;true 才真调一次 flush 比对前后字节账
下放排除最近活动1永远不动最近最活跃的这 N 个(多半正是你此刻在看的那个)
下放保护名单三大会话数组或逗号串都行
下放会话根目录%DSH_HOME%\sessions一般不用改

已实证的接口语义(2026-10-05 从内核 asar 读出 + 自测固化)

  1. sessions.list() 返回 Session 对象数组;get(id) 返回 Session。
  2. sessions.liveEntryFor(会话) 入参是 Session 对象;活着返回 entry(含 carrier/detach()), 不活直接抛错(session "<id>" is not live in this store)——所以"抛错"= 已经不在表里。
  3. sessions.detachEntered(entry) 入参是 entry:store.delete(id) + attachments.delete(session), announced 时顺带派发 session/disposed。不 flush、不删数据、不写文件。
  4. sessions.flush(会话) 是唯一持久化屏障(内部要用 enter 时捕获的 carrier);detach 之后再 flush 必抛错。
  5. SessionStore 上没有 dispose / close / release / evict(报告 10.2 写的 dispose 是笔误)。
  6. 下放的代价(如实说):detach 后该会话新事件只留在内存、不再落盘;session/disposed 会让 持久化后端最终排空并关闭写句柄,客户端下次进入时由 DSH 自己重新取句柄从磁盘读回 ——这一步还没在真宿主里验证过,所以第一版默认只报告。

配套路由

路由名一律用 ASCII 主用名(status / arm / probe / offload)。 原样中文名(装闸 / 探针 / 下放)和百分号编码名也各注册一份,只为兼容旧文档旧脚本—— 2026-10-05 21:51 实测:带中文的路径注册成功了、日志也写了"已注册",但请求一律 404 (同一个 handler、同一段代码注册的 /__dsh/host-guard/status 却 200)。原因多半是请求进来时 pathname 已被 URL 解析成百分号编码串(%E6%8E%A2%E9%92%88),跟注册表里的原样中文串对不上; 现在两种串都注册,且 ASCII 名没有这个歧义。

路由干什么
GET /__dsh/host-guard/status多出 下放 字段:状态/模式/候选数/已下放/已跳过(带原因)/最近动作/下放前后 heapUsed/会话数/日志条数
GET /__dsh/host-guard/probe只读回答报告 10.3 的五个语义问题(读函数源码 + 实测 liveEntryFor + 磁盘账),不调 flush、不下放。不读 3 秒内刚被写过的文件(见文末事故记录)
GET /__dsh/host-guard/offload手动扫一轮(默认配置下就是 dry-run:只报告候选与跳过原因)
GET /__dsh/host-guard/arm重读 host-guard.json + 带 ?t= 缓存破坏重新 import,把回填闸 + 写闸 + 下放一起重装一遍(等效于重启,不用真重启);响应里回显这次生效的配置

/arm 会先重读配置再装代码,所以「改 host-guard.json → GET 一次 /arm」等效于「改配置 + 重启」—— 报告 10.4 把这条列为杀掉开关({"启用下放": false} 或 {"下放动作": "只报告"} 一改就生效)。 lib\宿主接线自测.mjs 的 ③b 段就是拿临时 host-guard.json 实测这件事的(改 777→888,不重启宿主直接生效)。

每次动作写一行 JSONL 到 D:\dsh\_audit\guard\dsh-local-host-guard\下放.jsonl(4 MB 轮转)。

装闸入口只有一个(2026-10-05 22:00 改动)

回填闸.mjs 以前会"搭便车"顺手把 写闸.mjs、下放.mjs 也装一遍(本意是让 /arm 一次重装三件套)。 实测后果:两处都装 ⇒ 后装的那份把前一份的定时器顶掉,而 status 里暴露的却是被顶掉的那份状态对象—— 表现为日志里每分钟都在扫、status.下放.扫描轮次 却一直是 0(宿主 PID 51280 实测),写闸同理。 现在装载入口收敛到 lib\index.js 一处,/arm 由 index.js 自己显式重装三件套,计数器不再说谎。

真动作验收(dry-run 已跑通;真动作还没做,按这个顺序走,别跳步)

⚠️ 2026-10-05 21:48 那次启动(宿主 PID 51280)里,新代码已经进内存了: status 里 写闸/下放 两个键都在。但它那一份 index.js 还是只注册中文路由的版本, 所以 /探针、/下放、/装闸 三条全是 404(原因见上面"配套路由")。 现在工作区里已经把 ASCII 主用名补齐 ⇒ 要再重启一次(或等下次自然重启)才能用 /probe、/offload、/arm。

  1. 重启(这一步会掐断当前会话,但会话本身在磁盘上,重进即可)。

  2. 跑一条命令验收(只读,不下放):

    node D:\数据库\dsh闲杂工作区\dsh-local-host-guard\lib\重启后验收.mjs
    

    它自己会:探活 → 看 status 里有没有 下放/写闸 字段(判断新代码进没进内存)→ 调 /__dsh/host-guard/probe(报告 10.3 五个问题的实测答案)→ 手动 dry-run 扫一轮 → 打印"现在该干什么"。退出码:0 = 新代码已装载|2 = 还是旧快照|1 = 连不上宿主。

    它自带一个最小 cookie 罐:DSH 的 web 服务会先回 303 + Set-Cookie 再放行请求, 用 fetch 裸调会撞"重定向次数超限(redirect count exceeded)"。 探针/扫描两条路由它都会挨个试 ASCII 名、中文名、编码名,并打印哪种写法真的通了。 它还会读本地日志数这个 PID 装了几份:重启后应该各是 1 次(多份 = 又有人搭便车装了, 那会让 status 的计数器冻住);定时扫描行数 > 0 说明扫描定时器活着。

  3. GET /__dsh/host-guard/status → 确认多出 下放 字段、模式 是 dry-run、服务.状态 是"已注入"。

  4. GET /__dsh/host-guard/probe → 看报告 10.3 五个问题的实测答案(只读,不下放)。

  5. dry-run 观察 ≥ 30 分钟:看 status.下放.候选数 / 已跳过(每条都带原因), 下放.jsonl 里应该只有 "动作":"报告",一行"已下放"都不该有。

  6. 确认无误后开真动作,只动一个中等会话(先把 下放事件下限 调到 500 左右, 三大会话留在 下放保护名单 里),手动 GET /__dsh/host-guard/offload 扫一轮。

  7. 三个判据都要过(缺一个就退回只报告):

    • ① 下放.jsonl 里 结果:"已下放",且"复核"那步是 已不在内存表;
    • ② status 里下放前后 heapUsedMB 真的有差别(量不出来就说明下放没意义,停);
    • ③ 在 GUI 里点进那个会话,内容完整、还能继续发消息 —— 这才是"没丢数据"的唯一证据。
  8. 三大会话(session-8174baf6 / session-fc7ee04c / session-fc3038fe): 必须经你明确同意才开始,而且从最小的那个先试。

  9. 出任何异常:下放动作 改回 "只报告"(或 GET /__dsh/host-guard/arm 重装)⇒ 立刻停手; 要彻底回滚就删插件(原始方法本来就没被包装,删掉即恢复)。

还没验证、只能靠真宿主回答的:下放之后 GUI 那侧有没有残留订阅会报错、写句柄被关掉之后 客户端重进能不能顺利从磁盘读回、heapUsed 究竟降多少、投影缓存的行有没有跟着少。 这些都写进了 下放.jsonl 的每一步,跑完一轮就有答案。


改参数

两层覆盖,后者优先:

  1. %USERPROFILE%\.dsh\host-guard.json(可选,没这个文件就用默认值)
  2. 插件行里的 config(cordis.patch.yml 的 - id: dsh-local-host-guard 那一节)

可改的项(默认值):

键(中文)默认说明
启用打点true是否装同步调用打点
启用哨兵true是否起 worker 哨兵
默认限时毫秒120000同步子进程默认限时,0 = 不限时(只打点)
杀进程限时毫秒10000taskkill / Stop-Process 这类调用的限时
慢调用阈值毫秒3000超过就记一条慢调用
卡死阈值毫秒8000主线程停摆多久算卡死
心跳间隔毫秒1000主线程给哨兵的心跳
检查间隔毫秒1000哨兵检查间隔
报告冷却毫秒300000同一轮卡死最多多久落一份报告
数据目录D:\dsh\_audit\guard\dsh-local-host-guard不可写时自动退回 ~\.dsh\host-guard 或临时目录

示例 %USERPROFILE%\.dsh\host-guard.json:

{
  "卡死阈值毫秒": 8000,
  "默认限时毫秒": 120000,
  "慢调用阈值毫秒": 3000
}

它和外部看门狗的分工

外部看门狗(计划任务 DSH-Hang-Watchdog)本插件(宿主内哨兵)
在哪跑独立的 powershell 进程,每分钟探一次 HTTPDSH 宿主进程内
能看见什么宿主完全不响应(HTTP 探不通)时,抓 dump、存快照、尝试解冻宿主内部卡在哪里(哪次同步调用)、停摆多久、内存/CPU 快照
局限宿主还活着但某个功能坏了(如子进程跑不起来)它看不出来宿主进程整个消失时它也跟着没了

两个一起装才完整:外部的负责"救",内部的负责"说清楚"。


卸载 / 回滚

  1. 从 C:\Users\Administrator\.dsh\profiles\desktop\package.json 里删掉 dependencies 的 "dsh-local-host-guard" 与 dsh.profile.bundles 里的 "dsh-local-host-guard";
  2. 删掉 node_modules\dsh-local-host-guard 这个 junction(别删源码目录);
  3. 重启 DSH。

源码目录本身可以留在工作区,不影响任何东西。数据目录(日志/报告)也可以随时整个删掉。


自测

早先的自测脚本故意放在插件目录之外(D:\数据库\dsh闲杂工作区\dsh-local-host-guard-测试\), 这样"安装面"里只有真正会被 DSH 加载的代码,安装前的安全扫描结果干净(0 项高危)。

$t = 'D:\数据库\dsh闲杂工作区\dsh-local-host-guard-测试'
node "$t\selftest.mjs"      # 打点/限时/流水/哨兵落证据,全在临时目录里跑
node "$t\host-half.mjs"     # 用假 ctx 走一遍 apply(),确认不会炸、路由与服务都注册了

两个自测都不碰真实数据目录,也不会改 DSH 的任何文件。2026-10-02 实测:24 项 + 20 项全部通过。

闸门与下放的自测(2026-10-05 新增,都在插件目录里,全部只跑假 ctx / 临时目录)

cd D:\数据库\dsh闲杂工作区\dsh-local-host-guard
npm test                    # 三份全跑(下面三条依次执行)
node 测试\写闸自测.mjs       # 22 项:写闸包装、剥壳四路、async 兼容
node lib\下放自测.mjs        # 137 项:默认只报告、保护名单、闲置/事件下限、冷却、
                            #         排除最近最活跃的、entry 即时标志、
                            #         flush 没让磁盘追平 ⇒ 中止、磁盘事件数不够 ⇒ 中止、
                            #         文件超强校验上限 ⇒ 拒绝下放、顺利路径 ⇒ detachEntered(entry) + 复核、
                            #         dispose 已停用 ⇒ 只报告、探针只读、
                            #         异常注入(找不到文件 / 文件损坏 / 目录 / 空文件 / 截断帧)、
                            #         崩溃面回归(㉑:任何坏输入都只能"返回对象",绝不许把
                            #         未捕获的 'error' 事件抛到进程顶层 —— 见文末事故记录)、
                            #         服务缺失降级、压力(下放↔重进循环 20 次、装载/热重装 20 次不泄漏)、收尾清理
node lib\宿主接线自测.mjs    # 54 项:用假 ctx 真跑 apply(),每条路由的多种写法都注册了、
                            #         状态里有 写闸/下放 字段、/probe /offload 可通、非回环一律 403、
                            #         /arm 重装三件套 **且重读 host-guard.json**(③b:777→888 不重启生效)

2026-10-05 实测:三份全绿(22 + 137 + 54 = 213 项断言,退出码都是 0)。

这三份自测都不断言"下放能省内存"——省不省必须在真宿主里量。它们能保证的是报告第 11 章那句话: 即使插件全错,也不会造成数据丢失、不会让 DSH 无法启动;最坏情况是它什么都不做。


事故记录:2026-10-05 22:41 / 22:45 宿主两次崩溃(已修,教训留在代码里)

这是本插件唯一一次把宿主弄死,必须记下来——它就长在「探针读会话文件」那条路上。

现场

  • 两次都发生在跑 lib\重启后验收.mjs 的时候,宿主弹「DeepSeek Harness 无法使用 / 应用无法启动或已意外停止」。
  • 宿主日志里是 dsh: fatal uncaught exception: Error: Unknown frame descriptor, 错误对象带着 errno: 10, code: 'ZSTD_error_prefix_unknown',宿主随后退出。
  • 也就是:zstd 解压流上报了一个错,而这个错没有人接 —— Node 把「流上的 error 没人监听」 当成未捕获异常抛到进程顶层,DSH 宿主没有任何兜底,整个应用当场死。

真凶(离线复现出来的)

崩溃前那段代码长这样(lib\下放.mjs 里的 强校验):

const 行流 = readline.createInterface({
  input: fs.createReadStream(文件).pipe(解压),
  crlfDelay: Infinity,
});
for await (const 行 of 行流) { /* … */ }   // ← 读流与解压流都没挂 error 监听

离线复现(拿真会话文件与伪造料逐种试)的结论:

输入旧代码的结果
文件不存在(ENOENT)/ 目标是目录Unhandled 'error' event on ReadStream ⇒ 进程退出(最容易复现的一条)
内容不是 zstd(垃圾 / 截断)被 for await 接住了,返回「解压失败」
好帧后面拖垃圾正常返回(读完帧就结束)

崩溃日志报的是解压流的错,最容易复现的是读流的错——同一类:流上出错而没人接。 探针一次要顺序摸 20 个会话文件,只要任何一个文件的读/解压在某一刻出错(被删、被换、 写到一半、权限不对),宿主就没了。这不是「可能」,是已经发生过两次的事。

修法(三件事,缺一不可)

  1. 每个流都先挂 error 监听:读.on("error", () => {}) / 解压.on("error", () => {})。 监听必须在数据流动之前挂上,任何 error 都不再可能变成未捕获异常。
  2. 用 pipeline 收口成一个 Promise(node:stream/promises):任一环节出错只 reject 这一次调用, 并保证两个流都被 destroy(不留悬空句柄,否则错误会推迟几百毫秒才炸,那时更没人接)。
  3. 整段再包 try/catch:强校验文件() 从此永不抛,一律返回 {做了:false, 原因}, 上层据此「中止下放」(报告 10.4:任何一步失败就中止;永不删除、永不重写)。

顺带加了第 4 件:探针不读「刚被写过」的文件(探针静默毫秒 = 3000)。 正在被追加写的会话文件,读到写了一半的帧本来就没意义;真下放路径不走这个门槛—— 真下放会先 flush 拿到持久化屏障再校验。

函数也因此从 装下放 内部搬到了模块顶层:export async function 强校验文件(文件, 内存事件数), 外加一个只给自测用的 测试钩子。

回归测试(防它再犯)

node lib\下放自测.mjs 的 ㉑ 段是专门为这次事故写的:它在进程顶层张网 (process.on("uncaughtException") + process.on("unhandledRejection") 把一切都记下来), 然后拿强校验去撞 ENOENT、目录、垃圾、空文件、截断帧、删除竞态,最后连打 20 次坏文件再等 60 毫秒。

判据不是「返回了什么」,而是**「这段代码有没有本事把进程弄死」**——一个未捕获异常都不许有。 这一段的注释里写着事故的完整来龙去脉,以后谁想「顺手把这函数改简单点」,先读它。