Token Pool worker crash 观测补强记录(2026-04-28)
当前结论
这轮已经完成的是:
master/workersupervisor 问题已修,worker 不会再“退出后无人拉起”- worker 启动链、崩溃链、最近一次 crash 快照已经可观测
- live runtime 已重启到新代码,
3100与3301当前健康
这轮还没完成的是:
- 真正打挂 worker 的业务请求路径还没有锁死
- 现在属于“已经拿到抓现场工具”,下一次复现时可以直接看 crash 快照,而不是继续黑盒猜
一句话区分:
- “系统不会再一直死着” -> 已修
- “为什么 worker 会死” -> 还在继续定位
这轮为什么继续改代码
不是为了继续兜底,也不是为了绕过根因,而是为了补 deterministic tracing:
- 之前
/master/diagnose只能告诉我们workerAlive、lastExit - 但 worker 如果在启动中途或请求中途炸掉,仍然缺:
- 炸在 boot 哪一段
- 当时有没有活跃请求
- 最后一个 requestId / model / provider / stopReason 是什么
所以这轮改动目标只有一个:
- 把“worker 怎么死的”从日志猜测,变成结构化证据
本轮代码改动
1. src/observability/worker-crash.js
新增能力:
bootStagebootTimelinesetWorkerBootStage(stage, extra)readLastWorkerCrashSnapshot()
现在 last-worker-crash.json 会带上:
bootStagebootTimelinerequestIdpathmodelproviderTypeselectedProviderIdstopReasonlastEventlastErrorMessage
也就是说,worker 就算还没进正式请求处理,也能知道死在:
config_loadedplugins_initializedservices_initializedhttp_server_createdlisten_pendingready_to_serve
中的哪一段。
2. src/services/api-server.js
新增 worker boot stage 标记:
api_server_module_loadedconfig_init_startconfig_loadedplugins_discovery_startplugins_initializedservices_init_startservices_initializedrequest_handler_createdhttp_server_createdlisten_pendinglisteningready_to_serveready_sent_to_master
同时补了几类 fatal capture:
startup_failurehttp_server_erroruncaught_exceptionunhandled_rejectionprocess_exit
并保留 broken pipe 抑制:
EPIPEECONNRESETclient socket 噪音不会再污染 fatal 结论
3. src/core/master.js
GET /master/diagnose 现在不只返回文件是否存在,还会直接带:
lastWorkerCrashSnapshot
这样下一次 worker 再挂,不用先 SSH/翻文件,直接打:
GET /master/diagnose
就能先看到最近一次 crash 的结构化摘要。
4. README.md
已同步补充:
logs/last-worker-crash.jsonlogs/worker-fatal.log/master/diagnose现在会返回最近 crash 摘要
本轮自测与证据
隔离端口自测
用隔离实例验证了新 boot-stage 日志链:
- master:
3112 - worker:
3312
结果:
GET http://127.0.0.1:3112/master/status返回正常- master stdout 中能看到完整 boot stage:
config_loadedplugins_initializedservices_initializedlisten_pendinglisteningready_to_serveready_sent_to_master
说明:
- 新观测链已真实落盘,不是只写了代码没走到
受控 crash 验证
随后故意制造一个可重复 crash:
- 直接起第二个
api-server - 端口仍用
3312 - 结果触发
EADDRINUSE
新快照成功写出:
E:\My Project\Token Pool\logs\isolated-crash\last-worker-crash.jsonE:\My Project\Token Pool\logs\isolated-crash\worker-fatal.log
快照关键字段:
reason = "http_server_error"bootStage = "listen_pending"lastErrorMessage = "listen EADDRINUSE: address already in use 0.0.0.0:3312"bootTimeline明确停在 listen 前
这证明:
- 不是只有 request 内 crash 能记录
- 启动期 crash 也已经能被结构化抓住
live runtime 已切到新代码
本地 live runtime 已重启:
- 新 master pid:
18700 - 新 worker pid:
30156
live 验证:
GET http://127.0.0.1:3100/master/status->200GET http://127.0.0.1:3100/master/diagnose->200GET http://127.0.0.1:3301/health->200
同时 live master stdout 已看到新的 worker boot 日志:
stage=api_server_module_loadedstage=config_loadedstage=plugins_initializedstage=services_initializedstage=listen_pendingstage=listeningstage=ready_to_servestage=ready_sent_to_master
说明:
- 不是只在隔离实例生效
- live
3301已经跑在这版 observability 代码上
现在怎么用这套观测
下次如果再出现“Codex 跑着跑着突然断,然后 worker 重启”:
第一步先打:
GET http://127.0.0.1:3100/master/diagnose
重点看:
lastExitlastWorkerCrashSnapshot.reasonlastWorkerCrashSnapshot.bootStagelastWorkerCrashSnapshot.requestIdlastWorkerCrashSnapshot.pathNamelastWorkerCrashSnapshot.modellastWorkerCrashSnapshot.providerTypelastWorkerCrashSnapshot.stopReasonlastWorkerCrashSnapshot.lastErrorMessage
如果需要再下钻文件:
E:\My Project\Token Pool\logs\last-worker-crash.jsonE:\My Project\Token Pool\logs\worker-fatal.logE:\My Project\Token Pool\logs\app-2026-04-28.log
还没修的部分
目前还没有宣称“根因已锁定”,因为缺最后一跳证据:
- 哪个真实请求
- 哪个 provider / stream path
- 在哪个 handler / common.js / adapter 里抛错
也就是说,这一轮的成果是:
- 把下一次 crash 从黑盒变成可定位事件
而不是:
- 已经修掉导致 crash 的业务 bug
当前已看到的历史线索
翻 E:\My Project\Token Pool\logs\app-2026-04-28.log 时,已经看到一条非常可疑的历史错误链:
- 时间:
2026-04-28 10:54:29 - 请求 logger id:
127.0.0.1:a22030ae - 现象:大量重复
[Server] Uncaught exceptioncode = EPIPE- 紧接着又被判为
Network error detected, continuing operation...
这说明旧版本里至少存在一条:
- 客户端已经断开
- worker 还在继续写
- EPIPE 被重复打成 uncaught exception
的路径。
本轮 live 代码已经把 EPIPE / broken pipe 从 worker 全局 fatal 路径里单独抑制掉,所以后续如果再复现:
- 若 crash 快照仍出现,说明已经不是这条旧
EPIPE链 - 若不再出现 worker crash,只剩 request abort,则说明这个历史
EPIPEstorm 很可能就是先前“看起来像服务挂掉”的一条主因
新一轮 live 受控复现结果
这轮我又对当前 live 3301 做了一次更接近历史症状的本地受控复现:
- 直接向
GET /openai/debug/requests发请求 - 客户端在发出请求后立刻用 reset 方式断开 socket
- 连续复现
80次
目的不是测业务正确性,而是验证:
- 当前 worker 遇到“客户端突然断开”时
- 会不会再次进入旧版本那种
EPIPE/ uncaught storm - 甚至把 worker 打崩
结果
/master/diagnose 结果仍然是:
workerAlive = truelastExit = nulllastWorkerCrashSnapshot = null
同时日志里已经不再出现旧版那种:
[Server] Uncaught exceptioncode = EPIPE- 无限刷屏
而是变成了可控的抑制日志:
Suppressed client socket error: ECONNRESET
并且这些日志已经带 request 关联,例如:
path=/openai/debug/requestsstatus=200- 随后记录
Suppressed client socket error: ECONNRESET
这一步说明什么
它说明:
- 历史上那条“客户端断开 -> worker 继续写 -> EPIPE storm”链,在当前 live 代码上已经被成功摘掉了 fatal 属性
换句话说:
- 现在就算客户端粗暴中断
- live worker 也不会像旧版本那样进入 crash 候选态
这一步还不能说明什么
它还不能单独证明:
- 所有 Codex “说到一半停住”的历史问题都只由 EPIPE 引起
因为:
- 这次复现打的是 debug 路径
- 不是完整的 provider stream / Codex 长输出链
但它已经足以证明两件事:
- 历史日志里的
EPIPE风暴是真问题,不是噪音 - 当前 live runtime 对这类 client-abort 已经不再会被打崩
下一步
下一次只要再复现一次 worker crash,就应该按这个顺序收证据:
GET /master/diagnose- 取
lastWorkerCrashSnapshot.requestId - 去
/openai/debug/requests对应 requestId 对照 - 再回
app-*.log找这个 requestId 的最后一条[Request] START/ABORT/END/FATAL
如果这条链完整,下一步就能把 root cause 压到具体模块和具体异常,而不是继续只停在 “worker 会 crash” 这个层级。