Skip to content

fix(config): kill provider-command process tree via job object on Windows#690

Open
PierrunoYT wants to merge 11 commits into
Gitlawb:mainfrom
PierrunoYT:fix/windows-provider-command-timeout
Open

fix(config): kill provider-command process tree via job object on Windows#690
PierrunoYT wants to merge 11 commits into
Gitlawb:mainfrom
PierrunoYT:fix/windows-provider-command-timeout

Conversation

@PierrunoYT

@PierrunoYT PierrunoYT commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #683TestLoadProviderCommandTimeout could take ~10.2s on Windows because the 5s provider-command timeout didn't reliably kill the process tree.

taskkill /T walks the process tree by parent PID in user space, so it can miss descendants (and depends on taskkill.exe being runnable in the environment). When the sleeping PowerShell child survived, it kept the inherited stdout/stderr pipe write ends open, and cmd.Wait() blocked until the child completed naturally.

Changes

  • Job object termination (Windows): the process is started with CREATE_SUSPENDED and assigned to a job object before its main thread resumes, so a fast command can't spawn and detach a descendant before containment is in place. On timeout, TerminateJobObject kills every member of the tree atomically. Note: the job does not set JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE — releasing the handle on the success path intentionally leaves a provider command's detached helpers running, matching pre-job-object behavior; termination only happens explicitly via Terminate() on timeout/error. taskkill /T /F + Process.Kill() remain as a fallback for the rare case where job creation/assignment fails, and are skipped when a job successfully contained the tree (to avoid acting on a PID that cmd.Wait() may have already reaped and that Windows could have reused).
  • cmd.WaitDelay = 1s: bounds pipe draining after process exit, so Wait can't hang on pipe handles held by an escaped orphan. Terminate() now runs for any error from Wait (not just exec.ErrWaitDelay), since a nonzero-exit shell (e.g. sleep 600 & exit 7) also needs its leaked background child cleaned up.
  • Resume-failure handling: if the suspended main thread can't be resumed (Toolhelp/OpenThread/ResumeThread failure), the process is terminated immediately instead of being left suspended until the timeout expires and silently misreported as an ordinary slow command.
  • Regression tests: the timeout fixture's sleeping child records its PID and the test asserts it's actually gone after LoadProviderCommand returns (Windows and POSIX), including a nonzero-exit background-child variant and a resumeMainThread failure-reporting test.

Known limitation

If job creation or assignment itself fails, the taskkill /T fallback can still miss a descendant that has reparented away from the tree it walks. This is not a regression versus pre-job-object behavior (taskkill was the only mechanism then too) and is tracked as a follow-up rather than blocking this fix.

Verification

  • go build ./... — pass
  • go vet ./internal/config/... — pass
  • go test ./internal/config/... — pass, including the new nonzero-exit background-child test and the resumeMainThread failure-reporting test

Summary by CodeRabbit

  • Bug Fixes
    • Improved provider command timeout handling so timed-out commands are reliably terminated along with any detached background child processes.
    • Added bounded output-draining behavior after process exit for more predictable timeout completion.
    • Strengthened cross-platform process cleanup by preserving/isolating process-group/job identity and ensuring proper termination on both POSIX and Windows.
    • Added/updated tests to verify no leaked background processes and correct behavior on timeout and nonzero exits.

…dows

taskkill /T walks the process tree by parent PID in user space and can
miss descendants, leaving the sleeping child holding the inherited
stdout/stderr pipes so cmd.Wait() blocked until natural completion
(~10s instead of the 5s timeout).

Assign the started command to a job object with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE and terminate the job on timeout so
the kernel kills the whole tree atomically; taskkill remains as a
fallback. Also set cmd.WaitDelay so Wait can never hang on pipes held
by an escaped orphan, and add a regression assertion that the sleeping
child is actually gone after the timeout returns.

Fixes Gitlawb#683

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 14, 2026 20:49

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 addresses Windows flakiness/performance in provider-command timeouts by ensuring the entire provider-command process tree is reliably terminated (fixing Issue #683, where TestLoadProviderCommandTimeout could take ~10s instead of ~5s on Windows).

Changes:

  • Introduces a cross-platform commandProcess abstraction and uses a Windows job object (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) to kill the process tree atomically on timeout (with taskkill/Process.Kill fallback).
  • Sets cmd.WaitDelay = 1s to bound post-exit pipe draining so Wait() can’t hang indefinitely.
  • Extends the timeout regression test to record a sleeper PID and assert the child process is actually terminated on both Windows and POSIX.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
internal/config/process_windows.go Adds job-object-based process-tree termination for provider commands on Windows (plus fallback termination).
internal/config/process_windows_test.go Adds Windows implementation of processAlive(pid) used by the timeout regression assertion.
internal/config/process_posix.go Refactors POSIX termination to the new commandProcess abstraction (process group kill + direct kill).
internal/config/process_posix_test.go Adds POSIX implementation of processAlive(pid) used by the timeout regression assertion.
internal/config/command.go Hooks in attachCommandProcess + Close() lifecycle and sets cmd.WaitDelay to bound Wait() behavior.
internal/config/command_test.go Updates timeout fixture to persist PID and assert no leftover sleeper process remains.

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

Comment thread internal/config/process_windows.go Outdated
Comment on lines +59 to +62
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&info)), uint32(unsafe.Sizeof(info))); err != nil {
_ = windows.CloseHandle(job)
return 0, err
}
Comment thread internal/config/process_windows_test.go Outdated
Comment on lines +18 to +19
const stillActive = 259 // STATUS_PENDING
return code == stillActive
Comment on lines +157 to +161
sleep := "Start-Sleep -Seconds " + itoa(script.SleepSeconds)
if script.PidFile != "" {
sleep = "Set-Content -Path '" + script.PidFile + "' -Value $PID -Encoding Ascii; " + sleep
}
lines = append(lines, "powershell -NoProfile -Command \""+sleep+"\"")
Comment on lines +179 to +181
if script.PidFile != "" {
lines = append(lines, "echo $$ > '"+script.PidFile+"'")
}

@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] Terminate the process group when WaitDelay expires
    internal/config/command.go:71
    A command such as sleep 600 & exit lets its shell exit immediately while the background child retains the inherited stdout/stderr pipes. cmd.Wait() then returns exec.ErrWaitDelay after the new one-second delay, which selects this branch and returns without calling proc.Terminate(). On POSIX Close is a no-op, so the child continues running; before this change the five-second timer would kill that process group. Treat the wait-delay result as an incomplete command, terminate the tree, and add a background-child regression case.

  • [P1] Close the Start-to-job-assignment escape window
    internal/config/process_windows.go:31
    The job is attached only after cmd.Start() has allowed cmd.exe to execute. A fast command can create and detach a child before AssignProcessToJobObject, leaving that child outside the job. On timeout, Terminate() kills the job/root first and only then runs taskkill against the now-dead root, so the fallback cannot traverse to that reparented child. It can continue holding the pipes or executing after the timeout, preserving the race this PR is intended to eliminate. Start suspended and assign before resuming (or use equivalent launch-time containment), and test a forced pre-assignment child.

  • [P2] Do not kill descendants after a successful provider command
    internal/config/process_windows.go:80
    Close is deferred for every return path, and the job has JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. Therefore a provider command which successfully emits its JSON but launches a detached helper/proxy has that child terminated as LoadProviderCommand returns. The old behavior only killed the tree on timeout. Restrict the kill-on-close cleanup to timeout/error cleanup, or otherwise preserve successful-command descendants.

  • [P3] Quote PID-file paths when generating the timeout fixture
    internal/config/command_test.go:159
    The new PowerShell and POSIX fixtures interpolate PidFile inside single-quoted shell literals without escaping apostrophes. A temporary directory such as O'Brien produces an invalid script, so the PID file is never written and this test fails before exercising the timeout behavior. Escape the platform-specific quote character (or avoid shell interpolation) for both generated scripts.

  • [P3] Correct the Windows process-status comment
    internal/config/process_windows_test.go:19
    Exit code 259 is STILL_ACTIVE, not STATUS_PENDING. The value is correct, but the comment misstates the Windows API contract and will mislead future maintenance; use the proper constant name or correct the comment.

Address code review on PR Gitlawb#690: terminate the process tree when
cmd.Wait() returns exec.ErrWaitDelay (a background descendant kept
inherited pipes open, previously left running); start the Windows
process suspended and assign it to the job object before resuming its
main thread, closing the window where a fast child could escape the
job; drop JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so a successful command's
detached helpers survive Close() instead of being reaped; escape
single quotes in generated PID-file paths; and correct a misnamed
Windows exit-code constant in a comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 15, 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: 6 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: 8d3a7cf4-b294-4631-b36c-3dedbb718408

📥 Commits

Reviewing files that changed from the base of the PR and between e10961e and 467ff55.

📒 Files selected for processing (3)
  • internal/config/process_posix.go
  • internal/config/process_posix_test.go
  • internal/tui/provider_wizard_test.go

Walkthrough

Provider command execution now bounds I/O draining, attaches platform-specific process handles, and terminates process trees on timeout or wait-delay errors. Cross-platform tests verify prompt termination of timed-out commands and detached children.

Changes

Provider command termination

Layer / File(s) Summary
Platform process lifecycle
internal/config/process_posix.go, internal/config/process_windows.go
Adds POSIX process-group and Windows job-object management with explicit termination and cleanup.
Timeout and wait-delay handling
internal/config/command.go
Sets a one-second WaitDelay, attaches the command process, and terminates the process tree on timeout or exec.ErrWaitDelay.
Process termination regression coverage
internal/config/command_test.go, internal/config/process_*_test.go
Adds PID-recording fixtures and platform-specific liveness checks for provider processes and detached children.

Provider wizard test update

Layer / File(s) Summary
Credential-step prompt expectation
internal/tui/provider_wizard_test.go
Pins the API key environment variable before validating credential-step navigation text.

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

Suggested reviewers: anandh8, gnanam1990, vasanthdev2004

Sequence Diagram(s)

sequenceDiagram
  participant ProviderCommand
  participant ProcessHandle
  participant ProcessTree
  ProviderCommand->>ProcessHandle: attach command
  ProcessHandle->>ProcessTree: register process group or job
  ProviderCommand->>ProcessHandle: terminate on timeout or wait-delay
  ProcessHandle->>ProcessTree: kill descendants
  ProcessTree-->>ProviderCommand: command returns timeout
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also changes POSIX process-group handling and a provider-wizard test, which are unrelated to the Windows timeout issue. Move the POSIX refactor and provider-wizard test into separate PRs unless they are required to land the Windows timeout fix.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title is concise and accurately highlights the main Windows provider-command process-tree termination fix.
Linked Issues check ✅ Passed The Windows job-object timeout changes, WaitDelay handling, and regression tests address #683's need to kill descendants promptly.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ 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[bot]
coderabbitai Bot previously approved these changes Jul 15, 2026
gofmt's doc-comment formatter rewrites a trailing '' into a curly
closing quote, which CI's gofmt -l check flags as unformatted. Reword
to avoid the pattern instead of embedding a raw quote pair.

@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] Terminate leaked descendants when the provider shell exits nonzero
    internal/config/command.go:71
    exec.Cmd.Wait preserves an ExitError over its pipe-drain ErrWaitDelay. A command such as sleep 600 & exit 7 therefore returns exit status 7 after the one-second drain deadline, bypasses this ErrWaitDelay branch, and leaves the background process running: POSIX Close is a no-op and the Windows job handle is merely released. Track the drain-timeout condition independently and terminate the tree even when the shell's exit status is nonzero; add a nonzero-exit background-child regression case.

  • [P1] Do not rely on taskkill after failed job assignment and a completed wait
    internal/config/process_windows.go:53
    If a parent job forbids nested jobs (or either job setup call fails), assignment falls back to taskkill. A provider command can then start a detached child and exit; by the time WaitDelay returns, cmd.exe has been reaped and /T /PID <cmd.exe> cannot walk to the reparented child. LoadProviderCommand reports a timeout while the child remains running, recreating the leak this PR is intended to eliminate. Provide launch-time containment for the fallback or otherwise preserve a reliable descendant handle, and test a forced assignment-failure/background-child path.

  • [P1] Never pass a reaped provider PID to taskkill
    internal/config/process_windows.go:85
    On the ErrWaitDelay path, cmd.Wait has already reaped the root process. Even after a successfully attached job has terminated the original tree, this method unconditionally runs taskkill /PID using that stale numeric PID. Windows can reuse PIDs, so a fast-reused PID can make a provider-config timeout forcibly kill an unrelated process tree. Skip the PID fallback once the job path was used, and do not use a post-reap PID as a tree identity.

  • [P2] Handle failure to resume the suspended command
    internal/config/process_windows.go:64
    Every provider command is created with CREATE_SUSPENDED, but failures from the Toolhelp snapshot, thread enumeration, OpenThread, and ResumeThread are ignored. If the primary thread cannot be reopened (Toolhelp acquisition itself is fallible), it stays suspended and an otherwise valid command is misreported as a five-second timeout. Retry or surface the resume failure, ensure the process is cleaned up, and add coverage for that failure path.

  • [P3] Correct the PR description's job-object semantics
    internal/config/process_windows.go:99
    The PR description says the job uses JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, but the current implementation explicitly does not set that limit and intentionally leaves successful-command descendants running when Close releases the job handle. Update the description so reviewers do not approve a behavior the code deliberately avoids.

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

The job-object approach is sound — suspend-start, assign to the job, then resume means descendants can't escape the tree, and the WaitDelay + ErrWaitDelay handling closes the pipe-leak gap that taskkill /T had. Build, vet, gofmt, and the config tests all pass on Windows, including the new background-child test (1.66s, confirming the WaitDelay path fires and the leftover child is actually killed). Two small things worth a glance: resumeMainThread resumes every thread owned by the pid, not just the primary one (harmless, arguably more correct, but the name oversells it); and the PR description claims JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE is set, but the code intentionally doesn't set it — the Close() comment is accurate, so the description should be corrected to avoid confusing future readers. Approving as-is.

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

Switching my earlier approve to request changes I reproduced the leak jatmn flagged.

The blocking one: a provider command that exits nonzero while a detached child holds the inherited pipes (e.g. sleep 600 & exit 7) leaks the child. Go only returns exec.ErrWaitDelay on a successful exit a nonzero exit yields the bare *ExitError, so the errors.Is(err, exec.ErrWaitDelay) branch at command.go:72 is skipped, proc.Terminate() never runs, and the child survives (on Windows Close just drops the job handle no KILL_ON_JOB_CLOSE; on POSIX Close is a no-op). I confirmed it on Windows: the sleeper PID stayed alive after a ...exit status 7. The new background-child test uses exit 0, so it only walks the already-working branch. It's cross-platform the POSIX path has the same gating. Smallest fix: call proc.Terminate() whenever err != nil (killing an already-dead parent is harmless), and add an ExitCode: 7 + BackgroundSleepSeconds test variant.

Second one, high: process_windows.go Terminate() runs taskkill /T /F /PID + Process.Kill() unconditionally, even after TerminateJobObject already killed the tree. On the ErrWaitDelay path the root is already reaped by Wait, so that PID is free and Windows can hand it to an unrelated process taskkill /T on a reused PID force-kills the wrong tree. Gate the PID fallback with if p.job == 0 (it's only the real fallback when job creation failed) and skip it once the process is reaped.

The rest are real but fine as follow-ups: the no-job fallback can't reach a reparented descendant (non-regressive pre-PR had no containment at all), resumeMainThread swallows Toolhelp/OpenThread/ResumeThread failures so a stuck-suspended command misreports as a 5s timeout, the new background-child test can flake if PowerShell cold-start loses the race with the 1s WaitDelay kill before bg.pid is written, and the itoa test helper returns "" for negatives. What's solid: CREATE_SUSPENDED + assign-before-resume closes the root escape window, the job kills the whole tree on the success path, the POSIX group-kill is correct, and bounding Wait with WaitDelay is the right idea.

…reuse

Terminate the process tree on any error returned from cmd.Wait, not only

exec.ErrWaitDelay. Go only returns ErrWaitDelay when the shell itself

exited successfully; a nonzero exit (e.g. sleep 600 & exit 7) yields the

bare *ExitError instead, so a detached background child that held the

inherited pipes open was never terminated on that path.

Also stop falling through to the taskkill/PID fallback once a job handle

successfully contained the tree: by the time Terminate runs on the

ErrWaitDelay path, cmd.Wait has already reaped the root process, so its

PID may have been reused by an unrelated process, and taskkill /T /PID

against a reused PID would force-kill the wrong tree.

Addresses PR Gitlawb#690 review feedback.
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 17, 2026
resumeMainThread now reports whether it actually resumed at least one

thread. If Toolhelp/OpenThread/ResumeThread all fail, the launched

process would otherwise sit suspended forever, never exit on its own,

and get misreported as a plain 5s provider-command timeout instead of

the real resume failure. attachCommandProcess now terminates the

process immediately in that case.

Also documents the known, non-regressive limitation that the taskkill

fallback (used only when job creation/assignment itself fails) can

still miss a descendant that has reparented away from the tree it

walks by PID.

Addresses remaining PR Gitlawb#690 review feedback; PR description corrected

separately to drop the outdated JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE claim.

@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] Do not invoke taskkill with a PID that Wait has already reaped
    internal/config/process_windows.go:115
    When creating or assigning the job fails (for example, in a non-nestable parent job), proc.job stays zero. On the new WaitDelay and nonzero-exit paths, cmd.Wait() has already reaped cmd.exe before Terminate reaches this fallback, so its numeric PID can already have been recycled. taskkill /T /F /PID can then force-kill an unrelated process tree; this is precisely the stale-PID hazard the job path avoids above. Keep the fallback from using the PID after a completed wait (and use an identity-preserving cleanup path for the no-job case).

  • [P2] Synchronize the Windows fixture before starting the one-second wait-delay race
    internal/config/command_test.go:235
    start /B powershell returns before the child gets to Set-Content. On a cold or loaded Windows machine, the new job can be terminated by the one-second WaitDelay before that write, after which assertProcessTerminated fails to read bg.pid. That makes the regression test flaky and can let it fail without ever proving that a pipe-holding child was started. Arrange for the fixture to publish the PID before the parent exits, or otherwise synchronize PID-file creation before exercising the wait-delay path.

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

Most of this landed exactly as asked: Terminate() now fires on any nonzero Wait result, the exit-7 background-child test proves the leak is closed, the job path no longer falls through to taskkill after TerminateJobObject, and surfacing resume failures instead of letting a suspended process masquerade as a 5s timeout is a nice touch. CI is green across the board, including the new tests on Windows.

One thing keeps me at request-changes, and it's the second half of my earlier ask — jatmn hit the same spot on this head. When job creation/assignment fails, Terminate() still hands the raw PID to taskkill /T /F on the WaitDelay/nonzero-exit paths (process_windows.go:123-125), where cmd.Wait() has already reaped cmd.exe. A dead root can't be tree-walked, so the call buys nothing there — and if Windows recycled the PID it force-kills an unrelated tree. Before this PR, taskkill only ever ran while the root was still alive, so this path is new exposure. Cheapest fix I see: skip the PID fallback whenever Wait has already returned (the caller knows which branch it's on), or keep the handle you already open in attachCommandProcess when assignment fails and check STILL_ACTIVE before touching the PID. The start /B fixture race jatmn mentioned is worth tightening while you're in there, but I'm not blocking on that one since CI is green.

@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/config/process_windows.go (1)

127-133: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a timeout to the taskkill fallback execution.

exec.Command(...).Run() blocks indefinitely until the command completes. While taskkill.exe usually completes quickly, underlying system issues (such as an unresponsive WMI or RPC service on Windows) can cause it to hang, which would indefinitely block the termination sequence on the request thread.

Consider using exec.CommandContext with a short timeout to ensure the process tree termination gracefully falls back to TerminateProcess if taskkill stalls.

💡 Proposed refactor
-	if err := windows.GetExitCodeProcess(p.processHandle, &exitCode); err == nil && exitCode == stillActive {
-		taskkill := taskkillPath()
-		_ = exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(p.cmd.Process.Pid)).Run()
-	}
+	if err := windows.GetExitCodeProcess(p.processHandle, &exitCode); err == nil && exitCode == stillActive {
+		taskkill := taskkillPath()
+		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+		defer cancel()
+		_ = exec.CommandContext(ctx, taskkill, "/T", "/F", "/PID", strconv.Itoa(p.cmd.Process.Pid)).Run()
+	}

(Note: Ensure the context and time packages are imported.)

🤖 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/config/process_windows.go` around lines 127 - 133, Update the
taskkill fallback in the process termination method to use exec.CommandContext
with a short context timeout, importing context and time as needed. Preserve the
existing taskkill arguments and ensure the flow continues to
windows.TerminateProcess even when taskkill times out or fails.
🤖 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/config/process_windows.go`:
- Around line 127-133: Update the taskkill fallback in the process termination
method to use exec.CommandContext with a short context timeout, importing
context and time as needed. Preserve the existing taskkill arguments and ensure
the flow continues to windows.TerminateProcess even when taskkill times out or
fails.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 767acdaf-31d1-41e7-a28a-505cf5c7cc40

📥 Commits

Reviewing files that changed from the base of the PR and between 2020680 and 4559643.

📒 Files selected for processing (3)
  • internal/config/command_test.go
  • internal/config/process_windows.go
  • internal/config/process_windows_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/config/command_test.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 18, 2026

@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] Do not signal a recycled POSIX process group after Wait reaps the shell
    internal/config/process_posix.go:26
    The new unconditional error path calls Terminate only after cmd.Wait() has returned, which means the shell has already been reaped. On every ordinary nonzero provider-command exit, the unconditional Kill(-p.cmd.Process.Pid, SIGKILL) can therefore use a recycled numeric process-group ID; if another process has claimed that PID and formed a group before this call, Zero kills that unrelated group. The same window exists after ErrWaitDelay when a child has left the original group, and the new tests only cover children that remain in it. Preserve an identity/liveness guarantee for the group, or avoid the group-directed kill once Wait has completed.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Addressed the remaining process-lifecycle feedback in d6700a1 and pushed the requested TUI baseline correction in 0089d0d.

Process safety

  • POSIX provider commands now join a process group led by a dedicated anchor process.
  • The anchor remains alive and unreaped until cleanup, so Terminate never signals a group identified by the already-reaped provider PID.
  • Timeout and nonzero-exit cleanup still kill pipe-holding descendants with one group-directed signal.
  • Added a POSIX regression test that reaps the provider first, verifies the independent group identity remains live, and confirms cleanup terminates it.
  • The Windows taskkill fallback now uses a two-second context timeout and still falls through to TerminateProcess on timeout/failure.

Additional requested fix

  • Updated the stale provider-wizard test assertion from Enter continue to the current Enter/→ continue rendering.

Validation

  • go test ./internal/config on Windows
  • go test ./internal/config on Ubuntu under WSL
  • Linux cross-compilation of internal/config tests
  • go test ./...
  • go vet ./...
  • govulncheck: no vulnerabilities found
  • The mandated lint command still reports the 35 pre-existing findings tracked in Resolve existing golangci-lint baseline (35 findings) #743; none are in the changed files.

Please re-review the POSIX process-group identity fix.

@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

🤖 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/config/process_posix.go`:
- Line 33: Update the process-group setup around cmd.SysProcAttr to preserve any
attributes already configured on the exec.Cmd: reuse the existing SysProcAttr
when non-nil, initialize it only when absent, and then set Setpgid and Pgid
without replacing the struct.
🪄 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: b23708cd-9bbf-4e5f-aa60-034c2618bf30

📥 Commits

Reviewing files that changed from the base of the PR and between 4559643 and 0089d0d.

📒 Files selected for processing (5)
  • internal/config/command.go
  • internal/config/process_posix.go
  • internal/config/process_posix_test.go
  • internal/config/process_windows.go
  • internal/tui/provider_wizard_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/config/process_posix_test.go
  • internal/config/command.go
  • internal/config/process_windows.go

Comment thread internal/config/process_posix.go Outdated

@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] Restore the credential-step footer assertion
    internal/tui/provider_wizard_test.go:194
    This test now expects Enter/→ continue, but the credential step has no right-arrow action and the renderer returns Enter continue ← back Esc close. The assertion therefore fails deterministically: Smoke (ubuntu-latest), Smoke (macos-latest), and Smoke (windows-latest) all fail on it. Restore the assertion to the current UI contract, or change the production shortcut and its rendering together if that is the intended product change.

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

Re-verified on the current head, and the good news first: every concern I raised is genuinely fixed. Nonzero-exit termination fires now, not just on ErrWaitDelay; the Windows job path no longer falls through to taskkill after TerminateJobObject; and the reaped-PID-to-taskkill hazard I flagged last round is closed the way we discussed, with the retained process handle plus a GetExitCodeProcess == STILL_ACTIVE gate before taskkill. The process-kill logic itself is ready.

The hold is CI: smoke is red on all three OSes, and it's a real failure this PR introduced, not a flake. TestProviderWizardAdvancesProviderAPIKeyAndModelSteps (provider_wizard_test.go:198) expects the key-step footer to contain "Enter/→ continue", but the wizard actually renders "Enter continue ← back Esc close". The test's expected string doesn't match what the footer prints, so it looks like a footer-string change got half-applied to the test. Fix the expectation (or the footer) so the suite goes green and I'll approve.

The credential step's footer only shows the /→ shortcut once a key
is present, whether typed or inherited from the provider's
AuthEnvVars. The previous commit changed the assertion to
"Enter/→ continue" without accounting for that, so the test only
passed by coincidence when ANTHROPIC_API_KEY happened to be set in the
environment — failing deterministically on a clean CI runner (as
smoke reported on all three OSes). Pin the env var empty and assert
the no-key footer text so the test is correct regardless of ambient
environment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 19, 2026

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

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

Review: PR #690 — fix(config): kill provider-command process tree via job object

Verdict: Approve. The core fix is correct and load-bearing — the retained POSIX group anchor and the Windows job object give Terminate() a stable handle on the whole tree even after cmd.Wait() has reaped the root, which is exactly the leak the PR targets. One minor behavioral regression is worth a conscious accept/decline, but nothing here blocks merge.

What the fix does well

  • cmd.WaitDelay = time.Second (internal/config/command.go:47) is what makes cmd.Wait() unblock ~1s after the shell exits instead of hanging on descendant-held pipes until the 5s timer — verified load-bearing: TestLoadProviderCommandTerminatesBackgroundChild (a sleep 10 & exit scenario) asserts return in <4s and that the leaked child PID is killed; that test fails on base (base has no WaitDelay, so it only unblocks at 5s).
  • The POSIX anchor process (internal/config/process_posix.go:20-43) deliberately does not trust the reaped shell PID to name the group — the comment at line 17-19 spells out why, and Terminate() SIGKILLs -groupID.
  • Windows Terminate() (process_windows.go:111-145) correctly refuses to fall through to a taskkill /T /PID against a possibly-reused PID once the root is reaped, gating the fallback on a live STILL_ACTIVE handle. The CREATE_SUSPENDED-then-assign-to-job sequencing (line 23-25, 45-79) closes the escape window for fast descendants. Good defensive commenting throughout.

[Minor] WaitDelay=1s turns a previously-successful command into a spurious timeout — PR-introduced

internal/config/command.go:47 (surfaces at :79-81 and :17-21)

Concrete scenario — a provider command that prints valid JSON then backgrounds a helper without redirecting its stdio and keeps it alive 1–5s:

echo '{"name":"x","provider":"openai","apiKey":"sk","model":"gpt-4"}'
sleep 2 &
exit 0
  • Base (no WaitDelay): the JSON is buffered, the shell exits 0, cmd.Wait() drains the inherited pipes until sleep 2 exits (~2s, under the 5s timer), returns nil, and LoadProviderCommand parses the buffered JSON → success.
  • PR: WaitDelay fires 1s after the shell exits, force-closes the pipes, cmd.Wait() returns exec.ErrWaitDelay; command.go:79-81 maps that to errProviderCommandTimeout, and LoadProviderCommand (line 17-21) discards the already-captured stdout and returns "provider command timed out after 5s" — a config load that worked before now fails (and the helper is SIGKILLed).

This only bites commands that leave an stdio-inheriting descendant alive for 1–5s; anything that redirects (daemon >/dev/null 2>&1 &) is unaffected, which is why this reads as an intended bound on drain time rather than an oversight. Two things worth noting if you accept it: (1) the message says "timed out after 5s" when the cut actually happens at ~1s, which is misleading; (2) on the ErrWaitDelay path stdout already holds valid JSON — parsing it before returning the timeout would preserve base success behavior for this case. Either fix the message or attempt the parse; otherwise, an explicit acknowledgement that this changes the success contract is enough.

Tests

  • gofmt -l clean on all 7 changed files; go build and go vet on internal/config/... and internal/tui/... both exit 0.
  • go test -race ./internal/config/...: ok. All PR tests pass, including TestLoadProviderCommandTimeout, TestLoadProviderCommandTerminatesBackgroundChild{,OnFailure}, TestCommandProcessPreservesSysProcAttr, TestCommandProcessRetainsGroupIdentityAfterProviderWait. Windows-only files are build-tag excluded on darwin.
  • internal/tui package reports FAIL, but solely from TestHandleAddDirCommand (add_dir_test.go:43) — this is a pre-existing environmental sandbox failure: it fails identically on the clean base worktree with the same error (the sandbox already has /private/tmp + the macOS temp dir in its write roots) and is unrelated to the diff. The one tui test the PR actually touches, TestProviderWizardAdvancesProviderAPIKeyAndModelSteps, passes on both head and base. Not PR-attributable.

Merge is kevin's call per the program gate.

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

Approving on 467ff55. Our only hold last round was CI (the provider-wizard credential-step footer assertion), and that is green now across macos, ubuntu, and windows.

The delta since then is clean: the footer test is fixed, and process_posix.go now sets Setpgid/Pgid on the existing SysProcAttr rather than replacing the whole struct, so any attributes a caller already set are preserved. The Windows process-kill logic we worked through last round is unchanged, still gated on the retained process handle plus a GetExitCodeProcess == STILL_ACTIVE check before taskkill.

Good to go, thanks for the thorough back-and-forth. LGTM.

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.

TestLoadProviderCommandTimeout waits for child completion on Windows

5 participants