diff --git a/TUnit.Engine.Tests/CanCancelTests.cs b/TUnit.Engine.Tests/CanCancelTests.cs new file mode 100644 index 0000000000..693d0d747b --- /dev/null +++ b/TUnit.Engine.Tests/CanCancelTests.cs @@ -0,0 +1,38 @@ +using Shouldly; +using TUnit.Core.Enums; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +/// +/// 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. +/// +[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)); + } +} diff --git a/TUnit.Engine.Tests/InvokableTestBase.cs b/TUnit.Engine.Tests/InvokableTestBase.cs index 1e264449bc..d63ea664ac 100644 --- a/TUnit.Engine.Tests/InvokableTestBase.cs +++ b/TUnit.Engine.Tests/InvokableTestBase.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using System.Text; using CliWrap; using CliWrap.Buffered; using TrxTools.TrxParser; @@ -72,7 +73,7 @@ private async Task RunWithoutAot(string filter, .WithWorkingDirectory(testProject.DirectoryName!) .WithValidation(CommandResultValidation.None); - await RunWithFailureLogging(command, trxFilename, assertions, assertionExpression); + await RunWithFailureLogging(command, runOptions, trxFilename, assertions, assertionExpression); } private async Task RunWithAot(string filter, List> assertions, @@ -106,7 +107,7 @@ private async Task RunWithAot(string filter, List> assertions, ) .WithValidation(CommandResultValidation.None); - await RunWithFailureLogging(command, trxFilename, assertions, assertionExpression); + await RunWithFailureLogging(command, runOptions, trxFilename, assertions, assertionExpression); } private async Task RunWithSingleFile(string filter, @@ -140,7 +141,7 @@ private async Task RunWithSingleFile(string filter, ) .WithValidation(CommandResultValidation.None); - await RunWithFailureLogging(command, trxFilename, assertions, assertionExpression); + await RunWithFailureLogging(command, runOptions, trxFilename, assertions, assertionExpression); } protected static FileInfo? FindFile(Func predicate) @@ -153,13 +154,27 @@ private async Task RunWithSingleFile(string filter, return FileSystemHelpers.FindFolder(predicate); } - private async Task RunWithFailureLogging(Command command, string trxFilename, List> assertions, string assertionExpression) + private async Task> RunWithFailureLogging(Command command, RunOptions runOptions, + string trxFilename, List> assertions, string assertionExpression) { + var commandTask = command.ExecuteBufferedAsync + ( + gracefulCancellationToken: runOptions.GracefulCancellationToken, + forcefulCancellationToken: runOptions.ForcefulCancellationToken, + standardOutputEncoding: runOptions.StandardOutputEncoding, + standardErrorEncoding: runOptions.StandardErrorEncoding + ); + BufferedCommandResult? commandResult = null; try { - commandResult = await command.ExecuteBufferedAsync(); + foreach (var onExecutingDelegate in runOptions.OnExecutingDelegates) + { + await onExecutingDelegate(commandTask); + } + + commandResult = await commandTask; await TrxAsserter.AssertTrx(testMode, command, commandResult, assertions, trxFilename, assertionExpression: assertionExpression); } @@ -176,16 +191,38 @@ private async Task RunWithFailureLogging(Command command, string trxFilename, Li Expression: {assertionExpression} """); } + + return commandTask; } } public record RunOptions { + public CancellationToken GracefulCancellationToken { get; set; } = CancellationToken.None; + public CancellationToken ForcefulCancellationToken { get; set; } = CancellationToken.None; + + public Encoding StandardOutputEncoding { get; set; } = Encoding.UTF8; + public Encoding StandardErrorEncoding { get; set; } = Encoding.UTF8; + public List AdditionalArguments { get; init; } = []; + public List, Task>> OnExecutingDelegates { get; init; } = []; + public RunOptions WithArgument(string argument) { AdditionalArguments.Add(argument); return this; } + + public RunOptions WithGracefulCancellationToken(CancellationToken token) + { + GracefulCancellationToken = token; + return this; + } + + public RunOptions WithForcefulCancellationToken(CancellationToken token) + { + ForcefulCancellationToken = token; + return this; + } } diff --git a/TUnit.Engine/Helpers/TimeoutHelper.cs b/TUnit.Engine/Helpers/TimeoutHelper.cs index c02cd45b27..5e6a0bd9fd 100644 --- a/TUnit.Engine/Helpers/TimeoutHelper.cs +++ b/TUnit.Engine/Helpers/TimeoutHelper.cs @@ -1,5 +1,3 @@ -using System.Diagnostics.CodeAnalysis; - namespace TUnit.Engine.Helpers; /// @@ -7,129 +5,128 @@ namespace TUnit.Engine.Helpers; /// internal static class TimeoutHelper { + /// + /// Grace period to allow tasks to handle cancellation before throwing timeout exception. + /// + private static readonly TimeSpan GracePeriod = TimeSpan.FromSeconds(1); + /// /// 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. /// - /// Factory function that creates the task to execute - /// Optional timeout duration. If null, no timeout is applied - /// Cancellation token for the operation - /// Optional custom timeout message. If null, uses default message - /// The completed task - /// Thrown when the timeout elapses before task completion + /// Factory function that creates the task to execute. + /// Optional timeout duration. If null, no timeout is applied. + /// Cancellation token for the operation. + /// Optional custom timeout message. If null, uses default message. + /// A task representing the asynchronous operation. + /// Thrown when the timeout elapses before task completion. + /// Thrown when cancellation is requested. public static async Task ExecuteWithTimeoutAsync( Func 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); } /// /// 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. /// - /// The type of result returned by the task - /// Factory function that creates the task to execute - /// Optional timeout duration. If null, no timeout is applied - /// Cancellation token for the operation - /// Optional custom timeout message. If null, uses default message - /// The result of the completed task - /// Thrown when the timeout elapses before task completion + /// The type of result returned by the task. + /// Factory function that creates the task to execute. + /// Optional timeout duration. If null, no timeout is applied. + /// Cancellation token for the operation. + /// Optional custom timeout message. If null, uses default message. + /// The result of the completed task. + /// Thrown when the timeout elapses before task completion. + /// Thrown when cancellation is requested. public static async Task ExecuteWithTimeoutAsync( Func> taskFactory, TimeSpan? timeout, CancellationToken cancellationToken, string? timeoutMessage = null) { + // 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(TaskCreationOptions.RunContinuationsAsynchronously); + using var reg = cancellationToken.Register( + static state => ((TaskCompletionSource)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(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = timeoutCts.Token.Register( + static state => ((TaskCompletionSource)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); } -} \ No newline at end of file +} diff --git a/TUnit.Engine/TestSessionCoordinator.cs b/TUnit.Engine/TestSessionCoordinator.cs index db303b1cf1..bcecb5e828 100644 --- a/TUnit.Engine/TestSessionCoordinator.cs +++ b/TUnit.Engine/TestSessionCoordinator.cs @@ -87,10 +87,14 @@ private async Task PrepareTestOrchestrator(List testList #endif private async Task ExecuteTestsCore(List testList, CancellationToken cancellationToken) { - // Combine cancellation tokens + // Combine cancellation tokens from multiple sources: + // - cancellationToken: Per-request cancellation from test platform + // - FailFastCancellationSource: Triggered when fail-fast is enabled and a test fails + // - CancellationToken: Engine-level graceful shutdown (e.g., Ctrl+C) using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, - _serviceProvider.FailFastCancellationSource.Token); + _serviceProvider.FailFastCancellationSource.Token, + _serviceProvider.CancellationToken.Token); // Schedule and execute tests (batch approach to preserve ExecutionContext) var success = await _testScheduler.ScheduleAndExecuteAsync(testList, linkedCts.Token); diff --git a/TUnit.TestProject/CanCancelTests.cs b/TUnit.TestProject/CanCancelTests.cs new file mode 100644 index 0000000000..1634b2e8df --- /dev/null +++ b/TUnit.TestProject/CanCancelTests.cs @@ -0,0 +1,10 @@ +namespace TUnit.TestProject; + +public class CanCancelTests +{ + [Test, Explicit] + public async Task CanCancel() + { + await Task.Delay(TimeSpan.FromMinutes(5)); + } +}