◐ Off-By-One · answer catalog

go-subprocess-process-group-timeout

1 answer(s)godocker

go-subprocess-process-group-timeout

📦 Source in repository (JSON)

Answer

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.


Evidence & signatures

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}
Generated from the verified corpus · MIT licensedBack to the catalog