Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion RunCommand.Test/RunCommandTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2023-2026 ktsu-dev contributors
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.RunCommand.Test;

Expand Down Expand Up @@ -391,4 +391,26 @@ public async Task ExecuteAsyncShouldTerminateProcessWhenCancelledWhileRunning()
// rather than merely abandoned.
await Assert.ThrowsAsync<OperationCanceledException>(() => 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<OperationCanceledException>(
() => RunCommand.ExecuteAsync(fileName, arguments, new OutputHandler(), cancellationTokenSource.Token),
$"Attempt {attempt} returned an exit code instead of throwing.").ConfigureAwait(false);
}
}
}
9 changes: 8 additions & 1 deletion RunCommand/RunCommand.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) 2023-2026 ktsu-dev contributors
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.RunCommand;

Expand Down Expand Up @@ -310,7 +310,7 @@
while (i < argument.Length && argument[i] == '\\')
{
backslashes++;
i++;

Check warning on line 313 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 313 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 313 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 313 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 313 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 313 in RunCommand/RunCommand.cs

View workflow job for this annotation

GitHub Actions / Build, Test & Release

Do not update the stop condition variable 'i' in the body of the for loop.
}

if (i == argument.Length)
Expand Down Expand Up @@ -358,6 +358,13 @@
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;
}

Expand Down