fix(cron): Windows detach with constructor-only compatibility fallback and file-backed output capture - #43252
Conversation
Companion fix to #43253 — both replace the legacy |
tonydwb
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: Approved
✅ Looks Good
- Windows detach-flag fix for
cron/scheduler.py: Replaces the legacywindows_hide_flags()withwindows_detach_popen_kwargs(). On restrictive Windows job objects, the old pattern caused cron-spawned scripts to be silently reaped when the parent Hermes process exited — now they properly break away from the job object. - OSError nested fallback with security awareness: The dual-attempt pattern (primary with breakaway flag → fallback without) mirrors the canonical
hermes_cli/gateway.py:716-742. On the rare double-failure path, the code logs a WARNING with only the argv head (not the full argv list) — this is explicitly documented as a security measure since cron scripts can embed secrets in environment variables passed inline. - Comprehensive AST-based tests: Three tests verify (1) the new helper is used and the old one removed, (2)
subprocess.runis wrapped intry/except OSError, and (3) the fallback useswindows_detach_flags_without_breakaway(). These are robust against future refactoring attempts. - Well-scoped: 2 files, +253/-11, single focused concern. Pairs with sibling PR #43253 (which applies the same fix to
tools/environments/local.py).
Reviewed by Hermes Agent
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tracing this to the cron script runner. Current main still uses windows_hide_flags() at cron/scheduler.py:2099, so the Windows detach premise remains relevant.
Problems
- The new unconditional
windows_detach_popen_kwargs()call changes POSIX behavior: the helper returns{"start_new_session": True}on non-Windows (hermes_cli/_subprocess_compat.py:204-234), whereas the current cronsubprocess.runhas no session kwarg (cron/scheduler.py:2099-2107). The detach/retry path should be Windows-only unless this POSIX behavior change is intentionally designed and covered. - The proposed tests inspect source/AST shape rather than exercising
_run_job_scriptor anOSErrorretry. Please replace them with mocked runtime behavior tests for the full-flags first call, no-breakaway retry, and dual-failure result. - The PR base predates the current subprocess environment sanitizer. Salvage the change into
cron/scheduler.py:2096-2107without losing_sanitize_subprocess_env(os.environ.copy()).
This is an automated hermes-sweeper review.
| @@ -1044,16 +1047,68 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: | |||
| pass | |||
There was a problem hiding this comment.
windows_detach_popen_kwargs() returns {"start_new_session": True} on POSIX, while the current cron script runner supplies no such kwarg. Please gate this detach helper and its fallback to Windows so this Windows fix does not alter Linux/macOS process-session semantics.
There was a problem hiding this comment.
You were right. The reworked cron path does not call windows_detach_popen_kwargs() unconditionally. POSIX remains on the existing subprocess.run path with no new start_new_session behavior, and the Windows-only helper preserves the sanitized environment and interpreter overlay.
The rework also closes two deeper defects:
-
The earlier
except OSError: retry subprocess.run(...)boundary could relaunch after a child already existed. The new fallback is confined to child construction. OncePopenreturns, wait, timeout, output-read, decode, redaction, and cleanup failures cannot create another child. Native fault injection produced one spawn and one side effect under the failure that previously produced two. -
A detached child using anonymous pipes still depends on its parent-owned reader. Native Windows testing showed output-producing children failing after parent death, while the file-backed runner completed all eight modes with zero temp-file leaks.
One native finding changed the wording, not the architecture: restrictive nested jobs on the tested Windows 11 build did not raise WinError 5. Child construction succeeded while Windows silently retained the child in the forbidding job. A permitting nested job allowed escape and survival. The compatibility fallback remains for configurations that do surface WinError 5, but the native test now asserts the cross-version invariant (one successful execution with either one full-flags constructor attempt or one constructor fallback) rather than requiring a particular OS error mechanism.
The public claim is therefore narrow: a child survives teardown of jobs that permit breakaway; a child retained by a forbidding job may still be reaped. Orphan output/status is not recovered after restart.
…k and file-backed output capture Route Windows cron script launches through an explicit Popen helper: - full detach flags on the primary constructor attempt; at most one constructor fallback, only when a parent-in-job launch reports WinError 5; no fallback after a Popen object exists - stdout/stderr captured in inherited unnamed TemporaryFile handles so a child that outlives the parent keeps a valid output sink; the O_TEMPORARY file self-deletes when the last handle closes - stdin=DEVNULL; utf-8 decoding with errors=replace and newline normalization; sanitizer, env overlay, timeout, redaction, and the return contract unchanged - POSIX path unchanged On the tested Windows 11 nested-job configuration, a job that forbids breakaway silently retains the child instead of raising WinError 5; the native test asserts the cross-configuration invariant of exactly one execution rather than one error mechanism.
2c30848 to
ed6cb6b
Compare
2c30848 to
ed6cb6b
Compare
Problem
On Windows, cron pre-run /
no_agentscripts are launched withCREATE_NO_WINDOWonly, so they remain tied to the parent's job-object lifetime. Controlled shutdown is drained elsewhere, but terminal/Desktop teardown or process death can still kill a mid-run script.Why the previous revision was wrong
The previous revision had three structural defects:
subprocess.run()lifecycle afterOSError, so an error after child creation could execute a side-effectful script twice;capture_output=True, leaving the detached child writing to anonymous pipes whose reader died with the parent.Change
The Windows path now uses an explicit
Popenhelper:Popenobject exists;TemporaryFilehandles for stdout/stderr;stdin=DEVNULL;subprocess.runpath with no new session behavior.Windows job-object behavior
Breakaway refusal is not represented identically on every Windows/job configuration.
CREATE_BREAKAWAY_FROM_JOB.Therefore, survival is guaranteed only for teardown of jobs that permit breakaway. A child retained by a forbidding job may still be reaped when that job closes.
Why file-backed output is necessary
Native testing reproduced the anonymous-pipe failure: quiet children survived parent death, while output-producing modes failed on their next write. The file-backed runner completed all eight tested modes with all side effects and zero leaked temp files.
The change also avoids two pipe-lifetime bugs while the parent is alive:
subprocess.run()falsely wait until timeout;communicate()waits on a descendant-held pipe.Waiting on the direct process with file-backed output removes those pipe-EOF dependencies. Output written by a grandchild after the direct child exits is not collected.
Guarantees and limitations
Guaranteed:
Not guaranteed:
Evidence