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));
+ }
+}