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
9 changes: 6 additions & 3 deletions TUnit.Engine/Helpers/HookTimeoutHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
}
Expand Down Expand Up @@ -123,7 +124,8 @@ public static Func<Task> CreateTimeoutHookAction<T>(
}
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));
}
};
}
Expand Down Expand Up @@ -159,7 +161,8 @@ public static Func<Task> CreateTimeoutHookAction<T>(
}
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));
}
};
}
Expand Down
138 changes: 138 additions & 0 deletions TUnit.Engine/Helpers/TimeoutDiagnostics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using System.Text;

namespace TUnit.Engine.Helpers;

/// <summary>
/// Provides diagnostic information when a test or hook times out,
/// including stack trace capture and deadlock pattern detection.
/// </summary>
internal static class TimeoutDiagnostics
{
/// <summary>
/// Common synchronization patterns that may indicate a deadlock when found in a stack trace.
/// </summary>
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."),
];

/// <summary>
/// Builds an enhanced timeout message that includes diagnostic information.
/// </summary>
/// <param name="baseMessage">The original timeout message.</param>
/// <param name="executionTask">The task that was being executed when the timeout occurred.</param>
/// <returns>An enhanced message with diagnostics appended.</returns>
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical: Environment.StackTrace captures the wrong call stack

Environment.StackTrace only captures the stack of the current thread — the timeout handler continuation — not the stack of the executionTask that actually hung.

By the time BuildTimeoutDiagnosticsMessage is called (after Task.WhenAny returns + grace period expires), the current thread's stack looks roughly like:

TimeoutDiagnostics.BuildTimeoutDiagnosticsMessage
TimeoutHelper.ExecuteWithTimeoutAsync
... async continuation frames ...

The blocking patterns you're scanning for (Monitor.Enter, SemaphoreSlim.Wait, etc.) would appear on an entirely different thread — the one that's actually stuck — or may not appear on any active thread at all (if the task is awaiting a TaskCompletionSource with no active thread holding it).

The diagnostic output will label this the "Timeout Stack Trace" and claim to detect deadlocks, but it's showing the timeout infrastructure's own call stack rather than the user's blocked code.

Better approach: Capture Environment.StackTrace at the start of the task delegate (or use AsyncLocal<string> to propagate it), then include that in the timeout message. For example:

// Inside the test executor, before starting the task:
var capturedStack = Environment.StackTrace;
// Store in context, include in timeout message if it fires

Alternatively, for IsFaulted tasks the existing code already captures the exception — that's the most useful diagnostic for those cases. For still-running tasks on another thread, there's no safe public API to capture their stack without a debugger.

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

/// <summary>
/// Scans a stack trace for common patterns that may indicate a deadlock.
/// </summary>
internal static List<string> DetectDeadlockPatterns(string stackTrace)
{
var hints = new List<string>();

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;
}
}
4 changes: 3 additions & 1 deletion TUnit.Engine/Helpers/TimeoutHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,9 @@ public static async Task<T> ExecuteWithTimeoutAsync<T>(
}

// 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);
Expand Down
Loading