fix(windows): assign spawned shells to a kill-on-exit Job Object - #69076
fix(windows): assign spawned shells to a kill-on-exit Job Object#69076Sora-bluesky wants to merge 3 commits into
Conversation
6a12c62 to
03b8552
Compare
|
Rebased onto main. One conflict, in main added I also added three assertions to |
9b15d41 to
9282e44
Compare
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the detailed Windows-specific design and the real Job Object coverage. The foreground/local and shared backend premise is still present on current main.
Problems
terminal(background=true)has a separate local shell path:tools/terminal_tool.py:2552callsProcessRegistry.spawn_local, whose direct shellPopen(..., start_new_session=True)remains attools/process_registry.py:780-792. Since this PR changes onlytools/environments/base.pyandtools/environments/local.py, that Windows shell can still survive an ungraceful Hermes exit. Please cover this path, and account for the separatewinptybranch attools/process_registry.py:726-762.tests/test_windows_terminal_kill_on_exit_job.py:707usesinspect.getsource()and a substring assertion.AGENTS.md:1381-1385bans source-reading tests; this check does not prove_run_bashroutes its Popen through the wrapper.
Suggested changes
- Add behavioral coverage for the background local spawn and replace the source inspection with a mocked-Popen/wrapper contract test that executes
LocalEnvironment._run_bash.
Automated hermes-sweeper review.
| stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL, | ||
| text=True, encoding="utf-8", errors="replace", | ||
| **kwargs, | ||
| proc = spawn_bash_with_kill_on_exit( |
There was a problem hiding this comment.
Please extend this cleanup mechanism to ProcessRegistry.spawn_local as well. terminal(background=true) routes local work there (tools/terminal_tool.py:2552), and its direct login-shell Popen(..., start_new_session=True) remains at tools/process_registry.py:780-792, leaving the reported crash-orphan class unfixed for background shells.
There was a problem hiding this comment.
Partly done in 8f74aaaea, pushed the same day as this review — sorry for leaving the thread open, the change went up without a reply here.
The Popen you pointed at is now wrapped in spawn_bash_with_kill_on_exit(...) (tools/process_registry.py:780-796), with start_new_session=True kept inside the wrapper at :794, and the comment above it cites this review so the reason survives the next refactor.
What that does not cover: spawn_local's use_pty=True branch spawns through _PtyProcessCls.spawn and returns at :762, before reaching the wrapped call. So the PTY path is still outside the job object and the crash-orphan class you described survives there. I would rather say that than let the thread close on a half fix — do you want the PTY path brought into this PR, or split out?
|
|
||
| from tools.environments import local as env_local | ||
|
|
||
| src = inspect.getsource(env_local) |
There was a problem hiding this comment.
This is a banned source-inspection test (AGENTS.md:1381-1385) and only proves the helper name exists somewhere in the module. Replace it with a behavioral test that executes LocalEnvironment._run_bash while spying on the wrapper/Popen contract.
|
Confirmed: spawn_local's non-PTY shell called subprocess.Popen directly, and the job patch is gated per-thread, so that spawn never entered the kill-on-exit job. Fixed in 8f74aaaea by routing it through spawn_bash_with_kill_on_exit (no-op on POSIX; start_new_session stays for the POSIX cleanup story). A call-contract regression pins it, same style as the base.py one — it fails if the wrapper is bypassed. 125 passed on the two suites; the 4 failures in test_process_registry.py are identical on the parent commit, so they predate this change. One honest scope note: the PTY branch spawns through winpty, which bypasses Python's |
|
Thanks for the follow-up. Two notes on the current head ( The source-inspection finding still applies. The winpty PTY branch also remains outside this mechanism. As I noted in the PR, it bypasses Python's |
|
Replaced in a8f5d1c2a. The test now runs I checked two mutations against it: bypassing the wrapper fails the test, and dropping |
SummaryOne PR addresses issue #69033. #69076 adds atomic Windows Job Object assignment for the shared container/remote, foreground-local, and non-PTY background shell paths, while the documented winpty PTY path remains outside the mechanism. Related pull requests
Suggested consolidationKeep #69076 open with a salvage path: retain the Job Object implementation and coverage for the shared backend, foreground-local, and non-PTY background paths, while requiring an explicit design or scope resolution for the winpty PTY branch before further disposition. Complex graphflowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
I69033(["issue #69033 (open)"])
P69076["PR #69076 (open)"]
P69076 -->|best fix| I69033
class I69033 open
class P69076 open
class P69076 best
class P69076 target
click I69033 "https://github.com/NousResearch/hermes-agent/issues/69033"
click P69076 "https://github.com/NousResearch/hermes-agent/pull/69076"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label). Cross-PR triage: Reviewed 1 pull request and 1 issue in this complex. Each diff was read against this issue; Assessment working set: 62 kB of PR diffs, 12 kB of issue/PR text, 6 kB of discussion (8 comments), 3 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch. |
a8f5d1c to
a2f7760
Compare
a2f7760 to
77d5b48
Compare
77d5b48 to
64c87a4
Compare
On Windows, terminal-tool shells accumulate as orphaned processes when the Hermes parent exits ungracefully (crash, force-kill, TUI restart) — observed 20+ stray bash/find/grep processes, one at ~8 CPU-hours. Root cause corrected from the issue's original attribution: the LOCAL backend spawns bash with start_new_session=True, and the shared _popen_bash (docker/ssh/singularity) sets only windows_hide_flags(). start_new_session is POSIX-only (os.setsid) — a silent no-op on Windows — so neither path actually ties the child's lifetime to the parent's on Windows. POSIX is already correct via setsid + the existing pgid-kill machinery; this fix is Windows-only. Adds a process-wide Job Object configured with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE in hermes_cli/_subprocess_compat.py: the OS kills every process still assigned to the job the instant its last handle closes, including on hard process termination with no cleanup code running. This is the opposite of gateway.py's job-object usage (CREATE_BREAKAWAY_FROM_JOB, detach-and-survive) — here the child is deliberately kept attached so it cannot outlive Hermes. AssignProcessToJobObject approach: handle-only (proc._handle), no CREATE_SUSPENDED/resume dance. subprocess.Popen doesn't expose the child's thread handle, so suspend-assign-resume would require bypassing Popen entirely via a raw win32process.CreateProcess call across every spawn site. Verified empirically instead: a scratch harness (parent spawns a child via the wrapper; the child immediately spawns its own grandchild and reports its pid; parent assigns the child to the kill-job right after Popen() returns, with no suspend) showed the grandchild was always inside the job by the time it existed — process/interpreter startup dominates the race. Closing the job handle reliably killed both child and grandchild across repeated runs; this is also asserted as a real (non-mocked, Windows-gated) integration test in tests/test_windows_terminal_kill_on_exit_job.py. Both spawn sites (LocalEnvironment's Popen in tools/environments/local.py and the shared _popen_bash in tools/environments/base.py, used by docker/ssh/singularity) now go through one shared helper, spawn_bash_with_kill_on_exit(), so the two paths can't drift. Job assignment fails open at every step (missing pywin32, job creation failure, AssignProcessToJobObject error e.g. pre-Windows-8 nested-job restriction) — spawning is never blocked by cleanup wiring. Non-goal: the startup orphan-sweep is a separate follow-up, not this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n-exit job terminal(background=true) on a local env spawns its shell via ProcessRegistry.spawn_local, which called subprocess.Popen directly — the one local spawn site outside the job object, so on Windows that whole tree could outlive Hermes. Wrap it in spawn_bash_with_kill_on_exit (no-op on POSIX) and pin the call contract with a regression. PTY spawns use winpty, a different creation API, and stay outside this mechanism (NousResearch#69076 sweeper review). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ontract test test_local_backend_run_bash_uses_kill_on_exit_wrapper asserted `spawn_bash_with_kill_on_exit` appears in inspect.getsource() output, which AGENTS.md forbids (source-reading tests). The replacement drives LocalEnvironment._run_bash with the wrapper and subprocess.Popen mocked, and asserts the spawn routes through the wrapper and the Popen kwargs survive (text/encoding/errors/start_new_session/stdio). Verified both mutations fail the new test: bypassing the wrapper, and dropping encoding="utf-8" from the Popen call. The old test caught only the first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
64c87a4 to
d95fec3
Compare
Fixes #69033.
Problem
On Windows, shell subprocesses spawned by the terminal tool are orphaned when the Hermes parent exits (session end / TUI restart / crash). Users see 20+ leaked
bash.exe/find.exe/grep.exe/head.exeaccumulating across sessions, one with ~8 CPU-hours before being noticed. The root cause is thatstart_new_session=True(POSIXsetsid) is a silent no-op on Windows, every shell-spawn site in the codebase (_popen_bashfor docker/ssh/singularity,local.pyfor the local backend, and even the slash-worker/MCP paths cited in the issue as "already solved") lacks real subtree cleanup on ungraceful parent exit.Note that the issue's attribution to
_popen_bashalone is incomplete, the local backend spawns directly inlocal.py(bypassing_popen_bash, as base.py's docstring notes), so both call sites need the fix.Fix
A process-wide Windows Job Object with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. When Hermes exits, cleanly or via SIGKILL, the OS closes the job handle and every process assigned to it dies.Getting this right on Windows required more work than the POSIX equivalent:
Assign atomically, not after Popen returns. Handle-only assignment after
Popen()is racy for fast native pipelines: bash can startfind.exe | grep.exe | head.exebefore the parent thread runsAssignProcessToJobObject, and job membership is inherited only by children created AFTER assignment. Fix:CREATE_SUSPENDED→ assign →ResumeThread, so the child structurally cannot execute until it's already in the job.subprocess.Popencloses the thread handle internally before returning, so we get the handle by installing a wrapper oversubprocess._winapi.CreateProcess.Install the wrapper once, never restore it. Dynamic patch/restore under a lock was our first attempt; adversarial review found it leaked stale patches under thread interleaving (capture-before-lock → wrong restore) and swept unrelated concurrent
subprocess.Popencalls from other libraries into our job. Fix: install exactly once via double-checked locking, and make the wrapper inert by default, it only suspends/assigns/resumes when the CALLING thread'sthreading.local(_spawn_owner.job) is set, whichspawn_bash_with_kill_on_exitsets around onepopen_fn()call. Unrelated threads pass straight through to the realCreateProcess.Singleton Job Object under double-checked locking. Two first callers racing on
_get_kill_on_exit_job()could each create a job, publish one, and let the other be GC'd, closing its handle immediately kills whichever shell was just assigned to the loser. Fixed with athreading.Lockaround create-and-publish.Fail open, but visibly. Job assignment failures (unavailable pywin32, nested-job restrictions, WinError from an outer restrictive job) never block the spawn, the terminal keeps working. But the first failure logs a WARNING with the WinError code, so the fix isn't silently disabled on any given install.
Correct cleanup on ResumeThread failure. If
ResumeThreadfails after assignment,TerminateProcessruns on the still-suspended child before we re-raise, so it can't hang forever with pipes attached. CPython's_winapireturns raw int handles rather than PyHANDLE objects, so cleanup uses_winapi.CloseHandle(int(h)), a naive.Close()would raiseAttributeErrorand leak both handles.Applied at both spawn sites via a shared helper (
spawn_bash_with_kill_on_exit), so_popen_bash(docker/ssh/singularity) and local.py cannot drift.Known limitations
Documented honestly rather than papered over:
_spawn_owner.jobis set/cleared around one call; the only caller (spawn_bash_with_kill_on_exit) does not nest. A hypothetical reentrant spawn from a guarded audit hook would either clear the outer owner (later outer spawns escape) or be swept into the job. Documented in the docstring, extend to a stack if a nested caller ever appears.Tests
22 passed in
tests/test_windows_terminal_kill_on_exit_job.py, including:threading.Barrier, RED-verified against an unlocked reimplementation (produced 2 jobs, dropped to 1 with the fix).subprocess.Popensurvives when we close our job handle, proves the thread-local gate isolates us from other libraries.importlib.reloadsafety: install → reload → install again, no recursion (real repro, RED-verified against the unmarked wrapper).ResumeThreadfailure path terminates the child, closes both handles via_winapi.CloseHandle, warns onTerminateProcessfailure, re-raises the original error.Also: 4 rounds of adversarial review (each catching a real defect I had missed) shaped the design, the review trail is why the final shape is atomic and thread-isolated rather than the naive "assign after Popen" I started with.
The bash-native equivalent E2E was investigated and dropped: MSYS reports internal
$$/$!PIDs that don't match the Win32 ProcessId that Popen or WMIC see, making deterministic pid tracking impractical. The Python-Python E2E exercises the identicalCreateProcess+ Job-inheritance mechanism.I corrected the issue's root-cause attribution (
_popen_bashisn't the only path,local.pyalso spawns directly), verifiedpywin32is a declared win32 dependency, and confirmedgateway.pyalready uses Job Objects for the opposite purpose (CREATE_BREAKAWAY_FROM_JOB, detach-and-survive), so this is a genuinely new use of the primitive rather than a copy of an existing pattern.Reporter's item 3 (startup orphan-sweep) is a good follow-up but is genuinely separate, this PR closes the source of the leak; a sweeper for legacy accumulated orphans from prior Hermes versions can land later.