package subprocess import ( "context" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "sort" "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 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() logs, err := openLogWriters(req.StdoutLogPath, req.StderrLogPath) if err != nil { return RunResult{}, err } defer logs.Close() cmd := exec.CommandContext(runCtx, req.Executable, req.Args...) cmd.Dir = req.WorkingDir cmd.Env = mergeEnv(os.Environ(), req.EnvOverrides) cmd.Stdout = logs.Stdout cmd.Stderr = logs.Stderr started := time.Now().UTC() result := RunResult{ ExitCode: -1, StartedAt: started, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, } if err := cmd.Start(); 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() 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) { result.TimedOut = true } if errors.Is(ctxErr, context.Canceled) && !result.TimedOut { result.Canceled = true } if waitErr == nil { return result, nil } stderrTail := readRedactedTail(req.StderrLogPath, req.EnvOverrides, 2048) diagnostics := buildDiagnostics(req, result, stderrTail) if result.TimedOut { return result, fmt.Errorf("command timed out after %s (%s)", req.Timeout, diagnostics) } if result.Canceled { return result, fmt.Errorf("command canceled (%s)", diagnostics) } if exitErr, ok := waitErr.(*exec.ExitError); ok { return result, fmt.Errorf("command failed with exit code %d (%s): %w", exitErr.ExitCode(), diagnostics, waitErr) } return result, fmt.Errorf("command failed to run (%s): %w", diagnostics, waitErr) } // 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 } type logWriters struct { files []*os.File Stdout io.Writer Stderr io.Writer } func (l *logWriters) Close() { for _, f := range l.files { _ = f.Close() } } func openLogWriters(stdoutPath, stderrPath string) (*logWriters, error) { cleanStdout := cleanLogPath(stdoutPath) cleanStderr := cleanLogPath(stderrPath) // Keep stdout/stderr on the same file descriptor when both paths target // the same file to avoid descriptor aliasing surprises across runtimes. if cleanStdout != "" && cleanStdout == cleanStderr { f, err := openLogFile(cleanStdout) if err != nil { return nil, fmt.Errorf("open shared stdout/stderr log %q: %w", cleanStdout, err) } return &logWriters{ files: []*os.File{f}, Stdout: f, Stderr: f, }, nil } stdoutFile, stdoutWriter, err := logWriter(cleanStdout) if err != nil { return nil, fmt.Errorf("open stdout log: %w", err) } stderrFile, stderrWriter, err := logWriter(cleanStderr) if err != nil { closeFile(stdoutFile) return nil, fmt.Errorf("open stderr log: %w", err) } files := make([]*os.File, 0, 2) if stdoutFile != nil { files = append(files, stdoutFile) } if stderrFile != nil { files = append(files, stderrFile) } return &logWriters{ files: files, Stdout: stdoutWriter, Stderr: stderrWriter, }, nil } func cleanLogPath(path string) string { trimmed := strings.TrimSpace(path) if trimmed == "" { return "" } return filepath.Clean(trimmed) } func logWriter(path string) (*os.File, io.Writer, error) { if strings.TrimSpace(path) == "" { return nil, io.Discard, nil } f, err := openLogFile(path) if err != nil { return nil, nil, err } return f, f, nil } func openLogFile(path string) (*os.File, error) { if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(path)); err != nil { return nil, fmt.Errorf("create log directory for %q: %w", path, err) } f, err := os.Create(path) if err != nil { return nil, fmt.Errorf("open log file %q: %w", path, err) } if err := f.Chmod(fileops.WorkspaceFileMode); err != nil { _ = f.Close() return nil, fmt.Errorf("set log file permissions %q: %w", path, err) } return f, nil } func closeFile(f *os.File) { if f != nil { _ = f.Close() } } 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 "" } func readRedactedTail(path string, envOverrides map[string]string, maxBytes int64) string { if strings.TrimSpace(path) == "" || maxBytes <= 0 { return "" } f, err := os.Open(path) if err != nil { return "" } defer f.Close() info, err := f.Stat() if err != nil { return "" } size := info.Size() start := int64(0) if size > maxBytes { start = size - maxBytes } if _, err := f.Seek(start, io.SeekStart); err != nil { return "" } data, err := io.ReadAll(f) if err != nil { return "" } tail := strings.TrimSpace(string(data)) if tail == "" { return "" } return redactSensitiveTail(tail, envOverrides) } func redactSensitiveTail(tail string, envOverrides map[string]string) string { out := tail for k, v := range envOverrides { if strings.TrimSpace(v) == "" { continue } if looksSensitiveEnvKey(k) { out = strings.ReplaceAll(out, v, "") } } return out } func looksSensitiveEnvKey(key string) bool { k := strings.ToUpper(strings.TrimSpace(key)) return strings.Contains(k, "KEY") || strings.Contains(k, "TOKEN") || strings.Contains(k, "SECRET") || strings.Contains(k, "PASSWORD") } func mergeEnv(base []string, overrides map[string]string) []string { if len(overrides) == 0 { return base } kv := make(map[string]string, len(base)+len(overrides)) for _, item := range base { k, v, ok := strings.Cut(item, "=") if !ok { continue } kv[k] = v } for k, v := range overrides { kv[k] = v } keys := make([]string, 0, len(kv)) for k := range kv { keys = append(keys, k) } sort.Strings(keys) out := make([]string, 0, len(keys)) for _, k := range keys { out = append(out, k+"="+kv[k]) } return out }