fix(supervisor): make child-monitor exit finalization atomic - #659
Conversation
Rust 1.97's clippy flags the manual round-up. This was the last warning in the tree, so `cargo clippy --all-targets --all-features -- -D warnings` — the command CLAUDE.md documents — now passes cleanly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The child monitoring task snapshotted daemon ownership, then wrote the terminal state without holding the state lock across both steps. Nothing prevents a restart running on another thread from installing a successor in that gap, and the write would then clear the successor's PID and replace its Running status — orphaning a live process and undoing the restart. The adopted-daemon poll monitor already closed this window, so the adopted path was strictly safer than the child path it was modelled on. Both now share finalize_monitored_exit, which revalidates the monitor's registry token and the record's PID inside the same critical section that performs the write. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Central YAML (base), Organization UI (inherited) Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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 |
Greptile SummaryMakes monitored-exit finalization atomic.
Confidence Score: 5/5The PR appears safe to merge, with monitored-exit state updates now protected against successor races. The shared finalizer validates both monitor generation and PID while holding the state lock, preserves the prior terminal-state field behavior, and introduces no supported-toolchain incompatibility. Important Files Changed
Reviews (1): Last reviewed commit: "fix(supervisor): make child-monitor exit..." | Re-trigger Greptile |
## 🤖 New release * `pitchfork-cli`: 2.17.0 -> 2.18.0 <details><summary><i><b>Changelog</b></i></summary><p> <blockquote> ## [2.18.0](v2.17.0...v2.18.0) - 2026-07-25 ### Added - *(supervisor)* re-adopt orphaned daemons by default after a crash ([#633](#633)) - *(project)* add project enter/leave/list for IDE session management ([#619](#619)) - *(config)* add top-level env with tera template injection ([#618](#618)) - *(daemons)* add --global flag to daemons add ([#621](#621)) - full Windows support with CI ([#602](#602)) ### Fixed - *(logs)* tolerate concurrent opens of the log store ([#660](#660)) - *(supervisor)* make child-monitor exit finalization atomic ([#659](#659)) - *(supervisor)* make unobserved daemon deaths retryable after a crash ([#657](#657)) - *(deps)* update rust crate clx to v3 ([#656](#656)) - *(supervisor)* verify process identity before killing orphaned daemons ([#632](#632)) - *(web)* resolve spa assets from app root on nested routes ([#603](#603)) - *(tui)* hoist shared namespace into the daemons panel title ([#622](#622)) ### Other - *(procs)* use div_ceil for the process-exit poll count ([#658](#658)) - *(deps)* update rust crate tokio to v1.53.0 ([#653](#653)) - *(deps)* update rust crate uuid to v1.24.0 ([#651](#651)) - *(deps)* update rust crate http-body-util to v0.1.4 ([#643](#643)) - *(deps)* update rust crate rustls to v0.23.42 ([#646](#646)) - *(deps)* update rust crate syn to v2.0.119 ([#647](#647)) - *(deps)* update rust crate tokio to v1.52.4 ([#648](#648)) - *(deps)* update rust crate mdns-sd to v0.20.2 ([#644](#644)) - *(deps)* update rust crate regex to v1.13.1 ([#645](#645)) - *(deps)* update rust crate globset to v0.4.19 ([#642](#642)) - *(deps)* update rust crate clx to v2.1.1 ([#641](#641)) - *(deps)* update rust crate clap to v4.6.2 ([#640](#640)) - *(deps)* update rust crate xx to v2.6.1 ([#611](#611)) - *(deps)* update rust crate ratatui to v0.30.2 ([#609](#609)) - *(deps)* update rust crate rcgen to v0.14.8 ([#610](#610)) - raise MSRV to Rust 1.88 ([#624](#624)) </blockquote> </p></details> --- This PR was generated with [release-plz](https://github.com/release-plz/release-plz/). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Version and documentation-only release PR with no runtime code changes in the diff. > > **Overview** > **Release v2.18.0** — bumps `pitchfork-cli` from **2.17.0** to **2.18.0** across `Cargo.toml`, `Cargo.lock`, `pitchfork.usage.kdl`, and generated CLI docs (`docs/cli/commands.json`, `docs/cli/index.md`). > > **CHANGELOG** gains a dated **[2.18.0]** section (2026-07-25) documenting what ships in this tag; the **[Unreleased]** section stays empty for the next cycle. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 74b9e9c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
Context
Second in a stack; based on #658 (merge that first). Found while hardening the adopted-daemon path in #657 — the bots never flagged it because
lifecycle.rswasn't in any of those diffs.The race
The child monitoring task takes an ownership snapshot:
…then, some lines later, writes the terminal state with a separate
upsert_daemoncall that acquires the lock again. Nothing holds it across both. On the multi-threaded runtime a concurrentstart()/restartcan install a successor in that gap, and the write then clears the successor's PID and replaces itsRunningstatus — orphaning a live process and silently undoing the restart. That is the same silent-duplicate failure mode #631 was about, reached from the other direction.The window is narrow (no
.awaitbetween the two, so only a genuine cross-thread interleaving hits it), but it is real, and it is exactly the window I closed on the adopted path in #657. That left the adopted poll monitor strictly safer than the child monitor it was modelled on — an asymmetry worth removing.Change
Both monitors now share
finalize_monitored_exit, which revalidates two things inside the same state-lock critical section as the write:If either fails, the monitor stands down and logs at debug instead of writing. This also removes the poll monitor's hand-rolled copy of the same logic, so there is one implementation rather than two.
Behavior is otherwise unchanged: the existing
already_stopped/pre_drain_is_stoppinggates still decide whether to write, and the recorded status andlast_exit_successvalues are identical.Tests
Existing coverage exercises the paths this touches —
restart triggers on_stop and on_exit hooks,restart all includes ad-hoc daemons, and the adoption suite all pass (61/61 acrosssupervisor.bats+basic.bats, 519/519 unit). I did not add a test for the race itself: reproducing a cross-thread interleaving with no await point between the two operations is not something a bats test can do deterministically, and a flaky test would be worse than none.🤖 Generated with Claude Code
Note
Medium Risk
Touches core supervisor state on every monitored exit; behavior is intended to be equivalent when no race, but incorrect stand-down would leave stale state and wrong stand-down would still be a serious bug.
Overview
Daemon exit finalization is now one shared, lock-held path for both the child monitor and the adopted poll monitor via
finalize_monitored_exit.That helper re-checks the monitor token and that the state file still records the exiting PID, then clears runtime fields and writes terminal status and
last_exit_successin a single state-lock section. If a concurrentstart/restartinstalled a successor, the stale monitor skips the write and logs at debug instead of clearing a live PID or undoing a restart.The child monitor no longer uses a separate
upsert_daemonafter an unlockedget_daemonsnapshot; it keeps the token fromMonitoredGuard::registerand calls the same helper. The poll monitor drops its duplicated inline finalization in favor of that call.Reviewed by Cursor Bugbot for commit 67a216c. Bugbot is set up for automated code reviews on this repo. Configure here.