105 lines
3.5 KiB
C#
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;
|
|
}
|
|
}
|