using System.Diagnostics; using System.Text; 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 /// or external cancellation the whole process tree is always killed, and the /// captured output is bounded so a chatty script cannot exhaust memory. /// public static class ProcessRunner { /// Per-stream capture ceiling. Extra output is drained and discarded. internal const int MaxCapturedChars = 1024 * 1024; 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. Both readers drain without bound but keep // only MaxCapturedChars in memory. var stdoutTask = ReadCappedAsync(process.StandardOutput, MaxCapturedChars); var stderrTask = ReadCappedAsync(process.StandardError, MaxCapturedChars); 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. } } try { process.StandardInput.Close(); } catch (IOException) { // The child exited between the write and the close, so the pipe is // already broken — likewise normal, not a failure. } using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(timeout); try { await process.WaitForExitAsync(timeoutCts.Token); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { throw new TimeoutException($"process exceeded the {timeout.TotalSeconds:0}s time limit"); } finally { // The wait throws on timeout AND on external cancellation; without // this the child tree survived as an orphan whenever a run was // cancelled or the host shut down. Reap it unconditionally, then // drain the readers so they are never left unobserved. if (!process.HasExited) { try { process.Kill(entireProcessTree: true); } catch { /* already gone */ } // Reap the killed child so it does not linger as a zombie; bounded // so a pathological kill cannot wedge the runner. try { await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5)); } catch { /* best effort */ } } await IgnoreFailureAsync(stdoutTask); await IgnoreFailureAsync(stderrTask); } var stdout = await stdoutTask; var stderr = await stderrTask; return new ProcessOutput(process.ExitCode, stdout, stderr); } /// /// Reads to the end while capturing at most /// characters. The remainder is still drained so /// the child never blocks on a full pipe, but it is not retained. /// public static async Task ReadCappedAsync(TextReader reader, int maxChars, CancellationToken ct = default) { var buffer = new char[8192]; var captured = new StringBuilder(Math.Min(maxChars, 64 * 1024)); while (true) { var read = await reader.ReadAsync(buffer.AsMemory(0, buffer.Length), ct); if (read == 0) break; if (captured.Length >= maxChars) continue; // over the cap — keep draining, discard var take = Math.Min(read, maxChars - captured.Length); captured.Append(buffer, 0, take); } return captured.ToString(); } private static async Task IgnoreFailureAsync(Task task) { try { await task; } catch { /* best-effort drain */ } } /// /// 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; } }