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 "" }