w4c-workflows-api/Services/Execution/ProcessRunner.cs

105 lines
3.5 KiB
C#

using System.Diagnostics;
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
/// the whole process tree is killed.
/// </summary>
public static class ProcessRunner
{
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.
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);
}
/// <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;
}
}