[RFC] fix(supervisor): prevent stale monitor state overwrites - #635
[RFC] fix(supervisor): prevent stale monitor state overwrites#635brandon-julio-t wants to merge 6 commits into
Conversation
Assisted by OpenAI GPT-5.6 Sol with High reasoning effort. Reviewed by Brandon Julio T.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughDaemon lifecycle operations now serialize per daemon, classify attempts by spawn state, and prevent stale processes from overwriting replacement state. Exit monitoring records state only for matching PIDs, with coverage for concurrent restarts and retry supersession. ChangesDaemon lifecycle ownership
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Supervisor
participant Lifecycle
participant DaemonProcess
participant StateFile
Supervisor->>Lifecycle: acquire daemon lifecycle guard
Lifecycle->>DaemonProcess: stop owned attempt before replacement
Lifecycle->>DaemonProcess: spawn replacement attempt
DaemonProcess-->>Lifecycle: PID and readiness result
Lifecycle->>StateFile: record exit or stop for PID
StateFile-->>Lifecycle: record or ignore based on PID ownership
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
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 SummaryThis PR makes daemon lifecycle transitions ownership-aware and serializes retry, restart, and explicit-stop operations.
Confidence Score: 5/5The PR appears safe to merge, with the previously reported explicit-stop retry and supersession failures addressed. No blocking failure remains. Important Files Changed
Reviews (6): Last reviewed commit: "fix(supervisor): make explicit stop canc..." | Re-trigger Greptile |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/supervisor/lifecycle.rs (1)
1593-1688: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winstop_inner's terminal writes aren't PID-ownership-checked like the new
record_daemon_exit.
stop_inner's two terminal branches (Lines 1647-1656, 1668-1677) still callupsert_daemonunconditionally with thepidsnapshotted at the top of the function — there's no re-check that the daemon still belongs to that PID before writingstatus/last_exit_success.
stop_inneris now also invoked fromrun_once_serializedviastop_before_replacement(the new retry-replacement path), which is exactly the "process may still be exiting on its own" scenario this PR targets. Since the daemon_lifecycle_guard only excludes other guarded callers, the daemon's own (unguarded) exit monitor can race on the same PID and call the ownership-checkedrecord_daemon_exitconcurrently. Whichever write lands last wins unconditionally — so a monitor-recordedErrored/last_exit_success=false(from e.g. a forced SIGKILL after a readiness timeout) can be silently overwritten by stop_inner'sStopped/last_exit_success=true, or vice versa. No process is leaked (both writers reference the same dead PID), but the persistedstatus/last_exit_successcan end up wrong and is visible viapitchfork status/list.Consider re-verifying
daemon.pid == Some(pid)at the point of these two writes (mirroringrecord_daemon_exit's pattern) so a losing writer no-ops instead of clobbering.♻️ Sketch of the fix direction
- self.upsert_daemon( - UpsertDaemonOpts::builder(id.clone()) - .set(|o| { - o.pid = None; - o.status = DaemonStatus::Stopped; - o.last_exit_success = Some(true); - }) - .build(), - ) - .await?; + // Only commit this write if the daemon still belongs to `pid`; + // a concurrently-racing exit monitor for the same PID may have + // already recorded a more accurate terminal status. + let mut state_file = self.state_file.lock().await; + if state_file.daemons.get(id).and_then(|d| d.pid) == Some(pid) { + state_file.daemons.get_mut(id).map(|d| { + d.pid = None; + d.status = DaemonStatus::Stopped; + d.last_exit_success = Some(true); + }); + state_file.mark_dirty(); + }🤖 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/supervisor/lifecycle.rs` around lines 1593 - 1688, Update both terminal upsert paths in stop_inner—after the kill attempt and in the already-not-running branch—to re-fetch the daemon and verify its PID is still Some(pid) immediately before writing terminal status. Only apply the Stopped/last_exit_success update when ownership remains; otherwise skip the write so a concurrent record_daemon_exit cannot be clobbered.
🤖 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/supervisor/lifecycle.rs`:
- Around line 1593-1688: Update both terminal upsert paths in stop_inner—after
the kill attempt and in the already-not-running branch—to re-fetch the daemon
and verify its PID is still Some(pid) immediately before writing terminal
status. Only apply the Stopped/last_exit_success update when ownership remains;
otherwise skip the write so a concurrent record_daemon_exit cannot be clobbered.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Central YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro Plus
Run ID: 1d677780-bc53-4440-b550-abcafb81d837
📒 Files selected for processing (5)
src/state_file.rssrc/supervisor/lifecycle.rssrc/supervisor/mod.rssrc/supervisor/state.rstest/supervisor.bats
Track retry ownership with a per-daemon lifecycle generation so an older readiness retry cannot resurrect a daemon after a newer owner was started and stopped. Assisted by OpenAI GPT-5.6 Sol with High reasoning effort.\nReviewed by Brandon Julio T.
Keep explicit-stop and failed-stop recovery writes from overwriting terminal state that a monitor or replacement process already owns. Assisted by OpenAI GPT-5.6 Sol with High reasoning effort.\nReviewed by Brandon Julio T.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/state_file.rs (1)
286-355: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valuePID-ownership gating in
record_daemon_terminal_state/set_daemon_status_if_ownedlooks correct.Mutation only happens after confirming
daemon.pid == Some(pid),mark_dirtyis only invoked on the success path, and the whole check-then-act runs synchronously inside the caller's held lock (persrc/supervisor/state.rs), so there's no TOCTOU window. This matches all of the new regression tests below.One minor duplication:
set_daemon_status_if_owned(Lines 293-298) andrecord_daemon_terminal_state(Lines 340-345) repeat the identical "look up daemon, verify pid ownership" guard. Consider extracting a smallfn owned_daemon_mut(&mut self, id: &DaemonId, pid: u32) -> Option<&mut Daemon>helper to keep the ownership check in one place as more PID-gated mutators get added.♻️ Optional refactor
+ fn owned_daemon_mut(&mut self, id: &DaemonId, pid: u32) -> Option<&mut Daemon> { + let daemon = self.daemons.get_mut(id)?; + if daemon.pid != Some(pid) { + return None; + } + Some(daemon) + } + pub fn set_daemon_status_if_owned( &mut self, id: &DaemonId, pid: u32, status: DaemonStatus, ) -> bool { - let Some(daemon) = self.daemons.get_mut(id) else { - return false; - }; - if daemon.pid != Some(pid) { - return false; - } + let Some(daemon) = self.owned_daemon_mut(id, pid) else { + return false; + }; daemon.status = status; self.mark_dirty(); true }🤖 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/state_file.rs` around lines 286 - 355, Optionally extract the repeated daemon lookup and PID ownership check from set_daemon_status_if_owned and record_daemon_terminal_state into an owned_daemon_mut helper returning Option<&mut Daemon>. Replace both guard blocks with this helper while preserving their existing success, failure, and mark_dirty behavior.
🤖 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/state_file.rs`:
- Around line 286-355: Optionally extract the repeated daemon lookup and PID
ownership check from set_daemon_status_if_owned and record_daemon_terminal_state
into an owned_daemon_mut helper returning Option<&mut Daemon>. Replace both
guard blocks with this helper while preserving their existing success, failure,
and mark_dirty behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Central YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro Plus
Run ID: d6a1d417-7519-49ad-845b-b88f26dda062
📒 Files selected for processing (3)
src/state_file.rssrc/supervisor/lifecycle.rssrc/supervisor/state.rs
🚧 Files skipped from review as they are similar to previous changes (2)
- src/supervisor/state.rs
- src/supervisor/lifecycle.rs
Prevent the background retry watcher from racing a foreground readiness retry while allowing superseded generations to stop blocking newer owners. Assisted by OpenAI GPT-5.6 Sol with High reasoning effort.\nReviewed by Brandon Julio T.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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.
Inline comments:
In `@src/supervisor/retry.rs`:
- Around line 48-53: Update the background retry flow around
has_readiness_retry_for_current_generation and self.run(...) so the
readiness-retry ownership check and background start form one atomic lifecycle
claim under the daemon’s per-daemon lifecycle lock. Hold or otherwise serialize
that lock through the decision and start initiation, preventing a foreground
request from registering readiness retries between the check and background
retry execution.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Central YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro Plus
Run ID: ce29c2e8-3aea-4916-a0a9-eaf017e8d8dc
📒 Files selected for processing (3)
src/supervisor/lifecycle.rssrc/supervisor/mod.rssrc/supervisor/retry.rs
🚧 Files skipped from review as they are similar to previous changes (1)
- src/supervisor/lifecycle.rs
Assisted by OpenAI GPT-5.6 Sol with High reasoning effort.\nReviewed by Brandon Julio T.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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.
Inline comments:
In `@test/hooks.bats`:
- Around line 302-309: Update the marker-waiting flow in the hook test to add a
settle delay after the marker first appears and before counting its lines. Keep
the existing existence assertion, then wait long enough for the retried attempt
and any delayed on_retry firing to complete before asserting the marker contains
exactly one line.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Central YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro Plus
Run ID: cf019fa9-0636-4826-a3e8-bfe6fae7a30b
📒 Files selected for processing (6)
src/daemon.rssrc/state_file.rssrc/supervisor/lifecycle.rssrc/supervisor/mod.rssrc/supervisor/retry.rstest/hooks.bats
🚧 Files skipped from review as they are similar to previous changes (3)
- src/supervisor/mod.rs
- src/state_file.rs
- src/supervisor/lifecycle.rs
Assisted by OpenAI GPT-5.6 Sol with High reasoning effort. Reviewed by Brandon Julio T.
|
This PR currently has merge conflicts. If this continues for 7 days, it will be closed automatically. This is warning day 1 of 7. Please update the PR when you have a chance. Feel free to reopen or create a new PR if it is closed and you'd like to continue working on it. This comment was generated by an automated workflow. |
|
This PR currently has merge conflicts. If this continues for 7 days, it will be closed automatically. This is warning day 3 of 7. Please update the PR when you have a chance. Feel free to reopen or create a new PR if it is closed and you'd like to continue working on it. This comment was generated by an automated workflow. |
|
Invalidated and superseded by #634 (comment) 🤞 |
RFC status
This is a Draft PR intended to make the failure mode and one possible fix concrete for maintainer feedback. I am happy to change the design or combine the relevant pieces with ongoing supervisor work.
Related Discussion: #634
Problem
A forced restart can register a replacement daemon as running, then a late exit-state write from the old process monitor can overwrite it with
pid = Noneandstopped.The replacement remains alive but untracked. When it later exits, retry logic does not restart it because the supervisor already considers the daemon stopped.
The core invariant proposed here is:
This was observed in production on v2.17.0 during a cron
retrigger = "always"restart. Currentmainnarrows one timing window with a pre-drain stopping snapshot, but the final ownership check and state write are still separate operations.Root cause
There are two related races:
Readiness failure adds another ownership case: failure can be reported before graceful termination completes. A retry must replace its own still-live failed PID, but must not replace a different PID installed by a newer request.
Proposed implementation
StateFile::record_daemon_exittransition that updates status only when the stored PID matches the exiting PID.{ PID, lifecycle generation }token through readiness retry decisions.on_retry, and registers the replacement PID under one lifecycle guard.Reproduction and regression coverage
Exact stale monitor callback
Companion tests cover a matching PID transitioning from stopping to stopped, an already-cleared PID ignoring a stale callback, and dirty-state behavior.
Overlapping forced restarts
The end-to-end test:
stopping.Still-terminating retry owner
Another end-to-end test makes readiness fail after 100 ms while the daemon ignores TERM for two seconds. The one-second retry backoff expires while the original PID is still alive; the test asserts that retry attempt 1 is nevertheless started.
Superseded retry after explicit stop
A third end-to-end test lets an older readiness retry enter backoff, starts and explicitly stops a newer owner, then asserts the old request cannot resurrect the daemon after the current PID has been cleared.
Explicit stop during retry backoff
The cancellation test waits until readiness attempt 3 enters its eight-second backoff, issues an explicit stop while there is no current PID, then asserts the daemon remains stopped and attempt 4 never starts. Companion coverage asserts an inactive requested daemon does not cause its running dependency to be stopped.
Foreground/background retry ownership
State-level coverage keeps a foreground readiness-retry marker alive and asserts the periodic watcher neither starts a process nor mutates retry state. Companion tests cover safe missing-command exhaustion and confirm
on_retryis scheduled exactly once for a successful background retry.Validation
cargo nextest run --all-features: 525 passedcargo clippy --all-targets --all-features --no-deps -- -D warnings -A clippy::collapsible_if: passedconcurrent restarts leave one tracked process: passedretry replaces its still-terminating failed attempt: passedstopping a superseding owner cancels an older retry: passedexplicit stop during retry backoff prevents resurrection: passedstopping an inactive daemon does not stop its running dependency: passedretry with ready_output re-checks on each attempt: passedon_retry hook fires for a background retry: passedA broader local Bats run encountered existing macOS
/varversus/private/varpath-equivalence assertion failures before reaching the full suite; the new focused tests pass independently.Related work
Authorship note: the investigation, reproduction, and implementation were assisted by OpenAI GPT-5.6 Sol with High reasoning effort. The changes and this RFC were reviewed by @brandon-julio-t.
Summary by CodeRabbit
on_retryhook fires exactly once.