diff --git a/src/TUnit.Engine/Helpers/TimeoutDiagnostics.cs b/src/TUnit.Engine/Helpers/TimeoutDiagnostics.cs
index c935abfb12..20c2cabb0b 100644
--- a/src/TUnit.Engine/Helpers/TimeoutDiagnostics.cs
+++ b/src/TUnit.Engine/Helpers/TimeoutDiagnostics.cs
@@ -31,18 +31,22 @@ private static readonly (string Pattern, string Hint)[] DeadlockPatterns =
///
/// The original timeout message.
/// The task that was being executed when the timeout occurred.
+ /// An exception observed while the task handled timeout cancellation.
/// An enhanced message with diagnostics appended.
- 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)
{
@@ -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;
diff --git a/src/TUnit.Engine/Helpers/TimeoutHelper.cs b/src/TUnit.Engine/Helpers/TimeoutHelper.cs
index 901506d35c..1b997f4b51 100644
--- a/src/TUnit.Engine/Helpers/TimeoutHelper.cs
+++ b/src/TUnit.Engine/Helpers/TimeoutHelper.cs
@@ -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);
}
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 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 ObserveCompletedTaskExceptionAsync(Task executionTask)
+ {
+ try
+ {
+ await executionTask.ConfigureAwait(false);
+ return null;
+ }
+ catch (Exception ex)
+ {
+ return ex;
+ }
+ }
}
diff --git a/src/TUnit.Engine/TUnitMessageBus.cs b/src/TUnit.Engine/TUnitMessageBus.cs
index 56b89e4990..84d2296af8 100644
--- a/src/TUnit.Engine/TUnitMessageBus.cs
+++ b/src/TUnit.Engine/TUnitMessageBus.cs
@@ -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)
diff --git a/tests/TUnit.Engine.Tests/Issue6688Tests.cs b/tests/TUnit.Engine.Tests/Issue6688Tests.cs
new file mode 100644
index 0000000000..b1b1294b8e
--- /dev/null
+++ b/tests/TUnit.Engine.Tests/Issue6688Tests.cs
@@ -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"));
+ }
+}
diff --git a/tests/TUnit.TestProject/Bugs/_6688/TimeoutCancellationExceptionTests.cs b/tests/TUnit.TestProject/Bugs/_6688/TimeoutCancellationExceptionTests.cs
new file mode 100644
index 0000000000..76e10e33d3
--- /dev/null
+++ b/tests/TUnit.TestProject/Bugs/_6688/TimeoutCancellationExceptionTests.cs
@@ -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);
+ }
+ }
+}
diff --git a/tests/TUnit.UnitTests/TimeoutHelperTests.cs b/tests/TUnit.UnitTests/TimeoutHelperTests.cs
new file mode 100644
index 0000000000..25d175e1f6
--- /dev/null
+++ b/tests/TUnit.UnitTests/TimeoutHelperTests.cs
@@ -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();
+
+ await Assert.That(exception!.Message).Contains(cancellationMessage);
+ await Assert.That(exception.InnerException).IsTypeOf();
+ 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();
+
+ 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();
+
+ var taskCanceledException = await Assert.That(exception!.InnerException).IsTypeOf();
+ await Assert.That(taskCanceledException!.Message).IsEqualTo(cancellationMessage);
+ await Assert.That(taskCanceledException.InnerException).IsSameReferenceAs(diagnosticException);
+ }
+}