fix(tools): kill the whole process tree on Windows terminal timeouts - #43499
fix(tools): kill the whole process tree on Windows terminal timeouts#43499lEWFkRAD wants to merge 2 commits into
Conversation
LocalEnvironment._kill_process kills the entire process group on POSIX (setsid + killpg with SIGTERM->SIGKILL escalation), but the Windows branch was a bare proc.terminate(), which reaches only the direct child - the shell wrapper. Grandchildren survived as orphans every time a terminal command hit its timeout. Production impact observed on Windows 11: during a gateway restart window in which the terminal tool logged repeated 5s timeouts (rc 124), four orphaned hermes.exe/python.exe pairs were left behind, each burning 0.5-1.0 CPU cores for 7-9 hours (~22 CPU-hours) until killed manually. The orphaning mechanism reproduces deterministically on demand (see tests and PR body). Fix: enumerate and kill the tree via psutil (children(recursive=True) + wait_procs, with one re-enumeration sweep for children spawned mid-kill) before falling back to the old wrapper-only terminate(). Same pattern as gateway/platforms/whatsapp.py and the replacement scripts/check-windows-footguns.py prescribes. psutil is already a hard dependency. The helper is platform-neutral, so the new tests exercise the real Windows branch (via monkeypatched _IS_WINDOWS) on every platform - no skips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Verification: process-tree kill on Windows timeouts Reviewed the full diff — this is a clean fix for a real Windows orphan-process problem.
No issues found. The implementation matches the existing |
austinpickett
left a comment
There was a problem hiding this comment.
✅ Approved
The bug: On Windows, _kill_process called bare proc.terminate() which kills only the direct child (the shell wrapper). Grandchildren survived as orphans after a terminal timeout — observed in production as python.exe processes burning CPU for hours.
The fix: Introduces _kill_process_tree(pid) using psutil for a recursive tree kill, replacing the bare terminate() on Windows. Two-sweep strategy (enumerate → kill → re-enumerate survivors → kill again) handles processes spawned between enumeration and the first kill. Falls back to the legacy single-process kill if psutil is unavailable or the sweep fails. The POSIX killpg path is unchanged.
Tests: 3 tests — grandchild survival (the exact production bug), missing-PID no-op, and the LocalEnvironment integration test confirming the code path is exercised.
Reviewed by Hermes Agent
|
Verification comment — reviewed the diff, found no issues. The |
|
Approved by austinpickett, verified by liuhao1024 (two independent verifications). Clean fix for Windows process tree kill on timeouts. Ready for merge. |
|
Approved by austinpickett + verified by liuhao1024. Ready. |
|
Closing: upstream e5253d8 (fix(desktop): tree-kill Windows terminal descendants, merged via #55547) rewrote the same Windows branch of LocalEnvironment._kill_process to route through gateway.status.terminate_pid (taskkill /PID /T /F - a full tree kill with hide flags), with a proc.kill() fallback and regression tests. The intent of this PR (no orphaned grandchildren on Windows terminal timeouts) is fully served on main, so there is nothing left to salvage here. |
What does this PR do?
Fixes a Windows-only process leak in
LocalEnvironment._kill_process(tools/environments/local.py).On POSIX,
_kill_processkills the entire process group —_spawn_processcallsos.setsid, and the kill path doeskillpg(SIGTERM)→ wait →killpg(SIGKILL), with a comment explicitly warning that returning early "leaves orphaned process-group members behind."On Windows, the branch was a single
proc.terminate(). That reaches only the direct child — the shell wrapper — and every grandchild survives as an orphan whenever a terminal command hits its timeout or is interrupted.Production impact (Windows 11): during a gateway restart window in which the agent's terminal tool logged repeated 5s timeouts (
[Command timed out after 5s], rc 124), four orphanedhermes.exe→python.exepairs were left behind, burning 0.5–1.0 CPU cores each for 7–9 hours (~22 CPU-hours total) until killed manually. The orphaning mechanism itself reproduces deterministically (step 1 below), independent of that incident.Why this approach: Windows has no process group for this spawn path, so explicit tree enumeration is the only way to reach grandchildren. The fix mirrors two existing in-repo patterns: the psutil
children(recursive=True)sweep ingateway/platforms/whatsapp.py, and the exact replacementscripts/check-windows-footguns.pyprescribes (line ~215). psutil is already a hard dependency. The oldproc.terminate()is kept as the fallback when the sweep cannot complete, so the new path never does less than the old one. Adjacent prior art: #40110 fixed the same bug class for shell-wrapper MCP servers.Related Issue
No existing issue — found via production incident forensics on a Windows 11 deployment. Happy to open one first if preferred.
Type of Change
Changes Made
tools/environments/local.py— new module-level_kill_process_tree(pid, *, timeout)helper: psutil sweep of[root] + children(recursive=True),wait_procsverification, one re-enumeration pass for children spawned mid-kill; returnsTrueonly when everything is verified gone. The_IS_WINDOWSbranch of_kill_processnow calls it and falls back to the previousproc.terminate()if the sweep can't finish, then reaps the wrapper handle.tests/tools/test_local_env_tree_kill.py— three new tests (details below). The helper is platform-neutral, so they run on all platforms with no skips, including the test that exercises the real Windows branch via monkeypatched_IS_WINDOWS.How to Test
python -c "<parent that spawns a sleeping grandchild>", callLocalEnvironment._kill_process(parent), observe the grandchild survives:--timeout-method=threadper the Windows runner caveat.)test_base_environment,test_local_interrupt_cleanup,test_local_background_child_hang,test_local_env_blocklist,test_local_env_cwd_recovery,test_local_env_windows_msys,test_local_shell_init+ the new file): 16 failed / 76 passed with this patch vs 16 failed / 73 passed on cleanorigin/main— the 16 are identical pre-existing POSIX-assumption failures on Windows (Homebrew PATH,/root cwd, bashrc sourcing); this PR adds the 3 new passes and changes nothing else.scripts/check-windows-footguns.py --diff origin/main: ✓ No Windows footguns found (2 file(s) scanned).Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — relevant suite run on Windows 11 with--timeout-method=thread; the only failures are 16 pre-existing POSIX-assumption failures identical on cleanorigin/main(counts above); the hermetic Linux runner is unaffected by this diffDocumentation & Housekeeping
docs/, docstrings) — docstrings on the new helper and branch comment; no user-facing docs affectedcli-config.yaml.exampleif I added/changed config keys — N/A, no config changesCONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/AScreenshots / Logs
Before (unpatched, Windows 11 — grandchild orphaned):
After (this branch, same machine):
Incident telemetry that motivated the fix (orphans found burning cores hours after their 5s timeout):
🤖 Generated with Claude Code