dsh-local-host-guard
DSH 宿主内哨兵:同步调用打点 + 主线程卡死取证 + 内存三闸(写闸 / 冷回填闸 / 闲置会话下放)+ 队列哨兵。零依赖,全中文。
- Stars
- 0
- Language
- JavaScript
- Created
- Oct 7, 2026
- Updated
- Oct 7, 2026
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"的调用方(CommonJSrequire、以及插件装好之后才加载的模块)。 - ❌ 限时管不到"已经绑定好"的 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 | 太小的会话不值得动 |
下放阈值MB | 2600 | heapUsed 到这儿就算"有压力",立即扫一轮 |
下放压力最小间隔毫秒 | 15000 | 两次压力扫描之间至少隔这么久 |
下放每次上限 | 1 | 一轮最多下放几个 |
下放每分钟上限 | 2 | 每分钟最多下放几个(防抖动) |
下放扫描间隔毫秒 | 60000 | 定时扫描间隔 |
下放冷却毫秒 | 600000 | 同一个会话多久内不再动 |
下放强校验MB | 64 | 超过就拒绝下放(不是跳过校验) |
下放探针允许flush | false | 探针默认只读;true 才真调一次 flush 比对前后字节账 |
下放排除最近活动 | 1 | 永远不动最近最活跃的这 N 个(多半正是你此刻在看的那个) |
下放保护名单 | 三大会话 | 数组或逗号串都行 |
下放会话根目录 | %DSH_HOME%\sessions | 一般不用改 |
已实证的接口语义(2026-10-05 从内核 asar 读出 + 自测固化)
sessions.list()返回 Session 对象数组;get(id)返回 Session。sessions.liveEntryFor(会话)入参是 Session 对象;活着返回 entry(含carrier/detach()), 不活直接抛错(session "<id>" is not live in this store)——所以"抛错"= 已经不在表里。sessions.detachEntered(entry)入参是 entry:store.delete(id)+attachments.delete(session), announced 时顺带派发session/disposed。不 flush、不删数据、不写文件。sessions.flush(会话)是唯一持久化屏障(内部要用 enter 时捕获的 carrier);detach 之后再 flush 必抛错。- SessionStore 上没有
dispose/close/release/evict(报告 10.2 写的 dispose 是笔误)。 - 下放的代价(如实说):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。
-
重启(这一步会掐断当前会话,但会话本身在磁盘上,重进即可)。
-
跑一条命令验收(只读,不下放):
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 说明扫描定时器活着。 -
GET /__dsh/host-guard/status→ 确认多出下放字段、模式是dry-run、服务.状态是"已注入"。 -
GET /__dsh/host-guard/probe→ 看报告 10.3 五个问题的实测答案(只读,不下放)。 -
dry-run 观察 ≥ 30 分钟:看
status.下放.候选数 / 已跳过(每条都带原因),下放.jsonl里应该只有"动作":"报告",一行"已下放"都不该有。 -
确认无误后开真动作,只动一个中等会话(先把
下放事件下限调到 500 左右, 三大会话留在下放保护名单里),手动GET /__dsh/host-guard/offload扫一轮。 -
三个判据都要过(缺一个就退回只报告):
- ①
下放.jsonl里结果:"已下放",且"复核"那步是 已不在内存表; - ②
status里下放前后heapUsedMB真的有差别(量不出来就说明下放没意义,停); - ③ 在 GUI 里点进那个会话,内容完整、还能继续发消息 —— 这才是"没丢数据"的唯一证据。
- ①
-
三大会话(
session-8174baf6/session-fc7ee04c/session-fc3038fe): 必须经你明确同意才开始,而且从最小的那个先试。 -
出任何异常:
下放动作改回"只报告"(或GET /__dsh/host-guard/arm重装)⇒ 立刻停手; 要彻底回滚就删插件(原始方法本来就没被包装,删掉即恢复)。
还没验证、只能靠真宿主回答的:下放之后 GUI 那侧有没有残留订阅会报错、写句柄被关掉之后
客户端重进能不能顺利从磁盘读回、heapUsed 究竟降多少、投影缓存的行有没有跟着少。
这些都写进了 下放.jsonl 的每一步,跑完一轮就有答案。
改参数
两层覆盖,后者优先:
%USERPROFILE%\.dsh\host-guard.json(可选,没这个文件就用默认值)- 插件行里的 config(
cordis.patch.yml的- id: dsh-local-host-guard那一节)
可改的项(默认值):
| 键(中文) | 默认 | 说明 |
|---|---|---|
启用打点 | true | 是否装同步调用打点 |
启用哨兵 | true | 是否起 worker 哨兵 |
默认限时毫秒 | 120000 | 同步子进程默认限时,0 = 不限时(只打点) |
杀进程限时毫秒 | 10000 | taskkill / 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 进程,每分钟探一次 HTTP | DSH 宿主进程内 |
| 能看见什么 | 宿主完全不响应(HTTP 探不通)时,抓 dump、存快照、尝试解冻 | 宿主内部卡在哪里(哪次同步调用)、停摆多久、内存/CPU 快照 |
| 局限 | 宿主还活着但某个功能坏了(如子进程跑不起来)它看不出来 | 宿主进程整个消失时它也跟着没了 |
两个一起装才完整:外部的负责"救",内部的负责"说清楚"。
卸载 / 回滚
- 从
C:\Users\Administrator\.dsh\profiles\desktop\package.json里删掉dependencies的"dsh-local-host-guard"与dsh.profile.bundles里的"dsh-local-host-guard"; - 删掉
node_modules\dsh-local-host-guard这个 junction(别删源码目录); - 重启 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 个会话文件,只要任何一个文件的读/解压在某一刻出错(被删、被换、 写到一半、权限不对),宿主就没了。这不是「可能」,是已经发生过两次的事。
修法(三件事,缺一不可)
- 每个流都先挂 error 监听:
读.on("error", () => {})/解压.on("error", () => {})。 监听必须在数据流动之前挂上,任何 error 都不再可能变成未捕获异常。 - 用
pipeline收口成一个 Promise(node:stream/promises):任一环节出错只 reject 这一次调用, 并保证两个流都被 destroy(不留悬空句柄,否则错误会推迟几百毫秒才炸,那时更没人接)。 - 整段再包 try/catch:
强校验文件()从此永不抛,一律返回{做了:false, 原因}, 上层据此「中止下放」(报告 10.4:任何一步失败就中止;永不删除、永不重写)。
顺带加了第 4 件:探针不读「刚被写过」的文件(探针静默毫秒 = 3000)。
正在被追加写的会话文件,读到写了一半的帧本来就没意义;真下放路径不走这个门槛——
真下放会先 flush 拿到持久化屏障再校验。
函数也因此从 装下放 内部搬到了模块顶层:export async function 强校验文件(文件, 内存事件数),
外加一个只给自测用的 测试钩子。
回归测试(防它再犯)
node lib\下放自测.mjs 的 ㉑ 段是专门为这次事故写的:它在进程顶层张网
(process.on("uncaughtException") + process.on("unhandledRejection") 把一切都记下来),
然后拿强校验去撞 ENOENT、目录、垃圾、空文件、截断帧、删除竞态,最后连打 20 次坏文件再等 60 毫秒。
判据不是「返回了什么」,而是**「这段代码有没有本事把进程弄死」**——一个未捕获异常都不许有。 这一段的注释里写着事故的完整来龙去脉,以后谁想「顺手把这函数改简单点」,先读它。