diff --git a/RunCommand.Test/RunCommandTests.cs b/RunCommand.Test/RunCommandTests.cs index e3e265c..91f1721 100644 --- a/RunCommand.Test/RunCommandTests.cs +++ b/RunCommand.Test/RunCommandTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.RunCommand.Test; @@ -391,4 +391,26 @@ public async Task ExecuteAsyncShouldTerminateProcessWhenCancelledWhileRunning() // rather than merely abandoned. await Assert.ThrowsAsync(() => execution).ConfigureAwait(false); } + + [TestMethod] + public async Task ExecuteAsyncShouldThrowRatherThanReturnAnExitCodeWhenCancellationWinsTheRace() + { + (string fileName, string[] arguments) = GetSleepCommand(); + + // Cancelling this close to the start puts two paths in a near dead heat: the registration + // kills the process, and the kill makes it exit fast enough that the wait can observe a + // normal exit before it observes the token. Losing that race returns the killed process's + // exit code instead of throwing, so a caller cannot tell cancellation from real failure. + // A single attempt still throws most of the time, which is why this repeats: 50 attempts + // make a false pass vanishingly unlikely. + for (int attempt = 0; attempt < 50; attempt++) + { + using CancellationTokenSource cancellationTokenSource = new(); + cancellationTokenSource.CancelAfter(TimeSpan.FromMilliseconds(1)); + + await Assert.ThrowsAsync( + () => RunCommand.ExecuteAsync(fileName, arguments, new OutputHandler(), cancellationTokenSource.Token), + $"Attempt {attempt} returned an exit code instead of throwing.").ConfigureAwait(false); + } + } } diff --git a/RunCommand/RunCommand.cs b/RunCommand/RunCommand.cs index 18d7bb5..1947383 100644 --- a/RunCommand/RunCommand.cs +++ b/RunCommand/RunCommand.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors +// Copyright (c) 2023-2026 ktsu-dev contributors namespace ktsu.RunCommand; @@ -358,6 +358,13 @@ private static async Task RunAsync(ProcessStartInfo startInfo, OutputHandle await Task.WhenAll(outputReader.Start(), process.WaitForExitAsync(cancellationToken)).ConfigureAwait(false); } + // Cancellation reaches the wait two ways at once: the registration above kills the process, + // and the wait separately observes the token. The kill makes the process exit fast enough + // that the normal-exit path can win, which would return the killed process's exit code and + // throw nothing. Re-checking here makes both paths end the same way, so a cancelled call is + // never mistaken for a command that genuinely failed. + cancellationToken.ThrowIfCancellationRequested(); + return process.ExitCode; }