From 16a03c7b452b4bb1972903acb220f9760a7109af Mon Sep 17 00:00:00 2001
From: Samrat Banerjee <106741933+SamratB8@users.noreply.github.com>
Date: Wed, 15 Jul 2026 21:13:50 +0530
Subject: [PATCH] feat(adb): add async process runner for executing commands
---
src/WinDroid.Adb/Models/CommandResult.cs | 205 ++++++++++++++
src/WinDroid.Adb/Services/ProcessRunner.cs | 301 +++++++++++++++++++++
2 files changed, 506 insertions(+)
create mode 100644 src/WinDroid.Adb/Models/CommandResult.cs
create mode 100644 src/WinDroid.Adb/Services/ProcessRunner.cs
diff --git a/src/WinDroid.Adb/Models/CommandResult.cs b/src/WinDroid.Adb/Models/CommandResult.cs
new file mode 100644
index 0000000..93014a0
--- /dev/null
+++ b/src/WinDroid.Adb/Models/CommandResult.cs
@@ -0,0 +1,205 @@
+namespace WinDroid.Adb.Models;
+
+///
+/// Represents the outcome of running an external process.
+///
+///
+/// Exactly one high-level outcome applies to any result:
+///
+/// -
+/// Normal completion — is ,
+/// and are
+/// , and is the process exit
+/// code (which may be non-zero).
+///
+/// -
+/// Timeout — is and
+/// is .
+///
+/// -
+/// Caller cancellation — is .
+/// This may occur before the process is started (the caller token was
+/// already cancelled, so is )
+/// or after it is started ( is ).
+///
+/// -
+/// Startup failure — is and
+/// is non-empty.
+///
+///
+/// Use the static factory methods to construct values that honour these
+/// invariants.
+///
+public sealed class CommandResult
+{
+ ///
+ /// Sentinel exit code used when the real process exit code is unavailable,
+ /// for example after a startup failure or after a process is terminated
+ /// without a reportable exit code.
+ ///
+ public const int UnknownExitCode = -1;
+
+ ///
+ /// Gets the process exit code, or when it is
+ /// unavailable.
+ ///
+ public int ExitCode { get; init; }
+
+ ///
+ /// Gets the captured standard output. Never . Holds the
+ /// text of a completed stream read (including output produced before a
+ /// terminated process exited); if the stream did not finish draining within
+ /// the bounded cleanup period, this is an empty string.
+ ///
+ public string StandardOutput { get; init; } = string.Empty;
+
+ ///
+ /// Gets the captured standard error. Never . Holds the
+ /// text of a completed stream read (including output produced before a
+ /// terminated process exited); if the stream did not finish draining within
+ /// the bounded cleanup period, this is an empty string.
+ ///
+ public string StandardError { get; init; } = string.Empty;
+
+ ///
+ /// Gets a value indicating whether the process was started successfully.
+ ///
+ public bool Started { get; init; }
+
+ ///
+ /// Gets a value indicating whether the process was terminated because the
+ /// timeout elapsed.
+ ///
+ public bool TimedOut { get; init; }
+
+ ///
+ /// Gets a value indicating whether the process was terminated because the
+ /// caller cancelled the operation.
+ ///
+ public bool Cancelled { get; init; }
+
+ ///
+ /// Gets a concise, user-safe message describing why the process could not be
+ /// started when is ; otherwise
+ /// . Never contains a stack trace.
+ ///
+ public string? ErrorMessage { get; init; }
+
+ ///
+ /// Creates a result for a process that ran to completion.
+ ///
+ /// The process exit code (may be non-zero).
+ /// The captured standard output.
+ /// The captured standard error.
+ public static CommandResult Completed(int exitCode, string standardOutput, string standardError)
+ {
+ ArgumentNullException.ThrowIfNull(standardOutput);
+ ArgumentNullException.ThrowIfNull(standardError);
+
+ return new CommandResult
+ {
+ Started = true,
+ TimedOut = false,
+ Cancelled = false,
+ ExitCode = exitCode,
+ StandardOutput = standardOutput,
+ StandardError = standardError,
+ ErrorMessage = null,
+ };
+ }
+
+ ///
+ /// Creates a result for a process that was terminated because the timeout
+ /// elapsed.
+ ///
+ ///
+ /// The exit code observed after termination, or
+ /// when unavailable.
+ ///
+ /// Any standard output captured before termination.
+ /// Any standard error captured before termination.
+ public static CommandResult Timeout(int exitCode, string standardOutput, string standardError)
+ {
+ ArgumentNullException.ThrowIfNull(standardOutput);
+ ArgumentNullException.ThrowIfNull(standardError);
+
+ return new CommandResult
+ {
+ Started = true,
+ TimedOut = true,
+ Cancelled = false,
+ ExitCode = exitCode,
+ StandardOutput = standardOutput,
+ StandardError = standardError,
+ ErrorMessage = null,
+ };
+ }
+
+ ///
+ /// Creates a result for a process that was terminated because the caller
+ /// cancelled the operation.
+ ///
+ ///
+ /// The exit code observed after termination, or
+ /// when unavailable.
+ ///
+ /// Any standard output captured before termination.
+ /// Any standard error captured before termination.
+ public static CommandResult Cancellation(int exitCode, string standardOutput, string standardError)
+ {
+ ArgumentNullException.ThrowIfNull(standardOutput);
+ ArgumentNullException.ThrowIfNull(standardError);
+
+ return new CommandResult
+ {
+ Started = true,
+ TimedOut = false,
+ Cancelled = true,
+ ExitCode = exitCode,
+ StandardOutput = standardOutput,
+ StandardError = standardError,
+ ErrorMessage = null,
+ };
+ }
+
+ ///
+ /// Creates a result for an operation that was cancelled before the process
+ /// was started, because the caller's token was already cancelled.
+ ///
+ public static CommandResult CancellationBeforeStart()
+ {
+ return new CommandResult
+ {
+ Started = false,
+ TimedOut = false,
+ Cancelled = true,
+ ExitCode = UnknownExitCode,
+ StandardOutput = string.Empty,
+ StandardError = string.Empty,
+ ErrorMessage = null,
+ };
+ }
+
+ ///
+ /// Creates a result for a process that could not be started.
+ ///
+ /// A concise, user-safe explanation of the failure.
+ ///
+ /// is , empty, or whitespace.
+ ///
+ public static CommandResult StartupFailure(string errorMessage)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(errorMessage);
+
+ return new CommandResult
+ {
+ Started = false,
+ TimedOut = false,
+ Cancelled = false,
+ ExitCode = UnknownExitCode,
+ StandardOutput = string.Empty,
+ StandardError = string.Empty,
+ ErrorMessage = errorMessage,
+ };
+ }
+}
diff --git a/src/WinDroid.Adb/Services/ProcessRunner.cs b/src/WinDroid.Adb/Services/ProcessRunner.cs
new file mode 100644
index 0000000..f8597a8
--- /dev/null
+++ b/src/WinDroid.Adb/Services/ProcessRunner.cs
@@ -0,0 +1,301 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using WinDroid.Adb.Models;
+
+namespace WinDroid.Adb.Services;
+
+///
+/// Runs external processes asynchronously, capturing standard output, standard
+/// error, and the exit code, with optional timeout and cancellation support.
+///
+///
+/// This is a low-level primitive. It launches the executable directly (never a
+/// shell), passes each argument individually through
+/// , and does not interpret output or
+/// exit codes. ADB-specific behaviour is layered on top of it elsewhere.
+///
+public sealed class ProcessRunner
+{
+ ///
+ /// Maximum time to wait for a process to exit after it has been killed, so a
+ /// misbehaving process cannot make execution hang indefinitely.
+ ///
+ private static readonly TimeSpan TerminationWaitTimeout = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Maximum time to wait for redirected output to finish draining after the
+ /// process has exited, so a stuck stream cannot make execution hang.
+ ///
+ private static readonly TimeSpan OutputDrainTimeout = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Runs the given executable asynchronously and captures its output and exit
+ /// code.
+ ///
+ /// The path to the executable to run.
+ ///
+ /// The arguments to pass. Each entry is added individually to
+ /// ; no shell quoting is applied.
+ /// May be for no arguments. Individual entries must not
+ /// be .
+ ///
+ ///
+ /// Optional maximum run time. When it elapses the process and its child tree
+ /// are terminated and the result reports .
+ /// means no timeout. Must be greater than zero when
+ /// supplied.
+ ///
+ ///
+ /// When cancelled, the process and its child tree are terminated and a result
+ /// with is returned rather than throwing.
+ ///
+ /// A describing the outcome.
+ ///
+ /// is , empty, or
+ /// whitespace, or an entry of is
+ /// .
+ ///
+ ///
+ /// is less than or equal to zero.
+ ///
+ public async Task RunAsync(
+ string executablePath,
+ IReadOnlyList? arguments = null,
+ TimeSpan? timeout = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(executablePath);
+
+ if (timeout is { } requestedTimeout && requestedTimeout <= TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(timeout),
+ timeout,
+ "Timeout must be greater than zero, or null for no timeout.");
+ }
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = executablePath,
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ };
+
+ if (arguments is not null)
+ {
+ foreach (string argument in arguments)
+ {
+ if (argument is null)
+ {
+ throw new ArgumentException(
+ "Argument values must not be null.", nameof(arguments));
+ }
+
+ startInfo.ArgumentList.Add(argument);
+ }
+ }
+
+ // Honour an already-cancelled caller token before spending resources on
+ // launching a process only to immediately terminate it.
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return CommandResult.CancellationBeforeStart();
+ }
+
+ using var process = new Process { StartInfo = startInfo };
+
+ try
+ {
+ if (!process.Start())
+ {
+ return CommandResult.StartupFailure(
+ $"The process '{executablePath}' could not be started.");
+ }
+ }
+ catch (Exception ex) when (
+ ex is Win32Exception or InvalidOperationException or PlatformNotSupportedException)
+ {
+ return CommandResult.StartupFailure(
+ $"Failed to start process '{executablePath}'. {ex.Message}");
+ }
+
+ // Begin draining both streams immediately. Reading them concurrently
+ // prevents a full pipe on one stream from blocking the process (and thus
+ // deadlocking) while we wait on the other. No cancellation token is
+ // forwarded: each read completes when its stream reaches end-of-file
+ // (including once the process is terminated), and its captured text is
+ // returned. A read that does not complete within the bounded drain
+ // period is reported as an empty string.
+ Task stdoutTask = process.StandardOutput.ReadToEndAsync(CancellationToken.None);
+ Task stderrTask = process.StandardError.ReadToEndAsync(CancellationToken.None);
+
+ bool timedOut = false;
+ bool cancelled = false;
+
+ using (var timeoutCts = new CancellationTokenSource())
+ using (var linkedCts =
+ CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token))
+ {
+ if (timeout is { } activeTimeout)
+ {
+ timeoutCts.CancelAfter(activeTimeout);
+ }
+
+ try
+ {
+ await process.WaitForExitAsync(linkedCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ if (!process.HasExited)
+ {
+ // Caller cancellation takes precedence over timeout when both
+ // have fired, matching the documented precedence.
+ if (cancellationToken.IsCancellationRequested)
+ {
+ cancelled = true;
+ }
+ else
+ {
+ timedOut = true;
+ }
+
+ await TerminateProcessAsync(process).ConfigureAwait(false);
+ }
+
+ // If the process exited on its own just as the token fired, fall
+ // through and report normal completion instead of a spurious
+ // timeout or cancellation.
+ }
+ }
+
+ (string standardOutput, string standardError) =
+ await DrainOutputAsync(stdoutTask, stderrTask).ConfigureAwait(false);
+
+ int exitCode = TryGetExitCode(process);
+
+ if (cancelled)
+ {
+ return CommandResult.Cancellation(exitCode, standardOutput, standardError);
+ }
+
+ if (timedOut)
+ {
+ return CommandResult.Timeout(exitCode, standardOutput, standardError);
+ }
+
+ return CommandResult.Completed(exitCode, standardOutput, standardError);
+ }
+
+ ///
+ /// Kills the process and its child tree, then waits a bounded amount of time
+ /// for it to exit. Tolerates the race where the process has already exited.
+ ///
+ private static async Task TerminateProcessAsync(Process process)
+ {
+ try
+ {
+ process.Kill(entireProcessTree: true);
+ }
+ catch (Exception ex) when (
+ ex is InvalidOperationException or Win32Exception or NotSupportedException)
+ {
+ // The process already exited, could not be accessed, or the tree
+ // cannot be killed on this platform. Fall through to the bounded wait.
+ }
+
+ try
+ {
+ await process.WaitForExitAsync()
+ .WaitAsync(TerminationWaitTimeout)
+ .ConfigureAwait(false);
+ }
+ catch (TimeoutException)
+ {
+ // The process did not exit within the bounded wait; stop waiting so
+ // execution cannot hang forever.
+ }
+ }
+
+ ///
+ /// Awaits the two output reads with a bounded wait, returning whatever was
+ /// captured. Never throws and never hangs indefinitely.
+ ///
+ private static async Task<(string StandardOutput, string StandardError)> DrainOutputAsync(
+ Task stdoutTask,
+ Task stderrTask)
+ {
+ Task bothReads = Task.WhenAll(stdoutTask, stderrTask);
+
+ try
+ {
+ // The reads complete when each stream reaches end-of-file, which
+ // happens once the process exits. The bounded wait guards against a
+ // process that never releases a stream.
+ await bothReads.WaitAsync(OutputDrainTimeout).ConfigureAwait(false);
+ }
+ catch (TimeoutException)
+ {
+ // Draining exceeded the bounded wait; return whatever completed.
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException)
+ {
+ // A redirected stream broke (for example, after a kill); return
+ // whatever completed successfully below.
+ }
+
+ string standardOutput = stdoutTask.IsCompletedSuccessfully ? stdoutTask.Result : string.Empty;
+ string standardError = stderrTask.IsCompletedSuccessfully ? stderrTask.Result : string.Empty;
+
+ // A read that did not finish within the bounded drain is abandoned here.
+ // Observe any eventual fault so it cannot surface as an unobserved task
+ // exception, without blocking on the unfinished read.
+ ObserveEventualFault(stdoutTask);
+ ObserveEventualFault(stderrTask);
+
+ return (standardOutput, standardError);
+ }
+
+ ///
+ /// Ensures a read task's eventual fault is observed. If the task has already
+ /// finished, any fault is observed immediately; otherwise a fault-only
+ /// continuation observes it later without blocking the caller.
+ ///
+ private static void ObserveEventualFault(Task task)
+ {
+ if (task.IsCompleted)
+ {
+ _ = task.Exception;
+ return;
+ }
+
+ _ = task.ContinueWith(
+ static t => _ = t.Exception,
+ CancellationToken.None,
+ TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default);
+ }
+
+ ///
+ /// Returns the process exit code, or
+ /// when it is not available.
+ ///
+ private static int TryGetExitCode(Process process)
+ {
+ if (!process.HasExited)
+ {
+ return CommandResult.UnknownExitCode;
+ }
+
+ try
+ {
+ return process.ExitCode;
+ }
+ catch (InvalidOperationException)
+ {
+ return CommandResult.UnknownExitCode;
+ }
+ }
+}