go-subprocess-process-group-timeout
Root cause (helix BUG-002): exec.CommandContext only kills the direct child. For sh -c "sleep 30" the direct child is sh; its grandchild sleep survives the context expiry, keeps the stdout pipe's write end open, and blocks cmd.Wait()'s pipe-copy. The test binary then hangs at package exit → Test I/O incomplete.
Fix: put the child in its own process group and make context cancellation SIGKILL the whole group:
// run.go — the fix
func RunFixed(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "sh", "-c", "sleep 30")
// 1. Child becomes leader of a brand-new process group.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// 2. On ctx expiry, kill the entire group, not just the child.
cmd.Cancel = func() error {
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // negative pid = group
}
return cmd.Run()
}
Two invariants that make this safe:
- -cmd.Process.Pid is only meaningful because of Setpgid — without it, kill(-pid) would signal the test binary's own process group. The two lines must ship together.
- cmd.Process is set by Start before os/exec's watch goroutine can invoke Cancel, so no nil deref; and if the context is already done, Start returns ctx.Err() without spawning anything (verified in Go 1.26 exec.go:705).
Regression test — pgrep before/after set comparison (Linux-only):
func TestFixedKillsWholeProcessGroup(t *testing.T) {
before := pgrepSet(t, "sleep 30") // set of PIDs matching pattern
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
err := RunFixed(ctx) // expect exit-137 (SIGKILL)
cancel()
if after := waitForLeak(t, before); !reflect.DeepEqual(before, after) {
t.Fatalf("process-group leak: before=%v after=%v", before, after)
}
}
pgrepSet runs pgrep -f "sleep 30" and treats pgrep's exit-code-1 (no matches) as an empty set. A 3s poll handles the kernel's reap latency; waitForLeak returns the latest snapshot, and the assertion is set equality (order/PID-number differences are irrelevant).
Complementary safety net (Go issue 23019): group-kill covers the ctx-cancel case; it cannot help when the direct child exits cleanly while abandoning a backgrounded descendant holding the pipe (sh -c "sleep 30 &"). Pair the fix with cmd.WaitDelay so Wait returns exec.ErrWaitDelay instead of hanging forever.
Built and ran a real harness (`/tmp/psgtimeout`, Go 1.26, Linux amd64; `syscall` group signals are Unix-only):
**Fixed suite — `go test -v .` → PASS 4/4:**
```
--- PASS: TestFixedKillsWholeProcessGroup (0.31s) # pgrep before=[] after=[]; err: signal: killed
--- PASS: TestFixedNormalRun (1.01s) # no-cancel path still works, nothing left behind
--- PASS: TestFixedOutputDoesNotHang (0.30s) # Output() returns promptly, no ErrWaitDelay
--- PASS: TestFixedCleanAbandonmentEdgeCase (2.00s) # "sleep 30 &": bounded by WaitDelay → ErrWaitDelay
ok psgtimeout 3.624s
```
**Bug reproductions — `PSG_BUG_REPRO=1 go test -run TestBuggy` → both FAIL as designed:**
```
--- FAIL: TestBuggyLeaksGrandchild
BUG-002 reproduced: grandchild survived ctx expiry: before=map[] after=map[3043:true]
--- FAIL: TestBuggyOutputHangs
BUG-002 reproduced: Output blocked forever after ctx expiry;
grandchild holds the stdout pipe => 'Test I/O incomplete' at package exit
```
The pgrep set comparison is the regression test's teeth: it provably fails on the unfixed code (before=`{}`, after=`{3043}`) and passes on the fix.
**Edge cases tested / verified:**
1. **Pre-canceled context** — `Start` returns `ctx.Err()` before spawning (confirmed in `exec.go:705`); nothing to leak.
2. **Clean-exit abandonment** (`sh -c "sleep 30 &"`) — group-kill never fires (no cancellation); `WaitDelay` returns `exec: WaitDelay expired before I/O complete` in 2s instead of hanging. Documented as the WaitDelay complement, not a gap in the fix.
3. **No-cancel normal run** — `sleep 1` completes, group dies naturally with it, no leftovers.
4. **ErrWaitDelay semantics** — in Go 1.26, `ErrWaitDelay` is only reported when no other error exists (`if err == nil { err = ErrWaitDelay }`), which is why the killed-process case surfaces as `signal: killed` (ExitError 137) rather than a WaitDelay error.
5. **System hygiene** — after all runs, `pgrep -ax sleep` → none; `pkill -f "sleep 30"` cleanup keeps repeated runs deterministic.
---{"model": "deepseek-v4-flash", "problem_class": "go-subprocess-process-group-timeout", "result": "passed", "tests": 4}