fix(pm2): 避免 SIGKILL 被正常退出码吞掉 - #694
Conversation
|
补充 CI 基线证据:本 PR 的 CI
master 基线:https://github.com/deepcoldy/botmux/actions/runs/30658765244 本改动未触及上述模块;本地同 SHA 的独立 master worktree 也复现了对应基线失败。 |
deepcoldy
left a comment
There was a problem hiding this comment.
复审结论:请求修改(1 个 P2)。主修复机制本身成立,但 PM2 私有哨兵会泄漏到支持的 PTY 会话子进程。
[P2] 在 daemon → worker / CLI child 边界剥离 BOTMUX_PM2_GRACEFUL_EXIT_CODE(src/cli.ts:582)
这里把标记注入 daemon 后,workerForkEnv(process.env) 只删除 GITHUB_TOKEN/GH_TOKEN,随后 worker 的 redactChildEnv(process.env) 也不删除该标记。因此显式 BACKEND_TYPE=pty、非 sandbox 的 CLI 子进程会继承 BOTMUX_PM2_GRACEFUL_EXIT_CODE=90(tmux 路径因为会 sweep BOTMUX* 而碰巧不受影响)。若该会话里启动前台 daemon/dashboard,尤其公开命令 botmux serve --api-only,gracefulProcessExitCode() 会把它误判为 PM2 托管进程,正常 Ctrl+C 以 90 退出,破坏本 PR 声明的“直接/前台运行仍以 0 正常退出”契约。
我在固定 head 2d4f552 上复现:
redactChildEnv({ BOTMUX_PM2_GRACEFUL_EXIT_CODE: '90' })保留该 key;- 带此 env 运行
node dist/cli.js serve --api-only ...,Ctrl+C 的最终 exit code = 90; - unset 同一 env 后完全相同的前台探针 exit code = 0。
建议在 daemon → worker 边界(或至少 CLI child 边界)删除这个仅属于 PM2 core 进程的标记,并加一条回归测试钉住 PTY/直接子进程不继承它。
其余复核结果:PM2 6.0.14 隔离 PM2_HOME 实探确认旧 [0] 下 SIGKILL 后 pid=0/restart_time=0,新 [90] 下优雅 SIGTERM 不复活、SIGKILL 换新 pid 且 restart_time=1;sentinel 90 仓内无退出码碰撞;daemon/dashboard 的其它 exit(1) 均为 fatal 路径;静态 ecosystem.config.cjs 没有 stop_exit_codes:[0],不属于原缺陷路径。pnpm build、相关 57 tests、git diff --check 均通过。完整 CI 的 5 个失败与 base c21f2a49 一致,非本 PR 引入。
未提交 PM2 e2e 仍建议补,但在上述真实探针已验证依赖行为后,我不把它单独作为合并门槛;真正需要先修的是环境标记跨会话泄漏。
复审(codex)发现的 P2:ecosystemConfig 把 BOTMUX_PM2_GRACEFUL_EXIT_CODE=90 注入 daemon env,但 workerForkEnv(只剔 GH 两键)和 redactChildEnv(deny-list 无此键)都不剥离它,导致 PTY/direct-spawn/sandbox 的 CLI 子进程继承该 marker。 若会话里前台跑 `botmux serve --api-only` / `daemon` / `dashboard`,其正常 Ctrl+C 会被 gracefulProcessExitCode() 误判返 90,违反 PR 自称的「前台返 0」—— 外层 supervisor/launcher 会把非零 90 误读成 crash。 该 marker 只被 daemon.ts + dashboard.ts 两个 PM2 托管核心进程读取,worker/CLI child 从不读,故在 fork 边界安全剥离: - REDACTED_CHILD_ENV_KEYS 加入该键(覆盖 redactChildEnv = PTY/direct/sandbox) - WORKER_REDACTED_ENV_KEYS 加入该键(覆盖 worker 进程自身) tmux/zellij 本就不受影响(pane 走 BOTMUX_INJECTED_ENV_KEYS allowlist + server-global 扫 BOTMUX* 前缀)。daemon/dashboard 自身的 pm2-注入 env 不经 fork/redact,仍正常退 90——PR 主机制不变。 测试(test/child-env.test.ts,+4): - redactChildEnv 剔除 sentinel 单测 - 字面量↔PM2_GRACEFUL_EXIT_CODE_ENV drift-guard - 扩展真实 node-pty 端到端探针,断言子进程该 marker 为 UNSET(真实泄漏向量) - worker-pool WORKER_REDACTED_ENV_KEYS source-pin 验证:pnpm build 绿;child-env.test.ts 16/16;相关 env/pm2/tmux/restart 8 套 157/157;两处剥离各做 mutation 均令对应测试转红(有牙);E2E 复探 leak 已封死 且 daemon 仍退 90。 Co-Authored-By: Riff <riff@example.com>
deepcoldy
left a comment
There was a problem hiding this comment.
Delta 复审结论:worker / CLI-child 主路径已修好,但仍有 1 个 P2(同一根因下两条非 worker 泄漏路径),因此继续请求修改。
[P2] PM2 sentinel 仍会进入 dashboard/daemon 启动的其它长寿命用户进程
本 delta 只在 workerForkEnv 和 redactChildEnv 剥离 marker,能封住 worker → PTY/direct/sandbox 以及 persistent backend;但 BOTMUX_PM2_GRACEFUL_EXIT_CODE=90 仍存在于 daemon/dashboard 的 process.env,以下支持路径继续原样继承:
src/core/plugins/pm2.ts:29的pm2Env()复制整个process.env,只删除kill_timeout。dashboard 从 UI 启动插件服务时,marker 会被 PM2 写进任意 plugin service 的 app env。我用一个先在无 marker 环境启动的隔离 PM2 God 验证:随后带 marker 执行 start,jlist中该 app 的pm2_env.BOTMUX_PM2_GRACEFUL_EXIT_CODE和嵌套 env 都是90。插件服务/包装器若再启动前台botmux serve --api-only,会复现正常停机返回 90。src/core/local-terminal-opener.ts:149的spawnDetached()未传env,Linux GUI 的 terminal → login shell → 本地 AI CLI 会继承 daemon 的 marker。这是另一条用户可交互 CLI 路径;在该 CLI 中启动前台 botmux core,同样被误判为 PM2 managed。
这两条与原 P2 的触发机制相同:中间子进程本身不读 marker,但它是任意/交互式长寿命进程,能启动真正读取 marker 的 daemon/dashboard/core-only。因而当前注释所说“ONLY those two managed cores”仍不成立。
建议把剥离收敛成一个可复用的 exact-key helper,并至少用于:worker fork、CLI child、plugin pm2Env、local-terminal launcher env;或等价地在后两条边界显式删除。测试分别钉住 plugin PM2 app env 与 local-terminal spawn env 不含 sentinel。
对提问 ②:字面量 + drift-guard 在本仓现有 env key 列表风格下可以接受,不是阻塞点;两个 drift guard 确实会随常量改名转红。若这次要覆盖更多边界,抽共享 pure helper 会比继续复制字面量/source-pin 更自然。
已验证通过:pnpm build;8 suites / 161 tests(child-env 16、PM2、daemon env、worker env、tmux、maintenance、api-only wiring 等);git diff --check。GitHub CI 的 5 个失败仍与 c21f2a49 base 完全同构,不是 delta 引入。
codex delta 复审(ea661e3)发现同机制 P2 还剩两条泄漏面:上一版只堵了 worker→CLI-child fork,但 sentinel 仍从 daemon/dashboard 泄漏到两类非 worker 的长寿命进程: 1. src/core/plugins/pm2.ts pm2Env() 复制整个 process.env;dashboard 从 UI 启动 plugin service 走 `pm2 start --update-env`,marker 被写进插件 app env (codex 隔离探针 jlist 确认 app marker=90)。 2. src/core/local-terminal-opener.ts spawnDetached() 未净化 env;Linux GUI terminal → login shell → 本地 AI CLI 继承 daemon marker。 两条同原 P2 机制:中间进程自己不读 marker,但可再启动前台 botmux 使正常退出 变 90 被 supervisor 误判 crash。 修法(codex 建议的收敛):在 sentinel 本模块 pm2-graceful-exit.ts 抽出 stripPm2GracefulExitMarker(env)——始终返回剔除该键的新副本(不 mutate 入参, 故 pm2Env 后续 delete kill_timeout 不会碰 process.env)。两条 raw-env-copy 边界改用它。持 key-deny-list 的两边界(redactChildEnv / workerForkEnv)保持 把 key 列在各自列表里(上一版已加),不重复过滤。 测试: - pm2-graceful-exit.test.ts +2:stripPm2GracefulExitMarker 剔除 + 不 mutate 入参 + marker 缺席时仍返新副本(可安全 mutate)。 - plugin-pm2-env.test.ts +1:daemon 带 marker 时 runPluginPm2 传给 spawnSync 的 env 不含 marker。 - local-terminal-opener-spawn-env.test.ts(新):mock child_process,真实驱动 openLocalTerminalForSession→openOnLinux→spawnDetached,断言 spawn 的 env 不含 marker(真实行为非 source-pin)。 验证:pnpm build 绿;affected 7 套 91/91;两条边界各 mutation 均令对应测试 转红(有牙);E2E 复探四条边界(worker/CLI child/plugin pm2/local-terminal) 全 sealed 且 daemon core 仍退 90。 Co-Authored-By: Riff <riff@example.com>
deepcoldy
left a comment
There was a problem hiding this comment.
Delta 复审(head 7d8d8bede374251afa224c7e1e969f6ec8877364)
结论:✅ 无阻塞,批准。
本轮两条 P2 均已闭环:
stripPm2GracefulExitMarker()始终返回新副本、只删除 sentinel,未命中时也不 aliasprocess.env;pm2Env()后续删除kill_timeout不会污染全局。- plugin PM2 边界与 local-terminal detached spawn 均在真实出进程前剥离 marker;前者仍允许显式 plugin env 覆盖(那是用户配置而非继承泄漏),后者保留其它环境变量。
- 普通 worker fork / CLI child 的 deny-list 方案与新 helper 分工一致,无循环依赖。
我也补扫了 daemon/dashboard 的 fork/spawn 出口。唯一值得记录的非阻塞 P3:workflows/v3/ephemeral-pool.ts 的独立 worker fork 仍直接展开 process.env,而 daemon 内 daemon-run.ts 会调用这条路径,所以 PM2 marker 会进入 ephemeral worker;它不经过普通 workerForkEnv()。当前没有可观测的 exit-90 回归:worker 本身不读 gracefulProcessExitCode(),其 CLI child 又会经过 redactChildEnv() 二次剥离。因此不作为合并门槛,但它与 helper 注释“只有 daemon/dashboard 可见 marker”的强约束不完全一致;后续可在该 fork 同样调用 helper并加 env 捕获测试,或收窄注释。
其余 dashboard start/stop/update、detached restart、版本探针等继承点都是短生命周期内部控制面,当前不读该 helper;生成 ecosystem 时仍显式注入 marker,主机制不依赖这些内部子进程继承它,未发现第三条会把 marker 带到用户 CLI / plugin app / local terminal 的可观测泄漏路径。
验证:
pnpm build✅- 相关 8 套测试(含
workflow-v3-ephemeral-pool)133/133 ✅ git diff --check✅- GitHub CI 的 5 个失败仍为既有无关项(card-handler grant、bwrap 权限、v3 distillation timing),本地 build 与本 PR 相关测试均绿。
未执行合并,等待申晗最终确认。
codex delta 复审 APPROVE 时留的非阻塞 P3:workflows/v3/ephemeral-pool.ts 的 worker fork 走独立 `...process.env`(不经普通 workerForkEnv),daemon 的 daemon-run.ts 会走它,故 sentinel 会进入 ephemeral worker。当前不致 exit-90 (worker 自身不读 graceful helper,它再起 CLI 时由 redactChildEnv 剥离),但 与 stripPm2GracefulExitMarker 注释「仅 daemon/dashboard 可见」的强约束不一致。 顺手用同一 helper 收口,让边界与注释一致(defense-in-depth,堵住这条唯一 剩下的 marker-carrying fork 出口)。 测试:workflow-v3-ephemeral-pool.test.ts +1——daemon 带 marker 时 spawn 的 worker env 不含 marker(复用既有 factory.lastOpts.env 捕获 seam)。mutation 还原 `...process.env` 令该测试转红(有牙)。 验证:pnpm build 绿;affected 10 套 186/186;git diff --check 绿。 Co-Authored-By: Riff <riff@example.com>
deepcoldy
left a comment
There was a problem hiding this comment.
P3 收尾 delta 复审(head e43f69a3401d6d78f088ca8487edeec68607dc19)
✅ 确认无阻塞,继续批准。
本 delta 仅改 7d8d8bede..e43f69a3 两个文件,接线与测试成立:
ephemeral-pool.ts在独立factory.spawn边界先调用stripPm2GracefulExitMarker(process.env),再合并 runtime 生成的req.env(该对象只含受控GOAL_ENV键),因此既剥离 PM2 marker,也不影响 workflow 节点所需环境。- helper 是 leaf 模块,未引入循环依赖;仍保留 fresh-copy 语义。
- 新测试经现有
factory.lastOpts.env行为 seam 检查真实交给 fork 的环境,并完整收尾 worker/promise,不遗留计时器;覆盖点与 mutation 目标一致。
独立验证:
pnpm build✅- 相关 10 套 192/192 ✅(含
workflow-v3-ephemeral-pool20/20、v3-daemon-run52/52) - delta
git diff --check✅ - GitHub CI 仍为相同 5 个既有无关失败;CodeQL 全绿。
至此我上轮记录的非阻塞 P3 已闭环。未执行合并,继续等待申晗最终确认。
背景 / 动机
Botmux 为避免
botmux restart的正常退出被 PM2 立即复活,给 daemon/dashboard 配置了stop_exit_codes: [0]。但 Node 子进程被信号终止时 exit code 为
null,PM2 在判断stop_exit_codes前会把它归一化为0。因此进程遭遇SIGKILL时也可能命中“正常停止”规则:PM2 记录signal=SIGKILL, code=0后不再拉起,daemon/dashboard 会长期停留在无 PID 状态。改动
90,并把stop_exit_codes从0改为该专用值。0正常退出。SIGKILL的 code 0 重新进入 PM2 crash-autorestart。测试覆盖
0视为正常停止码。0,以及非法/旧环境值不启用 sentinel。[0]配置下 SIGKILL 后 PID=0、restart_count=0;新配置下正常 sentinel 退出不重启,SIGKILL 后生成新 PID、restart_count=1。验证
pnpm vitest run test/pm2-graceful-exit.test.ts test/pm2-command.test.ts test/daemon-lifecycle-env.test.ts test/restart-coordinator.test.ts test/restart-intent-store.test.ts test/restart-live-worker-env.test.ts test/restart-report.test.ts(57 passed)pnpm buildgit diff --checkpnpm test:11954 passed,3 个与本改动无关的现有失败;已在相同origin/master独立 worktree 复现相同失败(card-handler-grant-partial、v2-run-archive、v3-host-execution)。影响范围
只改变 Botmux 核心 daemon/dashboard 在 PM2 管理下的正常退出码约定;不改变用户配置、CLI 参数、会话恢复、更新计划或前台运行的成功退出语义。PM2 God daemon 本身被终止的场景不在本修复范围内。