mirror of
https://github.com/charmbracelet/crush.git
synced 2026-05-30 18:47:33 +00:00
144 lines
4.0 KiB
Go
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
|
|
}
|