Terminate owned subprocess trees

This commit is contained in:
2026-08-10 18:44:10 +00:00
parent ab5a7e8e3d
commit 7bd575187e
10 changed files with 437 additions and 12 deletions

View File

@@ -2,7 +2,6 @@ package subprocess
import (
"context"
"errors"
"fmt"
"io"
"os"
@@ -61,7 +60,12 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
}
defer logs.Close()
cmd := exec.CommandContext(runCtx, req.Executable, req.Args...)
tree, err := newOwnedProcessTree()
if err != nil {
return RunResult{}, fmt.Errorf("prepare owned subprocess tree: %w", err)
}
cmd := exec.Command(req.Executable, req.Args...)
cmd.Dir = req.WorkingDir
cmd.Env = mergeEnv(os.Environ(), req.EnvOverrides)
cmd.Stdout = logs.Stdout
@@ -75,28 +79,37 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
StderrLogPath: req.StderrLogPath,
}
if err := cmd.Start(); err != nil {
if err := runCtx.Err(); err != nil {
result.CompletedAt = time.Now().UTC()
result.Duration = result.CompletedAt.Sub(result.StartedAt)
return result, fmt.Errorf("command was not started: %w", err)
}
if err := tree.Start(cmd); err != nil {
result.CompletedAt = time.Now().UTC()
result.Duration = result.CompletedAt.Sub(result.StartedAt)
return result, fmt.Errorf("start command %q with args %v: %w", req.Executable, req.Args, err)
}
waitErr := cmd.Wait()
waitCh := make(chan error, 1)
go func() { waitCh <- cmd.Wait() }()
waitErr, ctxErr, cleanupErr := waitForOwnedCommand(runCtx, tree, waitCh)
cleanupErr = joinErrors(cleanupErr, tree.Close())
result.CompletedAt = time.Now().UTC()
result.Duration = result.CompletedAt.Sub(result.StartedAt)
if cmd.ProcessState != nil {
result.ExitCode = cmd.ProcessState.ExitCode()
}
ctxErr := runCtx.Err()
if errors.Is(ctxErr, context.DeadlineExceeded) {
if ctxErr == context.DeadlineExceeded {
result.TimedOut = true
}
if errors.Is(ctxErr, context.Canceled) && !result.TimedOut {
if ctxErr == context.Canceled && !result.TimedOut {
result.Canceled = true
}
if waitErr == nil {
if waitErr == nil && ctxErr == nil && cleanupErr == nil {
return result, nil
}
@@ -104,14 +117,17 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
diagnostics := buildDiagnostics(req, result, stderrTail)
if result.TimedOut {
return result, fmt.Errorf("command timed out after %s (%s)", req.Timeout, diagnostics)
return result, fmt.Errorf("command timed out after %s (%s): %w", req.Timeout, diagnostics, joinErrors(ctxErr, waitErr, cleanupErr))
}
if result.Canceled {
return result, fmt.Errorf("command canceled (%s)", diagnostics)
return result, fmt.Errorf("command canceled (%s): %w", diagnostics, joinErrors(ctxErr, waitErr, cleanupErr))
}
if exitErr, ok := waitErr.(*exec.ExitError); ok {
return result, fmt.Errorf("command failed with exit code %d (%s): %w", exitErr.ExitCode(), diagnostics, waitErr)
}
if cleanupErr != nil {
return result, fmt.Errorf("command cleanup failed (%s): %w", diagnostics, joinErrors(waitErr, cleanupErr))
}
return result, fmt.Errorf("command failed to run (%s): %w", diagnostics, waitErr)
}
@@ -203,7 +219,13 @@ func cleanLogPath(path string) string {
func logWriter(path string) (*os.File, io.Writer, error) {
if strings.TrimSpace(path) == "" {
return nil, io.Discard, nil
// Use a descriptor instead of io.Discard so os/exec does not create a
// pipe and wait for a descendant that inherited it after its leader exits.
f, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
return nil, nil, fmt.Errorf("open null output: %w", err)
}
return f, f, nil
}
f, err := openLogFile(path)
if err != nil {