fix(process): identity-aware liveness and win32 console-ctrl safety - #30
Conversation
Liveness was bare pid existence: a reused pid (immediate on Windows) read as a false "alive", blocking resume of a dead run, stranding worktree reclaim, leaking sessions, and showing dead runs as RUNNING. - Persist process identity (create-time / starttime) in the pid file; add ProcessHost.alive_and_ours(pid, identity) as a distinct identity-aware op while is_alive stays the raw existence primitive. - Route engine_alive, tui.data.liveness, and stop_run's pre-terminate check through it. A corrupt identity token fails closed; a legacy pid-only file degrades to bare existence. The forced-path stop keeps a stop-time identity fallback for legacy files so a wedged pre-upgrade run can still be force-killed. - On win32, ignore console SIGINT/SIGBREAK during a run so a ConPTY Ctrl+C broadcast can't kill the engine. - Tests: guard the os.kill(pid,0)=CTRL_C hazard (win32-skip + psutil self-test) and cover reuse / legacy / malformed-identity and the console-ctrl paths.
WalkthroughThis PR introduces identity-aware PID liveness checks to prevent false positives from PID reuse. ChangesIdentity-aware PID liveness and Windows signal handling
Estimated code review effort: 3 (Moderate) | ~30 minutes Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🤖 Augment PR SummarySummary: This PR makes engine liveness identity-aware (detects PID reuse) and hardens Windows console Ctrl+C/Ctrl+Break handling so runs can’t be killed mid-flight by console broadcasts. Changes:
Technical Notes: Malformed/non-finite identity tokens fail closed (treated as not-ours), while true legacy pid-only files degrade to PID-existence behavior. 🤖 Was this summary useful? React with 👍 or 👎 |
Replace the best-effort try/except/pass around the console-ctrl journal write with contextlib.suppress(Exception) — functionally identical, but clears bandit B110 (try/except/pass) that the CI lint gate flagged, and drops the now-redundant blind-except noqa.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/automator/engine.py (1)
284-294: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSuppress the flagged
try/except/passper Ruff S110.Static analysis flags this block (S110). The rest of this file suppresses the same pattern with an explicit reason comment (e.g.
# noqa: BLE001 # nosec B110 - best-effort teardown...), but this new block only silencesBLE001, leaving S110 unaddressed.🔧 Suggested fix
if sys.platform == "win32" and signum in windows_ctrl_signals: try: self.journal.append("console-ctrl-ignored", signum=signum) - except Exception: # noqa: BLE001 # best-effort signal-handler journal + except Exception: # noqa: BLE001, S110 # best-effort signal-handler journal; safe to swallow inside a signal handler pass return🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/automator/engine.py` around lines 284 - 294, Update the signal handler in engine.py so the best-effort journal write no longer triggers Ruff S110: keep the existing try/except around self.journal.append in handler, but add the same explicit nosec/justification-style suppression used elsewhere in this file for similar teardown paths, and make sure the exception handler is documented to satisfy both BLE001 and S110. Locate the change in the handler nested inside the automator engine logic, and align its comment/suppression with the other teardown-safe try/except/pass blocks.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/automator/engine.py`:
- Around line 284-294: Update the signal handler in engine.py so the best-effort
journal write no longer triggers Ruff S110: keep the existing try/except around
self.journal.append in handler, but add the same explicit
nosec/justification-style suppression used elsewhere in this file for similar
teardown paths, and make sure the exception handler is documented to satisfy
both BLE001 and S110. Locate the change in the handler nested inside the
automator engine logic, and align its comment/suppression with the other
teardown-safe try/except/pass blocks.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 58a8535a-48ef-45e6-bf4e-ecc5040818fd
📒 Files selected for processing (9)
src/automator/engine.pysrc/automator/process_host.pysrc/automator/runs.pysrc/automator/tui/data.pytests/test_cli.pytests/test_engine.pytests/test_process_host.pytests/test_runs.pytests/test_tui_data.py
An unreadable identity() (permission glitch, or no psutil off-Linux) also returns None, not only a gone pid; note that such a live-but-unreadable pid reads as not-ours (dead) — the documented, safe-biased residual.
✅ Validation — safe to mergeValidated against current What it fixesLiveness was bare PID existence, so a recycled PID read as a false "alive" — cascading into refused POSIX safety — checked
Residual (Windows-only, out of scope here)On win32 a live pid whose LGTM. 🤖 Validation assisted by Claude Code. |
What
Make engine process-liveness identity-aware and harden Windows signal handling. Liveness now answers "is the specific engine I launched still alive?" — comparing a persisted process identity, not just a PID number — and on Windows the engine ignores stray console Ctrl+C during a run so it can't be killed mid-flight.
Why
Liveness was computed as bare PID existence (
does this pid number exist?). The OS recycles a dead process's PID — immediately on Windows, once the last handle closes — so a stranger inheriting the number read as a false "alive". That single defect cascaded:resumerefused to resume a genuinely-dead run,Separately, on native Windows a terminal/ConPTY Ctrl+C is a console broadcast (
GenerateConsoleCtrlEvent) that can deliverCTRL_C_EVENTto the engine sharing that console and kill it mid-run.How
write_pidnow records the process identity (POSIX/procstart-time / Windows psutilcreate_time) as a second token next to the pid;read_pid_identityreads both. A newProcessHost.alive_and_ours(pid, identity)is a distinct identity-aware op —is_alivestays the raw existence primitive — and is routed throughengine_alive, the TUI liveness column, andstop_run's pre-terminate check, so a reused PID reads as dead-and-gone.None/mismatched identity.SIGINT/SIGBREAKon win32 (journaling the event) so a broadcast Ctrl+C can't kill the engine;SIGTERMand non-win32SIGINTstill stop cleanly. The deliberate stop path on Windows istaskkillvia the process-host seam, not Ctrl+C.Process management: win32 vs POSIX
The change exists because process lifecycle differs in kind, not just in API, between the two platforms. Everything sits behind a
ProcessHostseam (PosixProcessHost/WindowsProcessHost, selected by a platform registry) so the engine,runs, and the TUI never branch onsys.platformat the call site.os.kill(pid, 0)— a harmless, read-only existence checkos.kill(pid, 0)is destructive: Python maps signal 0 toCTRL_C_EVENTand sends a real Ctrl+C to the target's console. Must use psutil (pid_exists/Process.is_running) insteadpid_max32768) before wrapping → reuse is delayed and rare/proc/<pid>/statfield 22 (start-time in clock ticks)Process.create_time()SIGTERM(catchable, the engine unwinds and marks itself stopped)taskkill /PID(postsWM_CLOSE);SIGTERMgenerally does not run a Python handler — the process is ended viaTerminateProcessSIGKILLtaskkill /F /T(kills the process tree)SIGINTto the foreground process groupGenerateConsoleCtrlEventbroadcastsCTRL_C/CTRL_BREAKto every process attached to the console — a Ctrl+C meant for one pane can hit the engineTwo consequences drive this PR directly:
create_time/start-time, so it reads as not-ours.os.kill(pid, 0)and console Ctrl+C are the same underlying Windows behaviour (GenerateConsoleCtrlEvent). The liveness path avoidsos.killentirely on win32 (psutil), and the engine ignores console-ctrl during a run so an errant broadcast can't take it down.Testing
New/updated unit tests across
test_process_host.py,test_runs.py,test_tui_data.py,test_engine.py, andtest_cli.pycover: identity match vs. reuse (reused PID → dead / INTERRUPTED), legacy pid-file degradation, malformed-identity fail-closed (garbage/nan/inf), pre-stop PID reuse → clean stop (no stray SIGTERM, no error), the legacy force-kill fallback, and the win32 console-ctrl-ignore path (both platforms). The POSIX self-liveness test that callsos.kill(pid, 0)is skipped on win32 (it would fire a real Ctrl+C and abort the runner) with a psutil-basedWindowsProcessHostself-liveness test covering Windows.Green on Linux (WSL — full touched-file suite passes; the full suite is green aside from two pre-existing, unrelated skill-sync failures) and on native Windows (targeted Windows-safe subset). Lint clean: ruff, black, isort, bandit.
Follow-up
A native Windows CI runner (
windows-latestjob) plus correct POSIX/Win32 test-skipping — gating the POSIX-only tests that crash or fail on Windows (theos.kill(pid, 0)self-liveness probe,true-binary dead-PID helpers,test -fverify commands, and the real-signal self-terminate test) — so the whole suite runs green on Windows CI and continuously validates this PR's Windows behaviour. Kept out of this PR to keep it focused on the process-safety change; tracked as a separate follow-up.Separate PR, on top of this one): the inverse, native-Windows counterpart of what this PR fixes. On win32 the identity-aware read can report a live pid as DEAD when its identity is merely unreadable rather than gone -
psutil.Process(pid).create_time()raising ERROR_ACCESS_DENIED (cross-session / elevation mismatch), a failure mode the POSIX /proc start-time path doesn't have — surfacing a running engine as INTERRUPTED. It belongs to this same win32 process-safety line and will be handled on its own by biasing a non-destructive liveness read to unknown/alive on an unreadable-but-existing pid (the destructive stop path keeps failing safe); kept out of this PR because it builds directly on this read path and is a read-vs-stop bias design decision.Summary by CodeRabbit