fix: deterministic IPC response attribution and whole-group stop wait - #606
Conversation
📝 WalkthroughWalkthroughIPC exchanges are serialized and recover from failed reads, batch daemon tasks use dedicated connections, and supervisor lifecycle operations coordinate whole-process-group termination with per-daemon locks. Startup orphan cleanup and autostop execution are also updated. ChangesIPC concurrency and batch execution
Process termination and lifecycle coordination
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant IpcClient
participant Supervisor
participant Procs
Client->>IpcClient: start or stop request
IpcClient->>Supervisor: serialized IPC exchange
Supervisor->>Procs: terminate process group
Procs-->>Supervisor: group termination status
Supervisor-->>IpcClient: operation response
IpcClient-->>Client: result
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 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 fixes two race conditions observed in production with pitchfork 2.16.0, plus a hardening pass for the system-wide implications of the second fix.
Confidence Score: 5/5Safe to merge — the two production race conditions are addressed by construction and the hardening changes are consistently applied across all affected code paths. The core fixes are structurally sound: dedicated IPC connections make response mis-attribution impossible regardless of timing, and killpg(pgid, 0) polling is the correct POSIX primitive for whole-group emptiness. The per-daemon stop lock is acquired before the already-running check and held through PID persistence in run_once, closing the duplicate-spawn window. All four readiness-exhaustion kill sites are updated consistently. The stop/stop_locked split avoids re-entrant lock deadlock on force-restart. The full e2e suite (279/279) passes and three focused new tests cover the precise failure modes. Files Needing Attention: No files require special attention. The previously-flagged open item about IpcClient::run() client-side timeout not budgeting the post-exhaustion kill delay remains open for tracking. Important Files Changed
Reviews (6): Last reviewed commit: "test(supervisor): use run ! for negated ..." | Re-trigger Greptile |
|
Caution Review failedAn error occurred during the review process. Please try again later. 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 |
2 similar comments
|
Caution Review failedAn error occurred during the review process. Please try again later. 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 |
|
Caution Review failedAn error occurred during the review process. Please try again later. 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 |
|
Good improvements! It'll be better if we add some e2e(bats) tests for these. |
|
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 2 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. |
|
This PR currently has merge conflicts. If this continues for 7 days, it will be closed automatically. This is warning day 5 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 6 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. |
The IPC wire protocol has no request IDs: responses are attributed purely by read order on a connection. The supervisor handles each request in its own task and replies in completion order, so when parallel start tasks shared one connection, a task could consume a response belonging to a different daemon — e.g. a daemon that the supervisor marked ready being reported as failed with another daemon's exit code, aborting its dependents. The swap could also silently misroute resolved ports into dependent templates. Give each parallel start/adhoc-start/stop task its own connection so there is exactly one in-flight request per connection, making response attribution structural rather than timing-dependent. Also hold a mutex across each send/read pair so shared clients (TUI, web) serialize exchanges instead of interleaving them, and warn about supervisor version mismatch once per process instead of once per connection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kill_process_group signalled the whole group but only waited for the leader PID to die. A thin `sh -c` leader exits within milliseconds of SIGTERM while children in the group are still shutting down gracefully (e.g. `docker compose up` waiting for its container to stop), so stop() returned early, marked the daemon Stopped, and a force-restart spawned a replacement that collided with the still-terminating old instance. Change the wait predicate from "leader terminated or zombie" to "process group empty" (killpg(pgid, 0) == ESRCH), keeping the existing stop signal -> stop_timeout -> SIGKILL escalation, and verify group emptiness after SIGKILL with a bounded wait instead of a blind 100ms sleep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hange Stops now block server-side until the daemon's entire process group exits, which can far exceed the flat IPC request timeout — a correct, in-progress stop would be reported as a timeout failure. Budget the Stop request from the daemon's own stop configuration (per-daemon stop_signal.timeout or the global supervisor.stop_timeout, plus SIGKILL-verification and request slack). When an exchange fails mid-stream (timeout or I/O error), the abandoned response can still arrive on the connection later; reading it would attribute it to the next request, permanently desyncing shared clients (TUI, web). Mark the connection desynced and transparently reconnect on the next exchange instead. Also dedupe the version-mismatch warning on the supervisor side (parallel batch clients handshake once per connection) and key both client- and supervisor-side dedup by peer version so long-lived processes warn again when a different mismatched peer appears. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The whole-group stop wait turned stops from milliseconds into seconds (bounded by the stop budget), which exposed paths that assumed fast stops: - Add per-daemon stop locks: stop() holds the daemon's lock for the whole stop, and run() acquires it before its already-running check, so a start deterministically waits out an in-flight stop instead of racing the widened Stopping window (duplicate instances, port conflicts). - Run orphan cleanup in the background (parallel per orphan, guarded by the stop locks with a PID re-check) so supervisor autostart no longer delays IPC socket creation past the CLI's connect budget; boot daemons start after cleanup completes. - Spawn autostop stops so the UpdateShellDir handler (the cd shell hook) and the interval watcher are not blocked behind group waits. - In the ready-check-exhausted path, run the group kill as a separate task (the select loop exits and the post-loop drain keeps consuming output so chatty children cannot deadlock on a full pipe) and defer the failure notification until the kill completes so retries cannot respawn into the dying group. - Treat killpg EPERM as terminated (only unsignalable members remain, e.g. root-owned helpers our signals never reached — waiting cannot progress). - Install the zombie reaper whenever the supervisor runs as PID 1, not only in container mode, so orphaned zombies cannot keep a process group alive forever. - Report members that survive SIGKILL as an error, and check group liveness (not just the leader) in stop()'s failure path, so a daemon with a survivor is reported as DaemonStopFailed instead of Stopped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stantly The immediate=true test asserted count >= 2 right after wait_for_logs found the FIRST log line (written by the manual start), racing the cron watcher's next 1s tick and the ~100ms log-batch flush. On loaded CI runners (bats --jobs 16) the assertion could land before the cron-triggered second run had logged. Poll for the second occurrence with a 10s deadline instead, matching the wait_for_log_lines pattern used elsewhere in the suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
f9a0556 to
31e2205
Compare
… import Add test/group_stop.bats exercising the timing-sensitive paths fixed in the recent supervisor and IPC changes: - stop() waits for the entire process group to exit, escalating to SIGKILL for children that trap SIGTERM, and only returns once every member is gone (or merely an unreaped zombie) - a start requested during an in-flight slow stop serializes behind it instead of racing the widened Stopping window and spawning a duplicate instance - parallel multi-daemon start/stop with staggered readiness attributes each interleaved IPC response to the correct daemon task Also remove the mpsc import from src/supervisor/mod.rs left unused by the start/stop serialization rework. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (5)
src/procs.rs (3)
1143-1153: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueThis test is tautological.
It reads
start_time(pid)twice and asserts the second read differs fromactual + 1— true by construction, regardless of whether identity checking works. Consider asserting the positive case too (the second read equalsactual), which is what actually validates that the token is stable across calls.💚 Suggested assertion
- assert_ne!(procs.start_time(pid), Some(actual.saturating_add(1))); + assert_eq!(procs.start_time(pid), Some(actual)); + assert_ne!(procs.start_time(pid), Some(actual.saturating_add(1)));🤖 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/procs.rs` around lines 1143 - 1153, Update the test process_start_time_check_rejects_mismatch to assert that a second start_time(pid) lookup equals the previously captured actual value, validating token stability across calls; remove the tautological comparison against actual.saturating_add(1).
313-328: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueName the post-SIGKILL verification window.
0..40× 50 ms encodes a 2 s budget thatIpcClient::stopmirrors with a literalDuration::from_secs(2). A shared named constant would keep the two from drifting. Also consider checking before the first sleep so a group that dies immediately doesn't pay 50 ms.♻️ Suggested shape
+/// How long to wait for a process group to disappear after SIGKILL. The IPC +/// stop budget in `IpcClient::stop` must stay in sync with this. +pub(crate) const SIGKILL_VERIFY_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(2);🤖 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/procs.rs` around lines 313 - 328, Define a shared named constant for the post-SIGKILL verification window and use it in both the process-group wait loop near process_group_terminated and IpcClient::stop instead of duplicated duration literals. Update the loop to check process_group_terminated before its first sleep, while preserving the existing bounded polling and failure behavior.
343-420: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConfigured stop signal is silently discarded on the pidfd path.
cleanup_orphaned_daemonpassesstop_cfg.signal, but this path freezes the group and goes straight to SIGKILL, so a daemon with a gracefulstop_signalhandler never gets it during orphan reconciliation. Pinning arguably forces this (a SIGSTOPped process cannot run a SIGTERM handler), but the discard should be stated in the doc comment onkill_process_group_if_start_time_matches_asyncso callers aren't misled by the parameter.🤖 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/procs.rs` around lines 343 - 420, Update the documentation for kill_process_group_if_start_time_matches_async to explicitly state that stop_signal is ignored on the Linux pidfd path: the group is frozen and terminated with SIGKILL, so handlers for the configured graceful signal cannot run. Keep the kill_process_group_with_pidfds behavior unchanged.src/supervisor/mod.rs (2)
1386-1395: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winLog join failures instead of discarding them.
let _ = task.awaithides a panic in a reconcile task; that daemon's record silently stays stale and nothing appears in the log.♻️ Proposed change
for task in tasks { - let _ = task.await; + if let Err(e) = task.await { + error!("orphan reconciliation task failed: {e}"); + } }🤖 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/mod.rs` around lines 1386 - 1395, Update the task-await loop for the spawned cleanup_orphaned_daemon tasks to inspect each JoinHandle result instead of discarding it. Log any join failure, including panic details when available, while preserving the existing behavior for successfully completed reconciliation tasks.
1472-1481: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueThis
elseis unreachable.
process_identity_matchesreturnstrueonly when both start times areSomeand equal, and the!matchesblock above always returns. Socurrent_start_timeis necessarilySomehere and the warn can never fire. Fine as defence against a future weakening ofprocess_identity_matches, but a comment saying so would stop the next reader hunting for the case that triggers it.🤖 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/mod.rs` around lines 1472 - 1481, Document the intentional defensive-unreachable fallback in the `current_start_time` `let-else` block, explaining that `process_identity_matches` only succeeds when both start times are present and the preceding `!matches` branch returns; retain the existing warning and running-state behavior for future-proofing.
🤖 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/autostop.rs`:
- Around line 75-84: Make detached autostops cancellable and revalidate
directory activity before invoking SUPERVISOR.stop. In
src/supervisor/autostop.rs:75-84, register the immediate spawned task in
cancellable state and check the directory is still inactive before stopping; in
src/supervisor/autostop.rs:174-183, preserve cancellable in-flight state after
removing the due entry and perform the same revalidation before stopping.
In `@src/supervisor/lifecycle.rs`:
- Around line 190-216: Extend the stop-lock guard in the run path to remain held
through the vulnerable startup sequence, including run_once (at minimum until
the daemon’s Running state and PID are persisted). Keep the existing stop_locked
handling under this guard, and ensure the lock is not released before
spawning/upserting so concurrent run or stop operations cannot race with
startup.
- Around line 1166-1168: Extract a shared spawn_ready_fail_kill helper in
src/supervisor/lifecycle.rs that awaits kill_process_group_async and logs any
error with the daemon ID and PID instead of discarding the result. Replace the
inline spawn blocks at src/supervisor/lifecycle.rs lines 1166-1168, 1196-1198,
1263-1265, and 1397-1399 with calls to this helper, preserving each branch’s
existing daemon and stop configuration arguments.
---
Nitpick comments:
In `@src/procs.rs`:
- Around line 1143-1153: Update the test
process_start_time_check_rejects_mismatch to assert that a second
start_time(pid) lookup equals the previously captured actual value, validating
token stability across calls; remove the tautological comparison against
actual.saturating_add(1).
- Around line 313-328: Define a shared named constant for the post-SIGKILL
verification window and use it in both the process-group wait loop near
process_group_terminated and IpcClient::stop instead of duplicated duration
literals. Update the loop to check process_group_terminated before its first
sleep, while preserving the existing bounded polling and failure behavior.
- Around line 343-420: Update the documentation for
kill_process_group_if_start_time_matches_async to explicitly state that
stop_signal is ignored on the Linux pidfd path: the group is frozen and
terminated with SIGKILL, so handlers for the configured graceful signal cannot
run. Keep the kill_process_group_with_pidfds behavior unchanged.
In `@src/supervisor/mod.rs`:
- Around line 1386-1395: Update the task-await loop for the spawned
cleanup_orphaned_daemon tasks to inspect each JoinHandle result instead of
discarding it. Log any join failure, including panic details when available,
while preserving the existing behavior for successfully completed reconciliation
tasks.
- Around line 1472-1481: Document the intentional defensive-unreachable fallback
in the `current_start_time` `let-else` block, explaining that
`process_identity_matches` only succeeds when both start times are present and
the preceding `!matches` branch returns; retain the existing warning and
running-state behavior for future-proofing.
🪄 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: 0a1bc1a6-a5ec-41dc-99d8-b19962bb743f
📒 Files selected for processing (8)
src/ipc/batch.rssrc/ipc/client.rssrc/procs.rssrc/supervisor/autostop.rssrc/supervisor/ipc_handlers.rssrc/supervisor/lifecycle.rssrc/supervisor/mod.rstest/cron.bats
🚧 Files skipped from review as they are similar to previous changes (1)
- src/ipc/batch.rs
…efore stopping Autostop stops run as detached tasks so they don't stall the shell hook, but once spawned they could no longer be called off and never rechecked directory activity. Track in-flight stops in a cancellation map that cancel_pending_autostops_for_dir flips on re-entry, and revalidate that the daemon's directory is still inactive inside the task before stopping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
test/group_stop.bats (2)
127-158: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winParallel stop isn't staggered like parallel start.
The start phase staggers readiness (2s/1s/0s) specifically to interleave IPC responses out of request order — the scenario that exercises response misattribution. The stop phase (lines 152-158) stops all three simultaneously with identical shutdown behavior (plain
sleep 60, no signal handling), so it doesn't exercise the same out-of-order-response risk on the stop path, even though the PR/commit description calls out attribution for "parallel daemon operations" broadly, not just starts.Consider giving
par_a/par_b/par_cstaggered stop delays (e.g.trap 'sleep N; exit 0' TERM, mirroring the pattern already used in the earlier tests in this file) so the parallelstopalso completes out of request order.🤖 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 `@test/group_stop.bats` around lines 127 - 158, The parallel multi-daemon test currently staggers only startup readiness, not shutdown completion. Update the daemon definitions in “parallel multi-daemon start attributes readiness to the right daemon” to handle TERM with distinct delays for par_a, par_b, and par_c, using the existing trap pattern from nearby tests, so parallel stop responses complete out of request order while preserving the current start assertions.
106-111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winFixed
sleep 0.3for stop/start race timing is inherently flaky.Waiting a fixed 300ms to assume the stop is "in-flight" before issuing
startis timing-dependent and can be flaky under CI load. The PR description mentions starts "racing the widened Stopping window," implying an intermediate status exists — polling for that status instead of sleeping would make this deterministic.♻️ Suggested refactor (pending confirmation of the status name)
pitchfork stop slow_stop_test & local stop_job=$! - sleep 0.3 + wait_for_status slow_stop_test stopping run pitchfork start slow_stop_test🤖 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 `@test/group_stop.bats` around lines 106 - 111, Replace the fixed sleep in the slow_stop_test stop/start scenario with polling for the intermediate stopping status exposed by the service, proceeding to pitchfork start slow_stop_test only after that status is observed. Preserve the existing background stop job, start assertion, and wait 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.
Inline comments:
In `@test/group_stop.bats`:
- Line 73: Update the process-alive assertions around gone and the additional
checks near the later referenced assertions so failed status checks reliably
fail the Bats test. Use Bats run-plus-assertion patterns or an equivalent
wrapper that preserves each check’s failure status and prevents subsequent
assertions from masking it.
---
Nitpick comments:
In `@test/group_stop.bats`:
- Around line 127-158: The parallel multi-daemon test currently staggers only
startup readiness, not shutdown completion. Update the daemon definitions in
“parallel multi-daemon start attributes readiness to the right daemon” to handle
TERM with distinct delays for par_a, par_b, and par_c, using the existing trap
pattern from nearby tests, so parallel stop responses complete out of request
order while preserving the current start assertions.
- Around line 106-111: Replace the fixed sleep in the slow_stop_test stop/start
scenario with polling for the intermediate stopping status exposed by the
service, proceeding to pitchfork start slow_stop_test only after that status is
observed. Preserve the existing background stop job, start assertion, and wait
behavior.
🪄 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: a3e3eb56-7636-4600-8cca-6d6382bee3bf
📒 Files selected for processing (2)
src/supervisor/mod.rstest/group_stop.bats
🚧 Files skipped from review as they are similar to previous changes (1)
- src/supervisor/mod.rs
…l failures Two review fixes in the run path: - run now passes its owned stop-lock guard into run_once, which holds it until the daemon's Running state and PID are persisted. Previously the guard was released before spawning, so a concurrent run could pass the already-running check and spawn a duplicate, and a concurrent stop could see no PID and return without stopping the starting daemon. The guard is still released before the readiness wait and retry backoffs; retry attempts re-acquire it per attempt. - Extract a shared spawn_ready_fail_kill helper for the four readiness exhaustion paths, which logs a failed process-group kill with the daemon id and pid instead of discarding the result. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A bare '! pid_alive' is excluded from errexit, so the assertion could never fail the test if the old process survived the stop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/supervisor/lifecycle.rs (1)
1580-1584: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftDo not retry when readiness cleanup failed to terminate the process group.
spawn_ready_fail_killlogskill_process_group_asyncerrors but returns(), and Line 1581 discards the join result before sending retryable exit code 124.kill_process_group_asyncerrors specifically when group members survive SIGKILL;runwill then retry and can spawn into that still-live group. Propagate the kill outcome (includingJoinError) and suppress retries / retain a non-startable state until group termination is confirmed.🤖 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 1580 - 1584, Update the readiness-failure cleanup flow around spawn_ready_fail_kill and the ready_fail_kill await to propagate both kill_process_group_async failures and JoinError instead of discarding them. Only send retryable exit code 124 after confirmed process-group termination; when cleanup fails, suppress retries and retain a non-startable state until termination is confirmed.
🤖 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.
Outside diff comments:
In `@src/supervisor/lifecycle.rs`:
- Around line 1580-1584: Update the readiness-failure cleanup flow around
spawn_ready_fail_kill and the ready_fail_kill await to propagate both
kill_process_group_async failures and JoinError instead of discarding them. Only
send retryable exit code 124 after confirmed process-group termination; when
cleanup fails, suppress retries and retain a non-startable state until
termination is confirmed.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Central YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro Plus
Run ID: d32ef79d-1b51-42d1-88c6-9022f174aa00
📒 Files selected for processing (4)
src/supervisor/autostop.rssrc/supervisor/lifecycle.rssrc/supervisor/mod.rstest/group_stop.bats
🚧 Files skipped from review as they are similar to previous changes (3)
- test/group_stop.bats
- src/supervisor/autostop.rs
- src/supervisor/mod.rs
Summary
Fixes two race conditions observed in production with pitchfork 2.16.0, plus a hardening pass for the system-wide implications of the second fix.
1. Phantom start failure (
fix(ipc))Cold-starting many daemons at once (e.g. 9 in one
pitchfork start) occasionally reported a daemon as failed even though the supervisor had marked it ready, aborting its dependents.Root cause: the IPC wire protocol has no request IDs — responses are attributed purely by read order on a connection. The supervisor handles each request in its own spawned task (
conn_watch) and writes responses back in completion order, while all parallel start tasks shared a singleArc<IpcClient>connection and raced on the recv lock. Two concurrentRunrequests could each consume the other's response: daemon A's task reads daemon B'sDaemonFailedWithCode(which carries no daemon ID) while B's task reads A'sDaemonReady. The swap could also silently misrouteresolved_portsinto dependent daemons' templates.Fix: each parallel start/adhoc-start/stop task now opens its own dedicated IPC connection, so there is exactly one in-flight request per connection and response attribution is structural rather than timing-dependent — deterministic, with no wire-format change and parallelism within dependency levels preserved. As defense in depth,
IpcClientholds a mutex across each send/read pair so shared clients (TUI, web routes) serialize exchanges, and a client whose exchange fails mid-stream (timeout/IO error) marks itself desynced and transparently reconnects on the next request instead of reading the abandoned response. Version-mismatch warnings are deduped on both sides (keyed by peer version, so a different mismatched peer still warns).2. Restart race: stop returns while the group is still dying (
fix(supervisor))kill_process_groupsignalled the whole group but only waited for the leader PID to die. Daemons run assh -c "...", so the leader exits within ~30ms of SIGTERM while children in the group are still shutting down gracefully — e.g.docker compose upwaiting for its container to stop.stop()returned early, marked the daemon Stopped, and a force-restart spawned a replacementupthat attached to the dying container and exited with it (reproduced killing ClickHouse after every rebuild-restart).Fix: the wait predicate is now whole-group emptiness (
killpg(pgid, 0)) instead of leader death, keeping the existing stop signal →stop_timeout→ SIGKILL escalation, with group emptiness verified after SIGKILL (bounded). Members that survive even SIGKILL (uninterruptible sleep) are reported asDaemonStopFailedinstead of being silently marked Stopped.killpgEPERM is treated as terminated — only members we may not signal remain (e.g. root-owned helpers), which our signals never reached, so waiting cannot make progress.3. Hardening: the rest of the system assumed millisecond stops
Making stops wait for the whole group (seconds, bounded by the stop budget) exposed several paths that assumed stops return in milliseconds. These are addressed in the follow-up commits:
stop()holds the daemon's lock for the entire stop, andrun()acquires it before its already-running check — a start deterministically waits out an in-flight stop instead of racing the (now wider)Stoppingwindow into duplicate instances/port conflicts.IpcClient::stopbudgets its request from the daemon's ownstop_signal.timeout(orsupervisor.stop_timeout) instead of the flat IPC request timeout, so a correct in-progress stop is no longer reported as a timeout failure.cdshell hook (UpdateShellDir) and the 10s interval watcher responsive during group waits.--container), so orphaned zombies can't keep a process group alive indefinitely.Note for slow graceful shutdowns: the stop timeout is now the graceful budget for the entire group (like systemd's
TimeoutStopSecfor a cgroup) — daemons whose teardown legitimately takes longer (draining connections, stopping containers) should raisestop_signal.timeoutrather than rely on outliving the budget.Testing
mise run ci-devpasses on every commit (fmt, clippy-D warnings, full e2e suite 279/279, render — no generated-doc changes)kill_process_group_asynccall paths (IPC stop, force-restart, readiness-timeout kills, orphan cleanup, supervisor shutdown); the hardening pass came out of an adversarial multi-agent review of the diff (15 verified findings, all addressed)🤖 Generated with Claude Code
Summary by CodeRabbit