Skip to content

background-terminals: force-kill falls back to TerminateProcess(shell) when taskkill exceeds its 400ms budget, orphaning the process tree (Windows) #408

Description

@li-yongqvan

English TL;DR

On Windows machines where launching taskkill.exe is slow (≳400ms — e.g. under real-time antivirus scanning), the background-terminal kill path silently orphans the process tree. Graceful taskkill /pid X /T (no /F) never works on console processes (no window to close), and force taskkill /pid X /T /F exceeds its 400ms budget, so the code SIGKILLs the taskkill helper and falls back to child.kill() = TerminateProcess on the cmd.exe shell only — the tree's real children survive. The manager settles as killed anyway. Impact: bg_kill/timeout/teardown reports success while the actual process (e.g. a dev server) keeps running and holding its port; leaked grandchildren keep stdio handles open so the test process never exits. Reproducible: manager.test.ts passes all tests alone yet never exits, leaving ~20 orphaned setInterval stub processes on every run.


一句话(对用户的影响)

OpenPI 在部分 Windows 机器上会谎报军情:界面与 agent 被告知"进程已终止",而真正干活的进程(如 dev server)还活着——占端口、锁文件、吃内存,agent 的世界模型被污染后会在错误方向上自信排查。

对使用者的影响(真实下午)

用户让 agent 后台起 dev server → 中途喊停 → 管理器报"✅ 已终止"、外壳 cmd.exe 死了,node dev server 本体成为孤儿继续占端口 → agent 在新端口重启失败、查半天查不出"旧进程其实还活着" → 一天下来任务管理器躺一排僵尸 node,dist/ 删除报"文件被占用"(Windows 经典 EPERM)。这类 bug 不会当场报错,而是在半小时后让人陷入莫名其妙的泥潭。

环境

  • Windows 11 Home China 10.0.26200
  • Node v24.11.1、Bun 1.3.13(仓库 pin 1.3.14)
  • Pi 0.85.0 / OpenPI main @ 75018b5(package.json v0.5.0)
  • 触发判据taskkill.exe 启动到结束实测 ~880ms(>400ms 预算,详见"复现"的自检测试)

本机实测症状

  1. 每次杀进程固定 ~3.5s = 2s 温和窗口 + 0.5s 强制窗口 + 1s 结算宽限(对应 FORCE_KILL_AFTER_MS=2000 / FORCE_CLOSE_WAIT_MS=500 / SETTLE_GRACE_MS=1000)
  2. manager.test.ts 单跑:全部测试通过,但进程永不退出(5 分钟看门狗后强杀),每次运行遗留 ~20 个孤儿 node -e "eval(Buffer.from('c2V0SW50ZXJ2YWwoKCkgPT4ge30sIDEwMDAp',...))"(= setInterval(()=>{},1000))桩进程(两次独立运行各清出 20 个)
  3. 全套 bun run test:跑到该文件后冻结;test: eliminate Windows background-terminal process-test flakes in the full suite #304 报告的 flaky 与"进程停止输出、需中断"和本次观察同源

根因链(行号均指 extensions/background-terminals/src/manager.ts)

  1. 温和杀 taskkill /pid X /T(不带 /F)本质是发 WM_CLOSE,只对有窗口的进程有效;控制台进程(cmd/node)无窗口可关 → 温和杀必然失败,每次都升级(265-271;本机实测退出码 128、目标存活)
  2. 强制 phase 预算 = 400ms(FORCE_CLOSE_WAIT_MS 500 − TASKKILL_HELPER_CLOSE_WAIT_MS 100,49-52 / 278-280)
  3. 本机 taskkill 实测 ~880ms(实验 A:温和 881ms/退出 128;实验 B:强制 877ms/退出 0)→ 强制 phase 必超时
  4. 超时后对 taskkill helper 发 SIGKILL(319-335);helper 在 +100ms 内关闭 → helperClosed=true → 结果不是 unresolved → 落入兜底 directSignal(398-415)= child.kill() = TerminateProcess只杀 cmd.exe 外壳——代码注释自己写明:这样会使后代孤儿、原进程树句柄不可用(405-407)
  5. 孤儿孙进程继承 stdio 句柄 → close 事件永不触发 → 结算靠 1s 宽限强制落地 → 管理器报"已终止",但 OS 层根本没死
  6. 泄漏的后代让测试进程事件循环永不排空 → node --test 运行器无限等待 → 挂死

核心矛盾killed/settle 的判定依据是 taskkill 退出码与结算事件,不验证后代进程的真实存活——所以测试可以"通过"而进程照样活着。

复现

# ① 触发判据(你的机器是否受影响):taskkill 启动耗时是否 >~400ms
$s = Start-Process node -ArgumentList '-e','setInterval(()=>{},1000)' -PassThru -WindowStyle Hidden
Measure-Command { taskkill /pid $s.Id /T /F }    # 本机:~880ms

# ② 复现孤儿泄漏 + 挂死(在仓库根目录)
node --test --experimental-strip-types tests/extensions/background-terminals/manager.test.ts
#   → 全部测试通过,但进程永不退出(需手动中断)
#   中断后数孤儿:
Get-CimInstance Win32_Process -Filter "Name='node.exe'" |
  Where-Object { $_.CommandLine -like '*eval(Buffer.from*' }
#   → ~20 个存活的 setInterval 桩

注意:依赖机器条件——taskkill 启动 <100ms 的快机器不泄漏。这正是 CI(及多数 Windows)见不到的原因,也让 #304 更容易把现象误判为时序噪音。

#304 的关系

#304 将现象归为"测试基础设施时序噪音",提议对真进程测试做串行隔离。隔离能让套件变绿,但会掩盖本 bug:本机实测 manager.test.ts 即使单跑(无任何并发竞争)也泄漏 ~20 孤儿——病根不在调度,在杀进程的兜底路径。建议 #304 的验收标准补一条:"套件结束后无存活的后台后代进程",否则验收会以"绿但漏杀"收场。

建议修复方向(供讨论)

  1. 强制 phase 超时后不要directSignal 兜底——TerminateProcess 只杀外壳等于亲手制造孤儿;应标记 unresolved,或重试一次 taskkill /T /F
  2. 预算自适应:测量一次 taskkill 启动延迟并据此放大预算;或改异步轮询目标存活,而非对同步 helper 计时
  3. 根治:Windows 上用 Job Object + KILL_ON_JOB_CLOSE 把每个后台终端装进独立 Job——进程树物理上逃不出去,任何兜底都不会漏

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions