diff --git a/internal/config/command.go b/internal/config/command.go index ba4094ee1..01d11cdbb 100644 --- a/internal/config/command.go +++ b/internal/config/command.go @@ -42,16 +42,20 @@ var errProviderCommandTimeout = errors.New("provider command timeout") func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte, error) { cmd := shellCommand(command) - configureCommandProcess(cmd) + // Bound the time Wait spends draining I/O pipes after the process exits, + // in case an orphaned descendant still holds the write ends open. + cmd.WaitDelay = time.Second var stdout bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr - if err := cmd.Start(); err != nil { + proc, err := startCommandProcess(cmd) + if err != nil { return nil, nil, err } + defer proc.Close() done := make(chan error, 1) go func() { @@ -63,9 +67,21 @@ func runProviderCommand(command string, timeout time.Duration) ([]byte, []byte, select { case err := <-done: + if err != nil { + // The shell exited (whether via ErrWaitDelay because a + // background descendant kept the inherited stdout/stderr pipes + // open, or via a nonzero exit status) but a leftover descendant + // may still be running. Terminate is a no-op against an + // already-dead tree, so always call it rather than gating on + // the specific error to avoid leaking that descendant. + proc.Terminate() + } + if errors.Is(err, exec.ErrWaitDelay) { + return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout + } return stdout.Bytes(), stderr.Bytes(), err case <-timer.C: - terminateCommandProcess(cmd) + proc.Terminate() <-done return stdout.Bytes(), stderr.Bytes(), errProviderCommandTimeout } diff --git a/internal/config/command_test.go b/internal/config/command_test.go index 5127424c3..14aec9159 100644 --- a/internal/config/command_test.go +++ b/internal/config/command_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "strings" "testing" "time" @@ -67,7 +68,8 @@ func TestLoadProviderCommandFailureIncludesExitAndRedactsOutput(t *testing.T) { } func TestLoadProviderCommandTimeout(t *testing.T) { - command := writeCommand(t, commandScript{SleepSeconds: 10}) + pidFile := filepath.Join(t.TempDir(), "sleep.pid") + command := writeCommand(t, commandScript{SleepSeconds: 10, PidFile: pidFile}) start := time.Now() _, err := LoadProviderCommand(command) @@ -85,6 +87,87 @@ func TestLoadProviderCommandTimeout(t *testing.T) { if elapsed > maxElapsed { t.Fatalf("timeout returned after %s, want roughly 5s", elapsed) } + assertProcessTerminated(t, pidFile) +} + +// TestLoadProviderCommandTerminatesBackgroundChild covers a command that +// exits immediately but leaves a detached child holding the inherited +// stdout/stderr pipes open (e.g. `sleep 600 & exit`). cmd.Wait() only +// unblocks once WaitDelay elapses, and that path must still terminate the +// leftover child instead of just returning and leaking it. +func TestLoadProviderCommandTerminatesBackgroundChild(t *testing.T) { + pidFile := filepath.Join(t.TempDir(), "bg.pid") + command := writeCommand(t, commandScript{ + Stdout: `{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}`, + BackgroundSleepSeconds: 10, + BackgroundPidFile: pidFile, + }) + + start := time.Now() + _, err := LoadProviderCommand(command) + elapsed := time.Since(start) + if err == nil { + t.Fatal("LoadProviderCommand() error = nil, want error from WaitDelay-bounded wait") + } + if !strings.Contains(err.Error(), "timed out after 5s") { + t.Fatalf("error = %q, want timeout", err.Error()) + } + if elapsed > 4*time.Second { + t.Fatalf("returned after %s, want well under the 5s provider-command timeout since WaitDelay (1s) should trigger termination", elapsed) + } + assertProcessTerminated(t, pidFile) +} + +// TestLoadProviderCommandTerminatesBackgroundChildOnFailure covers the same +// leaked-descendant scenario as TestLoadProviderCommandTerminatesBackgroundChild, +// but with a shell that exits nonzero. Go's exec.Cmd.Wait only returns +// exec.ErrWaitDelay when the command itself exited successfully; a nonzero +// exit yields the bare *ExitError instead, so the leftover child must still +// be terminated on that path. +func TestLoadProviderCommandTerminatesBackgroundChildOnFailure(t *testing.T) { + pidFile := filepath.Join(t.TempDir(), "bg-fail.pid") + command := writeCommand(t, commandScript{ + Stderr: "boom", + ExitCode: 7, + BackgroundSleepSeconds: 10, + BackgroundPidFile: pidFile, + }) + + start := time.Now() + _, err := LoadProviderCommand(command) + elapsed := time.Since(start) + if err == nil { + t.Fatal("LoadProviderCommand() error = nil, want failure from nonzero exit") + } + if !strings.Contains(err.Error(), "exit status") { + t.Fatalf("error = %q, want exit status failure", err.Error()) + } + if elapsed > 4*time.Second { + t.Fatalf("returned after %s, want well under the 5s provider-command timeout since WaitDelay (1s) should trigger termination", elapsed) + } + assertProcessTerminated(t, pidFile) +} + +func assertProcessTerminated(t *testing.T, pidFile string) { + t.Helper() + + data, err := os.ReadFile(pidFile) + if err != nil { + t.Fatalf("read sleeper pid file: %v", err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + t.Fatalf("parse sleeper pid %q: %v", data, err) + } + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if !processAlive(pid) { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("sleeper process %d still alive after timeout", pid) } func TestLoadProviderCommandInvalidJSON(t *testing.T) { @@ -118,6 +201,14 @@ type commandScript struct { Stderr string ExitCode int SleepSeconds int + PidFile string + + // BackgroundSleepSeconds, if set, spawns a detached child that keeps the + // inherited stdout/stderr handles open well after the script itself + // exits, simulating a `sleep 600 & exit` style command. BackgroundPidFile + // records the detached child's PID. + BackgroundSleepSeconds int + BackgroundPidFile string } func writeCommand(t *testing.T, script commandScript) string { @@ -128,7 +219,11 @@ func writeCommand(t *testing.T, script commandScript) string { path := filepath.Join(dir, "provider.cmd") lines := []string{"@echo off"} if script.SleepSeconds > 0 { - lines = append(lines, "powershell -NoProfile -Command \"Start-Sleep -Seconds "+itoa(script.SleepSeconds)+"\"") + sleep := "Start-Sleep -Seconds " + itoa(script.SleepSeconds) + if script.PidFile != "" { + sleep = "Set-Content -Path '" + psSingleQuote(script.PidFile) + "' -Value $PID -Encoding Ascii; " + sleep + } + lines = append(lines, "powershell -NoProfile -Command \""+sleep+"\"") } if script.Stdout != "" { lines = append(lines, "echo "+script.Stdout) @@ -136,6 +231,15 @@ func writeCommand(t *testing.T, script commandScript) string { if script.Stderr != "" { lines = append(lines, "echo "+script.Stderr+" 1>&2") } + if script.BackgroundSleepSeconds > 0 { + readyFile := script.BackgroundPidFile + ".ready" + bgSleep := "Set-Content -LiteralPath '" + psSingleQuote(script.BackgroundPidFile) + "' -Value $PID -Encoding Ascii; " + + "Set-Content -LiteralPath '" + psSingleQuote(readyFile) + "' -Value ready -Encoding Ascii; " + + "Start-Sleep -Seconds " + itoa(script.BackgroundSleepSeconds) + lines = append(lines, "start /B powershell -NoProfile -Command \""+bgSleep+"\"") + waitForReady := "while (-not (Test-Path -LiteralPath '" + psSingleQuote(readyFile) + "')) { Start-Sleep -Milliseconds 10 }" + lines = append(lines, "powershell -NoProfile -Command \""+waitForReady+"\"") + } lines = append(lines, "exit /b "+itoa(script.ExitCode)) if err := os.WriteFile(path, []byte(strings.Join(lines, "\r\n")), 0o700); err != nil { t.Fatalf("write command: %v", err) @@ -146,6 +250,9 @@ func writeCommand(t *testing.T, script commandScript) string { path := filepath.Join(dir, "provider.sh") lines := []string{"#!/bin/sh"} if script.SleepSeconds > 0 { + if script.PidFile != "" { + lines = append(lines, "echo $$ > '"+shSingleQuote(script.PidFile)+"'") + } lines = append(lines, "sleep "+itoa(script.SleepSeconds)) } if script.Stdout != "" { @@ -154,6 +261,10 @@ func writeCommand(t *testing.T, script commandScript) string { if script.Stderr != "" { lines = append(lines, "printf '%s\\n' '"+script.Stderr+"' >&2") } + if script.BackgroundSleepSeconds > 0 { + lines = append(lines, "sleep "+itoa(script.BackgroundSleepSeconds)+" &") + lines = append(lines, "echo $! > '"+shSingleQuote(script.BackgroundPidFile)+"'") + } lines = append(lines, "exit "+itoa(script.ExitCode)) if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o700); err != nil { t.Fatalf("write command: %v", err) @@ -161,6 +272,19 @@ func writeCommand(t *testing.T, script commandScript) string { return path } +// psSingleQuote escapes a value for interpolation inside a PowerShell +// single-quoted string literal, where a literal quote is doubled. +func psSingleQuote(value string) string { + return strings.ReplaceAll(value, "'", "''") +} + +// shSingleQuote escapes a value for interpolation inside a POSIX shell +// single-quoted string literal, where a literal quote must close the +// quoted section, emit an escaped quote, then reopen it. +func shSingleQuote(value string) string { + return strings.ReplaceAll(value, "'", `'\''`) +} + func itoa(value int) string { if value == 0 { return "0" diff --git a/internal/config/process_posix.go b/internal/config/process_posix.go index 501b07536..258672337 100644 --- a/internal/config/process_posix.go +++ b/internal/config/process_posix.go @@ -3,18 +3,59 @@ package config import ( + "io" "os/exec" "syscall" ) -func configureCommandProcess(cmd *exec.Cmd) { - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +type commandProcess struct { + groupID int + anchor *exec.Cmd + anchorInput io.WriteCloser } -func terminateCommandProcess(cmd *exec.Cmd) { - if cmd.Process == nil { - return +// startCommandProcess retains a live process-group leader until cleanup. The +// provider shell may be reaped before Wait finishes draining descendant-held +// pipes, so its PID cannot safely identify the group after Wait returns. +func startCommandProcess(cmd *exec.Cmd) (*commandProcess, error) { + anchor := exec.Command("sh", "-c", "read _") + anchor.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + anchorInput, err := anchor.StdinPipe() + if err != nil { + return nil, err } - _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - _ = cmd.Process.Kill() + if err := anchor.Start(); err != nil { + _ = anchorInput.Close() + return nil, err + } + + proc := &commandProcess{groupID: anchor.Process.Pid, anchor: anchor, anchorInput: anchorInput} + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true + cmd.SysProcAttr.Pgid = proc.groupID + if err := cmd.Start(); err != nil { + proc.Close() + return nil, err + } + return proc, nil +} + +func (p *commandProcess) Terminate() { + if p.groupID != 0 { + _ = syscall.Kill(-p.groupID, syscall.SIGKILL) + } +} + +func (p *commandProcess) Close() { + if p.anchorInput != nil { + _ = p.anchorInput.Close() + p.anchorInput = nil + } + if p.anchor != nil { + _ = p.anchor.Wait() + p.anchor = nil + } + p.groupID = 0 } diff --git a/internal/config/process_posix_test.go b/internal/config/process_posix_test.go new file mode 100644 index 000000000..309ed9e74 --- /dev/null +++ b/internal/config/process_posix_test.go @@ -0,0 +1,66 @@ +//go:build !windows + +package config + +import ( + "os/exec" + "syscall" + "testing" +) + +func TestCommandProcessPreservesSysProcAttr(t *testing.T) { + cmd := exec.Command("sh", "-c", "exit 0") + attr := &syscall.SysProcAttr{} + cmd.SysProcAttr = attr + + proc, err := startCommandProcess(cmd) + if err != nil { + t.Fatalf("startCommandProcess: %v", err) + } + defer proc.Close() + + if cmd.SysProcAttr != attr { + t.Fatal("startCommandProcess replaced SysProcAttr") + } + if !cmd.SysProcAttr.Setpgid || cmd.SysProcAttr.Pgid != proc.groupID { + t.Fatalf("process group = Setpgid %v, Pgid %d; want true, %d", cmd.SysProcAttr.Setpgid, cmd.SysProcAttr.Pgid, proc.groupID) + } + if err := cmd.Wait(); err != nil { + t.Fatalf("Wait: %v", err) + } +} + +func TestCommandProcessRetainsGroupIdentityAfterProviderWait(t *testing.T) { + cmd := exec.Command("sh", "-c", "exit 7") + proc, err := startCommandProcess(cmd) + if err != nil { + t.Fatalf("startCommandProcess: %v", err) + } + defer func() { + proc.Terminate() + proc.Close() + }() + + providerPID := cmd.Process.Pid + anchorPID := proc.groupID + if err := cmd.Wait(); err == nil { + t.Fatal("Wait error = nil, want nonzero exit") + } + if anchorPID == providerPID { + t.Fatalf("group identity reused provider PID %d", providerPID) + } + if !processAlive(anchorPID) { + t.Fatalf("group leader %d exited before cleanup", anchorPID) + } + + proc.Terminate() + proc.Close() + if processAlive(anchorPID) { + t.Fatalf("group leader %d still alive after cleanup", anchorPID) + } +} + +func processAlive(pid int) bool { + err := syscall.Kill(pid, 0) + return err == nil || err == syscall.EPERM +} diff --git a/internal/config/process_windows.go b/internal/config/process_windows.go index 2e358887d..56209e67c 100644 --- a/internal/config/process_windows.go +++ b/internal/config/process_windows.go @@ -3,23 +3,165 @@ package config import ( + "context" "os" "os/exec" "path/filepath" "strconv" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/windows" ) -func configureCommandProcess(cmd *exec.Cmd) {} +// 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 terminateCommandProcess(cmd *exec.Cmd) { +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 } - taskkill := taskkillPath() - _ = exec.Command(taskkill, "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)).Run() - _ = cmd.Process.Kill() + 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 == "" { diff --git a/internal/config/process_windows_test.go b/internal/config/process_windows_test.go new file mode 100644 index 000000000..2e1f919ba --- /dev/null +++ b/internal/config/process_windows_test.go @@ -0,0 +1,65 @@ +//go:build windows + +package config + +import ( + "os/exec" + "testing" + + "golang.org/x/sys/windows" +) + +// TestResumeMainThreadReportsFailureForUnknownPID covers the contract +// attachCommandProcess relies on: when no thread belonging to pid can be +// found (here, because the pid doesn't correspond to a live process), +// resumeMainThread must report false rather than silently doing nothing, +// so a caller can react (e.g. terminate the process) instead of leaving it +// suspended indefinitely. +func TestResumeMainThreadReportsFailureForUnknownPID(t *testing.T) { + if resumeMainThread(0) { + t.Fatal("resumeMainThread(0) = true, want false for a pid with no threads") + } +} + +func TestCommandProcessRetainsIdentityAfterWait(t *testing.T) { + cmd := exec.Command("cmd", "/C", "exit /b 0") + configureCommandProcess(cmd) + if err := cmd.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + proc := attachCommandProcess(cmd) + t.Cleanup(proc.Close) + if proc.processHandle == 0 { + t.Fatal("attachCommandProcess did not retain a process handle") + } + if err := cmd.Wait(); err != nil { + t.Fatalf("Wait: %v", err) + } + + var code uint32 + if err := windows.GetExitCodeProcess(proc.processHandle, &code); err != nil { + t.Fatalf("GetExitCodeProcess after Wait: %v", err) + } + if code == stillActive { + t.Fatalf("exit code = STILL_ACTIVE after Wait") + } + + proc.Close() + if proc.job != 0 || proc.processHandle != 0 { + t.Fatalf("Close left handles open: job=%v process=%v", proc.job, proc.processHandle) + } +} + +func processAlive(pid int) bool { + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return false + } + defer func() { _ = windows.CloseHandle(handle) }() + + var code uint32 + if err := windows.GetExitCodeProcess(handle, &code); err != nil { + return false + } + return code == stillActive +} diff --git a/internal/tui/provider_wizard_test.go b/internal/tui/provider_wizard_test.go index 7efd36596..4a5786e92 100644 --- a/internal/tui/provider_wizard_test.go +++ b/internal/tui/provider_wizard_test.go @@ -169,6 +169,10 @@ func TestProviderWizardModelsAreProviderScoped(t *testing.T) { } func TestProviderWizardAdvancesProviderAPIKeyAndModelSteps(t *testing.T) { + // Pin the credential state so the footer assertion does not depend on the + // ambient environment. + t.Setenv("ANTHROPIC_API_KEY", "") + m := newModel(context.Background(), Options{}) m = openProviderWizardForTest(t, m)