Skip to content

Instantly share code, notes, and snippets.

@hidegh
Created February 27, 2020 09:36
Show Gist options
  • Select an option

  • Save hidegh/07d5588702e2b56a3fc2a3d73848d9f3 to your computer and use it in GitHub Desktop.

Select an option

Save hidegh/07d5588702e2b56a3fc2a3d73848d9f3 to your computer and use it in GitHub Desktop.
Process with redirected input/output/error streams (async) supporting timeout...
using System;
using System.Diagnostics;
using System.IO;
using System.Linq.Expressions;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace R
{
public static class ProcessEx
{
/// <summary>
/// The article/forum: http://alabaxblog.info/2013/06/redirectstandardoutput-beginoutputreadline-pattern-broken/
/// - issues with BeginOutputReadLine and EOF (possibility to loose EOF)
/// - issue with OutputDataReceived (hevy load)
/// - FileStream (CopyAsynchTo & WriteAsynch) does support cancellation only at beginning...
/// </summary>
/// <param name="fileName"></param>
/// <param name="arguments"></param>
/// <param name="timeout"></param>
/// <param name="standardInput"></param>
/// <param name="standardOutput"></param>
/// <param name="standardError"></param>
/// <returns></returns>
public static int ExecuteProcess(
string fileName,
string arguments,
int timeout,
Stream standardInput,
Stream standardOutput,
out string standardError)
{
int exitCode;
using (var process = new Process())
{
process.StartInfo.UseShellExecute = false;
process.StartInfo.CreateNoWindow = true;
process.StartInfo.FileName = fileName;
process.StartInfo.Arguments = arguments;
process.StartInfo.RedirectStandardInput = true;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
process.Start();
//
// Write input stream...then close it!
Object writerThreadLock = new object();
Thread writerThread = null;
using (Task inputWriter = Task.Factory.StartNew(() =>
{
try
{
// Mark as running...
lock (writerThreadLock)
writerThread = Thread.CurrentThread;
// NOTE: Closing the input (process.StandardInput.BaseStream) after write op. is done (or aborted) is important!
using (var processInputStream = process.StandardInput.BaseStream)
standardInput.CopyTo(processInputStream);
// Mark as finished
lock (writerThreadLock)
writerThread = null;
}
catch (ThreadAbortException)
{
// NOTE: to be able to "abort" the copy process and quit without additional exception we need to reset!
Thread.ResetAbort();
}
}))
//
// Read output stream and error string (both async)...
using (Task<bool> processWaiter = Task.Factory.StartNew(() => process.WaitForExit(timeout)))
using (Task outputReader = Task.Factory.StartNew(() => { process.StandardOutput.BaseStream.CopyTo(standardOutput); }))
using (Task<string> errorReader = Task.Factory.StartNew(() => process.StandardError.ReadToEnd()))
{
// Check result (whether process finished) from processWaiter...
bool processFinished = processWaiter.Result;
// If we got timeout, we need to kill the process...
if (!processFinished)
{
lock (writerThreadLock)
{
// NOTE: a non-null waitherThread signalizes that a inputWriter thread is still running (which we need to abort)...
writerThread?.Abort();
}
process.Kill();
}
// NOTE: even after calling process kill (asynchronously) - not just on success - , make sure we wait for the process to finish.
// See: https://msdn.microsoft.com/en-us/library/system.diagnostics.process.kill.aspx
process.WaitForExit();
// Make sure everything is read from the streams...
Task.WaitAll(outputReader, errorReader, inputWriter);
// Timeout-ed process has to raise an exception...
if (!processFinished)
throw new TimeoutException("Process wait timeout expired");
// Success...
standardError = errorReader.Result;
exitCode = process.ExitCode;
}
}
return exitCode;
}
}
}
@hidegh
Copy link
Author

hidegh commented Feb 6, 2026

Claude generated code:

using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

namespace R
{
    /// <summary>
    /// Executes an external process with full streaming I/O support.
    ///
    /// Key design decisions (see: http://alabaxblog.info/2013/06/redirectstandardoutput-beginoutputreadline-pattern-broken/):
    ///
    /// 1. We avoid BeginOutputReadLine/OutputDataReceived entirely — they have race conditions
    ///    that silently lose output, and drop data under heavy load.
    ///
    /// 2. We read from BaseStream directly (not StreamReader.ReadToEnd) to support binary/streaming.
    ///
    /// 3. All three pipes (stdin, stdout, stderr) are handled on separate Task.Run threads because
    ///    Process streams have IsAsync == false, meaning *Async methods fall back to synchronous
    ///    blocking reads. Without Task.Run, you get deadlocks when pipe buffers (~4KB) fill up.
    ///
    /// 4. On timeout, we always drain stderr for diagnostics before throwing.
    ///
    /// 5. stdin is closed (disposed) after writing — critical for child processes that read until EOF.
    /// </summary>
    public static class ProcessEx
    {
        /// <summary>
        /// Executes a process with streaming stdin/stdout/stderr.
        /// All three pipes run concurrently to prevent deadlocks.
        ///
        /// Returns the process exit code on normal completion.
        /// The caller's standardError stream is always populated before any exception is thrown.
        ///
        /// Exceptions:
        ///   <see cref="TimeoutException"/> — process did not complete within the specified timeout.
        ///     Process is killed, stderr is drained.
        ///   <see cref="OperationCanceledException"/> — caller's cancellationToken was cancelled
        ///     (e.g., HTTP request aborted). Process is killed, stderr is drained.
        ///   <see cref="System.ComponentModel.Win32Exception"/> — executable not found or cannot be started.
        ///   Other exceptions — unexpected failures (e.g., I/O errors). Process is killed, stderr is drained.
        /// </summary>
        /// <param name="fileName">The executable to run.</param>
        /// <param name="arguments">Command-line arguments.</param>
        /// <param name="standardInput">Input stream to feed to the process. Null if no input is needed.</param>
        /// <param name="standardOutput">Output stream where process stdout is written to.</param>
        /// <param name="standardError">Output stream where process stderr is written to. Null to discard (pipe is still drained to prevent deadlocks).</param>
        /// <param name="timeout">Maximum time to wait. Null means unlimited (only cancellationToken can stop it).</param>
        /// <param name="cancellationToken">Cancellation token (e.g., from an HTTP request in Azure).</param>
        /// <returns>The process exit code.</returns>
        public static async Task<int> ExecuteProcessAsync(
            string fileName,
            string arguments,
            Stream standardInput,
            Stream standardOutput,
            Stream standardError,
            TimeSpan? timeout = null,
            CancellationToken cancellationToken = default)
        {
            using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

            if (timeout.HasValue)
                cts.CancelAfter(timeout.Value);

            var ct = cts.Token;

            using var process = new Process();

            process.StartInfo.FileName = fileName;
            process.StartInfo.Arguments = arguments;
            process.StartInfo.UseShellExecute = false;
            process.StartInfo.CreateNoWindow = true;
            process.StartInfo.RedirectStandardInput = true;
            process.StartInfo.RedirectStandardOutput = true;
            process.StartInfo.RedirectStandardError = true;

            process.Start();

            // ═══════════════════════════════════════════════════════════════════
            // All three streams MUST run concurrently on separate threads.
            //
            // Why Task.Run and not just CopyToAsync?
            //   Process.StandardOutput.BaseStream is a FileStream with IsAsync == false.
            //   The *Async methods on such streams fall back to blocking synchronous reads
            //   wrapped in thread pool work, but continuations can serialize back onto the
            //   same context. Task.Run guarantees a dedicated thread pool thread for each
            //   pipe, preventing the deadlock where:
            //     - You're blocked writing to stdin (buffer full)
            //     - Child is blocked writing to stdout (buffer full, nobody reading)
            //     → deadlock
            // ═══════════════════════════════════════════════════════════════════

            // Task 1: Feed stdin, then close it (signals EOF to child process)
            using var inputTask = Task.Run(() =>
            {
                using (var processInputStream = process.StandardInput.BaseStream)
                {
                    if (standardInput != null)
                    {
                        CopyWithCancellation(standardInput, processInputStream, ct);
                    }
                }
                // Disposing processInputStream closes stdin → child sees EOF
            }, ct);

            // Task 2: Drain stdout into the caller's output stream
            using var outputTask = Task.Run(() =>
            {
                CopyWithCancellation(process.StandardOutput.BaseStream, standardOutput, ct);
            }, ct);

            // Task 3: Drain stderr directly into the caller's error stream (or discard).
            // CancellationToken.None — this task must always run to completion so we
            // can read error output even on timeout/kill. After kill, the pipe closes
            // and CopyWithCancellation returns with whatever was buffered.
            var errorDestination = standardError ?? Stream.Null;
            using var errorTask = Task.Run(() =>
            {
                CopyWithCancellation(process.StandardError.BaseStream, errorDestination, CancellationToken.None);
            }, CancellationToken.None);

            try
            {
                // Wait for the process to exit (respects our combined cancellation token)
                await process.WaitForExitAsync(ct).ConfigureAwait(false);

                // Process exited — drain remaining pipe data.
                // After process exit, the streams are finite, so these will complete.
                await Task.WhenAll(inputTask, outputTask, errorTask).ConfigureAwait(false);

                return process.ExitCode;
            }
            catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
            {
                // ═══════════════════════════════════════════════════════════════════
                // Timeout path (our cts fired, not the caller's token)
                // ═══════════════════════════════════════════════════════════════════

                KillProcessSafe(process);

                // errorTask is already running with CancellationToken.None — after kill
                // the pipe closes and it finishes with whatever was buffered.
                await AwaitTaskSafe(errorTask).ConfigureAwait(false);

                throw new TimeoutException(
                    $"Process '{fileName}' timed out after {timeout?.TotalSeconds:F1}s.");
            }
            catch (OperationCanceledException)
            {
                // ═══════════════════════════════════════════════════════════════════
                // Caller cancelled — still clean up the process and drain stderr
                // ═══════════════════════════════════════════════════════════════════

                KillProcessSafe(process);

                await AwaitTaskSafe(errorTask).ConfigureAwait(false);

                throw;
            }
            catch (Exception)
            {
                // ═══════════════════════════════════════════════════════════════════
                // Unexpected error — kill process, drain stderr, rethrow
                // ═══════════════════════════════════════════════════════════════════

                KillProcessSafe(process);

                await AwaitTaskSafe(errorTask).ConfigureAwait(false);

                throw;
            }
        }

        // ═══════════════════════════════════════════════════════════════════════════
        // Helpers
        // ═══════════════════════════════════════════════════════════════════════════

        /// <summary>
        /// Manual stream copy with cancellation check between chunks.
        /// Process streams don't support true async cancellation (IsAsync == false),
        /// so we check the token between each Read/Write pair.
        /// </summary>
        private static void CopyWithCancellation(Stream source, Stream destination, CancellationToken ct, int bufferSize = 81920)
        {
            var buffer = new byte[bufferSize];
            int bytesRead;

            while ((bytesRead = source.Read(buffer, 0, bufferSize)) > 0)
            {
                ct.ThrowIfCancellationRequested();
                destination.Write(buffer, 0, bytesRead);
            }
        }

        /// <summary>
        /// Kills the process and its entire process tree. Swallows exceptions
        /// because the process may have already exited.
        /// </summary>
        private static void KillProcessSafe(Process process)
        {
            try
            {
                // entireProcessTree: true — kills child processes too,
                // important if the process spawns subprocesses
                process.Kill(entireProcessTree: true);
            }
            catch (InvalidOperationException)
            {
                // Process already exited — fine
            }
            catch (SystemException)
            {
                // Various OS-level errors — process may be gone
            }
        }

        /// <summary>
        /// Awaits a task with a safety timeout after process kill.
        /// After kill the pipe closes and the task should finish promptly.
        /// The 5s timeout is a safeguard against unexpected hangs.
        /// </summary>
        private static async Task AwaitTaskSafe(Task task)
        {
            try
            {
                await task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
            }
            catch
            {
                // Best effort — if the task doesn't finish, we move on
            }
        }

    
    }

}

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