144 lines
4.8 KiB
Go
144 lines
4.8 KiB
Go
package subprocess
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// RunRequest defines a subprocess invocation.
|
|
type RunRequest struct {
|
|
Executable string
|
|
Args []string
|
|
WorkingDir string
|
|
EnvOverrides map[string]string
|
|
SensitiveEnvNames []string
|
|
DiagnosticOwner string
|
|
Timeout time.Duration
|
|
StdoutLogPath string
|
|
StderrLogPath string
|
|
}
|
|
|
|
// RunResult captures subprocess execution details.
|
|
type RunResult struct {
|
|
ExitCode int
|
|
StartedAt time.Time
|
|
CompletedAt time.Time
|
|
Duration time.Duration
|
|
StdoutLogPath string
|
|
StderrLogPath string
|
|
TimedOut bool
|
|
Canceled bool
|
|
}
|
|
|
|
// Run executes a subprocess with context cancellation and optional timeout.
|
|
func Run(ctx context.Context, req RunRequest) (RunResult, error) {
|
|
if strings.TrimSpace(req.Executable) == "" {
|
|
return RunResult{}, fmt.Errorf("subprocess executable is required")
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
runCtx := ctx
|
|
cancel := func() {}
|
|
if req.Timeout > 0 {
|
|
runCtx, cancel = context.WithTimeout(ctx, req.Timeout)
|
|
}
|
|
defer cancel()
|
|
|
|
childEnv := buildChildEnvironment(os.Environ(), req.EnvOverrides)
|
|
logs, err := openLogWriters(req.StdoutLogPath, req.StderrLogPath, req.diagnosticOwner(), sensitiveEnvironmentValues(childEnv, req.SensitiveEnvNames))
|
|
if err != nil {
|
|
return RunResult{}, err
|
|
}
|
|
defer logs.Close()
|
|
|
|
cmd := exec.Command(req.Executable, req.Args...)
|
|
cmd.Dir = req.WorkingDir
|
|
cmd.Env = childEnv
|
|
cmd.Stdout = logs.Stdout
|
|
cmd.Stderr = logs.Stderr
|
|
outcome := executeOwnedCommand(runCtx, cmd, logs.Limits())
|
|
cleanupErr := joinErrors(outcome.cleanupErr, logs.Flush())
|
|
captureLimit, _ := outcome.abortErr.(*captureLimitError)
|
|
if captureLimit == nil {
|
|
captureLimit = logs.Limit()
|
|
}
|
|
result := RunResult{ExitCode: outcome.exitCode, StartedAt: outcome.startedAt, CompletedAt: outcome.completedAt, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath}
|
|
result.Duration = result.CompletedAt.Sub(result.StartedAt)
|
|
result.TimedOut = outcome.reason == executionDeadline
|
|
result.Canceled = outcome.reason == executionCanceled
|
|
|
|
if outcome.reason == executionNaturalExit && outcome.waitErr == nil && cleanupErr == nil {
|
|
return result, nil
|
|
}
|
|
|
|
stderrTail := logs.stderr.Tail()
|
|
diagnostics := buildDiagnostics(req, result, stderrTail)
|
|
|
|
if captureLimit != nil {
|
|
if cause := joinErrors(outcome.waitErr, cleanupErr); cause != nil {
|
|
return result, fmt.Errorf("%w (%s): %w", captureLimit, diagnostics, cause)
|
|
}
|
|
return result, fmt.Errorf("%w (%s)", captureLimit, diagnostics)
|
|
}
|
|
if result.TimedOut {
|
|
return result, fmt.Errorf("command timed out after %s (%s): %w", req.Timeout, diagnostics, joinErrors(outcome.contextErr, outcome.waitErr, cleanupErr))
|
|
}
|
|
if result.Canceled {
|
|
return result, fmt.Errorf("command canceled (%s): %w", diagnostics, joinErrors(outcome.contextErr, outcome.waitErr, cleanupErr))
|
|
}
|
|
if exitErr, ok := outcome.waitErr.(*exec.ExitError); ok {
|
|
return result, fmt.Errorf("command failed with exit code %d (%s): %w", exitErr.ExitCode(), diagnostics, joinErrors(outcome.waitErr, cleanupErr))
|
|
}
|
|
if cleanupErr != nil {
|
|
return result, fmt.Errorf("command cleanup failed (%s): %w", diagnostics, joinErrors(outcome.waitErr, cleanupErr))
|
|
}
|
|
|
|
if outcome.startErr != nil {
|
|
return result, fmt.Errorf("start command %q with args %v: %w", req.Executable, req.Args, outcome.startErr)
|
|
}
|
|
return result, fmt.Errorf("command failed to run (%s): %w", diagnostics, joinErrors(outcome.waitErr, outcome.contextErr, outcome.abortErr, cleanupErr))
|
|
}
|
|
|
|
func buildDiagnostics(req RunRequest, result RunResult, stderrTail string) string {
|
|
details := fmt.Sprintf(
|
|
"executable=%q args=%v cwd=%q timeout=%s exit_code=%d timed_out=%t canceled=%t stdout_log=%q stderr_log=%q",
|
|
req.Executable,
|
|
req.Args,
|
|
req.WorkingDir,
|
|
req.Timeout,
|
|
result.ExitCode,
|
|
result.TimedOut,
|
|
result.Canceled,
|
|
req.StdoutLogPath,
|
|
req.StderrLogPath,
|
|
)
|
|
if strings.TrimSpace(stderrTail) == "" {
|
|
if hint := fdDiagnosticsHint(result.ExitCode, ""); hint != "" {
|
|
return details + fmt.Sprintf(" hint=%q", hint)
|
|
}
|
|
return details
|
|
}
|
|
details = details + fmt.Sprintf(" stderr_tail=%q", stderrTail)
|
|
if hint := fdDiagnosticsHint(result.ExitCode, stderrTail); hint != "" {
|
|
details = details + fmt.Sprintf(" hint=%q", hint)
|
|
}
|
|
return details
|
|
}
|
|
|
|
func fdDiagnosticsHint(exitCode int, stderrTail string) string {
|
|
lowerTail := strings.ToLower(stderrTail)
|
|
if strings.Contains(lowerTail, "bad file descriptor") || strings.Contains(lowerTail, "errno 9") {
|
|
return "stderr stream appears invalid in child process (fd 2); check wrapper/subprocess environment differences versus direct shell execution"
|
|
}
|
|
if exitCode == 120 {
|
|
return "exit code 120 may indicate interpreter shutdown stream-flush failures (commonly invalid stdout/stderr descriptors in Python processes)"
|
|
}
|
|
return ""
|
|
}
|