diff --git a/internal/subprocess/process_unix.go b/internal/subprocess/process_unix.go new file mode 100644 index 0000000..04d6cab --- /dev/null +++ b/internal/subprocess/process_unix.go @@ -0,0 +1,64 @@ +//go:build unix + +package subprocess + +import ( + "errors" + "os/exec" + "syscall" +) + +// processHandle wraps exec.Cmd for the runner. Its zero value is not usable; +// startProcess is the only constructor. +type processHandle struct { + cmd *exec.Cmd +} + +// startProcess launches the requested program in its own Unix process group +// (SysProcAttr.Setpgid) so SIGTERM/SIGKILL reach all descendants. An empty +// Command yields an inline error rather than a sentinel so the package keeps +// no package-level var. +func startProcess(request Request) (*processHandle, error) { + if len(request.Command) == 0 { + return nil, errors.New("subprocess: empty command") + } + cmd := exec.Command(request.Command[0], request.Command[1:]...) + cmd.Dir = request.Directory + cmd.Env = request.Environment + cmd.Stdin = request.Stdin + cmd.Stdout = request.Stdout + cmd.Stderr = request.Stderr + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + return nil, err + } + return &processHandle{cmd: cmd}, nil +} + +func (h *processHandle) pid() int { + if h.cmd.Process == nil { + return 0 + } + return h.cmd.Process.Pid +} + +func (h *processHandle) wait() error { + return h.cmd.Wait() +} + +func (h *processHandle) exitCode() int { + if h.cmd.ProcessState == nil { + return -1 + } + return h.cmd.ProcessState.ExitCode() +} + +// terminateGroup sends SIGTERM to the child's process group. +func terminateGroup(pid int) error { + return syscall.Kill(-pid, syscall.SIGTERM) +} + +// killGroup sends SIGKILL to the child's process group after the grace period. +func killGroup(pid int) error { + return syscall.Kill(-pid, syscall.SIGKILL) +} diff --git a/internal/subprocess/process_unix_test.go b/internal/subprocess/process_unix_test.go new file mode 100644 index 0000000..1556a46 --- /dev/null +++ b/internal/subprocess/process_unix_test.go @@ -0,0 +1,142 @@ +//go:build unix + +package subprocess + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +func TestProcessCancellation(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"cancel-before", testCancelBeforeStart}, + {"cancel-after", testCancelAfterStart}, + {"descendants", testDescendantsDie}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testCancelBeforeStart(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + request := Request{Command: []string{"sleep", "30"}} + result, err := Run(ctx, request) + if err == nil { + t.Fatal("err = nil, want cancellation") + } + if !result.SignalCanceled { + t.Fatal("SignalCanceled = false, want true") + } +} + +func testCancelAfterStart(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + body := "trap 'exit 0' TERM\nsleep 30" + script := writeScript(t, scriptTarget{name: "sleep.sh", body: body}) + waitCh := runInBackground(ctx, script) + sleepBriefly() + cancel() + observed := awaitResult(t, waitCh) + if !observed.result.SignalCanceled { + t.Fatal("SignalCanceled = false, want true") + } +} + +func testDescendantsDie(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + dir := t.TempDir() + body := "trap 'exit 0' TERM\nsleep 30 &\necho $! > child.pid\nwait $!\n" + target := scriptTarget{directory: dir, name: "spawner.sh", body: body} + script := writeScript(t, target) + go func() { + request := Request{Command: []string{script}, Directory: dir} + _, _ = Run(ctx, request) + }() + childPID := waitForPIDFile(t, filepath.Join(dir, "child.pid")) + sleepBriefly() + cancel() + if !awaitProcessDeath(childPID, 5*time.Second) { + t.Fatalf("descendant %d still alive after cancel", childPID) + } +} + +func runInBackground(ctx context.Context, script string) chan outcome { + waitCh := make(chan outcome, 1) + go func() { + result, err := Run(ctx, Request{Command: []string{script}}) + waitCh <- outcome{result: result, err: err} + }() + return waitCh +} + +func awaitResult(t *testing.T, waitCh <-chan outcome) outcome { + t.Helper() + select { + case observed := <-waitCh: + return observed + case <-time.After(10 * time.Second): + t.Fatal("Run did not return after cancel") + return outcome{} + } +} + +func sleepBriefly() { + time.Sleep(300 * time.Millisecond) +} + +func waitForPIDFile(t *testing.T, path string) int { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if pid, ok := readPIDFile(path); ok { + return pid + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("pid file %s never appeared", path) + return 0 +} + +func readPIDFile(path string) (int, bool) { + data, err := os.ReadFile(path) + if err != nil { + return 0, false + } + pid, parseErr := strconv.Atoi(strings.TrimSpace(string(data))) + if parseErr != nil { + return 0, false + } + return pid, true +} + +func awaitProcessDeath(pid int, limit time.Duration) bool { + deadline := time.Now().Add(limit) + for time.Now().Before(deadline) { + if !processAlive(pid) { + return true + } + time.Sleep(100 * time.Millisecond) + } + return !processAlive(pid) +} + +func processAlive(pid int) bool { + process, err := os.FindProcess(pid) + if err != nil { + return false + } + return process.Signal(syscall.Signal(0)) == nil +} diff --git a/internal/subprocess/run.go b/internal/subprocess/run.go new file mode 100644 index 0000000..0973261 --- /dev/null +++ b/internal/subprocess/run.go @@ -0,0 +1,152 @@ +// Package subprocess runs external programs with caller-supplied streams and +// process-group cancellation. It owns process creation, explicit streams, +// process-group shutdown, and exit status only; it neither captures nor +// redacts content. Callers wire stdin/stdout/stderr and own any buffering, +// limits, or redaction (internal/secrets and internal/hooks do this). +package subprocess + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "time" +) + +// gracePeriod is the SIGTERM-to-SIGKILL interval mandated by Section 10.4. +const gracePeriod = 5 * time.Second + +// Request describes one synchronous child invocation. Command[0] is the +// executable; Directory and Environment default to inherited when empty so +// the package stays neutral about caller policy. +type Request struct { + Command []string + Directory string + Environment []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// Result is the typed outcome of Run. A nonzero ExitCode is NOT an error: +// callers inspect ExitCode. SignalCanceled is true whenever Run returned +// because ctx was canceled, even if no signal was sent (cancel-before-start). +type Result struct { + ExitCode int + SignalCanceled bool + Duration time.Duration +} + +// LaunchError describes a failure to start the child. When NotFound is true +// the executable was missing from PATH or the filesystem; callers (secrets) +// map this to their own dependency category. +type LaunchError struct { + NotFound bool + Cause error +} + +func (e *LaunchError) Error() string { + if e == nil { + return "subprocess: launch failed" + } + if e.NotFound { + return fmt.Sprintf("subprocess: executable not found: %v", e.Cause) + } + return fmt.Sprintf("subprocess: launch failed: %v", e.Cause) +} + +// Unwrap exposes Cause to errors.Is and errors.As. +func (e *LaunchError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// Run executes request synchronously, applying the Section 10.4 process-group +// cancellation policy: SIGTERM to the child group on ctx cancellation, a +// gracePeriod wait, then SIGKILL. Nonzero exit codes return (Result, nil); +// only launch failures and cancellation return an error. +func Run(ctx context.Context, request Request) (Result, error) { + if err := ctx.Err(); err != nil { + return Result{SignalCanceled: true}, err + } + start := time.Now() + handle, err := startProcess(request) + if err != nil { + return Result{Duration: time.Since(start)}, launchError(err) + } + observed := runAndWait(ctx, handle, start) + return observed.result, observed.err +} + +// outcome bundles a Result with its error so helpers stay under three params. +type outcome struct { + result Result + err error +} + +// groupShutdown bundles the inputs needed by awaitShutdown so it stays under +// the three-parameter limit. +type groupShutdown struct { + handle *processHandle + waitCh chan error + elapsed time.Duration +} + +func runAndWait(ctx context.Context, handle *processHandle, start time.Time) outcome { + waitCh := make(chan error, 1) + go func() { waitCh <- handle.wait() }() + select { + case <-ctx.Done(): + shutdown := groupShutdown{handle: handle, waitCh: waitCh, elapsed: time.Since(start)} + return awaitShutdown(ctx, shutdown) + case err := <-waitCh: + return outcome{result: finalize(handle, time.Since(start)), err: exitError(err)} + } +} + +func awaitShutdown(ctx context.Context, shutdown groupShutdown) outcome { + _ = terminateGroup(shutdown.handle.pid()) + select { + case <-shutdown.waitCh: + return canceled(shutdown.elapsed, ctx.Err()) + case <-time.After(gracePeriod): + } + _ = killGroup(shutdown.handle.pid()) + <-shutdown.waitCh + return canceled(shutdown.elapsed, ctx.Err()) +} + +func canceled(elapsed time.Duration, err error) outcome { + return outcome{ + result: Result{SignalCanceled: true, Duration: elapsed}, + err: err, + } +} + +func finalize(handle *processHandle, elapsed time.Duration) Result { + return Result{ExitCode: handle.exitCode(), Duration: elapsed} +} + +// exitError suppresses *exec.ExitError; nonzero exit is reported via Result. +// Other wait-time errors (broken pipe, etc.) propagate to the caller. +func exitError(err error) error { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return nil + } + return err +} + +func launchError(err error) *LaunchError { + missing := errors.Is(err, exec.ErrNotFound) || isPathENOENT(err) + return &LaunchError{NotFound: missing, Cause: err} +} + +func isPathENOENT(err error) bool { + var pathErr *os.PathError + return errors.As(err, &pathErr) +} diff --git a/internal/subprocess/run_test.go b/internal/subprocess/run_test.go new file mode 100644 index 0000000..04aee15 --- /dev/null +++ b/internal/subprocess/run_test.go @@ -0,0 +1,94 @@ +package subprocess + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestProcessRun(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"normal", testNormalRun}, + {"nonzero", testNonzeroRun}, + {"missing", testMissingExecutable}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testNormalRun(t *testing.T) { + var stdout bytes.Buffer + request := Request{ + Command: []string{"go", "env", "GOVERSION"}, + Stdout: &stdout, + } + result, err := Run(context.Background(), request) + if err != nil { + t.Fatalf("err = %v", err) + } + if result.ExitCode != 0 { + t.Fatalf("exit = %d, want 0", result.ExitCode) + } + if !strings.HasPrefix(stdout.String(), "go") { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func testNonzeroRun(t *testing.T) { + target := scriptTarget{name: "exit3.sh", body: "exit 3"} + script := writeScript(t, target) + request := Request{Command: []string{script}} + result, err := Run(context.Background(), request) + if err != nil { + t.Fatalf("err = %v", err) + } + if result.ExitCode != 3 { + t.Fatalf("exit = %d, want 3", result.ExitCode) + } +} + +func testMissingExecutable(t *testing.T) { + request := Request{ + Command: []string{"cattery-definitely-not-a-real-binary"}, + } + _, err := Run(context.Background(), request) + if err == nil { + t.Fatal("err = nil, want LaunchError") + } + var launchErr *LaunchError + if !errors.As(err, &launchErr) { + t.Fatalf("err type = %T, want *LaunchError", err) + } + if !launchErr.NotFound { + t.Fatal("NotFound = false, want true") + } +} + +// scriptTarget bundles the inputs needed by writeScript so the helper stays +// under the three-parameter limit. directory is created fresh when empty. +type scriptTarget struct { + directory string + name string + body string +} + +func writeScript(t *testing.T, target scriptTarget) string { + t.Helper() + if target.directory == "" { + target.directory = t.TempDir() + } + path := filepath.Join(target.directory, target.name) + content := "#!/bin/sh\n" + target.body + "\n" + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + return path +}