From 3eac2bae11d3123c1b02f450d5e27d26a4d0cf41 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 3 Sep 2026 22:16:26 +0100
Subject: [PATCH 1/5] fix: preserve timeout cancellation details
Closes #6688
---
.../Helpers/TimeoutDiagnostics.cs | 42 ++++++++---
src/TUnit.Engine/Helpers/TimeoutHelper.cs | 73 +++++++++++++------
src/TUnit.Engine/TUnitMessageBus.cs | 11 ++-
tests/TUnit.Engine.Tests/Issue6688Tests.cs | 20 +++++
.../TimeoutCancellationExceptionTests.cs | 23 ++++++
tests/TUnit.UnitTests/TimeoutHelperTests.cs | 35 +++++++++
6 files changed, 170 insertions(+), 34 deletions(-)
create mode 100644 tests/TUnit.Engine.Tests/Issue6688Tests.cs
create mode 100644 tests/TUnit.TestProject/Bugs/_6688/TimeoutCancellationExceptionTests.cs
create mode 100644 tests/TUnit.UnitTests/TimeoutHelperTests.cs
diff --git a/src/TUnit.Engine/Helpers/TimeoutDiagnostics.cs b/src/TUnit.Engine/Helpers/TimeoutDiagnostics.cs
index c935abfb129..20c2cabb0b7 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 901506d35c1..5da62f17a12 100644
--- a/src/TUnit.Engine/Helpers/TimeoutHelper.cs
+++ b/src/TUnit.Engine/Helpers/TimeoutHelper.cs
@@ -87,32 +87,63 @@ 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 Task cancellation adds no useful context; preserve exceptions explicitly
+ // thrown while handling cancellation, such as Aspire's diagnostic exception.
+ var exceptionToPreserve = executionException is TaskCanceledException ? 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 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 56b89e49902..b4986045466 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 cancellationException = unwrapped as OperationCanceledException
+ ?? unwrapped.InnerException as OperationCanceledException;
+
+ if (cancellationException is not null and not TaskCanceledException)
+ {
+ explanation = $"{explanation}{Environment.NewLine}{cancellationException.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 00000000000..7ca3c6bc172
--- /dev/null
+++ b/tests/TUnit.Engine.Tests/Issue6688Tests.cs
@@ -0,0 +1,20 @@
+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"));
+ }
+}
diff --git a/tests/TUnit.TestProject/Bugs/_6688/TimeoutCancellationExceptionTests.cs b/tests/TUnit.TestProject/Bugs/_6688/TimeoutCancellationExceptionTests.cs
new file mode 100644
index 00000000000..7722fe9d07c
--- /dev/null
+++ b/tests/TUnit.TestProject/Bugs/_6688/TimeoutCancellationExceptionTests.cs
@@ -0,0 +1,23 @@
+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);
+ }
+ }
+}
diff --git a/tests/TUnit.UnitTests/TimeoutHelperTests.cs b/tests/TUnit.UnitTests/TimeoutHelperTests.cs
new file mode 100644
index 00000000000..b76af083f42
--- /dev/null
+++ b/tests/TUnit.UnitTests/TimeoutHelperTests.cs
@@ -0,0 +1,35 @@
+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)
+ {
+ // Ensure timeout detection wins before cancellation diagnostics finish.
+ 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);
+ }
+}
From 9c2f65f3bf07314f55ff422646ac98f8bf4ca34d Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 3 Sep 2026 22:36:06 +0100
Subject: [PATCH 2/5] fix: address timeout review feedback
---
src/TUnit.Core/Exceptions/TimeoutException.cs | 4 +++
src/TUnit.Engine/Helpers/TimeoutHelper.cs | 22 +++++++++++++--
src/TUnit.Engine/TUnitMessageBus.cs | 8 +++---
tests/TUnit.Engine.Tests/Issue6688Tests.cs | 13 +++++++++
.../TimeoutCancellationExceptionTests.cs | 16 +++++++++++
tests/TUnit.UnitTests/TimeoutHelperTests.cs | 27 ++++++++++++++++++-
6 files changed, 83 insertions(+), 7 deletions(-)
diff --git a/src/TUnit.Core/Exceptions/TimeoutException.cs b/src/TUnit.Core/Exceptions/TimeoutException.cs
index faf02f468b8..ebcac72cb8f 100644
--- a/src/TUnit.Core/Exceptions/TimeoutException.cs
+++ b/src/TUnit.Core/Exceptions/TimeoutException.cs
@@ -6,6 +6,10 @@ internal TimeoutException(TimeSpan timeSpan) : base(GetMessage(timeSpan))
{
}
+ internal TimeoutException(string? message, Exception? innerException) : base(message, innerException)
+ {
+ }
+
private static string GetMessage(TimeSpan timeSpan)
{
return $"The test timed out after {timeSpan.TotalMilliseconds} milliseconds";
diff --git a/src/TUnit.Engine/Helpers/TimeoutHelper.cs b/src/TUnit.Engine/Helpers/TimeoutHelper.cs
index 5da62f17a12..964b4cef784 100644
--- a/src/TUnit.Engine/Helpers/TimeoutHelper.cs
+++ b/src/TUnit.Engine/Helpers/TimeoutHelper.cs
@@ -89,9 +89,11 @@ public static async Task ExecuteWithTimeoutAsync(
// Timeout occurred - give the execution task a brief grace period to clean up
var executionException = await ObserveExceptionDuringGracePeriodAsync(executionTask).ConfigureAwait(false);
- // Routine Task cancellation adds no useful context; preserve exceptions explicitly
+ // Routine cancellation adds no useful context; preserve exceptions explicitly
// thrown while handling cancellation, such as Aspire's diagnostic exception.
- var exceptionToPreserve = executionException is TaskCanceledException ? null : executionException;
+ 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}";
@@ -102,6 +104,22 @@ public static async Task ExecuteWithTimeoutAsync(
await executionTask.ConfigureAwait(false);
}
+ private static bool IsRoutineCancellation(Exception? exception, CancellationToken timeoutToken)
+ {
+ if (exception is TaskCanceledException)
+ {
+ return true;
+ }
+
+ return exception is OperationCanceledException
+ {
+ InnerException: null
+ } operationCanceledException
+ && operationCanceledException.GetType() == typeof(OperationCanceledException)
+ && operationCanceledException.CancellationToken == timeoutToken
+ && operationCanceledException.Message == new OperationCanceledException(timeoutToken).Message;
+ }
+
private static async Task ObserveExceptionDuringGracePeriodAsync(Task executionTask)
{
#if NET8_0_OR_GREATER
diff --git a/src/TUnit.Engine/TUnitMessageBus.cs b/src/TUnit.Engine/TUnitMessageBus.cs
index b4986045466..69fde01f4ea 100644
--- a/src/TUnit.Engine/TUnitMessageBus.cs
+++ b/src/TUnit.Engine/TUnitMessageBus.cs
@@ -154,12 +154,12 @@ private static TestNodeStateProperty GetFailureStateProperty(TestContext testCon
&& duration >= testContext.Metadata.TestDetails.Timeout.Value)
{
var explanation = $"[{categoryLabel}] Test timed out after {testContext.Metadata.TestDetails.Timeout.Value.TotalMilliseconds}ms";
- var cancellationException = unwrapped as OperationCanceledException
- ?? unwrapped.InnerException as OperationCanceledException;
+ var diagnosticException = unwrapped.InnerException
+ ?? (unwrapped is OperationCanceledException ? unwrapped : null);
- if (cancellationException is not null and not TaskCanceledException)
+ if (diagnosticException is not null and not TaskCanceledException)
{
- explanation = $"{explanation}{Environment.NewLine}{cancellationException.Message}";
+ explanation = $"{explanation}{Environment.NewLine}{diagnosticException.Message}";
}
return new TimeoutTestNodeStateProperty(unwrapped, explanation);
diff --git a/tests/TUnit.Engine.Tests/Issue6688Tests.cs b/tests/TUnit.Engine.Tests/Issue6688Tests.cs
index 7ca3c6bc172..f0efadc01cf 100644
--- a/tests/TUnit.Engine.Tests/Issue6688Tests.cs
+++ b/tests/TUnit.Engine.Tests/Issue6688Tests.cs
@@ -17,4 +17,17 @@ await RunTestsWithFilter(
],
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"));
+ }
}
diff --git a/tests/TUnit.TestProject/Bugs/_6688/TimeoutCancellationExceptionTests.cs b/tests/TUnit.TestProject/Bugs/_6688/TimeoutCancellationExceptionTests.cs
index 7722fe9d07c..b8b55e5fa3a 100644
--- a/tests/TUnit.TestProject/Bugs/_6688/TimeoutCancellationExceptionTests.cs
+++ b/tests/TUnit.TestProject/Bugs/_6688/TimeoutCancellationExceptionTests.cs
@@ -20,4 +20,20 @@ public async Task Custom_Cancellation_Message(CancellationToken cancellationToke
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");
+ }
+ }
}
diff --git a/tests/TUnit.UnitTests/TimeoutHelperTests.cs b/tests/TUnit.UnitTests/TimeoutHelperTests.cs
index b76af083f42..a21a5bd90a2 100644
--- a/tests/TUnit.UnitTests/TimeoutHelperTests.cs
+++ b/tests/TUnit.UnitTests/TimeoutHelperTests.cs
@@ -19,7 +19,8 @@ await TimeoutHelper.ExecuteWithTimeoutAsync(
}
catch (OperationCanceledException ex)
{
- // Ensure timeout detection wins before cancellation diagnostics finish.
+ // 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);
}
@@ -32,4 +33,28 @@ await TimeoutHelper.ExecuteWithTimeoutAsync(
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));
+ }
}
From efcad9198225ec12fd4885836e34ca988f6731af Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 3 Sep 2026 22:41:50 +0100
Subject: [PATCH 3/5] refactor: drop unused timeout constructor
---
src/TUnit.Core/Exceptions/TimeoutException.cs | 4 ----
1 file changed, 4 deletions(-)
diff --git a/src/TUnit.Core/Exceptions/TimeoutException.cs b/src/TUnit.Core/Exceptions/TimeoutException.cs
index ebcac72cb8f..faf02f468b8 100644
--- a/src/TUnit.Core/Exceptions/TimeoutException.cs
+++ b/src/TUnit.Core/Exceptions/TimeoutException.cs
@@ -6,10 +6,6 @@ internal TimeoutException(TimeSpan timeSpan) : base(GetMessage(timeSpan))
{
}
- internal TimeoutException(string? message, Exception? innerException) : base(message, innerException)
- {
- }
-
private static string GetMessage(TimeSpan timeSpan)
{
return $"The test timed out after {timeSpan.TotalMilliseconds} milliseconds";
From 99dc836be605d6307bd70217038f3e09214afa48 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 3 Sep 2026 22:45:06 +0100
Subject: [PATCH 4/5] fix: preserve custom task cancellation details
---
src/TUnit.Engine/Helpers/TimeoutHelper.cs | 22 ++++++++++------
tests/TUnit.UnitTests/TimeoutHelperTests.cs | 29 +++++++++++++++++++++
2 files changed, 43 insertions(+), 8 deletions(-)
diff --git a/src/TUnit.Engine/Helpers/TimeoutHelper.cs b/src/TUnit.Engine/Helpers/TimeoutHelper.cs
index 964b4cef784..1b997f4b519 100644
--- a/src/TUnit.Engine/Helpers/TimeoutHelper.cs
+++ b/src/TUnit.Engine/Helpers/TimeoutHelper.cs
@@ -106,18 +106,24 @@ public static async Task ExecuteWithTimeoutAsync(
private static bool IsRoutineCancellation(Exception? exception, CancellationToken timeoutToken)
{
- if (exception is TaskCanceledException)
+ if (exception is not OperationCanceledException
+ {
+ InnerException: null
+ } operationCanceledException
+ || operationCanceledException.CancellationToken != timeoutToken)
{
- return true;
+ return false;
}
- return exception is OperationCanceledException
+ return operationCanceledException switch
{
- InnerException: null
- } operationCanceledException
- && operationCanceledException.GetType() == typeof(OperationCanceledException)
- && operationCanceledException.CancellationToken == timeoutToken
- && operationCanceledException.Message == new OperationCanceledException(timeoutToken).Message;
+ 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)
diff --git a/tests/TUnit.UnitTests/TimeoutHelperTests.cs b/tests/TUnit.UnitTests/TimeoutHelperTests.cs
index a21a5bd90a2..25d175e1f66 100644
--- a/tests/TUnit.UnitTests/TimeoutHelperTests.cs
+++ b/tests/TUnit.UnitTests/TimeoutHelperTests.cs
@@ -57,4 +57,33 @@ await TimeoutHelper.ExecuteWithTimeoutAsync(
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);
+ }
}
From 0d5e5ba7447466cc739aa17c737d6d1ccbdbb38c Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Thu, 3 Sep 2026 22:46:44 +0100
Subject: [PATCH 5/5] fix: report custom task cancellations
---
src/TUnit.Engine/TUnitMessageBus.cs | 4 ++--
tests/TUnit.Engine.Tests/Issue6688Tests.cs | 13 +++++++++++++
.../TimeoutCancellationExceptionTests.cs | 19 +++++++++++++++++++
3 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/src/TUnit.Engine/TUnitMessageBus.cs b/src/TUnit.Engine/TUnitMessageBus.cs
index 69fde01f4ea..84d2296af84 100644
--- a/src/TUnit.Engine/TUnitMessageBus.cs
+++ b/src/TUnit.Engine/TUnitMessageBus.cs
@@ -155,9 +155,9 @@ private static TestNodeStateProperty GetFailureStateProperty(TestContext testCon
{
var explanation = $"[{categoryLabel}] Test timed out after {testContext.Metadata.TestDetails.Timeout.Value.TotalMilliseconds}ms";
var diagnosticException = unwrapped.InnerException
- ?? (unwrapped is OperationCanceledException ? unwrapped : null);
+ ?? (unwrapped is OperationCanceledException and not TaskCanceledException ? unwrapped : null);
- if (diagnosticException is not null and not TaskCanceledException)
+ if (diagnosticException is not null)
{
explanation = $"{explanation}{Environment.NewLine}{diagnosticException.Message}";
}
diff --git a/tests/TUnit.Engine.Tests/Issue6688Tests.cs b/tests/TUnit.Engine.Tests/Issue6688Tests.cs
index f0efadc01cf..b1b1294b8ec 100644
--- a/tests/TUnit.Engine.Tests/Issue6688Tests.cs
+++ b/tests/TUnit.Engine.Tests/Issue6688Tests.cs
@@ -30,4 +30,17 @@ await RunTestsWithFilter(
],
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
index b8b55e5fa3a..76e10e33d31 100644
--- a/tests/TUnit.TestProject/Bugs/_6688/TimeoutCancellationExceptionTests.cs
+++ b/tests/TUnit.TestProject/Bugs/_6688/TimeoutCancellationExceptionTests.cs
@@ -36,4 +36,23 @@ public async Task Custom_Non_Cancellation_Exception_Message(CancellationToken ca
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);
+ }
+ }
}