Skip to content

fix: deterministic IPC response attribution and whole-group stop wait - #606

Merged
jdx merged 9 commits into
jdx:mainfrom
disintegrator:fix/ipc-response-attribution-and-group-stop-wait
Jul 27, 2026
Merged

fix: deterministic IPC response attribution and whole-group stop wait#606
jdx merged 9 commits into
jdx:mainfrom
disintegrator:fix/ipc-response-attribution-and-group-stop-wait

Conversation

@disintegrator

@disintegrator disintegrator commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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 single Arc<IpcClient> connection and raced on the recv lock. Two concurrent Run requests could each consume the other's response: daemon A's task reads daemon B's DaemonFailedWithCode (which carries no daemon ID) while B's task reads A's DaemonReady. The swap could also silently misroute resolved_ports into 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, IpcClient holds 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_group signalled the whole group but only waited for the leader PID to die. Daemons run as sh -c "...", so the leader exits within ~30ms of SIGTERM while children in the group are still shutting down gracefully — e.g. docker compose up waiting for its container to stop. stop() returned early, marked the daemon Stopped, and a force-restart spawned a replacement up that 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 as DaemonStopFailed instead of being silently marked Stopped. killpg EPERM 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:

  • Per-daemon stop locks: stop() holds the daemon's lock for the entire stop, and run() acquires it before its already-running check — a start deterministically waits out an in-flight stop instead of racing the (now wider) Stopping window into duplicate instances/port conflicts.
  • Client stop timeout tracks the server-side budget: IpcClient::stop budgets its request from the daemon's own stop_signal.timeout (or supervisor.stop_timeout) instead of the flat IPC request timeout, so a correct in-progress stop is no longer reported as a timeout failure.
  • Orphan cleanup off the startup path: it now runs 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.
  • Autostop stops are spawned, keeping the cd shell hook (UpdateShellDir) and the 10s interval watcher responsive during group waits.
  • Ready-check-exhausted path: the group kill runs as a separate task (the monitor loop keeps draining output, so children logging during SIGTERM cleanup can't deadlock on a full pipe) and the failure notification is deferred until the kill completes, so the retry loop cannot respawn into the dying group.
  • Zombie reaper installs whenever the supervisor is PID 1 (not only with --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 TimeoutStopSec for a cgroup) — daemons whose teardown legitimately takes longer (draining connections, stopping containers) should raise stop_signal.timeout rather than rely on outliving the budget.

Testing

  • mise run ci-dev passes on every commit (fmt, clippy -D warnings, full e2e suite 279/279, render — no generated-doc changes)
  • Bug 1 fix reasoned end-to-end: one in-flight request per connection makes mis-attribution impossible by construction; supervisor-side handling is unchanged
  • Bug 2 fix verified against all kill_process_group_async call 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

  • Bug Fixes
    • Improved reliability when starting and stopping multiple daemons concurrently.
    • Prevented stale or misattributed IPC responses by isolating IPC exchanges per operation.
    • Reduced repeated version-mismatch warnings from both client and supervisor.
    • Shutdown now waits for the entire process group to exit before reporting success.
    • Stronger coordination between run/stop, readiness failures, autostop, and orphan cleanup (including container/PID 1 handling).
  • Tests
    • Added E2E coverage for whole process-group stop/start serialization and multi-daemon readiness attribution.
    • Clarified cron startup timing behavior in test documentation.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

IPC 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.

Changes

IPC concurrency and batch execution

Layer / File(s) Summary
Serialized IPC exchanges and dedicated batch connections
src/ipc/client.rs, src/ipc/batch.rs
IPC request/response exchanges are serialized and reconnected after failures; parallel batch tasks use dedicated connections and extended stop timeouts.

Process termination and lifecycle coordination

Layer / File(s) Summary
Whole process-group termination
src/procs.rs
Graceful and forced shutdown now verify that the entire process group disappears, with group-level liveness reporting.
Serialized run, stop, and readiness failure handling
src/supervisor/lifecycle.rs, src/supervisor/mod.rs
Per-daemon stop locks serialize lifecycle operations, and readiness failures wait for asynchronous termination before reporting.
Startup cleanup and orphan reconciliation
src/supervisor/mod.rs
Orphan cleanup runs before boot daemons start and reconciles candidates concurrently with process identity checks.
Detached autostops and warning deduplication
src/supervisor/autostop.rs, src/supervisor/ipc_handlers.rs, src/ipc/client.rs
Autostops run in cancellable detached tasks, and version-mismatch warnings are deduplicated.
End-to-end validation
test/group_stop.bats, test/cron.bats
Tests cover process-group shutdown, stop/start serialization, parallel readiness attribution, and cron timing behavior.

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
Loading

Possibly related PRs

Suggested reviewers: gaojunran, jdx

Poem

A rabbit hops through locks so tight,
Each IPC task gets its own flight.
Warnings speak once, then settle low,
Whole process groups leave in tow.
Cleanup waits before boots take light.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the two main fixes in the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This 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.

  • IPC response mis-attribution: Each parallel start/stop task now opens its own dedicated IPC connection (one in-flight request per connection), making response attribution structural and deterministic. An exchange mutex and a desynced reconnect flag are added as defense-in-depth for shared clients.
  • Whole-group stop: kill_process_group now polls killpg(pgid, 0) (not just the leader PID) before declaring termination, with escalation to SIGKILL and a bounded post-kill verification; stop() holds a per-daemon stop lock for its entire duration so concurrent starts wait out the widened Stopping window instead of spawning duplicate instances.
  • Hardening pass: Autostop stops are spawned as detached tasks; orphan cleanup runs in the background with parallel per-orphan kills; readiness-exhaustion group kill runs as a separate task so the monitor loop can drain output; zombie reaper installs whenever the supervisor is PID 1.

Confidence Score: 5/5

Safe 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

Filename Overview
src/ipc/batch.rs Removes the shared Arc from spawn_start_task, spawn_adhoc_start_task, and spawn_stop_task; each task now calls connect_dedicated() to get its own connection, making parallel response attribution deterministic.
src/ipc/client.rs Adds exchange Mutex (serializes send/recv on shared clients), desynced AtomicBool (triggers reconnect after mid-stream failure), version-mismatch dedup, and a stop-timeout budget for IpcClient::stop that tracks the server-side graceful window.
src/procs.rs kill_process_group now polls killpg(pgid, 0) for whole-group termination instead of watching only the leader PID; adds process_group_terminated helper and process_group_alive; post-SIGKILL verification loop added with bounded wait and Err on surviving D-state members.
src/supervisor/lifecycle.rs run() acquires per-daemon stop lock before the already-running check and passes it through run_once (held until PID is persisted); stop/stop_locked split serializes the whole group wait; ready_fail_kill task defers ready_tx until kill completes.
src/supervisor/mod.rs Adds stop_locks and in_flight_autostops maps; orphan cleanup moved to a background spawn; boot daemons start after cleanup; PID-1 detection always enables zombie reaper.
src/supervisor/autostop.rs Autostop stops now spawned as detached tasks (spawn_autostop/run_autostop) with cancellation flag + re-validation; in_flight_autostops tracks spawned-but-not-yet-stopping tasks so directory re-entry can cancel them.
src/supervisor/ipc_handlers.rs Adds supervisor-side version-mismatch dedup (version_mismatch_should_warn) to prevent one warning per parallel dedicated connection.
test/group_stop.bats New E2E tests covering: whole-group stop waiting for TERM-ignoring child processes, start serializing behind an in-flight slow stop, and parallel multi-daemon start attributing readiness to the correct daemon.
test/cron.bats Adds a wait_for_logs guard before count assertion in the cron_immediate test to fix a race with log flush on slow CI runners.

Reviews (6): Last reviewed commit: "test(supervisor): use run ! for negated ..." | Re-trigger Greptile

Comment thread src/procs.rs Outdated
Comment thread src/procs.rs
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Caution

Review failed

An 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.

❤️ Share

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

2 similar comments
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Caution

Review failed

An 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.

❤️ Share

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

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Caution

Review failed

An 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.

❤️ Share

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

@gaojunran

gaojunran commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Good improvements! It'll be better if we add some e2e(bats) tests for these.

@github-actions

Copy link
Copy Markdown
Contributor

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.

@github-actions

Copy link
Copy Markdown
Contributor

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.

@github-actions

Copy link
Copy Markdown
Contributor

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.

@github-actions

Copy link
Copy Markdown
Contributor

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.

@github-actions

Copy link
Copy Markdown
Contributor

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.

disintegrator and others added 5 commits July 27, 2026 15:17
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>
@disintegrator
disintegrator force-pushed the fix/ipc-response-attribution-and-group-stop-wait branch from f9a0556 to 31e2205 Compare July 27, 2026 15:09
@disintegrator
disintegrator marked this pull request as ready for review July 27, 2026 15:09
… 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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
src/procs.rs (3)

1143-1153: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

This test is tautological.

It reads start_time(pid) twice and asserts the second read differs from actual + 1 — true by construction, regardless of whether identity checking works. Consider asserting the positive case too (the second read equals actual), 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 value

Name the post-SIGKILL verification window.

0..40 × 50 ms encodes a 2 s budget that IpcClient::stop mirrors with a literal Duration::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 win

Configured stop signal is silently discarded on the pidfd path.

cleanup_orphaned_daemon passes stop_cfg.signal, but this path freezes the group and goes straight to SIGKILL, so a daemon with a graceful stop_signal handler 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 on kill_process_group_if_start_time_matches_async so 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 win

Log join failures instead of discarding them.

let _ = task.await hides 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 value

This else is unreachable.

process_identity_matches returns true only when both start times are Some and equal, and the !matches block above always returns. So current_start_time is necessarily Some here and the warn can never fire. Fine as defence against a future weakening of process_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

📥 Commits

Reviewing files that changed from the base of the PR and between f13b07e and 31e2205.

📒 Files selected for processing (8)
  • src/ipc/batch.rs
  • src/ipc/client.rs
  • src/procs.rs
  • src/supervisor/autostop.rs
  • src/supervisor/ipc_handlers.rs
  • src/supervisor/lifecycle.rs
  • src/supervisor/mod.rs
  • test/cron.bats
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/ipc/batch.rs

Comment thread src/supervisor/autostop.rs Outdated
Comment thread src/supervisor/lifecycle.rs Outdated
Comment thread src/supervisor/lifecycle.rs Outdated
…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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
test/group_stop.bats (2)

127-158: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Parallel 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_c staggered stop delays (e.g. trap 'sleep N; exit 0' TERM, mirroring the pattern already used in the earlier tests in this file) so the parallel stop also 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 win

Fixed sleep 0.3 for stop/start race timing is inherently flaky.

Waiting a fixed 300ms to assume the stop is "in-flight" before issuing start is 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

📥 Commits

Reviewing files that changed from the base of the PR and between 31e2205 and d1f916d.

📒 Files selected for processing (2)
  • src/supervisor/mod.rs
  • test/group_stop.bats
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/supervisor/mod.rs

Comment thread test/group_stop.bats
disintegrator and others added 2 commits July 27, 2026 16:59
…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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lift

Do not retry when readiness cleanup failed to terminate the process group.

spawn_ready_fail_kill logs kill_process_group_async errors but returns (), and Line 1581 discards the join result before sending retryable exit code 124. kill_process_group_async errors specifically when group members survive SIGKILL; run will then retry and can spawn into that still-live group. Propagate the kill outcome (including JoinError) 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

📥 Commits

Reviewing files that changed from the base of the PR and between d1f916d and 72e9bd0.

📒 Files selected for processing (4)
  • src/supervisor/autostop.rs
  • src/supervisor/lifecycle.rs
  • src/supervisor/mod.rs
  • test/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

@jdx
jdx merged commit 04714cc into jdx:main Jul 27, 2026
12 checks passed
@jdx jdx mentioned this pull request Jul 27, 2026
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.

3 participants