228 lines
6.7 KiB
Go
228 lines
6.7 KiB
Go
//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)
|
|
}
|
|
}
|