Skip to content

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

Open
PierrunoYT wants to merge 3 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 3 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): after cmd.Start(), the process is assigned to a job object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; on timeout the job is terminated, so the kernel kills the entire tree atomically. taskkill /T /F + Process.Kill() remain as a fallback for the rare case where job assignment fails or a descendant was spawned before assignment.
  • cmd.WaitDelay = 1s: bounds pipe draining after process exit, so Wait can never hang on pipe handles held by an escaped orphan.
  • Regression test: the timeout fixture's sleeping child now records its PID, and the test asserts the process is actually gone after LoadProviderCommand returns (both Windows and POSIX).

Verification

On Windows (go1.26.5 windows/amd64):

  • go test ./internal/config -run '^TestLoadProviderCommandTimeout$' -count=1 -v → PASS in 5.3s, including the new no-leftover-child assertion.
  • With the taskkill fallback temporarily disabled, the job-object path alone terminates the tree: PASS in 5.02s.
  • Full go build ./... and go test ./internal/config pass.

Note: I couldn't reproduce the original 10.2s failure locally (taskkill happens to work here), but the job object removes the user-space tree-walk race entirely and the new assertion would catch any regression.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved how provider command timeouts stop the full process tree.
    • Added more reliable post-timeout cleanup to prevent lingering background processes and avoid hangs while draining output streams.
  • Tests
    • Expanded timeout tests to verify both main and detached background processes are terminated.
    • Added OS-specific process-aliveness helpers for POSIX and Windows.

…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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c2fb4e79-0609-4103-ad65-998f99e2b3cb

📥 Commits

Reviewing files that changed from the base of the PR and between c847946 and f9da403.

📒 Files selected for processing (1)
  • internal/config/command_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 that timed-out commands and detached child processes exit promptly.

Changes

Provider command termination

Layer / File(s) Summary
Platform process lifecycle
internal/config/process_posix.go, internal/config/process_windows.go
Introduces attached process handles using POSIX process groups and Windows job objects, with platform-specific 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 main processes and detached background children.

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

Suggested reviewers: 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 | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main Windows process-tree termination fix in the PR.
Linked Issues check ✅ Passed The changes match #683 by killing the full provider-command tree promptly and adding regression coverage that the child process is gone.
Out of Scope Changes check ✅ Passed The POSIX wrapper and tests support the same timeout/termination fix and regression coverage, so no unrelated changes stand out.
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.

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.

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

4 participants