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
42 changes: 30 additions & 12 deletions src/TUnit.Engine/Helpers/TimeoutDiagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,22 @@ private static readonly (string Pattern, string Hint)[] DeadlockPatterns =
/// </summary>
/// <param name="baseMessage">The original timeout message.</param>
/// <param name="executionTask">The task that was being executed when the timeout occurred.</param>
/// <param name="executionException">An exception observed while the task handled timeout cancellation.</param>
/// <returns>An enhanced message with diagnostics appended.</returns>
public static string BuildTimeoutDiagnosticsMessage(string baseMessage, Task? executionTask)
public static string BuildTimeoutDiagnosticsMessage(
string baseMessage,
Task? executionTask,
Exception? executionException = null)
{
var sb = new StringBuilder(baseMessage);

AppendTaskStatus(sb, executionTask);
AppendTaskStatus(sb, executionTask, executionException);
AppendStackTraceDiagnostics(sb);

return sb.ToString();
}

private static void AppendTaskStatus(StringBuilder sb, Task? executionTask)
private static void AppendTaskStatus(StringBuilder sb, Task? executionTask, Exception? executionException)
{
if (executionTask is null)
{
Expand All @@ -55,22 +59,36 @@ private static void AppendTaskStatus(StringBuilder sb, Task? executionTask)
sb.Append(executionTask.Status);
sb.Append(" ---");

if (executionTask.IsFaulted && executionTask.Exception is { } aggregateException)
if (executionException is not null)
{
sb.AppendLine();
sb.Append("Task exception: ");

AppendTaskExceptionHeader(sb);
AppendTaskException(sb, executionException);
}
else if (executionTask.IsFaulted && executionTask.Exception is { } aggregateException)
{
AppendTaskExceptionHeader(sb);
foreach (var innerException in aggregateException.InnerExceptions)
{
sb.AppendLine();
sb.Append(" ");
sb.Append(innerException.GetType().Name);
sb.Append(": ");
sb.Append(innerException.Message);
AppendTaskException(sb, innerException);
}
}
}

private static void AppendTaskExceptionHeader(StringBuilder sb)
{
sb.AppendLine();
sb.Append("Task exception: ");
}

private static void AppendTaskException(StringBuilder sb, Exception exception)
{
sb.AppendLine();
sb.Append(" ");
sb.Append(exception.GetType().Name);
sb.Append(": ");
sb.Append(exception.Message);
}

private static void AppendStackTraceDiagnostics(StringBuilder sb)
{
string stackTrace;
Expand Down
97 changes: 76 additions & 21 deletions src/TUnit.Engine/Helpers/TimeoutHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,32 +87,87 @@ public static async Task ExecuteWithTimeoutAsync(
}

// Timeout occurred - give the execution task a brief grace period to clean up
try
{
#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 all exceptions - task was cancelled, we're just giving it time to clean up
}
var executionException = await ObserveExceptionDuringGracePeriodAsync(executionTask).ConfigureAwait(false);

// Routine cancellation adds no useful context; preserve exceptions explicitly
// thrown while handling cancellation, such as Aspire's diagnostic exception.
var exceptionToPreserve = IsRoutineCancellation(executionException, timeoutCts.Token)
? null
: executionException;

// Even if task completed during grace period, timeout already elapsed so we throw
var baseMessage = timeoutMessage ?? $"Operation timed out after {timeout}";
var diagnosticMessage = TimeoutDiagnostics.BuildTimeoutDiagnosticsMessage(baseMessage, executionTask);
throw new TimeoutException(diagnosticMessage);
var diagnosticMessage = TimeoutDiagnostics.BuildTimeoutDiagnosticsMessage(baseMessage, executionTask, exceptionToPreserve);
throw new TimeoutException(diagnosticMessage, exceptionToPreserve);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

await executionTask.ConfigureAwait(false);
}

private static bool IsRoutineCancellation(Exception? exception, CancellationToken timeoutToken)
{
if (exception is not OperationCanceledException
{
InnerException: null
} operationCanceledException
|| operationCanceledException.CancellationToken != timeoutToken)
{
return false;
}

return operationCanceledException switch
{
TaskCanceledException taskCanceledException
when taskCanceledException.GetType() == typeof(TaskCanceledException) =>
taskCanceledException.Message == new TaskCanceledException().Message,
{ } when operationCanceledException.GetType() == typeof(OperationCanceledException) =>
operationCanceledException.Message == new OperationCanceledException(timeoutToken).Message,
_ => false
};
}

private static async Task<Exception?> ObserveExceptionDuringGracePeriodAsync(Task executionTask)
{
#if NET8_0_OR_GREATER
try
{
await executionTask.WaitAsync(GracePeriod, CancellationToken.None).ConfigureAwait(false);
return null;
}
catch (TimeoutException)
{
return executionTask.IsCompleted
? await ObserveCompletedTaskExceptionAsync(executionTask).ConfigureAwait(false)
: null;
}
catch (Exception ex)
{
return ex;
}
#else
// Use cancellable delay to avoid leaked tasks when executionTask completes first
using var graceCts = new CancellationTokenSource();
var delayTask = Task.Delay(GracePeriod, graceCts.Token);
if (await Task.WhenAny(executionTask, delayTask).ConfigureAwait(false) != executionTask)
{
return null;
}

graceCts.Cancel();
return await ObserveCompletedTaskExceptionAsync(executionTask).ConfigureAwait(false);
#endif
}

private static async Task<Exception?> ObserveCompletedTaskExceptionAsync(Task executionTask)
{
try
{
await executionTask.ConfigureAwait(false);
return null;
}
catch (Exception ex)
{
return ex;
}
}
}
11 changes: 10 additions & 1 deletion src/TUnit.Engine/TUnitMessageBus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,16 @@ private static TestNodeStateProperty GetFailureStateProperty(TestContext testCon
&& testContext.Metadata.TestDetails.Timeout != null
&& duration >= testContext.Metadata.TestDetails.Timeout.Value)
{
return new TimeoutTestNodeStateProperty($"[{categoryLabel}] Test timed out after {testContext.Metadata.TestDetails.Timeout.Value.TotalMilliseconds}ms");
var explanation = $"[{categoryLabel}] Test timed out after {testContext.Metadata.TestDetails.Timeout.Value.TotalMilliseconds}ms";
var diagnosticException = unwrapped.InnerException
?? (unwrapped is OperationCanceledException and not TaskCanceledException ? unwrapped : null);

if (diagnosticException is not null)
{
explanation = $"{explanation}{Environment.NewLine}{diagnosticException.Message}";
}

return new TimeoutTestNodeStateProperty(unwrapped, explanation);
}

if (category == FailureCategory.Assertion)
Expand Down
46 changes: 46 additions & 0 deletions tests/TUnit.Engine.Tests/Issue6688Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Shouldly;
using TUnit.Engine.Tests.Enums;

namespace TUnit.Engine.Tests;

public class Issue6688Tests(TestMode testMode) : InvokableTestBase(testMode)
{
[Test]
public async Task Timeout_Preserves_Custom_Cancellation_Message()
{
await RunTestsWithFilter(
"/*/*/TimeoutCancellationExceptionTests/Custom_Cancellation_Message",
[
result => result.ResultSummary.Outcome.ShouldBe("Failed"),
result => result.ResultSummary.Counters.Timeout.ShouldBe(1),
result => result.Results.Single().Output?.ErrorInfo?.Message.ShouldContain("Failed due to XYZ"),
],
new RunOptions().WithArgument("--detailed-stacktrace"));
}

[Test]
public async Task Timeout_Preserves_Custom_Non_Cancellation_Exception_Message()
{
await RunTestsWithFilter(
"/*/*/TimeoutCancellationExceptionTests/Custom_Non_Cancellation_Exception_Message",
[
result => result.ResultSummary.Outcome.ShouldBe("Failed"),
result => result.ResultSummary.Counters.Timeout.ShouldBe(1),
result => result.Results.Single().Output?.ErrorInfo?.Message.ShouldContain("Custom non-cancellation diagnostic"),
],
new RunOptions().WithArgument("--detailed-stacktrace"));
}

[Test]
public async Task Timeout_Preserves_Custom_Task_Cancellation_Message()
{
await RunTestsWithFilter(
"/*/*/TimeoutCancellationExceptionTests/Custom_Task_Cancellation_Message",
[
result => result.ResultSummary.Outcome.ShouldBe("Failed"),
result => result.ResultSummary.Counters.Timeout.ShouldBe(1),
result => result.Results.Single().Output?.ErrorInfo?.Message.ShouldContain("Custom task cancellation diagnostic"),
],
new RunOptions().WithArgument("--detailed-stacktrace"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._6688;

public class TimeoutCancellationExceptionTests
{
[Test]
[Timeout(50)]
[EngineTest(ExpectedResult.Failure)]
public async Task Custom_Cancellation_Message(CancellationToken cancellationToken)
{
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
catch (OperationCanceledException ex)
{
// Simulate Aspire gathering resource diagnostics after cancellation.
await Task.Delay(50);
throw new OperationCanceledException("Failed due to XYZ", ex.CancellationToken);
}
}

[Test]
[Timeout(50)]
[EngineTest(ExpectedResult.Failure)]
public async Task Custom_Non_Cancellation_Exception_Message(CancellationToken cancellationToken)
{
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
catch (OperationCanceledException)
{
await Task.Delay(50);
throw new InvalidOperationException("Custom non-cancellation diagnostic");
}
}

[Test]
[Timeout(50)]
[EngineTest(ExpectedResult.Failure)]
public async Task Custom_Task_Cancellation_Message(CancellationToken cancellationToken)
{
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
catch (OperationCanceledException ex)
{
await Task.Delay(50);
throw new TaskCanceledException(
"Custom task cancellation diagnostic",
new InvalidOperationException("Inner diagnostic"),
ex.CancellationToken);
}
}
}
89 changes: 89 additions & 0 deletions tests/TUnit.UnitTests/TimeoutHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using TUnit.Engine.Helpers;

namespace TUnit.UnitTests;

public class TimeoutHelperTests
{
[Test]
public async Task Timeout_Preserves_Exception_Thrown_During_Cancellation()
{
const string cancellationMessage = "Failed due to XYZ";

var exception = await Assert.That(async () =>
await TimeoutHelper.ExecuteWithTimeoutAsync(
async cancellationToken =>
{
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
catch (OperationCanceledException ex)
{
// Keep execution incomplete long enough for timeout detection to win before
// cancellation diagnostics finish, matching the Aspire failure in #6688.
await Task.Delay(50);
throw new OperationCanceledException(cancellationMessage, ex.CancellationToken);
}
},
TimeSpan.FromMilliseconds(50),
CancellationToken.None))
.ThrowsExactly<TimeoutException>();

await Assert.That(exception!.Message).Contains(cancellationMessage);
await Assert.That(exception.InnerException).IsTypeOf<OperationCanceledException>();
await Assert.That(exception.InnerException!.Message).IsEqualTo(cancellationMessage);
}

[Test]
public async Task Timeout_Does_Not_Preserve_Routine_Operation_Cancellation()
{
var exception = await Assert.That(async () =>
await TimeoutHelper.ExecuteWithTimeoutAsync(
async cancellationToken =>
{
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
catch (OperationCanceledException)
{
cancellationToken.ThrowIfCancellationRequested();
}
},
TimeSpan.FromMilliseconds(50),
CancellationToken.None))
.ThrowsExactly<TimeoutException>();

await Assert.That(exception!.InnerException).IsNull();
await Assert.That(exception.Message).DoesNotContain(nameof(OperationCanceledException));
}

[Test]
public async Task Timeout_Preserves_Custom_Task_Cancellation()
{
const string cancellationMessage = "Custom task cancellation diagnostic";
var diagnosticException = new InvalidOperationException("Inner diagnostic");

var exception = await Assert.That(async () =>
await TimeoutHelper.ExecuteWithTimeoutAsync(
async cancellationToken =>
{
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
catch (OperationCanceledException)
{
await Task.Delay(50);
throw new TaskCanceledException(cancellationMessage, diagnosticException, cancellationToken);
}
},
TimeSpan.FromMilliseconds(50),
CancellationToken.None))
.ThrowsExactly<TimeoutException>();

var taskCanceledException = await Assert.That(exception!.InnerException).IsTypeOf<TaskCanceledException>();
await Assert.That(taskCanceledException!.Message).IsEqualTo(cancellationMessage);
await Assert.That(taskCanceledException.InnerException).IsSameReferenceAs(diagnosticException);
}
}
Loading