Skip to content

fix(daemon): clean up child after startup timeout#774

Open
PierrunoYT wants to merge 5 commits into
Gitlawb:mainfrom
PierrunoYT:fix/issue-770-daemon-startup-cleanup
Open

fix(daemon): clean up child after startup timeout#774
PierrunoYT wants to merge 5 commits into
Gitlawb:mainfrom
PierrunoYT:fix/issue-770-daemon-startup-cleanup

Conversation

@PierrunoYT

@PierrunoYT PierrunoYT commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • retain ownership of the detached daemon child until readiness succeeds
  • perform a final readiness probe at the timeout boundary
  • terminate and reap the exact child when startup times out
  • cover timeout cleanup, successful release, and boundary readiness with subprocess tests

Validation

  • go test ./internal/cli -count=1
  • go vet ./internal/cli
  • git diff --check

Closes #770

Summary by CodeRabbit

  • Bug Fixes
    • Improved detached daemon startup reliability with readiness polling, deterministic timeout-driven termination, and consistent “see log” messaging.
    • Enhanced process termination to correctly stop process trees/groups and ensure the leader process is reaped on both POSIX and Windows.
  • Tests
    • Added coverage for readiness timing boundaries, detached release vs timeout behavior, and command termination/reaping scenarios (including cases where the leader exits early).

Copilot AI review requested due to automatic review settings July 20, 2026 13:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a startup-timeout failure mode in zero daemon start where the CLI could detach from the spawned daemon before readiness is confirmed, allowing a timed-out start attempt to leave a live background daemon behind.

Changes:

  • Refactors detached daemon startup to retain ownership of the child process until readiness succeeds, including a final readiness probe at the timeout boundary.
  • Adds timeout cleanup logic to terminate and reap the launched child on readiness timeout.
  • Adds subprocess-based tests covering timeout cleanup, successful release, and the timeout-boundary readiness probe.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
internal/cli/daemon.go Moves detached start into a start+await helper, adds readiness polling with a boundary check, and adds termination/reap cleanup on timeout.
internal/cli/daemon_test.go Adds subprocess helpers and tests to validate timeout cleanup, readiness-boundary behavior, and successful release behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/cli/daemon.go Outdated
Comment on lines +212 to +216
func terminateAndReapDaemonProcess(cmd *exec.Cmd) error {
killErr := cmd.Process.Kill()
waitErr := cmd.Wait()
var exitErr *exec.ExitError
if waitErr == nil || errors.As(waitErr, &exitErr) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 27c9bd3. Timeout cleanup now uses a coordinated background.TerminateCommand path instead of leader-only Process.Kill:

  • POSIX captures and signals the process group before Wait can reap the leader, then reaps concurrently while preserving escalation/liveness checks.
  • Windows uses taskkill /T, always reaps the leader, and preserves tree-termination failures if fallback leader termination is needed.
  • Added POSIX coverage for an exited leader with a surviving child and Windows coverage for the exited-leader reap race.
  • Daemon subprocess tests now configure the same child-process-group invariant as production.

Validated on Windows and WSL/Linux, plus go vet ./internal/background ./internal/cli.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@PierrunoYT, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b2f4a8cd-6bfb-4bdc-9092-cebf78a8841f

📥 Commits

Reviewing files that changed from the base of the PR and between 4cb866e and 3ed4571.

📒 Files selected for processing (4)
  • internal/background/process_posix.go
  • internal/background/process_posix_test.go
  • internal/background/process_windows.go
  • internal/background/terminate.go

Walkthrough

Detached daemon startup retains ownership of the child through readiness checks, performs a final timeout check, terminates and reaps failed processes, and releases successful ones. Cross-platform command termination now handles process trees and reaping.

Changes

Daemon startup and process lifecycle

Layer / File(s) Summary
Cross-platform command termination
internal/background/process_*.go, internal/background/terminate.go
TerminateCommand stops process trees or groups, escalates termination when needed, and reaps command leaders on POSIX and Windows.
Detached startup orchestration
internal/cli/daemon.go
Detached startup starts the child, waits for readiness, performs a final deadline check, releases it only after success, and terminates it on failure.
Lifecycle validation
internal/cli/daemon_test.go, internal/background/process_*_test.go
Tests cover timeout-boundary readiness, timeout cleanup, ready-child release, process-group termination, and Windows reaping.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant startAndAwaitDaemonProcess
  participant daemonReachable
  participant TerminateCommand
  participant daemonProcess
  CLI->>startAndAwaitDaemonProcess: start detached daemon
  startAndAwaitDaemonProcess->>daemonProcess: start child
  startAndAwaitDaemonProcess->>daemonReachable: poll readiness
  daemonReachable-->>startAndAwaitDaemonProcess: ready or unavailable
  alt Ready
    startAndAwaitDaemonProcess->>daemonProcess: release process handle
  else Timeout
    startAndAwaitDaemonProcess->>daemonReachable: perform final check
    startAndAwaitDaemonProcess->>TerminateCommand: terminate and reap child
    TerminateCommand->>daemonProcess: stop process tree and wait
  end
  startAndAwaitDaemonProcess-->>CLI: startup result
Loading

Possibly related PRs

  • Gitlawb/zero#134: Modified POSIX process termination with escalation, liveness polling, and reaping patterns used by this change.
  • Gitlawb/zero#203: Introduced the detached daemon startup flow extended by these lifecycle helpers and tests.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: cleaning up the detached child when daemon startup times out.
Linked Issues check ✅ Passed The changes implement retained ownership, final readiness probing, and cleanup/reap-on-timeout with tests, matching #770.
Out of Scope Changes check ✅ Passed The added process-management helpers and tests support the daemon timeout cleanup flow and do not introduce clear unrelated scope.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

🧹 Nitpick comments (1)
internal/cli/daemon_test.go (1)

144-160: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Good coverage of timeout-kill-reap, timeout-termination, and successful release paths.

Solid regression coverage for the behavior described in the PR (deadline-boundary readiness, termination+reaping of a hanging child, release of a ready child). One minor observation: all three tests duplicate the same exec.Command(os.Args[0], "-test.run=TestDaemonDetachedChildProcess") + env setup; consider extracting a small newHangingDaemonHelper(t *testing.T) *exec.Cmd to cut the repetition, but this is optional.

Also applies to: 162-171, 173-192

🤖 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 `@internal/cli/daemon_test.go` around lines 144 - 160, Optionally reduce
duplication across the daemon process tests, including
TestTerminateAndReapDaemonProcess and the related tests, by extracting the
shared exec.Command and ZERO_TEST_DAEMON_DETACHED_CHILD environment setup into a
helper such as newHangingDaemonHelper(t *testing.T). Use that helper wherever
the detached child process is started, preserving each test’s existing
assertions and 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 `@internal/cli/daemon_test.go`:
- Around line 144-160: Optionally reduce duplication across the daemon process
tests, including TestTerminateAndReapDaemonProcess and the related tests, by
extracting the shared exec.Command and ZERO_TEST_DAEMON_DETACHED_CHILD
environment setup into a helper such as newHangingDaemonHelper(t *testing.T).
Use that helper wherever the detached child process is started, preserving each
test’s existing assertions and behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3f62b5b4-f2b4-4e99-a7c5-91b81df6b580

📥 Commits

Reviewing files that changed from the base of the PR and between da9fb50 and 2f4d837.

📒 Files selected for processing (2)
  • internal/cli/daemon.go
  • internal/cli/daemon_test.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 20, 2026
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Fixed the macOS smoke failure in f73194f. The process-group cleanup succeeded, but macOS can retain an orphaned child briefly as a zombie; kill(pid, 0) still succeeds for zombies, so the regression test incorrectly classified the dead child as running. The POSIX assertion now accepts either ESRCH or Z process state. Revalidated internal/background on WSL/Linux and internal/background + internal/cli on Windows.

@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: 2

🧹 Nitpick comments (1)
internal/background/process_posix.go (1)

173-180: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared cmd.Wait() error classification instead of duplicating it 3x.

The same var exitErr *exec.ExitError; if waitErr != nil && !errors.As(waitErr, &exitErr) { return fmt.Errorf("reap process: %w", waitErr) } block is duplicated verbatim across both platform files. As per coding guidelines, "Prefer one cross-platform function with small conditional checks over duplicated platform-specific helpers when behavior can remain unified" — this logic is platform-independent and belongs in one shared helper (e.g. in terminate.go).

  • internal/background/process_posix.go#L173-L180: keep as the canonical helper, or replace its body with a call to a new shared classifyWaitError(err error) error.
  • internal/background/process_posix.go#L163-L166: replace inline block with a call to the shared helper.
  • internal/background/process_windows.go#L37-L41: replace inline block with a call to the same shared helper.
🤖 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 `@internal/background/process_posix.go` around lines 173 - 180, Extract the
platform-independent wait-error classification into one shared helper, such as
classifyWaitError, in the shared background package. In
internal/background/process_posix.go lines 173-180, retain the existing behavior
in that helper or delegate waitForTerminatedCommand to it; replace the inline
classification at lines 163-166 and in internal/background/process_windows.go
lines 37-41 with calls to the shared helper, preserving the existing handling of
*exec.ExitError and wrapped reap-process errors.

Source: Coding guidelines

🤖 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 `@internal/background/process_posix.go`:
- Around line 107-122: Update terminateCommand so the non-process-gone error
branch after syscall.Getpgid also calls waitForTerminatedCommand before
returning, ensuring the child is reaped on every error path while preserving the
existing error handling for processGoneError.

In `@internal/background/process_windows.go`:
- Around line 31-52: Update terminateCommand to reap cmd with a bounded wait,
mirroring the timeout-based approach used by the POSIX terminateCommand: perform
cmd.Wait() asynchronously and select between its result and the existing
termination timeout. Return a timeout error when the process cannot be reaped in
time, while preserving the current taskkill, fallback-kill, and wait-error
handling for completed waits.

---

Nitpick comments:
In `@internal/background/process_posix.go`:
- Around line 173-180: Extract the platform-independent wait-error
classification into one shared helper, such as classifyWaitError, in the shared
background package. In internal/background/process_posix.go lines 173-180,
retain the existing behavior in that helper or delegate waitForTerminatedCommand
to it; replace the inline classification at lines 163-166 and in
internal/background/process_windows.go lines 37-41 with calls to the shared
helper, preserving the existing handling of *exec.ExitError and wrapped
reap-process errors.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7291e022-8dba-44b4-b1a4-45dce9dd5ef2

📥 Commits

Reviewing files that changed from the base of the PR and between 2f4d837 and 27c9bd3.

📒 Files selected for processing (7)
  • internal/background/process_posix.go
  • internal/background/process_posix_test.go
  • internal/background/process_windows.go
  • internal/background/process_windows_test.go
  • internal/background/terminate.go
  • internal/cli/daemon.go
  • internal/cli/daemon_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/cli/daemon.go
  • internal/cli/daemon_test.go

Comment thread internal/background/process_posix.go
Comment thread internal/background/process_windows.go

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

🧹 Nitpick comments (1)
internal/background/process_posix_test.go (1)

153-162: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Lift the exit condition into the for loop.

The loops use an unidiomatic for { if condition { break } } pattern. As flagged by static analysis, lifting the check into the loop condition improves readability and clarifies the intent.

  • internal/background/process_posix_test.go#L153-L162: Lift !processStopped(childPID) into the loop condition and remove the internal break.
  • internal/background/process_posix_test.go#L196-L205: Lift !processStopped(childPID) into the loop condition and remove the internal break.
♻️ Proposed refactor
-	for {
-		if processStopped(childPID) {
-			break
-		}
+	for !processStopped(childPID) {
		if time.Now().After(deadline) {
🤖 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 `@internal/background/process_posix_test.go` around lines 153 - 162, Update
both polling loops in internal/background/process_posix_test.go at lines 153-162
and 196-205 to use !processStopped(childPID) as the for-loop condition, remove
the internal break, and preserve the existing deadline timeout, kill, failure,
and sleep behavior.

Source: Linters/SAST tools

🤖 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 `@internal/background/process_posix_test.go`:
- Around line 153-162: Update both polling loops in
internal/background/process_posix_test.go at lines 153-162 and 196-205 to use
!processStopped(childPID) as the for-loop condition, remove the internal break,
and preserve the existing deadline timeout, kill, failure, and sleep behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 66bb37f6-1461-4153-901a-27df32e5a8e3

📥 Commits

Reviewing files that changed from the base of the PR and between 27c9bd3 and f73194f.

📒 Files selected for processing (1)
  • internal/background/process_posix_test.go

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Fixed the remaining macOS CI race in 4cb866e. On Darwin, Getpgid can return ESRCH after the configured process-group leader exits even though descendants still remain in that group. TerminateCommand now uses the launch-time Setpgid/Pgid configuration (which guarantees PGID == leader PID) rather than trying to rediscover it after exit, so the descendants are still signalled. Validated internal/background 10x on Linux, internal/background + internal/cli and vet on Windows, and Darwin cross-compilation.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Addressed the remaining CodeRabbit feedback in 3ed4571: shared wait-error classification, bounded Windows reaping, fallback termination plus bounded reaping on non-ESRCH Getpgid failures, and the two staticcheck loop cleanups. Validated background/CLI tests and vet on Windows, background tests 10x plus vet on Linux, and Darwin cross-compilation.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Resolve the current merge conflict before this can be merged
    GitHub currently reports this PR as mergeable: false with merge state dirty. Please rebase or otherwise resolve the conflict against the current base, then rerun the affected checks so the reviewed diff is mergeable.

  • [P2] Do not count zombie descendants as a surviving process group
    internal/background/process_posix.go:158
    alive uses kill(-pgid, 0) == nil, but that call succeeds for zombie processes. The newly added test explicitly treats a Z process state as stopped because an orphaned child can remain a zombie temporarily. When a timed-out daemon's group contains such a child, this helper waits through both grace periods, sends SIGKILL to an already-dead group, and finally returns process group <pid> did not exit after SIGKILL. That makes zero daemon start report a cleanup failure even though the tree has stopped (and can make the new macOS scenario flaky). Use liveness semantics that distinguish zombies from running descendants, or otherwise accept the already-dead state before declaring cleanup failed.

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.

fix(daemon): startup timeout can abandon detached child

3 participants