package subprocess import ( "context" "fmt" "os" "os/exec" "strings" "time" "gitea.maximumdirect.net/eric/narratio/internal/fileops" "gopkg.in/yaml.v3" ) // 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() 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 = childEnv cmd.Stdout = logs.Stdout cmd.Stderr = logs.Stderr // Streaming capture uses pipes. Bound their lifetime when a leader exits // while a descendant still holds a stream descriptor. cmd.WaitDelay = forcefulTerminationWait started := time.Now().UTC() result := RunResult{ ExitCode: -1, StartedAt: started, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, } 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) } waitCh := make(chan error, 1) go func() { waitCh <- cmd.Wait() }() waitErr, ctxErr, captureLimit, cleanupErr := waitForOwnedCommand(runCtx, tree, waitCh, logs.Limits()) cleanupErr = joinErrors(cleanupErr, tree.Dispose()) cleanupErr = joinErrors(cleanupErr, logs.Flush()) if captureLimit == nil { captureLimit = logs.Limit() } result.CompletedAt = time.Now().UTC() result.Duration = result.CompletedAt.Sub(result.StartedAt) if cmd.ProcessState != nil { result.ExitCode = cmd.ProcessState.ExitCode() } if ctxErr == context.DeadlineExceeded { result.TimedOut = true } if ctxErr == context.Canceled && !result.TimedOut { result.Canceled = true } if waitErr == nil && ctxErr == nil && cleanupErr == nil { return result, nil } stderrTail := logs.stderr.Tail() diagnostics := buildDiagnostics(req, result, stderrTail) if captureLimit != nil { if cause := joinErrors(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(ctxErr, waitErr, cleanupErr)) } if result.Canceled { 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, joinErrors(waitErr, cleanupErr)) } 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, joinErrors(waitErr, cleanupErr)) } // WriteYAMLAtomic marshals value as YAML and atomically writes it to path. func WriteYAMLAtomic(path string, value any, perm os.FileMode) error { data, err := yaml.Marshal(value) if err != nil { return fmt.Errorf("marshal yaml for %q: %w", path, err) } if err := WriteFileAtomic(path, data, perm); err != nil { return fmt.Errorf("write yaml %q: %w", path, err) } return nil } // WriteFileAtomic writes bytes through the shared durable replacement primitive. func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { if strings.TrimSpace(path) == "" { return fmt.Errorf("write file: path is required") } if err := fileops.WriteFileAtomic(path, data, perm); err != nil { return fmt.Errorf("write file %q: %w", path, err) } return nil } 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 "" }