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