//go:build integration package builder import ( "context" "errors" "os" "path/filepath" "strconv" "strings" "syscall" "testing" "time" ) func TestAKilledBuildEndsItsWholeProcessGroup(t *testing.T) { dir := t.TempDir() child := filepath.Join(dir, "child") ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) began := time.Now() go func() { // A shell that starts a grandchild and waits on it: killing the shell alone would leave the // grandchild running, holding the output open. _, err := Command(ctx, dir, "sh", "-c", `sleep 60 & echo $! > child; wait`) done <- err }() var pid int for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) { if raw, err := os.ReadFile(child); err == nil && strings.TrimSpace(string(raw)) != "" { pid, _ = strconv.Atoi(strings.TrimSpace(string(raw))) break } } if pid == 0 { t.Fatal("the command never started its child") } cancel() select { case err := <-done: if err == nil { t.Fatal("a killed command reported success") } case <-time.After(KillWait + 5*time.Second): t.Fatal("the killed command never returned") } if took := time.Since(began); took > KillWait { t.Errorf("ending the command took %s: the group was not killed, the wait ran out", took) } for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) { if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) { return } } _ = syscall.Kill(pid, syscall.SIGKILL) t.Fatalf("the grandchild %d outlived the kill", pid) }