-
-
Notifications
You must be signed in to change notification settings - Fork 109
Better reactive test cancellation #4158
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4133d26
3ec8637
00829ea
292d17f
cc26d47
1027c06
092fd8d
4443f8d
f3fcaea
e335709
cbc2861
f013d2a
1039352
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| using Shouldly; | ||
| using TUnit.Core.Enums; | ||
| using TUnit.Engine.Tests.Enums; | ||
|
|
||
| namespace TUnit.Engine.Tests; | ||
|
|
||
| /// <summary> | ||
| /// Tests that verify test cancellation works correctly when graceful cancellation is requested. | ||
| /// Skipped on Windows because CliWrap's graceful cancellation uses GenerateConsoleCtrlEvent, | ||
| /// which doesn't work properly for subprocess control. | ||
| /// </summary> | ||
| [ExcludeOn(OS.Windows)] | ||
| public class CanCancelTests(TestMode testMode) : InvokableTestBase(testMode) | ||
| { | ||
| private const int GracefulCancellationDelaySeconds = 5; | ||
| private const int MaxExpectedDurationSeconds = 30; | ||
| // Test timeout must be higher than MaxExpectedDurationSeconds to allow for subprocess startup and assertions | ||
| private const int TestTimeoutMs = 60_000; | ||
|
|
||
| [Test, Timeout(TestTimeoutMs), Explicit("Graceful cancellation via SIGINT is unreliable in CI environments")] | ||
| public async Task GracefulCancellation_ShouldTerminateTestBeforeTimeout(CancellationToken ct) | ||
| { | ||
| // Send graceful cancellation (SIGINT) after delay - tests that engine reacts to cancellation | ||
| // even for tests that don't explicitly accept a CancellationToken parameter | ||
| using var gracefulCts = CancellationTokenSource.CreateLinkedTokenSource(ct); | ||
| gracefulCts.CancelAfter(TimeSpan.FromSeconds(GracefulCancellationDelaySeconds)); | ||
|
|
||
| await RunTestsWithFilter( | ||
| "/*/*/CanCancelTests/*", | ||
| [ | ||
| // A cancelled test is reported as "Failed" in TRX because it was terminated before completion. | ||
| // This is the expected behavior - the test did not pass, so it's marked as failed. | ||
| result => result.ResultSummary.Outcome.ShouldBe("Failed"), | ||
| result => TimeSpan.Parse(result.Duration).ShouldBeLessThan(TimeSpan.FromSeconds(MaxExpectedDurationSeconds)) | ||
| ], | ||
| new RunOptions().WithGracefulCancellationToken(gracefulCts.Token)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,135 +1,132 @@ | ||
| using System.Diagnostics.CodeAnalysis; | ||
|
|
||
| namespace TUnit.Engine.Helpers; | ||
|
|
||
| /// <summary> | ||
| /// Reusable utility for executing tasks with timeout support that can return control immediately when timeout elapses. | ||
| /// </summary> | ||
| internal static class TimeoutHelper | ||
| { | ||
| /// <summary> | ||
| /// Grace period to allow tasks to handle cancellation before throwing timeout exception. | ||
| /// </summary> | ||
| private static readonly TimeSpan GracePeriod = TimeSpan.FromSeconds(1); | ||
|
|
||
| /// <summary> | ||
| /// Executes a task with an optional timeout. If the timeout elapses before the task completes, | ||
| /// control is returned to the caller immediately with a TimeoutException. | ||
| /// </summary> | ||
| /// <param name="taskFactory">Factory function that creates the task to execute</param> | ||
| /// <param name="timeout">Optional timeout duration. If null, no timeout is applied</param> | ||
| /// <param name="cancellationToken">Cancellation token for the operation</param> | ||
| /// <param name="timeoutMessage">Optional custom timeout message. If null, uses default message</param> | ||
| /// <returns>The completed task</returns> | ||
| /// <exception cref="TimeoutException">Thrown when the timeout elapses before task completion</exception> | ||
| /// <param name="taskFactory">Factory function that creates the task to execute.</param> | ||
| /// <param name="timeout">Optional timeout duration. If null, no timeout is applied.</param> | ||
| /// <param name="cancellationToken">Cancellation token for the operation.</param> | ||
| /// <param name="timeoutMessage">Optional custom timeout message. If null, uses default message.</param> | ||
| /// <returns>A task representing the asynchronous operation.</returns> | ||
| /// <exception cref="TimeoutException">Thrown when the timeout elapses before task completion.</exception> | ||
| /// <exception cref="OperationCanceledException">Thrown when cancellation is requested.</exception> | ||
| public static async Task ExecuteWithTimeoutAsync( | ||
| Func<CancellationToken, Task> taskFactory, | ||
| TimeSpan? timeout, | ||
| CancellationToken cancellationToken, | ||
| string? timeoutMessage = null) | ||
| { | ||
| if (!timeout.HasValue) | ||
| { | ||
| await taskFactory(cancellationToken).ConfigureAwait(false); | ||
| return; | ||
| } | ||
|
|
||
| using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); | ||
| timeoutCts.CancelAfter(timeout.Value); | ||
|
|
||
| var executionTask = taskFactory(timeoutCts.Token); | ||
|
|
||
| // Use a cancellable timeout task to avoid leaving Task.Delay running in the background | ||
| using var timeoutTaskCts = new CancellationTokenSource(); | ||
| var timeoutTask = Task.Delay(timeout.Value, timeoutTaskCts.Token); | ||
|
|
||
| var completedTask = await Task.WhenAny(executionTask, timeoutTask).ConfigureAwait(false); | ||
|
|
||
| if (completedTask == timeoutTask) | ||
| { | ||
| // Timeout occurred - cancel the execution task and wait briefly for cleanup | ||
| timeoutCts.Cancel(); | ||
|
|
||
| // Give the execution task a chance to handle cancellation gracefully | ||
| try | ||
| { | ||
| await executionTask.ConfigureAwait(false); | ||
| } | ||
| catch (OperationCanceledException) | ||
| await ExecuteWithTimeoutAsync( | ||
| async ct => | ||
| { | ||
| // Expected when cancellation is properly handled | ||
| } | ||
| catch | ||
| { | ||
| // Ignore other exceptions from the cancelled task | ||
| } | ||
|
|
||
| var message = timeoutMessage ?? $"Operation timed out after {timeout.Value}"; | ||
| throw new TimeoutException(message); | ||
| } | ||
|
|
||
| // Task completed normally - cancel the timeout task to free resources immediately | ||
| timeoutTaskCts.Cancel(); | ||
|
|
||
| // Await the result to propagate any exceptions | ||
| await executionTask.ConfigureAwait(false); | ||
| await taskFactory(ct).ConfigureAwait(false); | ||
| return true; | ||
| }, | ||
| timeout, | ||
| cancellationToken, | ||
| timeoutMessage).ConfigureAwait(false); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Executes a task with an optional timeout and returns a result. If the timeout elapses before the task completes, | ||
| /// control is returned to the caller immediately with a TimeoutException. | ||
| /// </summary> | ||
| /// <typeparam name="T">The type of result returned by the task</typeparam> | ||
| /// <param name="taskFactory">Factory function that creates the task to execute</param> | ||
| /// <param name="timeout">Optional timeout duration. If null, no timeout is applied</param> | ||
| /// <param name="cancellationToken">Cancellation token for the operation</param> | ||
| /// <param name="timeoutMessage">Optional custom timeout message. If null, uses default message</param> | ||
| /// <returns>The result of the completed task</returns> | ||
| /// <exception cref="TimeoutException">Thrown when the timeout elapses before task completion</exception> | ||
| /// <typeparam name="T">The type of result returned by the task.</typeparam> | ||
| /// <param name="taskFactory">Factory function that creates the task to execute.</param> | ||
| /// <param name="timeout">Optional timeout duration. If null, no timeout is applied.</param> | ||
| /// <param name="cancellationToken">Cancellation token for the operation.</param> | ||
| /// <param name="timeoutMessage">Optional custom timeout message. If null, uses default message.</param> | ||
| /// <returns>The result of the completed task.</returns> | ||
| /// <exception cref="TimeoutException">Thrown when the timeout elapses before task completion.</exception> | ||
| /// <exception cref="OperationCanceledException">Thrown when cancellation is requested.</exception> | ||
| public static async Task<T> ExecuteWithTimeoutAsync<T>( | ||
| Func<CancellationToken, Task<T>> taskFactory, | ||
| TimeSpan? timeout, | ||
| CancellationToken cancellationToken, | ||
| string? timeoutMessage = null) | ||
|
Comment on lines
53
to
57
|
||
| { | ||
| // Fast path: no timeout specified | ||
| if (!timeout.HasValue) | ||
| { | ||
| return await taskFactory(cancellationToken).ConfigureAwait(false); | ||
| var task = taskFactory(cancellationToken); | ||
|
|
||
| // If the token can't be cancelled, just await directly (avoid allocations) | ||
| if (!cancellationToken.CanBeCanceled) | ||
| { | ||
| return await task.ConfigureAwait(false); | ||
| } | ||
|
|
||
| // Race against cancellation - TrySetCanceled makes the TCS throw OperationCanceledException when awaited | ||
| var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously); | ||
| using var reg = cancellationToken.Register( | ||
| static state => ((TaskCompletionSource<T>)state!).TrySetCanceled(), | ||
| tcs); | ||
|
|
||
| // await await: first gets winning task, then awaits it (propagates result or exception) | ||
| return await await Task.WhenAny(task, tcs.Task).ConfigureAwait(false); | ||
| } | ||
|
|
||
| // Timeout path: create linked token so task can observe both timeout and external cancellation. | ||
| using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); | ||
|
|
||
| // Set up cancellation detection BEFORE scheduling timeout to avoid race condition | ||
| // where timeout fires before registration completes (with very small timeouts) | ||
| var cancelledTcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously); | ||
| using var registration = timeoutCts.Token.Register( | ||
| static state => ((TaskCompletionSource<T>)state!).TrySetCanceled(), | ||
| cancelledTcs); | ||
|
|
||
| // Now schedule the timeout - registration is guaranteed to catch it | ||
| timeoutCts.CancelAfter(timeout.Value); | ||
|
|
||
| var executionTask = taskFactory(timeoutCts.Token); | ||
|
|
||
| // Use a cancellable timeout task to avoid leaving Task.Delay running in the background | ||
| using var timeoutTaskCts = new CancellationTokenSource(); | ||
| var timeoutTask = Task.Delay(timeout.Value, timeoutTaskCts.Token); | ||
|
|
||
| var completedTask = await Task.WhenAny(executionTask, timeoutTask).ConfigureAwait(false); | ||
| var winner = await Task.WhenAny(executionTask, cancelledTcs.Task).ConfigureAwait(false); | ||
|
|
||
| if (completedTask == timeoutTask) | ||
| if (winner == cancelledTcs.Task) | ||
| { | ||
| // Timeout occurred - cancel the execution task and wait briefly for cleanup | ||
| timeoutCts.Cancel(); | ||
|
|
||
| // Give the execution task a chance to handle cancellation gracefully | ||
| try | ||
| // Determine if it was external cancellation or timeout | ||
| if (cancellationToken.IsCancellationRequested) | ||
| { | ||
| await executionTask.ConfigureAwait(false); | ||
| throw new OperationCanceledException(cancellationToken); | ||
| } | ||
| catch (OperationCanceledException) | ||
|
|
||
| // Timeout occurred - give the execution task a brief grace period to clean up | ||
| try | ||
| { | ||
| // Expected when cancellation is properly handled | ||
| #if NET8_0_OR_GREATER | ||
| await executionTask.WaitAsync(GracePeriod, CancellationToken.None).ConfigureAwait(false); | ||
| #else | ||
| // Use cancellable delay to avoid leaked tasks when executionTask completes first | ||
| using var graceCts = new CancellationTokenSource(); | ||
| var delayTask = Task.Delay(GracePeriod, graceCts.Token); | ||
| var graceWinner = await Task.WhenAny(executionTask, delayTask).ConfigureAwait(false); | ||
| if (graceWinner == executionTask) | ||
| { | ||
| graceCts.Cancel(); | ||
| } | ||
| #endif | ||
| } | ||
| catch | ||
| { | ||
| // Ignore other exceptions from the cancelled task | ||
| // Ignore all exceptions - task was cancelled, we're just giving it time to clean up | ||
| } | ||
|
|
||
| var message = timeoutMessage ?? $"Operation timed out after {timeout.Value}"; | ||
| throw new TimeoutException(message); | ||
| } | ||
|
|
||
| // Task completed normally - cancel the timeout task to free resources immediately | ||
| timeoutTaskCts.Cancel(); | ||
| // Even if task completed during grace period, timeout already elapsed so we throw | ||
| throw new TimeoutException(timeoutMessage ?? $"Operation timed out after {timeout.Value}"); | ||
| } | ||
|
|
||
| // Await the result to propagate any exceptions | ||
| return await executionTask.ConfigureAwait(false); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| namespace TUnit.TestProject; | ||
|
|
||
| public class CanCancelTests | ||
| { | ||
| [Test, Explicit] | ||
| public async Task CanCancel() | ||
| { | ||
| await Task.Delay(TimeSpan.FromMinutes(5)); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Important XML documentation for parameters and return values has been removed. According to TUnit development guidelines, code documentation should be maintained. The removed documentation described the parameters (taskFactory, timeout, cancellationToken, timeoutMessage) and return value, as well as the TimeoutException that can be thrown. Restore this documentation.