Files
zero/internal/background/process_windows_test.go
9c292092a2 fix(daemon): clean up child after startup timeout (#774)
* fix(daemon): clean up child after startup timeout

* fix(daemon): terminate startup process tree

* test(background): accept reaped-later zombie children

* fix(background): retain configured process group target

* fix(background): address termination review feedback

* fix(daemon): use launch-time group identity, fix zombie/reap classification

Addresses jatmn's three #774 review findings:

- TerminateCommand rediscovered the process group via
  execution.TerminateProcessTree's Getpgid(pid) lookup. On Darwin,
  that lookup can return ESRCH once an unreaped group leader has
  already exited, even though live descendants remain in the group
  it configured at launch — silently falling back to signalling only
  the (already dead) leader PID and leaving descendants running (the
  required macOS smoke job's failure). Add
  execution.TerminateProcessGroup, which signals the negative PID
  directly using the launch-time Setpgid invariant instead of
  rediscovering it, and route TerminateCommand through it.

- signalTargetRunning's zombie check matched the individual-PID
  fallback target (a process that is not its own group leader)
  against the process table by PGID. That target's actual PGID
  differs from its own PID by definition, so no row ever matched,
  unknown resulted every time, and a zombie individual-PID target
  was always conservatively reported as still running. Branch on the
  sign of the target: group members by PGID, individual PIDs by PID.

- TerminateCommand returned the termination error even when the
  subsequent reap succeeded — a successful cmd.Wait is authoritative
  proof the leader is gone and collected, regardless of how the kill
  attempt itself went (e.g. taskkill /T racing an already-exited PID
  on Windows). Classify a successful reap as success.

All three regression tests are verified to fail against the pre-fix
code (confirmed by temporarily reverting each fix and rerunning).

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

* fix(daemon): harden command termination inputs

Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-abb8-73de-9489-51e9e6514c6b

Co-authored-by: Pierre Bruno <[email protected]>

* fix(background): preserve process tree failures

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* docs(daemon): clarify boundary-probe and Setsid-guard comments per PR #774 review

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-08-15 20:31:10 +08:00

107 lines
3.5 KiB
Go

//go:build windows
package background
import (
"bufio"
"errors"
"os/exec"
"strconv"
"strings"
"testing"
"time"
)
func TestTerminateCommandReturnsTreeFailureAfterRootFallbackReaps(t *testing.T) {
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 300")
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
treeErr := errors.New("taskkill /T failed")
originalTerminateProcess := terminateProcessForTest
terminateProcessForTest = func(pid int) error {
if err := cmd.Process.Kill(); err != nil {
return errors.Join(treeErr, err)
}
return treeErr
}
t.Cleanup(func() { terminateProcessForTest = originalTerminateProcess })
err := TerminateCommand(cmd)
if !errors.Is(err, treeErr) {
t.Fatalf("TerminateCommand error = %v, want tree failure", err)
}
if cmd.ProcessState == nil {
t.Fatal("root process was not reaped")
}
}
func TestTerminateCommandReapsExitedLeader(t *testing.T) {
cmd := exec.Command("cmd.exe", "/c", "exit", "0")
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
// Let the child exit without calling Wait so cleanup exercises the Windows
// taskkill/TerminateProcess race while it still owns the process handle.
time.Sleep(500 * time.Millisecond)
// jatmn's #774 finding: taskkill /T racing an already-exited PID (and the
// Process.Kill fallback) both fail here, but the reap below still succeeds —
// the process-handle exit-code check proves the leader was already gone, so
// TerminateCommand must not surface the expected kill-attempt error.
if err := TerminateCommand(cmd); err != nil {
t.Fatalf("TerminateCommand: %v, want nil (leader was already gone and got reaped)", err)
}
if cmd.ProcessState == nil {
t.Fatal("exited process was not reaped")
}
}
func TestTerminateCommandDeadLeaderCannotIdentifyDescendantTree(t *testing.T) {
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
`$p = Start-Process powershell.exe -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 300' -PassThru; $p.Id`)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil {
t.Fatalf("read descendant pid: %v", err)
}
descendantPID, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil {
t.Fatalf("parse descendant pid %q: %v", line, err)
}
t.Cleanup(func() {
_ = exec.Command("taskkill.exe", "/F", "/PID", strconv.Itoa(descendantPID)).Run()
})
// Wait until the leader is observably exited but deliberately leave it
// unreaped, matching the POSIX regression's ordering.
deadline := time.Now().Add(5 * time.Second)
for processExistsOnWindows(cmd.Process.Pid) {
if time.Now().After(deadline) {
t.Fatal("leader did not exit")
}
time.Sleep(20 * time.Millisecond)
}
if err := TerminateCommand(cmd); err != nil {
t.Fatalf("TerminateCommand: %v", err)
}
// Windows has no persistent group ID analogous to POSIX: once the leader is
// gone, taskkill /T cannot rediscover this descendant. Assert that platform
// distinction rather than claiming TerminateCommand can provide it here.
if !processExistsOnWindows(descendantPID) {
t.Fatal("descendant unexpectedly stopped after its root exited")
}
}
func processExistsOnWindows(pid int) bool {
return exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
"Get-Process -Id "+strconv.Itoa(pid)+" -ErrorAction Stop | Out-Null").Run() == nil
}