Files
Bo 142b7ee841 Bounded, cancellable eval subprocess runner with process-group reaping (#1010)
## Summary

Bead `age-eval-subprocess-lifecycle-uf9d6` — the 2026-07-24 Go audit's
G2 program, the last OPEN High-adjacent residue. New
`cli/internal/procrun` runner, migrated onto all four unbounded sites.

**The runner:**
- Head+tail bounded capture: fixed peak memory regardless of child
output; keeps draining so the child never blocks on a full pipe;
under-cap output byte-identical; over-cap preserves true head and true
tail with a discard marker.
- `Run(ctx, ...)`: process-group start (`Setpgid` / windows process
group), cancel kills the group, 3s `WaitDelay` so `Wait` can't hang on
pipes a grandchild holds, and — post-review — the group is reaped
**unconditionally after Wait on every path**, so a descendant surviving
a normal child exit is still killed (witnessed: backgrounded no-`wait`
sleeper reaped).
- Unix ESRCH race mapped to `os.ErrProcessDone` (50×
completion-vs-cancel race under `-race`: a completed command never
reports an error). Windows `taskkill` bounded by its own 5s timeout with
the best-effort no-Job-Object guarantee documented rather than faked.

**Migrated sites:** goals `MeasureOneContext` (SIGINT child-tracker
hooks preserved), gates `ScriptRunner` (gains group-kill + WaitDelay,
4KiB LogTail preserved), eval `executeCaseCommand` and expectations
autodetect — caller ctx threaded end-to-end via
`RunSuiteContext`/`RunBaselineABContext`/`RunContextABContext` +
`CoreRuntime`; no `context.Background()` remains on these paths.
Cancellation is semantic: guards before the suite, before each case,
**and after the loop** (a cancel during the final/only case is terminal,
never scored — single-case witness), A/B never starts its second leg
cancelled. Start-failure error text verified byte-identical at all three
consumer sites. Live-runtime capture and the adapter RunStats call are
explicitly out of scope (noted in commit).

## Validation

- `go build`/`go vet` clean; full `go test ./...` green; `-race
-count=1` clean on procrun/eval/goals; golangci 0 issues on five touched
packages; `GOOS=windows` build ok
- Cross-family review two rounds: round 1 five findings all fixed
(always-reap, bounded windows cancel, ESRCH race, semantic cancellation,
error-text compat); round 2's one residual (final-case cancellation)
fixed with witness

Tracker: `age-eval-subprocess-lifecycle-uf9d6`
2026-07-29 14:28:10 +00:00

200 lines
6.6 KiB
Go

//go:build !windows
package procrun
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// processAlive reports whether pid is still a live process. kill(pid, 0)
// performs no signal delivery but returns ESRCH when the process (and any
// zombie has been reaped) no longer exists.
func processAlive(pid int) bool {
err := syscall.Kill(pid, 0)
return err == nil || err == syscall.EPERM
}
// waitForPIDFile polls path until it contains a parseable PID or the deadline
// passes.
func waitForPIDFile(t *testing.T, path string) int {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if data, err := os.ReadFile(path); err == nil {
if s := strings.TrimSpace(string(data)); s != "" {
pid, err := strconv.Atoi(s)
if err == nil {
return pid
}
}
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("grandchild PID file %s never populated", path)
return 0
}
// assertReaped polls until pid is gone, failing if it survives.
func assertReaped(t *testing.T, pid int) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if !processAlive(pid) {
return
}
time.Sleep(10 * time.Millisecond)
}
// Best-effort cleanup so a failed run does not leave a 60s sleeper behind.
_ = syscall.Kill(pid, syscall.SIGKILL)
t.Fatalf("grandchild pid %d still alive; process group was not reaped", pid)
}
// grandchildScript backgrounds a long sleeper (a grandchild of the test: child
// = the shell, grandchild = sleep), records the sleeper's PID to pidFile, then
// waits — so the shell holds the pipe open until the sleeper is killed, exactly
// the orphaning hazard the runner must defeat.
func grandchildScript(pidFile string) string {
return "sleep 60 & echo $! > " + pidFile + "; wait"
}
// TestRun_CancelReapsGrandchild is witness (2): cancelling the caller context
// terminates the child AND its spawned grandchild.
func TestRun_CancelReapsGrandchild(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "grandchild.pid")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cmd := shellCmd(ctx, grandchildScript(pidFile))
done := make(chan Result, 1)
go func() {
res, _ := Run(ctx, cmd, Options{Combined: true})
done <- res
}()
grandchild := waitForPIDFile(t, pidFile)
if !processAlive(grandchild) {
t.Fatalf("grandchild pid %d not alive before cancel", grandchild)
}
cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatalf("Run did not return within 5s of cancel (Wait hung on inherited pipe)")
}
assertReaped(t, grandchild)
}
// TestRun_TimeoutReapsGrandchild is witness (3): a context deadline behaves like
// an explicit cancel, reaping the whole tree.
func TestRun_TimeoutReapsGrandchild(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "grandchild.pid")
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
cmd := shellCmd(ctx, grandchildScript(pidFile))
done := make(chan Result, 1)
go func() {
res, _ := Run(ctx, cmd, Options{Combined: true})
done <- res
}()
grandchild := waitForPIDFile(t, pidFile)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatalf("Run did not return within 5s of timeout")
}
if ctx.Err() != context.DeadlineExceeded {
t.Fatalf("ctx.Err() = %v, want DeadlineExceeded", ctx.Err())
}
assertReaped(t, grandchild)
}
// TestRun_ReapsSleeperAfterNormalChildExit is the review-round-1 finding-1
// witness: the direct child backgrounds a sleeper and exits NORMALLY (ctx is
// never cancelled, so Cancel never fires). Only the unconditional post-Wait reap
// can kill the orphan; without it the sleeper survives past WaitDelay.
func TestRun_ReapsSleeperAfterNormalChildExit(t *testing.T) {
pidFile := filepath.Join(t.TempDir(), "sleeper.pid")
ctx := context.Background()
// Note: no `wait` — the shell exits immediately, leaving the sleeper holding
// the stdout pipe until WaitDelay unblocks Wait.
cmd := shellCmd(ctx, "sleep 60 & echo $! > "+pidFile)
res, err := Run(ctx, cmd, Options{Combined: true, WaitDelay: 500 * time.Millisecond})
if err != nil {
t.Fatalf("Run start error: %v", err)
}
_ = res
sleeper := waitForPIDFile(t, pidFile)
assertReaped(t, sleeper)
}
// TestCancelHook_ESRCHMapsToProcessDone is the deterministic finding-3 witness:
// after the child and its group have fully exited, kill(-pgid) returns ESRCH,
// which the Cancel hook must report as ErrProcessDone (success), never as a
// failure that Wait would surface as "exec: canceling Cmd".
func TestCancelHook_ESRCHMapsToProcessDone(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cmd := exec.CommandContext(ctx, "true")
configureProcessGroup(cmd)
if err := cmd.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
if err := cmd.Wait(); err != nil {
t.Fatalf("Wait: %v", err)
}
// The group is now empty; kill(-pgid) yields ESRCH.
if err := cmd.Cancel(); err != nil && !errors.Is(err, os.ErrProcessDone) {
t.Fatalf("Cancel after exit = %v, want nil or ErrProcessDone", err)
}
}
// TestRun_CompletedCommandNotReportedCancelledUnderRace is the finding-3 race
// witness: a fast command whose completion races an immediate cancel must never
// surface a spurious error when it actually completed. Run under -race.
func TestRun_CompletedCommandNotReportedCancelledUnderRace(t *testing.T) {
dir := t.TempDir()
for i := 0; i < 50; i++ {
marker := filepath.Join(dir, "done-"+strconv.Itoa(i))
ctx, cancel := context.WithCancel(context.Background())
cmd := shellCmd(ctx, "echo done > "+marker)
go cancel() // race the child's completion against cancellation
res, outerErr := Run(ctx, cmd, Options{Combined: true})
// Three legitimate outcomes; the finding-3 regression is any MIXED
// state between them:
// cancel before start -> outerErr = ctx.Err, zero Result;
// killed mid-run -> res.Err set, killed ExitCode;
// completed -> no errors, ExitCode 0, work done.
// (A marker on disk alone does not prove the shell finished exiting —
// the kill can land between the redirect and the shell's own exit.)
switch {
case outerErr != nil:
// Cancelled before start: nothing ran, nothing to assert.
case res.Err == nil:
if res.ExitCode != 0 {
t.Fatalf("iter %d: no error but ExitCode = %d, want 0", i, res.ExitCode)
}
data, readErr := os.ReadFile(marker)
if readErr != nil || strings.TrimSpace(string(data)) != "done" {
t.Fatalf("iter %d: clean run without its work completed (marker read: %v)", i, readErr)
}
case res.ExitCode == 0:
t.Fatalf("iter %d: clean exit 0 surfaced spurious error %v", i, res.Err)
}
cancel()
}
}