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
27 changes: 3 additions & 24 deletions TUnit.Engine/Helpers/TimeoutHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,36 +28,15 @@ public static async Task ExecuteWithTimeoutAsync(
TimeSpan timeout,
CancellationToken cancellationToken,
string? timeoutMessage = null)
{
await ExecuteWithTimeoutCoreAsync<bool>(
async ct =>
{
await taskFactory(ct).ConfigureAwait(false);
return true;
},
timeout,
cancellationToken,
timeoutMessage).ConfigureAwait(false);
}

/// <summary>
/// Core timeout implementation. Only called when a timeout is actually configured.
/// Allocates CancellationTokenSource, TaskCompletionSource, and uses Task.WhenAny.
/// </summary>
private static async Task<T> ExecuteWithTimeoutCoreAsync<T>(
Func<CancellationToken, Task<T>> taskFactory,
TimeSpan timeout,
CancellationToken cancellationToken,
string? timeoutMessage)
{
// Timeout path: create linked token so task can observe both timeout and external cancellation.
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

// Set up cancellation detection BEFORE scheduling timeout to avoid race condition
// where timeout fires before registration completes (with very small timeouts)
var cancelledTcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var cancelledTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using var registration = timeoutCts.Token.Register(
static state => ((TaskCompletionSource<T>)state!).TrySetCanceled(),
static state => ((TaskCompletionSource<bool>)state!).TrySetCanceled(),
cancelledTcs);

// Now schedule the timeout - registration is guaranteed to catch it
Expand Down Expand Up @@ -102,6 +81,6 @@ private static async Task<T> ExecuteWithTimeoutCoreAsync<T>(
throw new TimeoutException(diagnosticMessage);
}

return await executionTask.ConfigureAwait(false);
await executionTask.ConfigureAwait(false);
}
}
18 changes: 15 additions & 3 deletions TUnit.Engine/Scheduling/TestRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,26 @@ public ValueTask ExecuteTestAsync(AbstractExecutableTest test, CancellationToken
return new ValueTask(existingTcs.Task);
}

return ExecuteTestWithCompletionAsync(test, cancellationToken, tcs);
// Skip the extra async state machine that a wrapper method would create. Start the
// inner ValueTask, fast-path synchronous completion into the TCS, and otherwise fall
// back to a minimal async helper that mirrors the outcome onto the TCS without
// allocating a Task via AsTask().
var innerTask = ExecuteTestInternalAsync(test, cancellationToken);

if (innerTask.IsCompletedSuccessfully)
{
tcs.SetResult(true);
return default;
}

return WrapAsync(innerTask, tcs);
}

private async ValueTask ExecuteTestWithCompletionAsync(AbstractExecutableTest test, CancellationToken cancellationToken, TaskCompletionSource<bool> tcs)
private static async ValueTask WrapAsync(ValueTask inner, TaskCompletionSource<bool> tcs)
{
try
{
await ExecuteTestInternalAsync(test, cancellationToken).ConfigureAwait(false);
await inner.ConfigureAwait(false);
tcs.SetResult(true);
}
catch (Exception ex)
Expand Down
43 changes: 23 additions & 20 deletions TUnit.Engine/Services/TestExecution/TestCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,22 +97,21 @@ private async ValueTask ExecuteTestInternalAsync(AbstractExecutableTest test, Ca
// Note: test.Context._dependencies is already populated during discovery
// in TestBuilder.InvokePostResolutionEventsAsync after dependencies are resolved

// Check if we can use the fast path (no retry, no timeout)
// Fast path: skip RetryHelper whenever there are no retries configured. Timeout is
// applied inside TestExecutor.ExecuteAsync regardless, so the retry wrapper is pure
// overhead when retryLimit == 0.
// Note: retryLimit == 0 means "no retries" (run once), not "unlimited retries"
var retryLimit = test.Context.Metadata.TestDetails.RetryLimit;
var testTimeout = test.Context.Metadata.TestDetails.Timeout;

if (retryLimit == 0 && !testTimeout.HasValue)
if (retryLimit == 0)
{
// Fast path: direct execution without wrapper overhead
test.Context.CurrentRetryAttempt = 0;
await ExecuteTestLifecycleAsync(test, cancellationToken).ConfigureAwait(false);
}
else
{
// Slow path: use retry wrapper
// Timeout is handled inside TestExecutor.ExecuteAsync, wrapping only the test body
// (not hooks or data source initialization) — fixes #4772
// Retry wrapper path. Timeout is handled inside TestExecutor.ExecuteAsync,
// wrapping only the test body (not hooks or data source initialization) — fixes #4772.
await RetryHelper.ExecuteWithRetry(test.Context,
() => ExecuteTestLifecycleAsync(test, cancellationToken)).ConfigureAwait(false);
}
Expand Down Expand Up @@ -327,22 +326,26 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C
/// Parented under the test case activity when available so cleanup stays in the same
/// per-test trace seen by external backends and the HTML report.
/// </summary>
private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest test)
private Task DisposeTestInstanceWithSpanAsync(AbstractExecutableTest test)
{
#if NET
var classType = test.Context.Metadata.TestDetails.ClassType;
await TUnitActivitySource.RunWithSpanAsync(
$"dispose {TUnitActivitySource.GetReadableTypeName(classType)}",
test.Context.ClassContext.Activity?.Context ?? default,
[
new(TUnitActivitySource.TagTestId, test.Context.Id),
new(TUnitActivitySource.TagTestClass, classType.FullName),
new(TUnitActivitySource.TagTraceScope, TUnitActivitySource.GetScopeTag(SharedType.None))
],
() => DisposeTestInstanceCoreAsync(test));
#else
await DisposeTestInstanceCoreAsync(test);
// When no OTEL listener is attached, skip the RunWithSpanAsync wrapper — it would
// otherwise allocate a Func<Task> closure and a state machine per test for nothing.
if (TUnitActivitySource.Source.HasListeners())
{
var classType = test.Context.Metadata.TestDetails.ClassType;
return TUnitActivitySource.RunWithSpanAsync(
$"dispose {TUnitActivitySource.GetReadableTypeName(classType)}",
test.Context.ClassContext.Activity?.Context ?? default,
[
new(TUnitActivitySource.TagTestId, test.Context.Id),
new(TUnitActivitySource.TagTestClass, classType.FullName),
new(TUnitActivitySource.TagTraceScope, TUnitActivitySource.GetScopeTag(SharedType.None))
],
() => DisposeTestInstanceCoreAsync(test));
}
#endif
return DisposeTestInstanceCoreAsync(test);
}

private async Task DisposeTestInstanceCoreAsync(AbstractExecutableTest test)
Expand Down
Loading