test(guard): stabilize spawn-race fixtures - #178
eggrollofchaos wants to merge 38 commits into
Conversation
Model the mocked daemon as live and bypass process-table probing in claim-focused contention tests. Keep freshness handling covered by a deterministic dead-PID test, so scheduler delays cannot turn fake child PIDs into apparent second spawns.
eggrollofchaos
left a comment
There was a problem hiding this comment.
Findings
High
tests/test_spawn_lock.py:455-463— The new "fresh dead PID" regression test is not deterministic under the supportedCOZEMPIC_PIDFILE_FRESH_SECONDSconfiguration. The implementation accepts any finite value in(0, 300], but the test advances its mocked clock by a fixed 1.0 second. With the valid settingCOZEMPIC_PIDFILE_FRESH_SECONDS=0.1, the file is correctly classified as stale, the claim is reclaimed, and this new test fails. Reproducer:COZEMPIC_PIDFILE_FRESH_SECONDS=0.1 env -u COZEMPIC_NO_AUTO_INIT uv run --frozen --with pytest pytest tests/test_spawn_lock.py::TestFreshClaimProtection::test_fresh_dead_pid_is_not_reclaimed -q. Fix by patching_FRESH_PIDFILE_SECONDSto a known value for this unit or deriving a mocked age strictly inside the active imported window.
Low
tests/test_spawn_lock.py:457-463— The new exception assertion leaves two calls in the expected-exception region and triggers the sole Sonar annotation on this PR. Construct the claim outside the assertion, then put onlyclaim.__enter__()(or an equivalent single helper call) insideassertRaises;assertRaisesalready fails if no exception is raised, so the explicitself.fail(...)is unnecessary.
Verification
- Packet command: 4 passed in 20.81s.
- Valid short-window probe above: 1 failed, reproducing the High finding.
- Valid long-window probe (
COZEMPIC_PIDFILE_FRESH_SECONDS=300): 1 passed. git diff --check fd41e95b85f283286eaca9419c19825abb6d053b..605aa1341c06859f186d198dd89c061de04f26d5: passed.- PR context checked at head
605aa1341c06859f186d198dd89c061de04f26d5: 1 top-level bot comment, 0 inline comments, 0 review threads (0 active), 0 prior reviews. The Sonar quality gate passed with the one Low annotation described above.
Counts: 0 Critical, 1 High, 0 Medium, 1 Low, 0 Nit.
Verdict: Critical/High findings remain.
Derive the mocked clock from the configured PID-file freshness window and keep only the claim operation in the expected-exception assertion.
eggrollofchaos
left a comment
There was a problem hiding this comment.
Self-review verdict: LGTM — approve.
The v2 continuation closes both prior findings:
- The fresh-dead-PID test now derives its mocked age from the active
_FRESH_PIDFILE_SECONDSvalue, so it remains strictly inside every supported positive window. Independent probes passed at0.1,1e-9, and300seconds. - The expected-exception block now contains only
claim.__enter__(), removing the multi-call assertion ambiguity and the prior static-analysis concern.
I also re-read both changed test files in full and traced the fixtures through start_guard_daemon, DaemonSpawnClaim, _is_process_alive, and the shared freshness gate. Treating the synthetic positive PIDs as alive correctly models the peer hand-off and makes the two-, three-, and ten-process contention fixtures independent of host PID state without weakening the production assertion.
Findings: none.
Verification:
- Exact-head targeted suite: 4 passed in 16.84s.
- Fresh-window probes:
0.1,1e-9, and300each passed. - Exact head:
b6fea7251c191f2c422a794d639f79cbec12a3ceover basefd41e95b85f283286eaca9419c19825abb6d053b. - PR context checked immediately before verdict: SonarCloud passed with 0 new issues; 1 top-level bot comment; 0 inline comments; 0 review threads; no unresolved current-head feedback; merge state CLEAN.
Counts: 0 Critical, 0 High, 0 Medium, 0 Low, 0 Nit.
Merge blocker: none from this review. This is a neutral confirmation only; no approval or merge action was performed.
Serialize stale PID-file replacement with a persistent companion lock, reserve the publication path before spawning, and terminate a child if PID publication fails. Add real stale-contention and pre-spawn temp-conflict coverage.
Harden reclaim-lock creation, recover vanished stale pidfiles, clean stale publication temps, and report a guard PID that could not be stopped after a publication failure.
Make interrupted claim/publication cleanup BaseException-safe, include inactive reclaim locks in doctor cleanup, and expose a surviving child PID structurally.
Recover old regular PID publication reservations before spawning while preserving fresh-peer and symlink protections.
Reuse the shared publication-reservation setup so the stale-reclaim regression stays covered without duplicated test code.
Reduce stale-artifact scan complexity, tighten shutdown cleanup, and avoid duplicated contention-fixture changes.
Keep persistent hook and stale-reclaim locks out of doctor cleanup and align the Windows artifact root.
Record the fixed-name and symlink-safe cleanup invariant for the Windows temp-root selection.
Safely clear abandoned publication symlinks while retaining doctor cleanup for ordinary hook-lock artifacts.
Avoid following lock symlinks, preserve orphan-child warnings across reload retries, and prevent stale publication temps from flaking concurrent guard startup.\n\nTrigger: Tier-1 review findings on the public contribution.
Clear armed reload state on every normal daemon exit and persist surviving child PIDs for doctor and command-line visibility.\n\nTrigger: Tier-1 review findings on the public contribution.
Reject symlink and non-regular PID artifacts at the shared parser, retain fresh empty claims during handoff, and preserve each orphaned PID marker. The repair closes the contention regression uncovered by the stress suite.
Make the guard-running probe read-only so it cannot unlink a concurrent exclusive claim; DaemonSpawnClaim remains the sole serialized stale-file reclaimer. Also use the existing atomic writer for other reviewer-identified predictable-path writes and preserve fresh/unreadable PID claims during cleanup.
Make guard log opening symlink-safe, preserve PID ownership checks when liveness probing lacks permission, and keep doctor aligned with randomized publication names while surfacing recorded orphan PIDs.
Use the platform-aware runtime directory for daemon status discovery and clarify that deterministic publication-temp cleanup covers only legacy artifacts.
Use the resolved session ID for watchdog identity tracking when daemon startup auto-detects its session, and report log-open failures against the correct artifact.
Use the canonical no-follow PID parser before legacy cleanup or watchdog remediation can signal a process, retain retry diagnostics, and update reservation fault tests for unique tempfiles.
Forward the resolved session path when recording Claude identity so the PID-recycling gate uses the same corroboration available to runtime checks. Label subprocess failures accurately and cover both contracts with focused regressions.
Read reload lock metadata only from regular non-symlink files and treat malformed bytes as unknown metadata, preventing a poisoned lock from disabling hard-tier reloads.
eggrollofchaos
left a comment
There was a problem hiding this comment.
Findings
High
-
src/cozempic/guard.py:2663—_open_guard_log()opens the predictable/tmp/cozempic_guard_<slug>.logpath with blockingO_WRONLYand never verifies that the opened object is a regular file. A pre-created FIFO with no reader blocksstart_guard_daemon()before it can return a structured failure or start the guard. Reproducer: create a FIFO at the log path, then call_open_guard_log; it blocks. Open withO_NONBLOCKand reject non-regular descriptors withfstatbefore returning the handle. -
src/cozempic/guard.py:3177andsrc/cozempic/guard.py:3254—read_armed()and_reload_rate_exceeded()still usePath.read_text()on predictable reload-state paths. A FIFO blocks the guard loop, while a symlink lets unrelated JSON drive the armed-warning and reload-rate decisions. The current hardening only protects the reload-lock metadata. Use the same no-follow, non-blocking, regular-file reader for both state files, treating every read/parse failure as fail-closed. -
tests/test_guard_polish_pr93.py:685— the target head makes the existingTestPolishPR93_PidfileEACCES.test_is_pidfile_fresh_returns_true_on_permission_errorfail.DaemonSpawnClaim._is_pidfile_fresh()now callspidfile_is_fresh(), which callsos.stat, so the test'sPath.statPermissionErrorprobe no longer reaches the implementation. Update the test to patch the active probe (or restore the tested seam) so the full suite remains green.
Verification
-
Packet-targeted suite: 244 passed, 21 subtests passed.
-
Additional changed-file suite: 84 passed, 5 skipped, 10 subtests passed, 1 failed (the High finding above).
-
FIFO probes: guard-log open, armed sentinel read, and reload-ledger read each blocked as described.
-
git diff --check fd41e95b85f283286eaca9419c19825abb6d053b..ea60b0b4de89b0fc91f5e156e1c8d8d66edc3359: passed. -
PR context: head
ea60b0b4de89b0fc91f5e156e1c8d8d66edc3359; 1 top-level bot comment, 0 current-head inline comments, 0 active threads; checks passed. -
Review tier: tier-3
-
Required review tiers: tier-1,tier-2,tier-3
-
Approval authority: requester
-
Cleared base: fd41e95
-
Cleared target: ea60b0b
-
Counts: Critical 0 | High 3 | Medium 0 | Low 0 | Nit 0
-
Verdict: Critical/High findings remain.
-
Final-head clearance: blocked: tier-3 has 3 High findings
-
Approval workflow: not-authority
|
Correction: review #4784824467 was submitted before a canonical packet claim. It is non-clearing and must not be used as review evidence. |
eggrollofchaos
left a comment
There was a problem hiding this comment.
Reviewed at ea60b0b. Summary: 0 Critical, 0 High, 1 Medium, 1 Low.
Verified
Centralizing PID parsing on spawn_lock._parse_pidfile_pid is the right shape — watchdog._read_pid and the guard.py shutdown paths all delegate to it, so the hardening applies at one chokepoint rather than per call site. The ordering is TOCTOU-correct: O_NOFOLLOW on open, then S_ISREG against the already-held fd via fstat, rather than a pre-open lstat.
I exercised the primitive directly against each attack shape:
| input | result |
|---|---|
| regular pidfile | parsed |
| symlink → valid pidfile | 0 (refused) |
| FIFO | 0 (refused, no hang) |
| non-UTF-8 bytes | 0 (fail closed) |
| directory / missing | 0 |
The symlink→valid case is the important one: it would otherwise redirect a SIGTERM at an arbitrary PID, and it is refused. Malformed input failing closed to 0 is the correct direction, since callers read 0 as "no live pid" and never signal. Termination is additionally gated behind _is_cozempic_guard_process, so a recycled PID is not signalled.
Reproduced the stated suite: 244 passed, 21 subtests.
Medium — _open_guard_log can hang on a pre-planted FIFO
guard.py:_open_guard_log opens with O_CREAT|O_WRONLY|O_APPEND|O_NOFOLLOW. O_NOFOLLOW rejects a symlink but not a FIFO, and without O_NONBLOCK a write-only open on a FIFO blocks until a reader appears. Since the log path is deterministic, a FIFO planted there stalls the guard at startup. Confirmed by probe, with the hardened read shape as a control:
_open_guard_log shape (FIFO planted): *** BLOCKED (hang) ***
hardened read shape (FIFO, +O_NONBLOCK): opened (no block)
The two O_RDWR lock opens (_stale_reclaim_lock, doctor._is_lock_held) do not block, and the O_CREAT|O_EXCL creators are fine by construction — so this is the single remaining instance, not a class. Suggested fix: add O_NONBLOCK and an S_ISREG check on the opened fd, matching what _parse_pidfile_pid, _read_regular_metadata, and _read_regular_log_tail already do. This also closes the last gap against the stated "reject symlinks and FIFOs" goal.
Low — the O_NOFOLLOW platform caveat is noted at one of nine sites
Nine call sites guard with hasattr(os, "O_NOFOLLOW"); on a platform lacking it the flag is silently dropped and the open follows the symlink, after which S_ISREG passes if the target is a regular file. Only reload_lock.py:272 records this ("not on Windows"); the other eight read as unconditionally symlink-safe. Behavior is consistent, documentation is not. Extracting one shared open-regular-no-follow helper would state the caveat once, prevent the flag sets from drifting apart, and give a single place to add a fallback later.
Not approving — leaving that to the PR owner.
Treat malformed armed sentinels as absent and make doctor lock probes nonblocking.
Distinguish an absent estimate from a valid zero projected reduction.
Reject malformed armed sentinel fields and keep guard startup errors structured.
eggrollofchaos
left a comment
There was a problem hiding this comment.
Tier 3 Review — PR #178 (guard race-test stability)
Review tier: tier-3
Required review tiers: tier-1,tier-2,tier-3
Approval authority: requester
Cleared base: not-applicable
Cleared target: not-applicable
Counts: Critical 0, High 1, Medium 0, Low 0, Nit 0
Verdict: CHANGES_REQUESTED
Approval workflow: not-authority
Findings
High — concurrent test invocations share one global PID path and fail each other
tests/test_spawn_lock.py:108 fixes SESSION_ID to fade1234-5678-9abc-def0-2026051811cc, which maps every invocation to /tmp/cozempic_guard_fade1234-567.pid. Two normal concurrent invocations of this test therefore contend on the same production-style claim. Each correctly sees the other invocation's fresh fake PID and returns already_running, but each test still requires one local started=True result. A concurrent run failed both copies (11/20 and 12/20 iterations respectively), so parallel CI or another local test run makes this claimed stability gate flaky.
Give each top-level test invocation a unique session ID (or isolate its PID path under a per-test temporary directory), while retaining the shared ID only within that invocation's three worker processes. Clean the matching persistent reclaim-lock path as part of that fixture cleanup.
Verification
git diff --check fd41e95b85f283286eaca9419c19825abb6d053b 8e94e4133da82740cc248d1f3e39fdc55980c15a— passed.- A single invocation of
TestThreeProcessContentionpassed on three retries. - Two simultaneous normal invocations of that exact test each failed because of the fixed shared
/tmpPID path.
eggrollofchaos
left a comment
There was a problem hiding this comment.
Findings
High
tests/test_spawn_lock.py:122-170— the claimed race-stability regression test is still nondeterministically red at this head. Two invocations ofTestThreeProcessContention::test_three_process_contentionfailed: one recorded 3 bad iterations out of 20 and the next 10 out of 20, each with all three workers returningalready_running=Trueagainst a freshly reset PID path. This removes the test's exactly-one-winner guarantee and makes the safety gate flaky. Isolate the fixture's per-iteration runtime artifacts and/or correct the claim handoff so every iteration deterministically yields onestarted=Trueresult.
Verification
- Focused 367-test command stopped at
TestThreeProcessContention::test_three_process_contention(3/20 bad iterations). - Exact re-run of that test failed (10/20 bad iterations).
TestV4TenProcessContention::test_ten_process_contention_30xpassed.git diff --checkpassed.
Review tier: tier-3
Required review tiers: tier-1,tier-2,tier-3
Approval authority: requester
Cleared base: not-applicable
Cleared target: not-applicable
Counts: Critical 0 | High 1 | Medium 0 | Low 0 | Nit 0
Verdict: Critical/High findings remain.
Approval workflow: not-authority
Give each contention-test invocation a distinct session ID while retaining one shared ID among its worker processes. Clean PID, log, temporary, and reclaim-lock artifacts for that invocation.
Use full UUID-derived session IDs for stateful spawn-lock fixtures and remove the production-format reclaim lock after each test.
Use invocation-unique session IDs and clean all corresponding lock artifacts so concurrent test runs cannot inherit stale guard state.
Escalate contention-test cleanup after a terminate timeout so a lingering worker cannot carry state into the next iteration.
Remove randomized PID reservations, verify child cleanup, and align spawn-lock test cleanup with its actual lock path.
Reap every child before reporting survivors so a lingering process cannot mask sibling cleanup or accumulated race failures. Focused pytest output was truncated before its summary; targeted Ruff and diff checks passed.
Collect queue failures without bypassing cleanup and continue reaping sibling processes when an operation fails.
eggrollofchaos
left a comment
There was a problem hiding this comment.
Findings
Critical
-
src/cozempic/guard.py:3790-3826,3853-3857; 4252-4277— When PID handoff fails afterPopenand the attempted child cleanup cannot stop the child, the code recordsorphaned_guard_pidbut leavesclaim.handed_offfalse. Thefinallyblock then removes the PID claim, andreload_self_daemon()retries with no live claim visible. That retry can start a second guard for the same session while the first remains alive, allowing concurrent prune/reload activity against one transcript.Reproduced by simulating a PID-handoff
os.replaceerror and a child that survives terminate/kill waits: the first call returnedorphaned_pid=712345; the retry started PID712346for the same session. When cleanup cannot stop the child, atomically publish its real PID, retain the claim, and treat it as already running instead of retrying. Add a regression test for this path.
Verification
- Focused guard/lock suite: 146 passed, 21 subtests passed.
- Two simultaneous normal invocations of
TestThreeProcessContention::test_three_process_contention: both passed. git diff --check fd41e95b85f283286eaca9419c19825abb6d053b...954d6ea6470ade52278fc70b1d47f17b81f374f3: passed.
Counts: 1 Critical, 0 High, 0 Medium, 0 Low, 0 Nit.
Verdict: Critical/High findings remain.
eggrollofchaos
left a comment
There was a problem hiding this comment.
Reviewed at 954d6ea. 0 Critical, 0 High, 2 Medium, 0 Nit-blocking — LGTM. Reviewed the full range since the last clean pass (ea60b0b4..954d6ea6, 11 commits), since the intermediate head was never separately reviewed.
The previously reported blocking-open issue is fixed. _open_guard_log and doctor._is_lock_held both take O_NONBLOCK plus an S_ISREG check now, which closes the FIFO hang and keeps the non-regular case fail-safe (_is_lock_held returns "held", so the file is left alone). O_NONBLOCK on the surviving regular-file path is a no-op for read/write, so the buffered writer is unaffected.
Test isolation verified empirically, not just read. Ran the contention suite from a clean tree: 159 passed, 31 subtests; pgrep -f cozempic unchanged at 189 before and after; zero new /tmp/cozempic_* artifacts in the run window; git status clean afterwards. The reaping refactor is also correctly ordered — assertFalse(errors) precedes the sum(result["claimed"] ...), so a queue timeout fails with diagnostics rather than a KeyError, and the try/finally guarantees reaping even when a worker never reports.
Medium — the read_armed validation admits bools and skips two fields
isinstance(True, int) is True in Python, so a JSON bool satisfies the new numeric check. Writing a sentinel with {"tier": true, "armed_at": true, "projected_pct": "sixty", "warned": "nope"} and reading it back:
armed_at survives validation: True (value True)
time.time() - armed_at = 1785152217.4
_grace_ok (>= grace) : True <- grace clock defeated
tier kept : True (bool)
projected_pct : 'sixty' <- unvalidated
warned truthy : True <- unvalidated
Two independent paths reach the same outcome. armed_at: true passes validation, and time.time() - True yields ~1.8e9 seconds, so the grace gate is always satisfied. Separately, warned is never type-checked, and the consumer does bool(_armed.get("warned")), so any truthy value short-circuits the same gate. Either one lets a reload proceed without the warning the grace clock exists to deliver.
projected_pct is also unvalidated. cli.py defends itself with an isinstance check at the display site, but write_armed carries the bad value forward via existing.get("projected_pct", 0.0), so a corrupt value persists across re-arms instead of being neutralized.
Corrupt sentinels are the threat model this function is being hardened for — the atomic writer means cozempic itself will not produce these, so reaching it takes external tampering or a foreign writer. That is what keeps this Medium rather than higher. Suggested fix: reject bools explicitly (isinstance(x, (int, float)) and not isinstance(x, bool)), extend the same treatment to warned (bool) and projected_pct (numeric), and reset rather than carry forward in write_armed.
Note the existing test pins the current behavior including this hole: test_read_armed_drops_nonnumeric_grace_fields passes with armed_at: true because a bool is numeric by isinstance.
Medium — two of the three new guards are not pinned by any test
Mutation-checked each hardening change against the full suite rather than a filtered subset:
| Guard removed | Result |
|---|---|
_open_guard_log S_ISREG |
1944 passed — uncovered |
doctor._is_lock_held S_ISREG |
1944 passed — uncovered |
read_armed field sanitization |
1 failed (test_read_armed_drops_nonnumeric_grace_fields) — covered |
The _open_guard_log guard is the fix for the blocking-open issue reported last round, and nothing in 1944 tests notices its removal — so a future refactor can silently reintroduce the hang with a green suite. A test that points the guard log at a FIFO and asserts the open raises rather than blocks would pin both it and the doctor equivalent.
Nits
_reap_processesis now duplicated verbatim intests/test_spawn_lock.pyandtests/test_guard_race_2026_05_18.py. A shared helper keeps the two from drifting, which matters because the reaping policy is the thing under test.TestStaleClaimContentiongot atry/finallyaround its result-collection loop so reaping always runs;TestV4TenProcessContentionhas the identical shape but notry/finally. Nothing in that window raises today, so it is not a live leak — but the sibling fix was not applied symmetrically, and a future edit that raises there would leak 10 workers per iteration.
Not approving — leaving that to the PR owner.
eggrollofchaos
left a comment
There was a problem hiding this comment.
Correction to my earlier review at this head
My previous review at 954d6ea6 gave an approve verdict. It was issued without triaging an unresolved Critical that had already been reported against this same commit 16 minutes earlier. I am withdrawing that approve verdict — it should not be treated as clearing this head.
Critical (still unresolved at this head)
src/cozempic/guard.py — PID-handoff failure path leaves a live child with no claim.
When the PID-file os.replace fails after Popen and the child survives both terminate() and kill(), the handler sets orphaned_guard_pid = proc.pid, records the orphan, and re-raises. claim.handed_off = True sits after that inner except BaseException block, so it is unreachable on this path. The claim's __exit__ then unlinks the .pid file — as the code's own comment states — while the child is still running. A subsequent reload retry sees no live claim and can start a second guard for the same session, allowing concurrent prune/reload activity against one transcript.
Verified at 954d6ea6 by reading the emitted blob: the handed_off assignment is unreachable on the orphan path, and the orphan branch is entered precisely when the child could not be stopped.
In scope for this PR — the diff modifies this region (guard.py hunks at 3794, 3827, 3834, 3994, 4253, 4261, 4277).
Suggested direction is unchanged from the original report: when cleanup cannot stop the child, atomically publish its real PID and retain the claim, treating it as already-running rather than retrying. Worth a regression test that forces a handoff failure with an unkillable child and asserts no second guard starts.
Still open from my prior pass (non-blocking)
- Medium —
read_armedfield validation admits bools (isinstance(True, int)isTrue), and does not validatewarnedorprojected_pctat all. Anarmed_at: truerecord satisfies the grace gate. - Medium — two of the three new
S_ISREGguards are unpinned: removing either leaves the full suite green, so the blocking-open regression can return silently. - Nit —
_reap_processesduplicated verbatim acrosstests/test_spawn_lock.pyandtests/test_guard_race_2026_05_18.py. - Nit —
try/finallyreaping applied inTestStaleClaimContentionbut not inTestV4TenProcessContention.
Counts: 1 Critical, 0 High, 2 Medium, 0 Low, 2 Nit.
Verdict: Critical/High findings remain.
Address the current-head lifecycle review: preserve an unkillable post-spawn child for later starts, refuse replacement while the old daemon remains live, reject non-finite reload sentinels, and always reap started contention workers.
|
|
Review notes on 1. fcntl.flock(fd, fcntl.LOCK_EX) # no LOCK_NB, no timeoutThis is on the SessionStart path, so any stalled holder wedges every subsequent session start with no error and no diagnostic. The lock file is also opened 2. A pre-existing test reaches real Running the suite inside tmux puts the developer's own pane into the exit-confirmation dialog, then tries to resume a fixture UUID from a directory that doesn't exist. Note One-line fix on the test side: add The changed test files in this PR are clean on that score: with the same stub, Smaller items: reclaim-lock files accumulate per session slug in Verification: full suite at head is 7 failed / 1941 passed / 5 skipped / 281 subtests. Five of those failures reproduce identically on |



Summary
Verification
uv run --frozen --with pytest pytest tests/test_guard_race_2026_05_18.py::TestR1_DaemonProcessRace::test_two_processes_one_winner tests/test_spawn_lock.py::TestThreeProcessContention::test_three_process_contention tests/test_spawn_lock.py::TestV4TenProcessContention::test_ten_process_contention_30x tests/test_spawn_lock.py::TestFreshClaimProtection::test_fresh_dead_pid_is_not_reclaimed -qReview
Required review tiers: tier-1,tier-2,tier-3
Approval authority: requester