Files

144 lines
4.0 KiB
Go

//go:build unix
package shell
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"golang.org/x/sys/unix"
)
// TestRun_CtxCancel_KillsProcessTree is the regression test for the
// motivating bug ("cancel leaves orphaned editors / dev servers
// running"). It exercises the exact shape the bug took:
//
// 1. A bare external command (no shebang dispatch, no path prefix) is
// run via the standard handler chain.
// 2. That command forks a grandchild and waits on it.
// 3. The caller cancels the context.
//
// With process-group isolation the grandchild dies along with its
// parent. Without the fix, the kernel reparents the grandchild to PID 1
// when the parent is killed and the grandchild keeps running. We probe
// for liveness with kill(pid, 0) (ESRCH means gone) on a deadline that
// includes killTimeout plus a small wall-clock fudge for the reaper.
func TestRun_CtxCancel_KillsProcessTree(t *testing.T) {
t.Parallel()
dir := t.TempDir()
pidfile := filepath.Join(dir, "child.pid")
scriptPath := filepath.Join(dir, "run.sh")
// /bin/sh runs as our external command; the inner `sleep` is its
// grandchild relative to the Crush process. Pre-fix, killing sh
// would leave that sleep orphaned. Use 600 so a leaked process is
// obviously a leak rather than racing the test's deadline.
script := fmt.Sprintf("sleep 600 & echo $! > %q\nwait\n", pidfile)
require := func(err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
require(os.WriteFile(scriptPath, []byte(script), 0o600))
ctx, cancel := context.WithCancel(t.Context())
t.Cleanup(cancel)
done := make(chan error, 1)
go func() {
done <- Run(ctx, RunOptions{
Command: "/bin/sh " + strconv.Quote(scriptPath),
Cwd: dir,
Env: os.Environ(),
})
}()
pid := waitForPIDFile(t, pidfile, 3*time.Second)
cancel()
select {
case err := <-done:
if err != nil && !IsInterrupt(err) {
t.Fatalf("Run returned unexpected error: %v", err)
}
case <-time.After(killTimeout + 5*time.Second):
t.Fatal("Run did not return after ctx cancel")
}
deadline := time.Now().Add(killTimeout + 3*time.Second)
for time.Now().Before(deadline) {
if err := unix.Kill(pid, 0); errors.Is(err, unix.ESRCH) {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("grandchild pid %d still alive %v after cancel; the process group fix did not reach it", pid, killTimeout+3*time.Second)
}
// TestRun_CtxCancel_ReturnsContextErr verifies that a cancelled external
// command surfaces ctx.Err() rather than a synthetic exit code. Callers
// downstream (the bash tool, the agent loop, IsInterrupt) rely on
// errors.Is(err, context.Canceled) to tell "user cancelled" apart from
// "program exited with a signal".
func TestRun_CtxCancel_ReturnsContextErr(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() {
done <- Run(ctx, RunOptions{
Command: "sleep 60",
Cwd: t.TempDir(),
Env: os.Environ(),
})
}()
// Give the sleep a chance to start before cancelling.
time.Sleep(100 * time.Millisecond)
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
case <-time.After(killTimeout + 3*time.Second):
t.Fatal("Run did not return after ctx cancel")
}
}
// waitForPIDFile polls path until it contains a positive integer or the
// timeout expires. The PID-file dance is the simplest way to learn the
// PID of a grandchild spawned by an external command without plumbing
// pipes through mvdan's interpreter.
func waitForPIDFile(t *testing.T, path string, timeout time.Duration) int {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
data, err := os.ReadFile(path)
if err == nil {
s := strings.TrimSpace(string(data))
if s != "" {
n, err := strconv.Atoi(s)
if err == nil && n > 0 {
return n
}
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("pidfile %s never contained a valid pid", path)
return 0
}