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
85 changes: 85 additions & 0 deletions RunCommand.Test/RunCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,91 @@
await Assert.ThrowsAsync<OperationCanceledException>(() => execution).ConfigureAwait(false);
}

[TestMethod]
public async Task CancelShouldNotRunTheCallersContinuationInline()
{
using CancellationTokenSource cancellationTokenSource = new();
(string fileName, string[] arguments) = GetSleepCommand();
bool insideCancel = false;
bool continuationRanInsideCancel = false;

Task execution = Task.Run(async () =>
{
try
{
_ = await RunCommand.ExecuteAsync(fileName, arguments, new OutputHandler(), cancellationTokenSource.Token).ConfigureAwait(false);
Comment thread
matt-edmondson marked this conversation as resolved.
}
catch (OperationCanceledException)
{
continuationRanInsideCancel = Volatile.Read(ref insideCancel);
}
});

Check warning on line 471 in RunCommand.Test/RunCommandTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaEPSB6hRaD1HFdww6b5&open=AaEPSB6hRaD1HFdww6b5&pullRequest=111

// Give the command time to start, so the cancellation reaches a run that is reading output.
await Task.Delay(TimeSpan.FromMilliseconds(500)).ConfigureAwait(false);

Check warning on line 474 in RunCommand.Test/RunCommandTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaEPSB6hRaD1HFdww6b6&open=AaEPSB6hRaD1HFdww6b6&pullRequest=111

Volatile.Write(ref insideCancel, true);
CancelSynchronously(cancellationTokenSource);
Volatile.Write(ref insideCancel, false);

Task finished = await Task.WhenAny(execution, Task.Delay(TimeSpan.FromSeconds(10))).ConfigureAwait(false);

Check warning on line 480 in RunCommand.Test/RunCommandTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaEPSB6hRaD1HFdww6b7&open=AaEPSB6hRaD1HFdww6b7&pullRequest=111
Assert.AreSame(execution, finished, "Expected the cancelled call to end.");
Assert.IsFalse(continuationRanInsideCancel, "Expected the caller's catch block to run after Cancel() returned, not inside it.");
}

[TestMethod]
public async Task CancelWhileHoldingALockTheCallersCleanupNeedsShouldNotDeadlock()
{
using CancellationTokenSource cancellationTokenSource = new();
using SemaphoreSlim gate = new(1, 1);
(string fileName, string[] arguments) = GetSleepCommand();
bool cleanupAcquiredTheLock = false;

Task execution = Task.Run(async () =>
{
try
{
_ = await RunCommand.ExecuteAsync(fileName, arguments, new OutputHandler(), cancellationTokenSource.Token).ConfigureAwait(false);
Comment thread
matt-edmondson marked this conversation as resolved.
}
catch (OperationCanceledException)
{
// Bounded so that, before the fix, this reports a failure instead of hanging the run:
// the cleanup ran inside Cancel(), on the thread that holds the gate.
cleanupAcquiredTheLock = WaitSynchronously(gate, TimeSpan.FromSeconds(5));
if (cleanupAcquiredTheLock)
{
_ = gate.Release();
}
}
});

Check warning on line 509 in RunCommand.Test/RunCommandTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaEPSB6hRaD1HFdww6b8&open=AaEPSB6hRaD1HFdww6b8&pullRequest=111

await Task.Delay(TimeSpan.FromMilliseconds(500)).ConfigureAwait(false);

Check warning on line 511 in RunCommand.Test/RunCommandTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaEPSB6hRaD1HFdww6b9&open=AaEPSB6hRaD1HFdww6b9&pullRequest=111

await gate.WaitAsync().ConfigureAwait(false);

Check warning on line 513 in RunCommand.Test/RunCommandTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaEPSB6hRaD1HFdww6b-&open=AaEPSB6hRaD1HFdww6b-&pullRequest=111
Stopwatch cancelTime = Stopwatch.StartNew();
try
{
CancelSynchronously(cancellationTokenSource);
}
finally
{
cancelTime.Stop();
_ = gate.Release();
}

Task finished = await Task.WhenAny(execution, Task.Delay(TimeSpan.FromSeconds(15))).ConfigureAwait(false);

Check warning on line 525 in RunCommand.Test/RunCommandTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_RunCommand&issues=AaEPSB6hRaD1HFdww6b_&open=AaEPSB6hRaD1HFdww6b_&pullRequest=111
Assert.AreSame(execution, finished, "Expected the cancelled call to end.");
Assert.IsTrue(cleanupAcquiredTheLock, $"Expected the caller's cleanup to acquire the lock once Cancel() released it; Cancel() took {cancelTime.ElapsedMilliseconds} ms.");
}

// Cancel() rather than CancelAsync(), because the difference under test is what runs on the
// cancelling thread before Cancel() returns.
private static void CancelSynchronously(CancellationTokenSource cancellationTokenSource) =>
cancellationTokenSource.Cancel();

private static bool WaitSynchronously(SemaphoreSlim semaphore, TimeSpan timeout) =>
semaphore.Wait(timeout);

[TestMethod]
public async Task ExecuteAsyncShouldThrowRatherThanReturnAnExitCodeWhenCancellationWinsTheRace()
{
Expand Down
7 changes: 6 additions & 1 deletion RunCommand/AsyncProcessStreamReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,15 @@
/// <param name="cancellationToken">The token the caller cancelled the run with.</param>
internal async Task Start(CancellationToken cancellationToken)
{
TaskCompletionSource<bool> cancellationSource = new();
// Continuations run asynchronously because the token's registration completes this source
// from inside CancellationTokenSource.Cancel(). Run synchronously, they took the whole
// unwind with them onto the cancelling thread, process-tree kill and the caller's own catch
// block included, before Cancel() returned. A caller cancelling while holding a lock its
// cleanup also needs then deadlocked.
TaskCompletionSource<bool> cancellationSource = new(TaskCreationOptions.RunContinuationsAsynchronously);

using CancellationTokenRegistration registration = cancellationToken.Register(
static state => ((TaskCompletionSource<bool>)state!).TrySetResult(true),

Check warning on line 64 in RunCommand/AsyncProcessStreamReader.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 64 in RunCommand/AsyncProcessStreamReader.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 64 in RunCommand/AsyncProcessStreamReader.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 64 in RunCommand/AsyncProcessStreamReader.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 64 in RunCommand/AsyncProcessStreamReader.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 64 in RunCommand/AsyncProcessStreamReader.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 64 in RunCommand/AsyncProcessStreamReader.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 64 in RunCommand/AsyncProcessStreamReader.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this null-forgiving operator; the compiler already knows this expression is not null here.
cancellationSource);

Task cancelled = cancellationSource.Task;
Expand Down
Loading