fix(environments): Windows job-breakaway parity for LocalEnvironment foreground commands - #43253
Conversation
Companion fix to #43252 — both replace the legacy |
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Approved
✅ Looks Good
- Same Windows detach-flag fix as #43252 but for
tools/environments/local.py: Replaces the legacywindows_hide_flags()(which only setsCREATE_NO_WINDOW) withwindows_detach_popen_kwargs()which sets all 4 detach flags includingCREATE_BREAKAWAY_FROM_JOB. On restrictive Windows job objects, this prevents terminal commands from being silently reaped when the parent process exits. - OSError fallback with nested retry: Mirrors the canonical pattern in
hermes_cli/gateway.py:716-742— if the breakaway flag is denied by the job object, the fallback useswindows_detach_flags_without_breakaway()and retries. This is a significant improvement over the pre-fix behavior where the command would crash withPermissionError. - Comprehensive static-analysis tests: Three AST-based tests verify (1) the new helper is imported and used, (2)
subprocess.Popenis wrapped intry/except OSError, and (3) the fallback uses the no-breakaway helper. These prevent future maintainers from reverting to the broken pattern. - Security consideration noted in code: The comment about not logging the full
argv(because cron scripts may embed secrets likeAWS_SECRET_ACCESS_KEY=...) is appropriate — no actual secrets appear in the diff. - Well-scoped: Only 2 files, clear description referencing the sibling PR #43252.
Reviewed by Hermes Agent
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tracing a real-looking Windows process-lifetime gap. tools/environments/local.py:1074 still uses only windows_hide_flags(), so this remains a relevant call site.
Problems
- The patch predates
515192c4b, which changed the same Popen tostart_new_session=Trueat currenttools/environments/local.py:1085to avoidpreexec_fnfork-time crashes. A current-main port must replace that argument with the helper, not retain both; on POSIX the helper itself suppliesstart_new_session=True. - The added tests are AST/source checks (
tests/tools/test_windows_native_support.py:871-1035), so they do not prove the firstPopenfailure actually retries withwindows_detach_flags_without_breakaway(). - Current main later reverted broad Windows popup spawn changes through this call site in
d3d621f7c; its maintainer rationale requires a focused native-Windows reproduction rather than another general spawn-layer migration.
Suggested changes
- Port only the current
_run_bashspawn, preserving the no-preexec_fnguarantee from515192c4b. - Add behavioral mocked-Popen coverage for primary flags, OSError retry flags, and the POSIX single-session-kwarg path.
Automated hermes-sweeper review.
| # containers, kiosk-mode shells), retry without it. Mirrors | ||
| # the canonical fallback in hermes_cli/gateway.py:716-742. | ||
| try: | ||
| _popen_kwargs = windows_detach_popen_kwargs() |
There was a problem hiding this comment.
Current main has start_new_session=True at this Popen from 515192c4b. When salvaging, replace that current argument with this helper rather than combining both: on POSIX the helper returns {"start_new_session": True}, so passing both would duplicate the Popen keyword.
There was a problem hiding this comment.
Thanks — done. The call site now passes **windows_detach_popen_kwargs() and no longer
passes a literal start_new_session=True, so on POSIX there is exactly one start_new_session=True
(supplied by the helper) and no duplicate keyword. A dedicated test asserts this —
test_posix_single_start_new_session checks the POSIX branch receives start_new_session=True, no
creationflags, and no preexec_fn.
On Windows the helper supplies the full detach bundle; the narrow fallback (see the PR body) drops only
CREATE_BREAKAWAY_FROM_JOB on an error consistent with a breakaway refusal (WinError 5 plus confirmed
job membership). No preexec_fn is introduced on either platform (test_no_respawn_after_success /
test_windows_primary_full_detach_kwargs assert the exact kwargs).
| "Remove the import and use windows_detach_popen_kwargs() instead." | ||
| ) | ||
|
|
||
| def test_local_env_subprocess_popen_is_wrapped_in_oserror_try(self): |
There was a problem hiding this comment.
This only verifies AST structure. Add a mocked subprocess.Popen test that raises OSError on the primary call and asserts the retry receives windows_detach_flags_without_breakaway(); that exercises the claimed recovery contract.
There was a problem hiding this comment.
Thanks — added. The new suite tests/tools/test_local_windows_breakaway_parity.py drives the real patched
_run_bash with subprocess.Popen mocked, exercising the recovery contract behaviorally rather than
by source inspection:
test_windows_breakaway_refusal_retries_once— primary raisesOSError(winerror=5)with the parent
confirmed in a job; asserts exactly two Popen calls and that the second receives
creationflags == windows_detach_flags_without_breakaway(), with argv/cwd/env/stdio/text/encoding/
errors preserved across the retry.test_winerror_5_not_in_job_propagatesandtest_winerror_not_5_propagates— assert one attempt and
propagation when the refusal conditions are not met.test_job_membership_query_failure_fails_closed— forces the real_process_in_jobctypes boundary
to fail and asserts the originalwinerror 5propagates with no retry (fail-closed).test_second_constructor_failure_propagates— asserts at most one retry.
Beyond the mocked contract, a native job-object experiment drove the exact patched call site and
demonstrated permitting-job escape (the child leaves the specific job, survives abrupt teardown while a
retained non-breakaway control dies, and stays explicitly killable) and restrictive-job retention
(the child stays in the job and dies on teardown). Evidence is in the package. No AST/source-text
assertions remain.
…foreground commands Route LocalEnvironment._run_bash's foreground spawn through windows_detach_popen_kwargs() so a Windows foreground child breaks out of the parent Hermes job object where that job permits breakaway, giving best-effort process-survival parity with the POSIX start_new_session=True path. On POSIX the helper supplies exactly one start_new_session=True (replacing the literal keyword this call site passed), so there is no duplicated Popen keyword and no preexec_fn. Add a narrow fallback for an error consistent with a construction-time breakaway refusal: retry once without CREATE_BREAKAWAY_FROM_JOB only when exc.winerror == 5 AND IsProcessInJob confirms this process is in a job. Fail closed when membership is unknown or the query fails, and propagate every other OSError after a single attempt. A successful Popen is never respawned. DETACHED_PROCESS does not sever explicitly redirected stdio (only an inherited console), so stdout=PIPE / stderr=STDOUT / stdin redirection at this call site are unaffected; explicitly redirected stdin, stdout, and merged stderr were confirmed functional under DETACHED_PROCESS in native testing. This is process-survival parity only. It is not universal job escape (a restrictive job may silently retain the child), not durable execution, and not durable output: after abrupt Hermes death a surviving process has no result owner, its output is lost, and side effects may complete unrecorded. Controlled ownership (timeout, interrupt, shutdown, explicit tree-kill) is retained. Tests: new tests/tools/test_local_windows_breakaway_parity.py drives the real _run_bash with subprocess.Popen mocked (both platform branches forced), and a native job-object experiment demonstrates permitting-job escape and restrictive-job retention at the exact patched call site.
d8fda54 to
fc0a6f0
Compare
|
Heads up that I force-pushed a reworked version of this since the last review, so the diff and any "viewed" state will have reset. Sorry for the extra friction. Here is what actually changed. The call site now routes through The OSError fallback is much narrower now. It only retries without The old AST only test is replaced by On scope, I want to be upfront: this is best effort process survival parity with POSIX, not durable execution. Where the parent job permits breakaway the child leaves the job and can outlive an abrupt parent death, but output and result ownership do not survive that, and on a restrictive job the child may be silently retained with no benefit at all. Controlled shutdown, timeout, interrupt, and explicit tree kill still terminate the child. One correction to something said earlier in review: I ran a native job object experiment against the exact patched call site on one Windows build (Windows 11 10.0.28120, CPython 3.11.9). In a permitting job the child leaves the job and survives an abrupt teardown while a retained control process in the same job dies, and in a restrictive job the child stays in the job and dies on teardown. The fallback path itself is covered by the mocked tests rather than natively. I am happy to share the harness and the raw run output, or to test other job configurations if you have one in mind. Thanks for the review. |
fix(environments): Windows job-breakaway parity for LocalEnvironment foreground commands
What this changes
LocalEnvironment._run_bash(the foreground terminal spawn path) previously used{"creationflags": windows_hide_flags()}on Windows (CREATE_NO_WINDOW only) alongsidestart_new_session=True. On Windows that left the spawned bash inside the parent Hermes job object,so it had no parity with the POSIX path, where
start_new_session=Truealready puts the child in itsown session.
This change routes the spawn through
windows_detach_popen_kwargs():start_new_session=True(the single keyword thiscall site already passed;
os.setsidin the child). Nopreexec_fn.CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS | CREATE_NO_WINDOW | CREATE_BREAKAWAY_FROM_JOB— so a foreground child breaks out of the parent's jobobject where that job permits breakaway, giving best-effort process-survival parity with POSIX.
It adds a narrow fallback for an error consistent with a construction-time breakaway refusal
(WinError 5 plus confirmed job membership makes a breakaway refusal the plausible cause, not a
certainty — but the predicate is safe either way):
OSErrorhaswinerror == 5(ERROR_ACCESS_DENIED) and anIsProcessInJobquery confirms this process is actually in a job (a process in no job cannotreceive a breakaway refusal, so
winerror 5there is unrelated);CREATE_BREAKAWAY_FROM_JOB;winerror != 5, theoriginal exception propagates with no retry;
Popenis never respawned — no post-construction fault can trigger a second spawn.Correcting a prior claim
An earlier discussion held that
DETACHED_PROCESS"severs stdio" and would break this call site'sstdout=PIPE. That is not correct.DETACHED_PROCESSonly removes an inherited console;explicitly redirected handles are unaffected. Native probes confirmed that explicitly
redirected stdin, stdout, and merged stderr remain functional with
DETACHED_PROCESS. This call sitealways redirects stdout/stderr/stdin explicitly, so there is no console to lose.
What this is and is not
This is best-effort process-survival parity with POSIX, nothing more:
parent death — the same property POSIX
setsidalready gives.explicit process-tree termination all still kill the child (the detached, broken-away child stays
explicitly killable — confirmed in native testing).
JOB_OBJECT_LIMIT_BREAKAWAY_OK), modernnested-job Windows can silently retain the child with no error raised — the fallback never fires,
and the child dies when the job is torn down. No benefit is claimed there.
process has no result owner: its stdout pipe reader is gone, output is lost, the return code and
CWD marker and session snapshot have no consumer, and a command may have completed side effects
that are never recorded. This change does not attempt to fix that; durable execution belongs on a
separately designed persistent surface, not on this foreground flag.
Scope
tools/environments/local.pyonly — the call-site change plus one private_process_in_job()helper (kept local; not exported).tests/tools/test_local_windows_breakaway_parity.py(new).Testing
10 new behavioral tests drive the real patched
_run_bashwithsubprocess.Popenmocked, forcingboth platform branches so they run on any host: primary Windows flags, qualifying
winerror 5+ in-jobsingle retry,
winerror 5not-in-job propagation,winerror != 5propagation, real-boundary queryfailure failing closed, second-failure propagation, POSIX single
start_new_session, POSIX OSErrorpropagation, no
preexec_fn, returned process killable, no respawn after success.Focused adjacent suites (12 files: base environment, background-child hang, threaded handle, timeout,
exit semantics, init-session cwd, cwd recovery, Windows native support, terminal tool, Windows
tree-kill, two gateway-shutdown kill tests): 201 passed, 7 deselected, zero new failures on native
Windows vs the identical clean-baseline command (191 passed clean; +10 new). The 7 deselections are
pre-existing known-Windows skips, unchanged by this PR.
A native job-object experiment drove the exact patched call site through a sacrificial launcher under
an external controller:
bash runner and its descendant confirmed not in that job; after abrupt launcher+job teardown the
descendant survived; a retained non-breakaway control process in the same job died on
teardown (showing the harness distinguishes escape from retention); the survivor was then explicitly
terminated and confirmed dead; exactly one side effect (no duplicate execution). Reproduced
deterministically across 3 runs with distinct PIDs.
native harness did not instrument the number of real
Popenattempts, so it does not distinguishsilent primary retention from a constructor fallback; both preserve the disclosed restrictive-job
limitation. (The mocked tests cover the fallback contract itself.)
Host: Windows 11 10.0.28120, CPython 3.11.9 (64-bit, AMD64). Full Win32 boundary detail (structure
sizes, argtypes/restype, API return values) is recorded in the evidence package.
Limitations
Live evidence covers the spawn/fallback contract, permitting-job escape, controlled killability, and
restrictive-job retention on one Windows build. The behavioral suite is mocked-
Popen(by design, soit runs cross-platform and asserts exact kwargs). The silent-retention behavior is
configuration-specific; other Windows builds may instead raise
winerror 5, which the fallbackhandles. See
RISK_STATEMENT.md.