fix(cron): script timeouts leave orphaned subprocess groups and get mislabeled as provider timeouts - #59379
Conversation
tonydwb
left a comment
There was a problem hiding this comment.
Code Review
COMMENT: fix(cron): script timeouts leave orphaned subprocess groups and get mislabeled as provider timeouts
LGTM. Important reliability fix for cron scheduler — prevents orphaned subprocess groups and mislabeled timeouts. Clean 1-file fix. No security concerns.
Reviewed by Hermes Agent
|
Filed #59549 with the two bugs and repro steps in case that's useful context for review. |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for addressing two real cron failures. Current main still uses subprocess.run(..., timeout=...) at cron/scheduler.py:2101 and classifies its Script timed out ... error as a provider timeout at cron/scheduler.py:75-79.
Problems
cron/scheduler.py:2013addspreexec_fn=os.setsid, but current main can start a claim-heartbeat thread before invoking this runner (cron/scheduler.py:2179-2196). Please use POSIXstart_new_session=Trueinstead;tools/tts_tool.py:787already uses that pattern.cron/scheduler.py:2029usesos.getpgid(proc.pid). Once a new session is created,proc.pidis the PGID; querying the leader can fail if it exits in the timeout race while descendants remain. Useos.killpg(proc.pid, signal.SIGKILL)so the surviving group is still targeted.- The diff changes no tests. Add coverage for descendant cleanup and for the script-specific delivery summary; current timeout coverage only checks a direct child at
tests/cron/test_cron_script.py:184-196.
This is an automated hermes-sweeper review.
| result = subprocess.run( | ||
| if sys.platform != "win32": | ||
| # Start the script in its own session so a timeout can clean up | ||
| # the whole process group, not just the direct child — a script |
There was a problem hiding this comment.
Use start_new_session=True rather than preexec_fn=os.setsid. Current main can start a claim-heartbeat thread before this call (_run_job_script_with_claim_heartbeat), and the repository already uses start_new_session=True for process-tree isolation in tools/tts_tool.py:787.
| stderr = (result.stderr or "").strip() | ||
| try: | ||
| stdout, stderr = proc.communicate(timeout=script_timeout) | ||
| returncode = proc.returncode |
There was a problem hiding this comment.
After creating a new session, proc.pid is the PGID. Calling getpgid(proc.pid) can fail if the direct child exits in the timeout race while descendants still hold the group, and this broad handler then leaves them alive. Target os.killpg(proc.pid, signal.SIGKILL) directly.
…timeout message
Two related bugs in _run_job_script's timeout handling:
1. subprocess.run(timeout=...) only kills the direct child on
TimeoutExpired, orphaning any grandchildren a script spawned
(background jobs, watchdog patterns). Switched to Popen +
communicate(timeout=...) with the child started via
preexec_fn=os.setsid so a timeout can os.killpg the whole group
(POSIX), matching the existing Windows proc.kill() fallback shape.
2. _summarize_cron_failure_for_delivery checked the generic "timed
out" substring before any script-specific case, so a script
timeout's own error text ("Script timed out after Ns: path")
matched that branch and got rewritten as "provider timeout.
Fallback chain was exhausted" — actively wrong, since it's not a
provider issue at all. Added a script-timeout-specific check ahead
of the generic one.
Address review: preexec_fn=os.setsid is unsafe here — callers can start a claim-heartbeat thread before invoking the script runner, and preexec_fn runs after fork in a multithreaded process. start_new_session=True is the POSIX-safe equivalent, matching tools/tts_tool.py. Since start_new_session makes the child its own group leader, kill the group via proc.pid rather than os.getpgid(proc.pid), which can raise if the leader exits in the timeout race while descendants are still running — the exact orphan case this cleanup targets.
Existing timeout coverage only asserts a direct child dies. Add a test that a grandchild spawned by a timed-out script is killed with the process group (verified to fail when the group kill is reverted), and one asserting the script-timeout delivery summary is not labeled a provider timeout.
dbdefd8 to
51dd37f
Compare
|
Thanks — all three addressed (6718c8e, 51dd37f), rebased onto current main.
Two notes on the descendant test, in case they're useful. The grandchild detaches its stdio deliberately: if it inherits the pipes, |
|
+1 with production confirmation on the message-mislabeling half — detailed evidence in #59549 (comment): a |
|
Coordination note for when the unified deadline layer lands (#85125 Phase 1, #85147): the tree-kill half of this PR is scheduled to be implemented on |
|
Executing the alignment per the notice above: the tree-kill half now migrates onto the unified deadline layer — #86791 replaces the site-local group kill with |
fix(cron): script timeouts leave orphaned subprocess groups and get mislabeled as provider timeouts
|
Two related bugs in `cron/scheduler.py`'s pre-run/data-collection script execution:
1. Orphaned subprocess groups on timeout. `_run_job_script()` uses `subprocess.run(..., timeout=script_timeout)`. On `TimeoutExpired`, `subprocess.run` internally kills only the direct child — any grandchildren the script spawned (background `&` jobs, a `wget`/`curl` a watchdog script kicked off, etc.) are orphaned and keep running after the cron tick reports failure. This is silent: nothing surfaces that cleanup didn't happen.
2. Script timeouts are mislabeled as provider timeouts in chat delivery. `_summarize_cron_failure_for_delivery()` checks `"timed out" in lower` before any more specific check, and the script-path error message is literally `f"Script timed out after {script_timeout}s: {path}"` — which matches that branch and gets rewritten as `"⚠️ Cron 'x' failed: provider timeout. Fallback chain was exhausted or unavailable."` That's actively wrong: it's not a provider issue and there's no fallback chain involved, it's a runaway script. An operator seeing this message would look in the wrong place to debug it.
The fix:
Test plan
A cron job pointed at a script that spawns a detached long-running background child (`sleep 300 &`) and then itself loops past the configured timeout — confirmed before the fix that the background `sleep` survived the cron tick's failure; confirmed after the fix that `os.killpg` cleans up the full group. Also confirmed the chat-delivered failure message for a script timeout no longer says "provider timeout."