166 lines
5.9 KiB
C#
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;
|
|
}
|
|
}
|