using System.Diagnostics; namespace w4c_workflows.Services.Execution; /// Raw subprocess output before JSON interpretation. public sealed record ProcessOutput(int ExitCode, string Stdout, string Stderr); /// /// Runs a subprocess with stdin/stdout/stderr redirection, an optional JSON /// stdin payload, extra environment variables, and a hard timeout. On timeout /// the whole process tree is killed. /// public static class ProcessRunner { public static async Task RunAsync( string executable, IReadOnlyList args, string workingDir, IReadOnlyDictionary? env, string? stdin, TimeSpan timeout, CancellationToken ct) { var psi = new ProcessStartInfo { FileName = executable, WorkingDirectory = workingDir, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, }; foreach (var arg in args) psi.ArgumentList.Add(arg); if (env != null) { foreach (var (key, value) in env) psi.Environment[key] = value; } using var process = new Process { StartInfo = psi }; if (!process.Start()) throw new InvalidOperationException($"failed to start '{executable}'"); // Start draining stdout/stderr before writing stdin so a chatty child // never blocks on a full pipe. var stdoutTask = process.StandardOutput.ReadToEndAsync(); var stderrTask = process.StandardError.ReadToEndAsync(); if (!string.IsNullOrEmpty(stdin)) { try { await process.StandardInput.WriteAsync(stdin); } catch (IOException) { // The child exited before reading stdin (e.g. a script that ignores // its input) — a normal condition, not a failure. } } process.StandardInput.Close(); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(timeout); try { await process.WaitForExitAsync(timeoutCts.Token); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { try { process.Kill(entireProcessTree: true); } catch { /* already gone */ } await stdoutTask; await stderrTask; throw new TimeoutException($"process exceeded the {timeout.TotalSeconds:0}s time limit"); } var stdout = await stdoutTask; var stderr = await stderrTask; return new ProcessOutput(process.ExitCode, stdout, stderr); } /// /// Resolves an executable name against PATH (or a literal path) without /// invoking it — a cheap availability probe. /// public static string? FindInPath(string executable) { if (executable.Contains(Path.DirectorySeparatorChar) || executable.Contains('/')) return File.Exists(executable) ? Path.GetFullPath(executable) : null; var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; foreach (var dir in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) { var full = Path.Combine(dir, executable); if (File.Exists(full)) return full; } return null; } }