Skip to content

Background: escalate process termination from SIGTERM to SIGKILL#134

Merged
gnanam1990 merged 4 commits into
mainfrom
background-kill-escalation
Jun 8, 2026
Merged

Background: escalate process termination from SIGTERM to SIGKILL#134
gnanam1990 merged 4 commits into
mainfrom
background-kill-escalation

Conversation

@gnanam1990

@gnanam1990 gnanam1990 commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

Background: escalate process termination from SIGTERM to SIGKILL

terminateProcess (POSIX) sent only SIGTERM, so a background process that traps or ignores it kept running after Kill/KillRunning — a process leak (notably on a Ctrl+C shutdown that kills running tasks).

What changed

  • internal/background/process_posix.go — SIGTERM (so the process can flush/clean up) → poll liveness with signal 0 until a 3s grace deadline → SIGKILL if still alive. An already-exited process (ESRCH / ErrProcessDone) is treated as success. Grace/poll are package vars so tests run fast.
  • Windows is unchanged — it already force-kills via taskkill /T /F.

Why

A force-kill that can't actually force-kill isn't one. This guarantees a killed task is gone.

Testing

  • A SIGTERM-ignoring process (trap '' TERM + a readiness handshake so we don't signal before the trap installs) is killed only after the grace period — proving SIGTERM is tried first, then escalated.
  • A well-behaved process dies on SIGTERM well before escalation.
  • An already-exited pid is a no-op.

-race + full suite + GOOS=windows build green. No new deps.

Summary by CodeRabbit

  • Bug Fixes

    • Improved Unix-like process termination: send graceful termination, poll during a configurable grace period, then escalate to forceful termination; treat already-exited processes as successful no-ops.
    • Ensure tasks marked as killed remain consistently recorded; on failed kills, restore running state to avoid inconsistent status.
  • Tests

    • Added POSIX-only tests for escalation to forceful termination, graceful shutdown within the grace period, already-exited handling, and task-kill race conditions.

terminateProcess (POSIX) sent only SIGTERM, so a background process that traps or ignores it kept running after a Kill/KillRunning — a leak, e.g. on Ctrl+C shutdown. It now sends SIGTERM (letting the process clean up), polls liveness, and force-kills with SIGKILL if the process is still alive after a grace period.

- internal/background/process_posix.go: SIGTERM -> poll (signal 0) until a 3s grace deadline -> SIGKILL; treats an already-exited process as success (ESRCH / ErrProcessDone). Grace/poll are vars so tests run fast. Windows already force-kills via taskkill /F, so it is unchanged.

Tests: a SIGTERM-ignoring process (trap, with a readiness handshake to avoid signalling before the trap installs) is killed only after the grace period; a well-behaved process dies on SIGTERM well before escalation; an already-exited pid is a no-op. -race + full-suite + GOOS=windows build green; no new deps.
@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 3312ffa9d5f2
Changed files (4): internal/background/manager.go, internal/background/manager_test.go, internal/background/process_posix.go, internal/background/process_posix_test.go

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 76e54ce6-ab54-4d9a-8727-d98afc7462d7

📥 Commits

Reviewing files that changed from the base of the PR and between 39cbd06 and 3312ffa.

📒 Files selected for processing (2)
  • internal/background/manager.go
  • internal/background/manager_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/background/manager.go
  • internal/background/manager_test.go

Walkthrough

The PR changes POSIX termination from a single SIGTERM send to a two-phase flow: send SIGTERM, poll liveness for a configurable grace period (signal 0), treat already-exited errors as success, and escalate to SIGKILL if still alive. Tests cover escalation, graceful termination, and already-exited no-op behavior. Manager.Kill now persists an optimistic killed mark and can roll it back on failed kill; a race test was added.

Changes

Graceful Process Termination with SIGKILL Escalation

Layer / File(s) Summary
Configuration and Helper Functions
internal/background/process_posix.go
Adds terminationGracePeriod and terminationPollInterval variables; processAlive() uses signal 0 liveness probe; processGoneError() classifies already-exited errors.
Core Termination Implementation
internal/background/process_posix.go
terminateProcess(pid int) now sends SIGTERM, polls for process liveness until the grace period, returns success if gone or already-exited, and calls Kill() (SIGKILL) if still alive.
POSIX Tests
internal/background/process_posix_test.go
Adds POSIX-only tests and helper terminatingSignal; tests assert SIGKILL escalation for SIGTERM-ignoring processes, graceful SIGTERM termination within the grace period, and no-op when the process already exited.
Manager Kill state handling
internal/background/manager.go, internal/background/manager_test.go
Manager.Kill now marks a task as killed before calling killProcess and restores running state if killProcess fails; adds a test reproducing a Kill/MarkExited race to assert StatusKilled persists.

Sequence Diagram

sequenceDiagram
  participant Caller
  participant terminateProcess
  participant TargetProcess as Target OS Process
  Caller->>terminateProcess: terminateProcess(pid)
  terminateProcess->>TargetProcess: send SIGTERM
  loop poll until grace period expires
    terminateProcess->>TargetProcess: signal 0 (liveness check)
    alt process exited
      TargetProcess-->>terminateProcess: no longer signallable
      terminateProcess->>Caller: return success
    else still alive
      terminateProcess->>terminateProcess: wait poll interval
    end
  end
  alt grace expired and still alive
    terminateProcess->>TargetProcess: send SIGKILL
    TargetProcess->>terminateProcess: forced termination
  end
  terminateProcess->>Caller: return (success if gone, error otherwise)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Gitlawb/zero#108: Both PRs touch Manager.Kill flow and termination wiring.
  • Gitlawb/zero#116: Related background Manager kill/exit state handling changes and tests preventing status clobbering.

Suggested reviewers

  • Vasanthdev2004
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% 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 directly and clearly summarizes the main change: escalating process termination from SIGTERM to SIGKILL on POSIX systems.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch background-kill-escalation

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

@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 (1)
internal/background/process_posix_test.go (1)

27-27: ⚡ Quick win

Use exec.CommandContext with test-scoped timeouts for subprocesses.

Using plain exec.Command can leave hanging children when setup/handshake paths stall. CommandContext aligns with the lint rule and makes these tests self-bounding.

Suggested pattern
+ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
+t.Cleanup(cancel)
-cmd := exec.Command("sh", "-c", "...")
+cmd := exec.CommandContext(ctx, "sh", "-c", "...")

Also applies to: 64-64, 87-87

🤖 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` at line 27, Replace uses of
exec.Command (e.g., the creation of cmd := exec.Command("sh", "-c", "trap ''
TERM; echo ready; while true; do sleep 0.2; done") and the other similar
invocations) with exec.CommandContext using a test-scoped context with a
reasonable timeout (context.WithTimeout(ctx, ...)) so subprocesses are
automatically killed when the test deadline is reached; update the test helper
to create and defer cancel on the context, pass that context into
exec.CommandContext when constructing cmd, and ensure any waits/handshake still
use the same context to avoid leaked children.
🤖 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_test.go`:
- Around line 64-83: TestTerminateProcessGracefulSIGTERM currently only checks
timing but not which signal killed the child; change the goroutine that calls
cmd.Wait() to send its error/result on a channel (e.g., errCh) and after the
process finishes inspect cmd.ProcessState or the returned error (type
*exec.ExitError) to obtain the syscall.WaitStatus and assert that
WaitStatus.Signal() == syscall.SIGTERM (use syscall.SIGTERM symbol) in addition
to the existing timing assertions; keep the use of terminateProcess and
terminationGracePeriod and fail the test if the observed signal is not SIGTERM.

---

Nitpick comments:
In `@internal/background/process_posix_test.go`:
- Line 27: Replace uses of exec.Command (e.g., the creation of cmd :=
exec.Command("sh", "-c", "trap '' TERM; echo ready; while true; do sleep 0.2;
done") and the other similar invocations) with exec.CommandContext using a
test-scoped context with a reasonable timeout (context.WithTimeout(ctx, ...)) so
subprocesses are automatically killed when the test deadline is reached; update
the test helper to create and defer cancel on the context, pass that context
into exec.CommandContext when constructing cmd, and ensure any waits/handshake
still use the same context to avoid leaked children.
🪄 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 Plus

Run ID: e5fff2eb-52f6-4f64-b12c-426ff0b82dce

📥 Commits

Reviewing files that changed from the base of the PR and between 4475b67 and 795b16b.

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

Comment thread internal/background/process_posix_test.go
The graceful and escalation tests now check the actual signal that killed the process (via WaitStatus.Signal()), not just timing — so a regression to an immediate SIGKILL (or to never escalating) is caught. Graceful path must be SIGTERM; the SIGTERM-ignoring process must be SIGKILL.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Fixed: both tests now assert the actual terminating signal via WaitStatus.Signal() — the graceful path must be SIGTERM and the SIGTERM-ignoring process must be SIGKILL — so a regression to an immediate force-kill (or to never escalating) is caught, not just timing. -race green. @coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
cat internal/background/process_posix.go
echo "---FILE_BOUNDARY---"
cat internal/background/process_posix_test.go

Length of output: 5909



@gnanam1990 Good improvement — asserting WaitStatus.Signal() directly is strictly stronger than timing alone and catches both regression directions. The logic is sound. Two items worth addressing:


1. terminateProcess returns nil before confirming the process is gone after SIGKILL

The function's own comment says "It returns nil once the process is gone", and the polling loop before SIGKILL correctly upholds that invariant. But the post-SIGKILL path breaks it:

// Still alive after the grace period: force-kill.
if err := process.Kill(); err != nil && !processGoneError(err) {
    return err
}
return nil   // ← returns immediately; process may still be a zombie

SIGKILL can't be caught, but the process can linger in zombie state until reaped. If any caller acts on the nil return assuming the PID is truly gone, there's a narrow race. Add a brief confirmation poll:

if err := process.Kill(); err != nil && !processGoneError(err) {
    return err
}
// Wait for the kernel to transition the process out of the runnable state.
// SIGKILL cannot be ignored so this loop exits quickly.
for range 20 {
    if !processAlive(process) {
        return nil
    }
    time.Sleep(terminationPollInterval)
}
return nil  // SIGKILL delivered; process is dying even if not yet reaped

2. sleep 0.2 in the test is not POSIX-portable

cmd := exec.Command("sh", "-c", "trap '' TERM; echo ready; while true; do sleep 0.2; done")

POSIX sleep only guarantees integer operands. On minimal sh implementations (busybox, some container base images), sleep 0.2 is undefined. Since the inner sleep is unrelated to signal handling — the shell process itself is what receives SIGTERM — using sleep 1 works equally well and is universally safe:

cmd := exec.Command("sh", "-c", "trap '' TERM; echo ready; while true; do sleep 1; done")

The terminatingSignal helper using errors.As + status.Signaled() is clean and correct. The TestTerminateProcessAlreadyExited ESRCH path is a good regression guard. Overall the approach is solid after these two fixes.

@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)
internal/background/process_posix_test.go (1)

75-80: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid sh -c in the graceful-path test.

terminateProcess(cmd.Process.Pid) only targets the shell PID here. If /bin/sh does not exec sleep, cmd.Wait() can report a SIGTERM-killed shell while the child sleep 30 keeps running, so this test can pass while still leaking a process. Spawn sleep directly so the PID under test is the long-lived process.

Suggested change
-	cmd := exec.Command("sh", "-c", "sleep 30") // default disposition: SIGTERM kills it
+	cmd := exec.Command("sleep", "30")
🤖 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 75 - 80, The test
spawns a shell so terminateProcess(cmd.Process.Pid) may only kill the shell PID
and leak the actual sleep child; replace the sh -c invocation with a direct
sleep process so the PID under test is the long-lived process (i.e., create the
command using the direct binary and args rather than "sh -c"), keep the same
start/Wait logic (cmd.Start(), goroutine sending cmd.Wait() to done) and ensure
terminateProcess is called against cmd.Process.Pid so the test verifies the
child is terminated rather than the shell.
🤖 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 `@internal/background/process_posix_test.go`:
- Around line 75-80: The test spawns a shell so
terminateProcess(cmd.Process.Pid) may only kill the shell PID and leak the
actual sleep child; replace the sh -c invocation with a direct sleep process so
the PID under test is the long-lived process (i.e., create the command using the
direct binary and args rather than "sh -c"), keep the same start/Wait logic
(cmd.Start(), goroutine sending cmd.Wait() to done) and ensure terminateProcess
is called against cmd.Process.Pid so the test verifies the child is terminated
rather than the shell.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d8b5f83a-31d4-4a31-9145-93b227ba0017

📥 Commits

Reviewing files that changed from the base of the PR and between 795b16b and 73154c4.

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

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

Verdict: Request changes

I found one lifecycle blocker on the current head 73154c4c2a0c.

[P1] Preserve killed status when POSIX stop waits for the child to exit

terminateProcess now waits until processAlive is false before returning. For background specialists, launchBackgroundProcess also has a command.Wait() goroutine that calls manager.MarkExited(...). On a normal SIGTERM stop, that waiter can reap the process and mark the task error with exit code -1 before Manager.Kill reaches markKilledIfStillRunning. Because markKilledIfStillRunning no-ops once the task is no longer running, the persisted task can end up as error even though TaskStop returned status: killed.

Path: internal/background/process_posix.go:39 waits for exit, while internal/specialist/exec.go:652 reaps and calls the onExit status update before internal/background/manager.go:273 marks killed.

Smallest fix: make user-initiated stop intent win over the waiter before/while waiting for process exit, or keep SIGTERM return non-blocking and do SIGKILL escalation separately. Please add a POSIX lifecycle regression test around launchBackgroundProcess/Manager.Kill (not only terminateProcess) that asserts the final task status remains killed after the waiter runs.

Validation I ran locally:

  • git diff --check origin/main...HEAD
  • go test ./internal/background -count=1
  • go test ./internal/specialist -count=1

Note: I’m on Windows, so the new //go:build !windows POSIX signal tests are not executed locally; the CI Ubuntu/macOS smoke jobs are the platform evidence for those tests.

…erminate

Vasanthdev P1: terminateProcess now blocks until the child exits, so the background Wait-goroutine can reap the process and MarkExited the task to 'error' BEFORE Kill marked it 'killed' — leaving a user-initiated stop persisted as 'error' even though TaskStop returned killed.

Manager.Kill now records the kill intent (markKilledIfStillRunning) BEFORE terminating. MarkExited only acts on a running task, so once the task is killed the waiter's MarkExited is a no-op and the status stays killed. If terminateProcess fails, restoreRunningAfterFailedKill reverts the optimistic mark so a still-running task isn't falsely reported killed (preserves TestManagerDoesNotMarkKilledWhenKillFails).

Adds TestManagerKillStaysKilledWhenExitWaiterReapsDuringTerminate (cross-platform: a KillProcess that simulates the waiter reaping mid-terminate; asserts final status stays killed). build/-race/full-suite + GOOS=windows green.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Fixed the P1 lifecycle race:

Manager.Kill now records the kill intent (markKilledIfStillRunning) before terminating. Since MarkExited only acts on a running task, once the task is marked killed the background Wait-goroutine's MarkExited becomes a no-op — so a user-initiated stop stays killed even when the (now blocking) terminate lets the waiter reap the child first. On a terminate failure the optimistic mark is rolled back (restoreRunningAfterFailedKill), preserving the not-killed-on-failure invariant.

Added TestManagerKillStaysKilledWhenExitWaiterReapsDuringTerminate — a cross-platform regression around Manager.Kill with a KillProcess that simulates the waiter reaping mid-terminate, asserting the final status stays killed. build / -race / full suite / Windows green. @Vasanthdev2004 @coderabbitai review

@gnanam1990
gnanam1990 requested a review from Vasanthdev2004 June 8, 2026 02:44
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and read all relevant background package files
fd . internal/background --type f | sort | xargs -I{} sh -c 'echo "=== {} ==="; cat "{}"'

Length of output: 37586



@gnanam1990 The lifecycle race fix is correct and well-structured. markKilledIfStillRunning before killProcess + MarkExited's "only act on running" guard is the right approach, and TestManagerKillStaysKilledWhenExitWaiterReapsDuringTerminate covers the exact race. A few items:


1. isRunningPID is now a redundant TOCTOU window

killTarget already validates StatusRunning. Then isRunningPID re-checks under a separate lock acquire — creating a gap where state can change — before markKilledIfStillRunning takes yet another lock. markKilledIfStillRunning already handles the "task no longer running" case safely (returns nil without marking). The middle check adds confusion without adding safety:

// Kill — current
pid, err := manager.killTarget(taskID)      // lock 1: asserts running
if !manager.isRunningPID(taskID, pid) {      // lock 2: redundant re-check
    return nil
}
if err := manager.markKilledIfStillRunning(taskID, pid); err != nil { // lock 3

isRunningPID can be dropped; markKilledIfStillRunning is the authoritative atomic transition:

pid, err := manager.killTarget(taskID)
if err != nil {
    return err
}
if err := manager.markKilledIfStillRunning(taskID, pid); err != nil {
    return err
}

2. restoreRunningAfterFailedKill can surface a dead PID as "running"

If killProcess returns an error that is not process-gone (e.g., EPERM), the sequence is:

  1. markKilledIfStillRunning → task=killed (MarkExited becomes no-op ✓)
  2. killProcess fails with EPERM
  3. restoreRunningAfterFailedKill → task=running, PID still set

But the process may in fact be dead (MarkExited was silenced by step 1). The task is now stuck as running with a dead PID that will never be reaped.

A targeted fix: check signal-0 liveness before restoring, or treat EPERM the same as ESRCH (process-gone) in processGoneError. At minimum, a comment acknowledging the edge case would be useful.


3. Two items from the previous review are still unaddressed

sleep 0.2 portability — POSIX sleep only guarantees integer operands; busybox-based containers will reject this. Replace with sleep 1:

cmd := exec.Command("sh", "-c", "trap '' TERM; echo ready; while true; do sleep 1; done")

Post-SIGKILL confirmation pollterminateProcess still returns nil immediately after process.Kill() without confirming the process has left the runnable state. The brief polling loop suggested in the previous review is still missing.


The manager-level fix and test are solid. The sleep 0.2 and SIGKILL confirmation items are the most portable/correctness-critical ones to resolve.

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

Rereviewed PR #134 on current head 39cbd06bef12.

Verdict: APPROVED

The previous blocker is fixed. Manager.Kill now records user kill intent before the blocking POSIX terminate path, so the background Wait() callback cannot clobber a user-initiated stop into error. The new regression test simulates the waiter calling MarkExited while termination is in progress and asserts the final task state remains killed.

Validation I ran locally:

  • git diff --check origin/main...HEAD
  • go test ./internal/background -count=1
  • go test ./internal/specialist -count=1
  • go test ./...
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke

Note: I’m on Windows, so the POSIX signal-specific tests remain CI-covered rather than locally executed here.

@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

🤖 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/manager.go`:
- Around line 275-283: markKilledIfStillRunning currently returns nil even when
it didn't apply the optimistic "killed" mark, causing Kill to proceed to
killProcess and potentially signal a stale PID; change markKilledIfStillRunning
to return a distinct indicator (e.g. (bool, error) or a sentinel error) that
conveys whether the optimistic mark was actually set, then update the caller in
Kill (the block using markKilledIfStillRunning, killProcess, and
restoreRunningAfterFailedKill) to only call killProcess when the mark was
applied and to return early (nil) when it wasn't; ensure
restoreRunningAfterFailedKill is only invoked when killProcess was attempted and
failed.
- Around line 279-282: The rollback path currently swallows persistence failures
when undoing an optimistic kill (called via
manager.restoreRunningAfterFailedKill after killProcess fails), so if
persistTaskLocked fails the task can remain incorrectly marked killed; change
restoreRunningAfterFailedKill to return an error (or return the
persistTaskLocked error) and update the callers in the kill error paths (the
block that calls manager.restoreRunningAfterFailedKill at the failing-kill
branch and the similar block around lines 352-366) to check that return value
and propagate a combined/wrapped error (e.g., include both the original
killProcess error and any persistTaskLocked error) instead of ignoring the
persistence failure. Ensure you reference and modify
manager.restoreRunningAfterFailedKill, killProcess, and persistTaskLocked
accordingly so rollback persistence failures are surfaced to callers.
🪄 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 Plus

Run ID: cdfafefe-6263-41ba-ad2d-c002e17de4a7

📥 Commits

Reviewing files that changed from the base of the PR and between 73154c4 and 39cbd06.

📒 Files selected for processing (2)
  • internal/background/manager.go
  • internal/background/manager_test.go

Comment thread internal/background/manager.go Outdated
Comment thread internal/background/manager.go Outdated

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

Updated rereview for PR #134 on current head 39cbd06bef12.

Verdict: CHANGES_REQUESTED

The original stop-vs-waiter race is fixed, but CodeRabbit's two current-head findings are valid and should be addressed before merge:

  1. [P1] Kill can still signal after the optimistic killed mark was not applied.
    markKilledIfStillRunning returns nil both when it successfully marks the task and when the task is no longer running. Kill then proceeds to killProcess(pid) either way. In the exit/reuse race, that can still signal a stale PID. Make markKilledIfStillRunning return whether it actually marked the task, and only call killProcess when the mark was applied.

  2. [P2] Rollback persistence failures are swallowed.
    If killProcess fails after the optimistic mark and restoreRunningAfterFailedKill cannot persist the rollback, callers only see the kill error while the task may remain stored as killed. Have restoreRunningAfterFailedKill return an error and join/wrap it with the original kill error.

Validation I ran locally before this verdict:

  • git diff --check origin/main...HEAD
  • go test ./internal/background -count=1
  • go test ./internal/specialist -count=1
  • go test ./...
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke

So the build/test surface is green, but the manager edge cases need a small fix.

Two follow-ups to the kill-ordering fix (CodeRabbit):

1. markKilledIfStillRunning now returns whether it actually marked. If the task exited (or was reused) between the running check and the mark, Kill returns without calling killProcess — so it never signals a possibly-stale pid in the exit/reuse race.

2. restoreRunningAfterFailedKill returns its persistence error; on a failed terminate, Kill joins the kill error and the rollback error (errors.Join) instead of swallowing a failed rollback that would leave the task wrongly marked killed.

Adds TestMarkKilledIfStillRunningReportsWhetherItMarked. build/-race/full-suite + GOOS=windows green.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Two follow-ups fixed:

  1. Stale-pid signal: markKilledIfStillRunning now reports whether it actually marked; if the task exited/was reused between the running check and the mark, Kill returns without calling killProcess, so it never signals a stale pid.
  2. Swallowed rollback error: restoreRunningAfterFailedKill returns its persist error, and Kill joins it with the kill error (errors.Join) instead of leaving the task wrongly marked killed.

Added TestMarkKilledIfStillRunningReportsWhetherItMarked. -race + full suite + Windows green. @coderabbitai @Vasanthdev2004 review

@gnanam1990
gnanam1990 requested a review from Vasanthdev2004 June 8, 2026 03:00
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd . internal/background --type f | sort | xargs -I{} sh -c 'echo "=== {} ==="; cat "{}"'

Length of output: 39192



@gnanam1990 The two new fixes are correct: the (bool, error) return from markKilledIfStillRunning + the if !marked { return nil } guard is a clean atomic solution to the stale-PID problem, and surfacing the rollback error via errors.Join is the right call. TestMarkKilledIfStillRunningReportsWhetherItMarked covers both the "marks on first call" and "returns false on second call" paths properly.

Two items have now been flagged in both previous rounds and are still not addressed:


1. sleep 0.2 — still present (flagged twice)

// process_posix_test.go
cmd := exec.Command("sh", "-c", "trap '' TERM; echo ready; while true; do sleep 0.2; done")

POSIX sleep guarantees integer operands only. On busybox/Alpine containers this will error at runtime. The fix is a one-word change:

cmd := exec.Command("sh", "-c", "trap '' TERM; echo ready; while true; do sleep 1; done")

The inner sleep has no bearing on signal delivery — the sh process itself receives SIGTERM — so sleep 1 works just as well.


2. Post-SIGKILL confirmation poll — still missing (flagged twice)

// process_posix.go — after force-kill
if err := process.Kill(); err != nil && !processGoneError(err) {
    return err
}
return nil  // ← process may still be a zombie; the comment says "returns nil once the process is gone" but this breaks that contract

The function's own doc comment says "It returns nil once the process is gone"; the pre-SIGKILL polling loop upholds that, but the post-SIGKILL path doesn't. Suggested fix (mirrors the existing polling pattern):

if err := process.Kill(); err != nil && !processGoneError(err) {
    return err
}
for range 20 {
    if !processAlive(process) {
        return nil
    }
    time.Sleep(terminationPollInterval)
}
return nil // SIGKILL delivered; process is dying even if not yet fully reaped

Minor: isRunningPID is now redundant

markKilledIfStillRunning already does the same check atomically under its own lock and returns (false, nil) if the task is no longer running. The isRunningPID call before it adds an extra lock round-trip and a TOCTOU window with no safety benefit. It can be removed:

pid, err := manager.killTarget(taskID)
if err != nil {
    return err
}
// markKilledIfStillRunning is the authoritative atomic transition
marked, err := manager.markKilledIfStillRunning(taskID, pid)

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

Rereviewed PR #134 on current head 3312ffa9d5f2.

Verdict: APPROVED

The prior manager edge cases are fixed. markKilledIfStillRunning now reports whether the optimistic killed mark was actually applied, so Kill does not signal a possibly stale PID when the task is no longer running. The failed-kill rollback now returns and surfaces persistence failures instead of silently leaving the task marked killed. CodeRabbit has also approved this head.

Validation I ran locally:

  • git diff --check origin/main...HEAD
  • go test ./internal/background -count=1
  • go test ./internal/specialist -count=1
  • go test ./...
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke

Note: I’m on Windows, so the POSIX signal-specific tests are CI-covered rather than locally executed here.

@gnanam1990
gnanam1990 merged commit 669ce4a into main Jun 8, 2026
6 checks passed
@Vasanthdev2004
Vasanthdev2004 deleted the background-kill-escalation branch June 28, 2026 08:27
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.

2 participants