w4c-workflows-api/Services/Execution/ProcessRunner.cs
2026-09-12 23:29:19 +03:00

166 lines
5.9 KiB
C#

using System.Diagnostics;
using System.Text;
namespace w4c_workflows.Services.Execution;
/// <summary>Raw subprocess output before JSON interpretation.</summary>
public sealed record ProcessOutput(int ExitCode, string Stdout, string Stderr);
/// <summary>
/// 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.
/// </summary>
public static class ProcessRunner
{
/// <summary>Per-stream capture ceiling. Extra output is drained and discarded.</summary>
internal const int MaxCapturedChars = 1024 * 1024;
public static async Task<ProcessOutput> RunAsync(
string executable,
IReadOnlyList<string> args,
string workingDir,
IReadOnlyDictionary<string, string>? 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);
}
/// <summary>
/// Reads <paramref name="reader"/> to the end while capturing at most
/// <paramref name="maxChars"/> characters. The remainder is still drained so
/// the child never blocks on a full pipe, but it is not retained.
/// </summary>
public static async Task<string> 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<string> task)
{
try { await task; } catch { /* best-effort drain */ }
}
/// <summary>
/// Resolves an executable name against PATH (or a literal path) without
/// invoking it — a cheap availability probe.
/// </summary>
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;
}
}