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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Xunit.Abstractions;
Expand Down Expand Up @@ -130,22 +131,25 @@ public async Task RunAsync_Action_when_cancellation_races_scheduling_a_canceled_
for (var round = 0; round < StressIterations; round++)
{
using var cts = new CancellationTokenSource();
var runs = 0;
// A box, not a local int: the closure captures a reference that never
// changes, and the count itself is only touched through Interlocked/Volatile.
var runs = new StrongBox<int>();
var cancel = Task.Run(cts.Cancel);

var call = Try.RunAsync(() => Interlocked.Increment(ref runs), cts.Token);
var thrown = await Record.ExceptionAsync(() => call);
var call = Try.RunAsync(() => Interlocked.Increment(ref runs.Value), cts.Token);
var thrown = await Record.ExceptionAsync(async () => await call);
await cancel;
Result? result = thrown is null ? await call : null;

// Exactly two legal outcomes: canceled before Task.Run started the
// action (OperationCanceledException, action never ran), or the
// action ran to completion once and the call succeeded.
Assert.True
(
thrown is OperationCanceledException
? Volatile.Read(ref runs) == 0
: thrown is null && call.Result.Succeeded && Volatile.Read(ref runs) == 1,
$"round {round}: thrown={thrown?.GetType().Name ?? "none"}, runs={runs}"
? Volatile.Read(ref runs.Value) == 0
: result is { Succeeded: true } && Volatile.Read(ref runs.Value) == 1,
$"round {round}: thrown={thrown?.GetType().Name ?? "none"}, runs={Volatile.Read(ref runs.Value)}"
);
canceled += thrown is null ? 0 : 1;
}
Expand All @@ -162,30 +166,31 @@ public async Task RunAsync_Func_when_cancellation_races_the_token_check_a_cancel
for (var round = 0; round < StressIterations; round++)
{
using var cts = new CancellationTokenSource();
var invocations = 0;
var invocations = new StrongBox<int>();
var cancel = Task.Run(cts.Cancel);

var call = Try.RunAsync
(
() =>
{
Interlocked.Increment(ref invocations);
Interlocked.Increment(ref invocations.Value);
return Task.FromResult(42);
},
cts.Token
);
var thrown = await Record.ExceptionAsync(() => call);
var thrown = await Record.ExceptionAsync(async () => await call);
await cancel;
Result<int>? result = thrown is null ? await call : null;

// Canceled before the token check: OperationCanceledException and the
// function was never invoked. Otherwise it was invoked exactly once and
// its value came back, even if the token was canceled a moment later.
Assert.True
(
thrown is OperationCanceledException
? Volatile.Read(ref invocations) == 0
: thrown is null && call.Result.Succeeded && call.Result.Value == 42 && Volatile.Read(ref invocations) == 1,
$"round {round}: thrown={thrown?.GetType().Name ?? "none"}, invocations={invocations}"
? Volatile.Read(ref invocations.Value) == 0
: result is { Succeeded: true, Value: 42 } && Volatile.Read(ref invocations.Value) == 1,
$"round {round}: thrown={thrown?.GetType().Name ?? "none"}, invocations={Volatile.Read(ref invocations.Value)}"
);
canceled += thrown is null ? 0 : 1;
}
Expand Down
32 changes: 12 additions & 20 deletions tests/Wolfgang.TryPattern.Tests.Unit/RunAsyncActionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,26 +245,19 @@ public async Task RunAsync_Action_CancellationToken_when_cancellation_is_request
var actionStarted = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var cancellationObserved = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);

// Straight-line, ending in an unconditional `throw`, so every line runs in
// every interleaving and the compiler emits no normal-exit sequence point.
// A polling loop left a line (the first delay, later the loop-back)
// uncovered whenever a fast runner cancelled early, and a trailing
// ThrowIfCancellationRequested left the closing brace uncovered; either
// fails the 100% test-assembly gate. WaitOne returns true only once the
// token is cancelled; the timeout turns a cancellation that never
// arrives into a failed assertion instead of a hang.
void Action()
{
try
{
// Signal "started" only after a full pass of the loop body, so
// every line runs before the test can cancel. Signalling first
// let a fast runner cancel before the first delay, leaving that
// line uncovered on some runs (97.9% on macOS ARM64).
while (true)
{
Task.Delay(10).Wait();
actionStarted.TrySetResult(true);
cts.Token.ThrowIfCancellationRequested();
}
}
catch (OperationCanceledException)
{
cancellationObserved.TrySetResult(true);
throw;
}
actionStarted.TrySetResult(true);
cancellationObserved.TrySetResult(cts.Token.WaitHandle.WaitOne(TimeSpan.FromSeconds(10)));
throw new OperationCanceledException(cts.Token);
}

var task = Try.RunAsync(Action, cts.Token);
Expand All @@ -274,7 +267,6 @@ void Action()
cts.Cancel();

await Assert.ThrowsAsync<OperationCanceledException>(() => task);
var completedTask = await Task.WhenAny(cancellationObserved.Task, Task.Delay(1000));
Assert.Same(cancellationObserved.Task, completedTask);
Assert.True(await cancellationObserved.Task, "the action never observed the cancellation");
}
}
Loading