Files
zero/internal/config/process_windows.go
PierrunoYTandClaude Sonnet 5 5c58256abc fix(config): kill provider-command process tree via job object on Windows (#690)
* fix(config): kill provider-command process tree via job object on Windows

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 #683

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(config): harden provider-command tree termination

Address code review on PR #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 <[email protected]>

* fix(config): avoid gofmt smart-quote rewrite in doc comment

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.

* fix(config): terminate leaked descendants on nonzero exit, avoid PID 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 #690 review feedback.

* fix(config): surface suspended-thread resume failures instead of hanging

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 #690 review feedback; PR description corrected

separately to drop the outdated JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE claim.

* fix(config): guard taskkill with retained process identity

* fix(config): retain POSIX process group identity

* test(tui): match provider wizard shortcut

* test(tui): pin credential-step env for deterministic footer assertion

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 <[email protected]>

* fix(config): preserve provider process attributes

* test(config): keep SysProcAttr coverage portable

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-24 09:03:59 +08:00

175 lines
5.7 KiB
Go

//go:build windows
package config
import (
"context"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
// configureCommandProcess starts the process suspended so it can be
// assigned to the job object before its main thread (and therefore any
// code it runs) executes. Without this, a fast command can spawn and
// detach a descendant before AssignProcessToJobObject runs, letting that
// descendant escape the job and survive termination.
func configureCommandProcess(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED}
}
func startCommandProcess(cmd *exec.Cmd) (*commandProcess, error) {
configureCommandProcess(cmd)
if err := cmd.Start(); err != nil {
return nil, err
}
return attachCommandProcess(cmd), nil
}
// commandProcess tracks a started provider command so its entire process
// tree can be terminated on timeout. It prefers a job object: taskkill /T
// walks the tree by parent PID in user space and can miss descendants,
// letting them run to completion while Wait blocks on inherited pipes.
type commandProcess struct {
cmd *exec.Cmd
job windows.Handle
processHandle windows.Handle
}
func attachCommandProcess(cmd *exec.Cmd) *commandProcess {
proc := &commandProcess{cmd: cmd}
if cmd.Process == nil {
return proc
}
// The main thread is suspended (see configureCommandProcess); resume it
// once we're done attaching, however that turns out. If resuming fails
// outright (Toolhelp/OpenThread/ResumeThread errors are otherwise
// swallowed), the process would sit suspended forever and never exit on
// its own, making the failure look like an ordinary 5s provider-command
// timeout. Terminate it immediately instead so the real cause surfaces
// quickly and no suspended process is left behind.
defer func() {
if !resumeMainThread(cmd.Process.Pid) {
proc.Terminate()
}
}()
handle, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(cmd.Process.Pid))
if err != nil {
return proc
}
proc.processHandle = handle
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return proc
}
if err := windows.AssignProcessToJobObject(job, handle); err != nil {
_ = windows.CloseHandle(job)
return proc
}
proc.job = job
return proc
}
// resumeMainThread resumes every (assumed suspended) thread of pid and
// reports whether at least one was actually resumed. Callers must treat a
// false result as a failed resume, not a benign no-op: a suspended process
// that's never woken up will neither exit nor produce output on its own.
func resumeMainThread(pid int) bool {
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
if err != nil {
return false
}
defer func() { _ = windows.CloseHandle(snapshot) }()
var entry windows.ThreadEntry32
entry.Size = uint32(unsafe.Sizeof(entry))
resumed := false
for err := windows.Thread32First(snapshot, &entry); err == nil; err = windows.Thread32Next(snapshot, &entry) {
if entry.OwnerProcessID != uint32(pid) {
continue
}
thread, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID)
if err != nil {
continue
}
if _, err := windows.ResumeThread(thread); err == nil {
resumed = true
}
_ = windows.CloseHandle(thread)
}
return resumed
}
func (p *commandProcess) Terminate() {
if p.job != 0 {
// A job handle means the process tree was contained from launch, so
// TerminateJobObject alone kills every member atomically. Don't also
// fall through to the taskkill/PID path below: 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 forcing /T /PID against a reused PID would
// kill the wrong tree.
_ = windows.TerminateJobObject(p.job, 1)
return
}
if p.cmd.Process == nil {
return
}
if p.processHandle == 0 {
// Without a retained identity, never target a numeric PID that may
// have been reaped and reused. os.Process.Kill uses Go's original
// process state instead.
_ = p.cmd.Process.Kill()
return
}
// Fallback for the rare case where job creation or assignment failed:
// only invoke taskkill while the identity-preserving handle confirms the
// original root is still active. Keeping that handle open through Run
// prevents Windows from reusing its PID for an unrelated process.
var exitCode uint32
if err := windows.GetExitCodeProcess(p.processHandle, &exitCode); err == nil && exitCode == stillActive {
taskkill := taskkillPath()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
_ = exec.CommandContext(ctx, taskkill, "/T", "/F", "/PID", strconv.Itoa(p.cmd.Process.Pid)).Run()
cancel()
}
_ = windows.TerminateProcess(p.processHandle, 1)
}
// Close releases the retained handles without touching any still-running
// descendants: the job carries no KILL_ON_JOB_CLOSE limit, so on the success
// path a provider command's detached helpers keep running exactly as they did
// before job objects were introduced. Descendant termination happens
// explicitly via Terminate, called only on timeout/error.
func (p *commandProcess) Close() {
if p.job != 0 {
_ = windows.CloseHandle(p.job)
p.job = 0
}
if p.processHandle != 0 {
_ = windows.CloseHandle(p.processHandle)
p.processHandle = 0
}
}
const stillActive = uint32(259) // STILL_ACTIVE
func taskkillPath() string {
systemRoot := os.Getenv("SystemRoot")
if systemRoot == "" {
systemRoot = os.Getenv("windir")
}
if systemRoot == "" {
systemRoot = `C:\Windows`
}
return filepath.Join(systemRoot, "System32", "taskkill.exe")
}