//go:build linux || darwin || windows package subprocess import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) func TestRunCancellationTerminatesProcessTree(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() resultCh := make(chan runOutcome, 1) req, sentinelPath := processTreeRequest(t) go func() { result, err := Run(ctx, req) resultCh <- runOutcome{result: result, err: err} }() awaitHelperReady(t, req.EnvOverrides["SUBPROCESS_HELPER_READY_PATH"]) cancel() outcome := awaitRunOutcome(t, resultCh) if !outcome.result.Canceled { t.Fatalf("Canceled = %v, want true", outcome.result.Canceled) } if !errors.Is(outcome.err, context.Canceled) { t.Fatalf("error = %v, want context cancellation", outcome.err) } assertDescendantDidNotSurvive(t, sentinelPath) } func TestRunTimeoutTerminatesProcessTree(t *testing.T) { req, sentinelPath := processTreeRequest(t) req.Timeout = 100 * time.Millisecond result, err := Run(context.Background(), req) if !result.TimedOut { t.Fatalf("TimedOut = %v, want true", result.TimedOut) } if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("error = %v, want context deadline exceeded", err) } assertDescendantDidNotSurvive(t, sentinelPath) } func TestRunCaptureLimitTerminatesProcessTree(t *testing.T) { req, sentinelPath := processTreeRequest(t) req.Args[len(req.Args)-1] = "tree-spam" result, err := Run(context.Background(), req) if err == nil { t.Fatal("Run() error = nil, want capture-limit error") } if result.ExitCode == 0 { t.Fatalf("ExitCode = %d, want terminated process", result.ExitCode) } if !strings.Contains(err.Error(), "stdout diagnostic capture for subprocess exceeded") { t.Fatalf("error = %q, want stdout capture-limit context", err) } info, statErr := os.Stat(req.StdoutLogPath) if statErr != nil { t.Fatalf("stat stdout diagnostic: %v", statErr) } if info.Size() != MaxStdoutDiagnosticBytes { t.Fatalf("stdout diagnostic size = %d, want %d", info.Size(), MaxStdoutDiagnosticBytes) } assertDescendantDidNotSurvive(t, sentinelPath) } func TestRunDisposesDescendantsAfterLeaderExit(t *testing.T) { tests := []struct { name string mode string wantExitCode int wantWaitDelay bool ignoreTerm bool }{ {name: "success retaining streams", mode: "leader-exit-retained", wantExitCode: 0, wantWaitDelay: true}, {name: "success redirecting streams", mode: "leader-exit-redirected", wantExitCode: 0}, {name: "failed leader", mode: "leader-fail-redirected", wantExitCode: 9, ignoreTerm: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req, sentinelPath, releasePath := leaderExitRequest(t, tt.mode) if tt.ignoreTerm { req.EnvOverrides["SUBPROCESS_HELPER_IGNORE_TERM"] = "1" } outcomes := make(chan runOutcome, 1) go func() { result, err := Run(context.Background(), req) outcomes <- runOutcome{result: result, err: err} }() var outcome runOutcome select { case outcome = <-outcomes: case <-time.After(6 * time.Second): t.Fatal("Run() did not complete bounded owned-tree disposal") } if outcome.result.ExitCode != tt.wantExitCode { t.Fatalf("ExitCode = %d, want %d", outcome.result.ExitCode, tt.wantExitCode) } if tt.wantWaitDelay { if !errors.Is(outcome.err, exec.ErrWaitDelay) { t.Fatalf("error = %v, want exec.ErrWaitDelay", outcome.err) } } else if tt.wantExitCode == 0 && outcome.err != nil { t.Fatalf("Run() error = %v, want nil", outcome.err) } else if tt.wantExitCode != 0 { var exitErr *exec.ExitError if !errors.As(outcome.err, &exitErr) || exitErr.ExitCode() != tt.wantExitCode { t.Fatalf("error = %v, want exit code %d", outcome.err, tt.wantExitCode) } } if _, err := os.Stat(req.EnvOverrides["SUBPROCESS_HELPER_READY_PATH"]); err != nil { t.Fatalf("descendant readiness file: %v", err) } if err := os.WriteFile(releasePath, []byte("release"), 0o600); err != nil { t.Fatalf("WriteFile(release) error = %v", err) } assertDescendantDidNotSurvive(t, sentinelPath) }) } } type runOutcome struct { result RunResult err error } func processTreeRequest(t *testing.T) (RunRequest, string) { t.Helper() executable, err := os.Executable() if err != nil { t.Fatalf("os.Executable() error = %v", err) } dir := t.TempDir() readyPath := filepath.Join(dir, "ready") sentinelPath := filepath.Join(dir, "descendant-survived") return RunRequest{ Executable: executable, Args: []string{"-test.run=^TestSubprocessHelper$", "--", "tree"}, EnvOverrides: map[string]string{ "GO_WANT_SUBPROCESS_HELPER": "1", "SUBPROCESS_HELPER_READY_PATH": readyPath, "SUBPROCESS_HELPER_SENTINEL_PATH": sentinelPath, }, StdoutLogPath: filepath.Join(dir, "stdout.log"), StderrLogPath: filepath.Join(dir, "stderr.log"), }, sentinelPath } func leaderExitRequest(t *testing.T, mode string) (RunRequest, string, string) { t.Helper() executable, err := os.Executable() if err != nil { t.Fatalf("os.Executable() error = %v", err) } dir := t.TempDir() readyPath := filepath.Join(dir, "ready") releasePath := filepath.Join(dir, "release") sentinelPath := filepath.Join(dir, "descendant-survived") return RunRequest{ Executable: executable, Args: []string{"-test.run=^TestSubprocessHelper$", "--", mode}, EnvOverrides: map[string]string{ "GO_WANT_SUBPROCESS_HELPER": "1", "SUBPROCESS_HELPER_READY_PATH": readyPath, "SUBPROCESS_HELPER_RELEASE_PATH": releasePath, "SUBPROCESS_HELPER_SENTINEL_PATH": sentinelPath, }, StdoutLogPath: filepath.Join(dir, "stdout.log"), StderrLogPath: filepath.Join(dir, "stderr.log"), }, sentinelPath, releasePath } func awaitHelperReady(t *testing.T, readyPath string) { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { if _, err := os.Stat(readyPath); err == nil { return } else if !errors.Is(err, os.ErrNotExist) { t.Fatalf("stat helper readiness: %v", err) } time.Sleep(10 * time.Millisecond) } t.Fatal("helper did not start its descendant") } func awaitRunOutcome(t *testing.T, outcomes <-chan runOutcome) runOutcome { t.Helper() select { case outcome := <-outcomes: if outcome.err == nil { t.Fatal("Run() error = nil, want cancellation error") } return outcome case <-time.After(3 * time.Second): t.Fatal("Run() did not return after cancellation") return runOutcome{} } } func assertDescendantDidNotSurvive(t *testing.T, sentinelPath string) { t.Helper() time.Sleep(700 * time.Millisecond) if _, err := os.Stat(sentinelPath); err == nil { t.Fatal("descendant survived cancellation and wrote its sentinel") } else if !errors.Is(err, os.ErrNotExist) { t.Fatalf("stat descendant sentinel: %v", err) } }