diff --git a/TUnit.Engine/Helpers/HookTimeoutHelper.cs b/TUnit.Engine/Helpers/HookTimeoutHelper.cs index aa9b7ec074..594c1c9abf 100644 --- a/TUnit.Engine/Helpers/HookTimeoutHelper.cs +++ b/TUnit.Engine/Helpers/HookTimeoutHelper.cs @@ -47,7 +47,8 @@ static async Task CreateTimeoutHookActionAsync( } catch (OperationCanceledException) when (cts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { - throw new TimeoutException($"Hook '{hook.Name}' exceeded timeout of {timeoutMs}ms"); + var baseMessage = $"Hook '{hook.Name}' exceeded timeout of {timeoutMs}ms"; + throw new TimeoutException(TimeoutDiagnostics.BuildTimeoutDiagnosticsMessage(baseMessage, executionTask: null)); } } } @@ -123,7 +124,8 @@ public static Func CreateTimeoutHookAction( } catch (OperationCanceledException) when (cts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { - throw new TimeoutException($"Hook '{hookName}' exceeded timeout of {timeoutMs}ms"); + var baseMessage = $"Hook '{hookName}' exceeded timeout of {timeoutMs}ms"; + throw new TimeoutException(TimeoutDiagnostics.BuildTimeoutDiagnosticsMessage(baseMessage, executionTask: null)); } }; } @@ -159,7 +161,8 @@ public static Func CreateTimeoutHookAction( } catch (OperationCanceledException) when (cts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { - throw new TimeoutException($"Hook '{hookName}' exceeded timeout of {timeoutMs}ms"); + var baseMessage = $"Hook '{hookName}' exceeded timeout of {timeoutMs}ms"; + throw new TimeoutException(TimeoutDiagnostics.BuildTimeoutDiagnosticsMessage(baseMessage, executionTask: null)); } }; } diff --git a/TUnit.Engine/Helpers/TimeoutDiagnostics.cs b/TUnit.Engine/Helpers/TimeoutDiagnostics.cs new file mode 100644 index 0000000000..c935abfb12 --- /dev/null +++ b/TUnit.Engine/Helpers/TimeoutDiagnostics.cs @@ -0,0 +1,138 @@ +using System.Text; + +namespace TUnit.Engine.Helpers; + +/// +/// Provides diagnostic information when a test or hook times out, +/// including stack trace capture and deadlock pattern detection. +/// +internal static class TimeoutDiagnostics +{ + /// + /// Common synchronization patterns that may indicate a deadlock when found in a stack trace. + /// + private static readonly (string Pattern, string Hint)[] DeadlockPatterns = + [ + ("Monitor.Enter", "A lock (Monitor.Enter) was being acquired. This may indicate a deadlock if another thread holds the lock."), + ("Monitor.Wait", "Monitor.Wait was called. Ensure the corresponding Monitor.Pulse/PulseAll is reachable."), + ("SemaphoreSlim.Wait", "SemaphoreSlim.Wait (synchronous) was called. Consider using SemaphoreSlim.WaitAsync() instead."), + ("ManualResetEvent.WaitOne", "ManualResetEvent.WaitOne was called. The event may never be signaled."), + ("AutoResetEvent.WaitOne", "AutoResetEvent.WaitOne was called. The event may never be signaled."), + ("Task.Wait", "Task.Wait (synchronous) was called inside an async context. This can cause deadlocks. Use 'await' instead."), + ("get_Result", "Task.Result was accessed synchronously. This can cause deadlocks in async contexts. Use 'await' instead."), + ("TaskAwaiter", "GetAwaiter().GetResult() was called synchronously. This can cause deadlocks in async contexts. Use 'await' instead."), + ("SpinWait", "A SpinWait was active. The condition being waited on may never become true."), + ("Thread.Sleep", "Thread.Sleep was called. Consider using Task.Delay in async code."), + ("Mutex.WaitOne", "Mutex.WaitOne was called. The mutex may be held by another thread or process."), + ]; + + /// + /// Builds an enhanced timeout message that includes diagnostic information. + /// + /// The original timeout message. + /// The task that was being executed when the timeout occurred. + /// An enhanced message with diagnostics appended. + public static string BuildTimeoutDiagnosticsMessage(string baseMessage, Task? executionTask) + { + var sb = new StringBuilder(baseMessage); + + AppendTaskStatus(sb, executionTask); + AppendStackTraceDiagnostics(sb); + + return sb.ToString(); + } + + private static void AppendTaskStatus(StringBuilder sb, Task? executionTask) + { + if (executionTask is null) + { + return; + } + + sb.AppendLine(); + sb.AppendLine(); + sb.Append("--- Task Status: "); + sb.Append(executionTask.Status); + sb.Append(" ---"); + + if (executionTask.IsFaulted && executionTask.Exception is { } aggregateException) + { + sb.AppendLine(); + sb.Append("Task exception: "); + + foreach (var innerException in aggregateException.InnerExceptions) + { + sb.AppendLine(); + sb.Append(" "); + sb.Append(innerException.GetType().Name); + sb.Append(": "); + sb.Append(innerException.Message); + } + } + } + + private static void AppendStackTraceDiagnostics(StringBuilder sb) + { + string stackTrace; + + try + { + stackTrace = Environment.StackTrace; + } + catch + { + return; + } + + if (string.IsNullOrEmpty(stackTrace)) + { + return; + } + + sb.AppendLine(); + sb.AppendLine(); + sb.Append("--- Timeout Handler Stack Trace ---"); + sb.AppendLine(); + sb.Append("Note: This is the timeout handler's stack trace, not the blocked test's stack."); + sb.AppendLine(); + sb.Append(stackTrace); + + var hints = DetectDeadlockPatterns(stackTrace); + + if (hints.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(); + sb.Append("--- Potential Deadlock Detected ---"); + + foreach (var hint in hints) + { + sb.AppendLine(); + sb.Append(" * "); + sb.Append(hint); + } + } + } + + /// + /// Scans a stack trace for common patterns that may indicate a deadlock. + /// + internal static List DetectDeadlockPatterns(string stackTrace) + { + var hints = new List(); + + foreach (var (pattern, hint) in DeadlockPatterns) + { +#if NET + if (stackTrace.Contains(pattern, StringComparison.OrdinalIgnoreCase)) +#else + if (stackTrace.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) >= 0) +#endif + { + hints.Add(hint); + } + } + + return hints; + } +} diff --git a/TUnit.Engine/Helpers/TimeoutHelper.cs b/TUnit.Engine/Helpers/TimeoutHelper.cs index 81ed952d31..c875158fc4 100644 --- a/TUnit.Engine/Helpers/TimeoutHelper.cs +++ b/TUnit.Engine/Helpers/TimeoutHelper.cs @@ -156,7 +156,9 @@ public static async Task ExecuteWithTimeoutAsync( } // Even if task completed during grace period, timeout already elapsed so we throw - throw new TimeoutException(timeoutMessage ?? $"Operation timed out after {timeout.Value}"); + var baseMessage = timeoutMessage ?? $"Operation timed out after {timeout.Value}"; + var diagnosticMessage = TimeoutDiagnostics.BuildTimeoutDiagnosticsMessage(baseMessage, executionTask); + throw new TimeoutException(diagnosticMessage); } return await executionTask.ConfigureAwait(false);