fix(process_registry): use taskkill /T /F for tree-kill on Windows

The Windows branch of `_terminate_host_pid` early-returned after
`os.kill(pid, SIGTERM)` (which Python maps to `TerminateProcess` for
the target handle only), leaving descendant processes — e.g. Chromium
renderer/GPU/network helpers spawned by an `agent-browser` daemon —
running on Windows even after the preceding commit fixed POSIX.

The right Windows primitive is `taskkill /PID <pid> /T /F`:
`/T` walks the tree, `/F` force-terminates. Same approach
`gateway.status.terminate_pid(force=True)` already uses for the
gateway's own shutdown path; reuse the same shape here.

Why NOT extend the POSIX psutil tree-walk to Windows:

  1. Windows doesn't maintain a Unix-style process tree. `psutil.
     Process.children(recursive=True)` walks PPID links that go stale
     when intermediate processes exit, so enumeration is best-effort
     and silently misses orphaned descendants. The whole bug we're
     fixing is orphaned descendants.

  2. `psutil.Process.terminate()` on Windows is `TerminateProcess()`
     for one handle — same single-PID scope as the existing
     `os.kill`. The existing comment in `gateway/status.py::
     terminate_pid` warns this explicitly: 'os.kill SIGTERM is not
     equivalent to a tree-killing hard stop' on Windows.

  3. Headless Chromium has no GUI window, so the softer
     `taskkill /T` without `/F` (which sends WM_CLOSE) won't reach
     it either. `/F` is required.

POSIX path is unchanged. The taskkill subprocess uses the same
`creationflags=windows_hide_flags()` pattern other Windows shellouts
in this codebase use. `FileNotFoundError` / `TimeoutExpired` /
`OSError` fall back to bare `os.kill(SIGTERM)` as cheap insurance.

Tests cover the Windows branch via the codebase's standard
`monkeypatch _IS_WINDOWS` pattern (`references/windows-native-
support.md`), plus POSIX tree-walk order, NoSuchProcess swallow,
and the OSError fallback path. 7 new tests, all green on Linux CI.
This commit is contained in:
teknium1
2026-05-23 17:55:29 -07:00
committed by Teknium
parent 22f3f5a75a
commit 7ce6b504a2
2 changed files with 203 additions and 2 deletions

View File

@@ -434,9 +434,50 @@ class ProcessRegistry:
@staticmethod
def _terminate_host_pid(pid: int) -> None:
"""Terminate a host-visible PID without requiring the original process handle."""
"""Terminate a host-visible PID and its descendants.
POSIX: walks the process tree with ``psutil`` and SIGTERMs
children before the parent so subprocess trees (e.g. Chromium
renderers/GPU helpers spawned by an ``agent-browser`` daemon)
don't get reparented to init and survive cleanup.
Windows: shells out to ``taskkill /PID <pid> /T /F``. This is
the documented Microsoft primitive for tree-kill and matches the
existing convention in ``gateway.status.terminate_pid``. We can't
reuse the POSIX psutil path on Windows because:
1. Windows doesn't maintain a Unix-style process tree —
``psutil.Process.children(recursive=True)`` walks PPID
links that go stale when intermediate processes exit, so
enumeration is best-effort and misses orphaned descendants.
2. ``psutil.Process.terminate()`` on Windows is
``TerminateProcess()`` which kills only the target handle
and is a hard kill — there is no Windows equivalent of a
SIGTERM that cascades through a process group. (See the
warning in ``gateway/status.py::terminate_pid``: "os.kill
with SIGTERM is not equivalent to a tree-killing hard stop"
on Windows.) Headless Chromium has no GUI window, so the
softer ``taskkill /T`` without ``/F`` won't reach it either.
``psutil`` is a hard dependency (see ``pyproject.toml``); the
bare-``os.kill`` fallback covers OSError / PermissionError on
POSIX and a missing ``taskkill.exe`` on Windows (effectively
unreachable on real Windows installs, but cheap insurance).
"""
if _IS_WINDOWS:
os.kill(pid, signal.SIGTERM)
try:
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
capture_output=True,
text=True,
timeout=10,
creationflags=windows_hide_flags(),
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
try:
os.kill(pid, signal.SIGTERM)
except (OSError, ProcessLookupError, PermissionError):
pass
return
import psutil