Skip to content

Instantly share code, notes, and snippets.

@ygoe
Created December 17, 2020 13:46
Show Gist options
  • Save ygoe/94ec60d80a760b189e8030bdd20691a4 to your computer and use it in GitHub Desktop.
Save ygoe/94ec60d80a760b189e8030bdd20691a4 to your computer and use it in GitHub Desktop.
ProcessHelper class: Provides methods for process execution and handling in C#. Because it's hard.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DotforwardControl.Shared.Extensions;
namespace DotforwardControl.Shared.Util
{
/// <summary>
/// Provides methods for process execution and handling.
/// </summary>
public static class ProcessHelper
{
/// <summary>
/// Executes a process with a timeout and reads all output.
/// </summary>
/// <param name="fileName">The application to execute.</param>
/// <param name="args">Command-line arguments to use when starting the application.</param>
/// <param name="timeout">The time to wait for the process to exit. When this time elapses, the process is killed.</param>
/// <param name="stdin">Optional content to pass in to the process. If null, nothing is sent.</param>
/// <param name="cancellationToken">Indicates that the wait for the completion should be aborted.</param>
/// <returns>The process result.</returns>
public static Task<ProcessResult> Execute(string fileName, IEnumerable<string> args, TimeSpan timeout, string stdin = null, CancellationToken cancellationToken = default)
{
var startInfo = new ProcessStartInfo
{
FileName = fileName,
UseShellExecute = false,
CreateNoWindow = true
};
foreach (string arg in args)
{
startInfo.ArgumentList.Add(arg);
}
return Execute(startInfo, timeout, stdin, cancellationToken);
}
/// <summary>
/// Executes a process with a timeout and reads all output.
/// </summary>
/// <param name="startInfo">The process start information. Any stream redirection is enabled automatically.</param>
/// <param name="timeout">The time to wait for the process to exit. When this time elapses, the process is killed.</param>
/// <param name="stdin">Optional content to pass in to the process. If null, nothing is sent.</param>
/// <param name="cancellationToken">Indicates that the wait for the completion should be aborted.</param>
/// <returns>The process result.</returns>
public static async Task<ProcessResult> Execute(ProcessStartInfo startInfo, TimeSpan timeout, string stdin = null, CancellationToken cancellationToken = default)
{
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
startInfo.RedirectStandardInput = stdin != null;
bool timedOut = false;
using var process = Process.Start(startInfo);
if (stdin != null)
{
process.StandardInput.Write(stdin);
process.StandardInput.Close();
}
// Read both streams asynchronously (in parallel) so that the process won't block on
// writing to them when we're not reading yet and the buffer becomes full. This
// ensures we're consuming the stream in a timely manner.
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
// Wait for both streams to close, as an indication that the process should be
// completed. At least we know that we have all the output there is. If this doesn't
// happen in a certain time, the process is killed asynchronously, which should also
// close both streams sooner or later. Before accessing ExitCode, the process must
// really have exited, so we also wait for that (still covered by the timeout).
using (var timeoutCts = new CancellationTokenSource(timeout))
using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token))
using (cts.Token.Register(() => { try { process.Kill(); timedOut = true; } catch { } }))
{
await Task.WhenAll(stdoutTask, stderrTask);
await process.WaitForExitAsync(cancellationToken);
}
return new ProcessResult(process.ExitCode, await stdoutTask, await stderrTask, timedOut);
}
/// <summary>
/// Returns a string that represents a single argument. If necessary, it is quoted.
/// </summary>
/// <param name="arg">The argument string.</param>
/// <returns>The quoted argument string.</returns>
public static string GetArgString(string arg) =>
string.IsNullOrWhiteSpace(arg) || arg.Contains(' ') ? "\"" + arg + "\"" : arg;
/// <summary>
/// Returns a string that represents multiple arguments. If necessary, each is quoted.
/// </summary>
/// <param name="args">The arguments.</param>
/// <returns>The quoted arguments string.</returns>
public static string GetArgsString(IEnumerable<string> args) =>
string.Join(' ', args.Select(a => GetArgString(a)));
}
/// <summary>
/// Contains data about an exited process.
/// </summary>
public class ProcessResult
{
/// <summary>
/// Initializes a new instance of the <see cref="ProcessResult"/> class.
/// </summary>
/// <param name="exitCode">The process exit code.</param>
/// <param name="stdout">The contents of the standard output stream.</param>
/// <param name="stderr">The contents of the standard error stream.</param>
/// <param name="timedOut">A value indicating whether the process has timed out.</param>
public ProcessResult(int exitCode, string stdout, string stderr, bool timedOut)
{
ExitCode = exitCode;
StandardOutput = stdout;
StandardError = stderr;
TimedOut = timedOut;
}
/// <summary>Gets the process exit code.</summary>
public int ExitCode { get; }
/// <summary>Gets the contents of the standard output stream.</summary>
public string StandardOutput { get; }
/// <summary>Gets the contents of the standard error stream.</summary>
public string StandardError { get; }
/// <summary>Gets a value indicating whether the process has timed out.</summary>
public bool TimedOut { get; }
}
}
@ygoe
Copy link
Author

ygoe commented Dec 21, 2023

Note to self: Possible alternative (yet unverified): https://github.com/Tyrrrz/CliWrap#piping

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment