Skip to content

fix(pm2): 避免 SIGKILL 被正常退出码吞掉 - #694

Open
xiongz-c wants to merge 4 commits into
masterfrom
codex/fix-pm2-signal-restart
Open

fix(pm2): 避免 SIGKILL 被正常退出码吞掉#694
xiongz-c wants to merge 4 commits into
masterfrom
codex/fix-pm2-signal-restart

Conversation

@xiongz-c

@xiongz-c xiongz-c commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

背景 / 动机

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 状态。

改动

  • 为 PM2 管理的正常退出引入专用 sentinel exit code 90,并把 stop_exit_codes0 改为该专用值。
  • daemon 和 dashboard 仅在生成的 PM2 环境明确标记时使用 sentinel;直接/前台运行仍以 0 正常退出。
  • daemon 与 dashboard 共用同一生命周期辅助逻辑,保留现有正常 stop/restart 不被抢先复活的行为,同时让 SIGKILL 的 code 0 重新进入 PM2 crash-autorestart。

测试覆盖

  • 覆盖 PM2 配置不再把 0 视为正常停止码。
  • 覆盖 PM2 标记进程使用 sentinel、未标记前台进程仍返回 0,以及非法/旧环境值不启用 sentinel。
  • 使用隔离 PM2_HOME 做真实进程探针:旧 [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 build
  • git diff --check
  • pnpm test:11954 passed,3 个与本改动无关的现有失败;已在相同 origin/master 独立 worktree 复现相同失败(card-handler-grant-partialv2-run-archivev3-host-execution)。

影响范围

只改变 Botmux 核心 daemon/dashboard 在 PM2 管理下的正常退出码约定;不改变用户配置、CLI 参数、会话恢复、更新计划或前台运行的成功退出语义。PM2 God daemon 本身被终止的场景不在本修复范围内。

@xiongz-c
xiongz-c requested a review from deepcoldy as a code owner August 1, 2026 07:08
@xiongz-c

xiongz-c commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

补充 CI 基线证据:本 PR 的 CI pnpm build 已通过,test/pm2-graceful-exit.test.ts 也通过;完整 pnpm test 的 5 个失败与当前 master 提交 c21f2a49 的上一轮 CI 完全一致:

  • card-handler-grant-partial 1 项
  • plugin-mcp-sandbox 3 项(GitHub runner 无法设置 bwrap uid map)
  • v3-distillation-runner 1 项

master 基线:https://github.com/deepcoldy/botmux/actions/runs/30658765244
本 PR CI:https://github.com/deepcoldy/botmux/actions/runs/30689122882

本改动未触及上述模块;本地同 SHA 的独立 master worktree 也复现了对应基线失败。

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审结论:请求修改(1 个 P2)。主修复机制本身成立,但 PM2 私有哨兵会泄漏到支持的 PTY 会话子进程。

[P2] 在 daemon → worker / CLI child 边界剥离 BOTMUX_PM2_GRACEFUL_EXIT_CODEsrc/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-onlygracefulProcessExitCode() 会把它误判为 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 deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delta 复审结论:worker / CLI-child 主路径已修好,但仍有 1 个 P2(同一根因下两条非 worker 泄漏路径),因此继续请求修改。

[P2] PM2 sentinel 仍会进入 dashboard/daemon 启动的其它长寿命用户进程

本 delta 只在 workerForkEnvredactChildEnv 剥离 marker,能封住 worker → PTY/direct/sandbox 以及 persistent backend;但 BOTMUX_PM2_GRACEFUL_EXIT_CODE=90 仍存在于 daemon/dashboard 的 process.env,以下支持路径继续原样继承:

  1. src/core/plugins/pm2.ts:29pm2Env() 复制整个 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。
  2. src/core/local-terminal-opener.ts:149spawnDetached() 未传 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 deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delta 复审(head 7d8d8bede374251afa224c7e1e969f6ec8877364

结论:✅ 无阻塞,批准。

本轮两条 P2 均已闭环:

  • stripPm2GracefulExitMarker() 始终返回新副本、只删除 sentinel,未命中时也不 alias process.envpm2Env() 后续删除 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 deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-pool 20/20、v3-daemon-run 52/52)
  • delta git diff --check
  • GitHub CI 仍为相同 5 个既有无关失败;CodeQL 全绿。

至此我上轮记录的非阻塞 P3 已闭环。未执行合并,继续等待申晗最终确认。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants