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; + } + } +}