Skip to content

fix(supervisor): make child-monitor exit finalization atomic - #659

Merged
jdx merged 2 commits into
mainfrom
claude/atomic-child-exit
Jul 24, 2026
Merged

fix(supervisor): make child-monitor exit finalization atomic#659
jdx merged 2 commits into
mainfrom
claude/atomic-child-exit

Conversation

@jdx

@jdx jdx commented Jul 24, 2026

Copy link
Copy Markdown
Owner

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.rs wasn't in any of those diffs.

The race

The child monitoring task takes an ownership snapshot:

let current_daemon = SUPERVISOR.get_daemon(&id).await;   // lock acquired and released

…then, some lines later, writes the terminal state with a separate upsert_daemon call that acquires the lock again. Nothing holds it across both. On the multi-threaded runtime a concurrent start() / restart can install a successor in that gap, and the write then clears the successor's PID and replaces its Running status — 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 .await between 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:

  • the registry entry still carries this monitor's token (so a successor that recycled the same numeric PID is still detected), and
  • the record still names this monitor's PID.

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_stopping gates still decide whether to write, and the recorded status and last_exit_success values 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 across supervisor.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_success in a single state-lock section. If a concurrent start/restart installed 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_daemon after an unlocked get_daemon snapshot; it keeps the token from MonitoredGuard::register and 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.

jdx and others added 2 commits July 24, 2026 23:29
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>
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 5dccda4d-acf9-4ad5-a08a-1baa40e647d7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Base automatically changed from claude/clippy-div-ceil to main July 24, 2026 23:39
@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Makes monitored-exit finalization atomic.

  • Adds a shared token-and-PID-validated terminal-state update for child and adopted-daemon monitors.
  • Prevents stale monitors from overwriting a concurrently installed successor.
  • Replaces manual timeout ceiling arithmetic with div_ceil.

Confidence Score: 5/5

The 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

Filename Overview
src/supervisor/adopt.rs Introduces shared lock-held exit finalization and reuses it in the adopted-daemon poll monitor without identifying a concrete regression.
src/supervisor/lifecycle.rs Carries the monitor generation token into child-exit handling and atomically validates ownership before writing terminal state.
src/procs.rs Replaces equivalent manual ceiling division with a compiler-compatible div_ceil call.

Reviews (1): Last reviewed commit: "fix(supervisor): make child-monitor exit..." | Re-trigger Greptile

@jdx
jdx enabled auto-merge (squash) July 24, 2026 23:48
@jdx
jdx merged commit ad27ad9 into main Jul 24, 2026
13 checks passed
@jdx
jdx deleted the claude/atomic-child-exit branch July 24, 2026 23:48
@jdx jdx mentioned this pull request Jul 24, 2026
jdx added a commit that referenced this pull request Jul 25, 2026
## 🤖 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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant