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
38 changes: 38 additions & 0 deletions TUnit.Engine.Tests/CanCancelTests.cs
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));
}
}
47 changes: 42 additions & 5 deletions TUnit.Engine.Tests/InvokableTestBase.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Runtime.CompilerServices;
using System.Text;
using CliWrap;
using CliWrap.Buffered;
using TrxTools.TrxParser;
Expand Down Expand Up @@ -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<Action<TestRun>> assertions,
Expand Down Expand Up @@ -106,7 +107,7 @@ private async Task RunWithAot(string filter, List<Action<TestRun>> assertions,
)
.WithValidation(CommandResultValidation.None);

await RunWithFailureLogging(command, trxFilename, assertions, assertionExpression);
await RunWithFailureLogging(command, runOptions, trxFilename, assertions, assertionExpression);
}

private async Task RunWithSingleFile(string filter,
Expand Down Expand Up @@ -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<FileInfo, bool> predicate)
Expand All @@ -153,13 +154,27 @@ private async Task RunWithSingleFile(string filter,
return FileSystemHelpers.FindFolder(predicate);
}

private async Task RunWithFailureLogging(Command command, string trxFilename, List<Action<TestRun>> assertions, string assertionExpression)
private async Task<CommandTask<BufferedCommandResult>> RunWithFailureLogging(Command command, RunOptions runOptions,
string trxFilename, List<Action<TestRun>> 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);
}
Expand All @@ -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<string> AdditionalArguments { get; init; } = [];

public List<Func<CommandTask<BufferedCommandResult>, 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;
}
}
163 changes: 80 additions & 83 deletions TUnit.Engine/Helpers/TimeoutHelper.cs
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)
Comment on lines 24 to 28
Copy link

Copilot AI Dec 24, 2025

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.

Copilot uses AI. Check for mistakes.
{
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
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important XML documentation for the generic type parameter and method parameters has been removed. According to TUnit development guidelines, code documentation should be maintained. The removed documentation described the type parameter T and the parameters (taskFactory, timeout, cancellationToken, timeoutMessage), return value, and TimeoutException. Restore this documentation.

Copilot uses AI. Check for mistakes.
{
// 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);
}
}
}
8 changes: 6 additions & 2 deletions TUnit.Engine/TestSessionCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,14 @@ private async Task PrepareTestOrchestrator(List<AbstractExecutableTest> testList
#endif
private async Task ExecuteTestsCore(List<AbstractExecutableTest> 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);
Expand Down
10 changes: 10 additions & 0 deletions TUnit.TestProject/CanCancelTests.cs
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));
}
}
Loading