Repository navigation
Refuse to stop a daemon whose PID file names this process (AI-1645) - #434
Conversation
Fixes AI-1645. The filed diagnosis was wrong in a way worth correcting: it attributed the CI failure to a stale PID file or a recycled PID, and proposed validating the PID before killing. That validation already exists (IsOurDaemon, since AI-839) and could not have helped — the PID was neither stale nor recycled. It was the test runner's own live PID, with a genuinely matching start token, so every identity check correctly passed. Mechanism, verified rather than inferred: DaemonLockPaths selects its directory through a process-global static. DaemonLockTests redirects it and acquires a real DaemonLock, which writes the acquiring process's PID. UninstallCommandTests sits in a DIFFERENT NotInParallel group, so it can run concurrently, and uninstall runs "daemon stop --yes", which enumerates that same redirected directory. It then finds a live PID whose token matches and kills its tree — the tree containing itself. Hence a random uninstall test failing with an identical stack every time. Two changes: 1. Test isolation (the actual flake fix): UninstallCommandTests joins the DaemonLockPaths.OverrideDirectoryForTesting constraint group. A reader of a globally-selected directory has to exclude everyone who can reselect it. Its existing per-test KCAP_CONFIG_DIR isolation could never have covered this, because the daemons directory deliberately ignores KCAP_CONFIG_DIR. 2. Production guard: refuse a PID equal to this process, and catch the InvalidOperationException for the ancestor case, which cannot be detected portably in advance. A daemon is never the process running a stop command, so reaching there means the PID file describes something it should not. Both paths now name the daemon and its PID file instead of surfacing an unattributed exception. Mutation evidence, stated precisely: removing BOTH guards fails the new test with the exact CI error. Removing either alone does not — they are two layers over one hazard — so the test pins the property, not the individual guards. Recorded in the test so nobody mistakes it for per-guard coverage. The first version of that test acquired a real DaemonLock and was vacuous: a held lock leaves the PID file unreadable, so stop returned 1 down its "unreadable" branch without ever reaching the kill. It passed with the guard removed, which is how that was caught. Local macOS baseline: UninstallCommandTests is 11/16 failing on origin/main too (pre-existing, unrelated) — confirmed in a detached baseline worktree. DaemonStopSelfPidTests 1/1, DaemonLockTests 17/17. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Summary by QodoPrevent
AI Description
Diagram
High-Level Assessment
Files changed (3)
|
Code Review by Qodo
Context used 1.
|
Codex round 1, P2. The new catch treated EVERY InvalidOperationException from Process.Kill as evidence of a malformed PID file. .NET also throws it when the target exits between GetProcessById and Kill — a perfectly ordinary daemon stop, which would then have been reported as corruption and returned 1 while preserving a marker that should have been cleaned up. Split on process.HasExited: an already-exited target reports "was not running", matching the existing ArgumentException path, and falls through to marker cleanup. Only the remaining case — .NET refusing to kill a tree containing the caller — gets the diagnostic and the non-zero exit. The success path is now gated on whether the kill actually happened, so the exit race no longer prints "stopped (PID ...)" for a process it did not stop. No test for the benign arm: it needs the process to exit inside that specific window, which cannot be scheduled deterministically. Noted in the code, and it is distinguished by HasExited rather than by message text. Also dropped Environment.HasShutdownStarted from the test — codex correctly called it vacuous: if the process had been killed the assertion would never run, and on every surviving path it is false anyway. The exit code and the absence of an unhandled exception already express the property. DaemonStopSelfPidTests 1/1, DaemonLockTests 17/17. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kcap-cli CI enforces this and the check caught it. The narrative context stays; only the identifiers go — the PR and commit messages carry the issue reference, which is where the repo wants it.
Qodo: disproportionate. The neighbouring constraint keys get two lines each and this one had fourteen. Cut roughly in half, keeping the mechanism a reader needs at that call site (global static -> concurrent override -> a real lock writing the runner's own PID -> self-kill, and why KCAP_CONFIG_DIR cannot help) and delegating the full account to DaemonStopSelfPidTests, which is where it belongs. A comment nobody finishes reading protects nothing.
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 74b5a7c |
Fixes AI-1645.
The filed diagnosis was wrong, and correcting it changes the fix
AI-1645 attributed the random
UninstallCommandTestsfailures to a stale PID file or a recycled PID, and proposed validating the PID before killing.That validation already exists —
IsOurDaemon, since AI-839 (#147) — and it could not have helped here. The PID was neither stale nor recycled. It was the test runner's own live PID, carrying a start token that genuinely matched, so every identity check correctly passed. No amount of extra validation catches a PID that is legitimately valid.What actually happens
Verified by reading the code, not inferred from the stack:
DaemonLockPathsselects its directory through a process-global static (OverrideDirectoryForTesting).DaemonLockTestsredirects it and acquires a realDaemonLock, which writes the acquiring process's own PID — its own assertion at line 185 proves this.UninstallCommandTestsis in a differentNotInParallelgroup, so it can run concurrently — and it sees the same global override.daemon stop --yes, which enumerates that redirected directory, finds a live PID with a matching token, and callsProcess.Kill(entireProcessTree: true)— on the tree containing itself.Hence: a random uninstall test, an identical stack every time, not reproducible on demand.
This also explains why the existing per-test
KCAP_CONFIG_DIRisolation never helped — the daemons directory deliberately ignoresKCAP_CONFIG_DIR, which its own doc comment says explicitly.Two changes
1. Test isolation — the actual flake fix.
UninstallCommandTestsjoins theDaemonLockPaths.OverrideDirectoryForTestingconstraint group. The rule worth remembering: a reader of a globally-selected directory has to exclude everyone who can reselect it. I swept all 12 mutator classes and every reader; uninstall was the only reader missing the key (DaemonRestartCommandTestsonly calls a pure parser).2. Production guard. Refuse a PID equal to this process, and catch the
InvalidOperationExceptionfor the ancestor case, which cannot be detected portably in advance. A daemon is never the process running a stop command, so reaching there means the PID file describes something it should not. Both paths now name the daemon and its PID file rather than surfacing an unattributed exception.The production hazard is narrower than AI-1645 claimed — in real use the CLI and daemon are separate processes, so self-reference should not arise. I kept the guard anyway because it converts an opaque crash into a diagnosable message, which is what issue suggestion (2) asked for.
Mutation evidence, stated precisely
Removing both guards fails the new test with the exact CI error:
Removing either one alone does not — the other still returns a clean exit code. That is deliberate defence-in-depth, but it means the test pins the property (a self-referencing PID file never escapes as an unhandled exception), not each guard individually. That limitation is written into the test, so nobody mistakes it for per-guard coverage.
The first version of this test was vacuous, and mutation is the only reason I know that. It acquired a real
DaemonLock— which leaves the PID file unreadable, sostopreturned 1 down its "PID file unreadable" branch without ever reaching the kill. It passed with the guard removed. Rewritten to write the PID file directly with a realProcessStartToken.ForPidtoken, so it reaches the code under test.Verification
DaemonStopSelfPidTests1/1 ·DaemonLockTests17/17.UninstallCommandTestsis 11/16 failing locally on macOS — and identically onorigin/main, confirmed in a detached baseline worktree. Pre-existing and unrelated; CI (ubuntu/windows) is the gate for that class.🤖 Generated with Claude Code