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
2 changes: 2 additions & 0 deletions docs/docs/execution/parallelism.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ Tests not assigned to any group run separately under normal parallel execution r

The limit is shared across **all** tests referencing the same `IParallelLimit` type. Tests with a different limiter type or no limiter are unaffected.

An explicit `[ParallelLimiter<T>]` always takes precedence over a limiter set programmatically through `TestRegisteredContext.SetParallelLimiter`, including one supplied by an executor. This precedence does not depend on registration callback order. Without an explicit attribute, the last programmatic limiter applies.

```csharp
using TUnit.Core;

Expand Down
8 changes: 8 additions & 0 deletions docs/docs/writing-tests/event-subscribing.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ The interfaces they can implement are:

This can be useful especially when generating data that you need to track and maybe dispose later. By hooking into these events, we can do things like track and dispose our objects when we need.

## Registration callbacks for executors

An executor that implements `ITestRegisteredEventReceiver` receives `OnTestRegistered` when a registration receiver installs it through `SetTestExecutor` or `SetHookExecutor`, unless it has already received the callback for that test. Its callback runs after the installing receiver returns. Dynamically installed executors are not globally sorted by their own `Order`.

Each receiver instance receives this callback at most once per test, using reference identity. This also applies when the same instance is already an eligible event object, installs itself, or is installed as both the test and hook executor. Distinct instances that compare equal still receive separate callbacks.

An explicit `[ParallelLimiter<T>]` takes precedence over a limiter set through `TestRegisteredContext.SetParallelLimiter`, including one set by an executor, regardless of callback order.

## Execution Stage Control

> **Note**: This feature is available on .NET 8.0+ only due to default interface member requirements.
Expand Down
4 changes: 2 additions & 2 deletions src/TUnit.Core/Attributes/ParallelLimiterAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ public sealed class ParallelLimiterAttribute<TParallelLimit> : TUnitAttribute, I
public int Order => 0;

/// <inheritdoc />
public ValueTask OnTestRegistered(TestRegisteredContext context)
public ValueTask OnTestRegistered(TestRegisteredContext context)
{
context.SetParallelLimiter(new TParallelLimit());
context.SetExplicitParallelLimiter(new TParallelLimit());
return default(ValueTask);
}
}
64 changes: 63 additions & 1 deletion src/TUnit.Core/Contexts/TestRegisteredContext.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using TUnit.Core.Interfaces;

namespace TUnit.Core;
Expand All @@ -8,6 +9,10 @@ namespace TUnit.Core;
/// </summary>
public class TestRegisteredContext
{
private List<ITestRegisteredEventReceiver>? _executorReceivers;
private int _dispatchedExecutorReceiverCount;
private IParallelLimit? _explicitParallelLimiter;

public string TestName { get; }
public string? CustomDisplayName { get; }
public TestContext TestContext { get; }
Expand Down Expand Up @@ -37,6 +42,7 @@ public TestRegisteredContext(TestContext testContext)
public void SetTestExecutor(ITestExecutor executor)
{
DiscoveredTest.TestExecutor = executor;
QueueExecutorEventReceiver(executor);
}

/// <summary>
Expand All @@ -46,13 +52,21 @@ public void SetTestExecutor(ITestExecutor executor)
public void SetHookExecutor(IHookExecutor executor)
{
TestContext.CustomHookExecutor = executor;
QueueExecutorEventReceiver(executor);
}

/// <summary>
/// Sets the parallel limiter for the test
/// Sets the programmatic parallel limiter for the test. An explicit
/// <see cref="ParallelLimiterAttribute{TParallelLimit}"/> takes precedence regardless of callback order.
/// </summary>
public void SetParallelLimiter(IParallelLimit parallelLimit)
{
TestContext.ParallelLimiter = _explicitParallelLimiter ?? parallelLimit;
}

internal void SetExplicitParallelLimiter(IParallelLimit parallelLimit)
{
_explicitParallelLimiter = parallelLimit;
TestContext.ParallelLimiter = parallelLimit;
}

Expand All @@ -66,4 +80,52 @@ public void SetSkipped(string reason)
TestContext.SkipReason = reason;
TestContext.Metadata.TestDetails.ClassInstance = SkippedTestInstance.Instance;
}

/// <summary>
/// Queues an executor that is itself an <see cref="ITestRegisteredEventReceiver"/> for dispatch.
/// </summary>
/// <param name="executor">The executor a registration receiver has just installed.</param>
/// <remarks>
/// An executor installed during registration may not have been collected by the engine's
/// eligible-object pass. An executor that is both an <see cref="ITestExecutor"/> and an
/// <see cref="IHookExecutor"/> arrives through two calls and is queued once.
/// </remarks>
private void QueueExecutorEventReceiver(object executor)
{
if (executor is not ITestRegisteredEventReceiver receiver)
{
return;
}

_executorReceivers ??= [];

// Identity, not equality: two distinct executors that compare equal each own their callback.
foreach (var queued in _executorReceivers)
{
if (ReferenceEquals(queued, receiver))
{
return;
}
}

_executorReceivers.Add(receiver);
}

/// <summary>
/// Dequeues the next executor awaiting its registration callback.
/// </summary>
/// <param name="receiver">When this method returns, contains the executor to dispatch to.</param>
/// <returns><see langword="true"/> if an executor was awaiting dispatch; otherwise, <see langword="false"/>.</returns>
/// <remarks>Each executor is returned once, however many times it was installed.</remarks>
internal bool TryDequeueExecutorEventReceiver([NotNullWhen(true)] out ITestRegisteredEventReceiver? receiver)
{
if (_executorReceivers is null || _dispatchedExecutorReceiverCount == _executorReceivers.Count)
{
receiver = null;
return false;
}

receiver = _executorReceivers[_dispatchedExecutorReceiverCount++];
return true;
}
}
7 changes: 7 additions & 0 deletions src/TUnit.Core/Interfaces/ITestRegisteredEventReceiver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ namespace TUnit.Core.Interfaces;
/// The <see cref="IEventReceiver.Order"/> property can be used to control the execution order
/// when multiple implementations of this interface exist.
/// </para>
/// <para>
/// Executors installed through <see cref="TestRegisteredContext.SetTestExecutor"/> or
/// <see cref="TestRegisteredContext.SetHookExecutor"/> during registration are invoked after
/// their installing receiver returns, rather than being globally sorted by their own Order.
/// Each receiver instance is invoked at most once per test, even when it is both an eligible
/// event object and an installed executor. Distinct instances are compared by reference identity.
/// </para>
/// </remarks>
public interface ITestRegisteredEventReceiver : IEventReceiver
{
Expand Down
36 changes: 33 additions & 3 deletions src/TUnit.Core/TestContext.Execution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ public partial class TestContext
private volatile bool _testCancellationRequested;
private List<CancellationToken>? _externalCancellationTokens;
private CancellationTokenSource? _linkedCancellationTokenSource;
private CancellationToken _linkedCancellationBaseToken;
// Token copies can escape into user code and remain in use through teardown (#6339).
// Keep replaced sources alive until the complete test lifecycle has finished.
private List<CancellationTokenSource>? _retiredLinkedCancellationTokenSources;
private List<(CancellationTokenSource Source, CancellationToken BaseToken)>? _retiredLinkedCancellationTokenSources;
internal CancellationToken CancellationToken { get; private set; }

// Linked source backing the per-test timeout token. Owned for the whole test lifecycle — the body
Expand Down Expand Up @@ -297,13 +298,42 @@ private void RebuildLinkedCancellationTokenSource()

if (_linkedCancellationTokenSource is { } previousLinkedCancellationTokenSource)
{
(_retiredLinkedCancellationTokenSources ??= []).Add(previousLinkedCancellationTokenSource);
(_retiredLinkedCancellationTokenSources ??= []).Add((previousLinkedCancellationTokenSource, _linkedCancellationBaseToken));
}

_linkedCancellationTokenSource = linkedCancellationTokenSource;
_linkedCancellationBaseToken = _baseCancellationToken;
CancellationToken = linkedCancellationTokenSource.Token;
}

internal bool IsLinkedCancellationToken(CancellationToken token, CancellationToken baseToken)
{
lock (Lock)
{
if (_linkedCancellationTokenSource is { } currentSource
&& _linkedCancellationBaseToken == baseToken
&& currentSource.Token == token)
{
return true;
}

// A test can still use a captured token after another linked token or retry replaces it.
// Match its original base as well, so earlier attempts cannot become the current timeout.
if (_retiredLinkedCancellationTokenSources is { } retiredSources)
{
foreach (var retired in retiredSources)
{
if (retired.BaseToken == baseToken && retired.Source.Token == token)
{
return true;
}
}
}

return false;
}
}

internal void DisposeLinkedCancellationTokenSources()
{
lock (Lock)
Expand All @@ -329,7 +359,7 @@ private void DisposeCancellationTokenSourcesUnderLock()
{
for (var i = retiredLinkedCancellationTokenSources.Count - 1; i >= 0; i--)
{
retiredLinkedCancellationTokenSources[i].Dispose();
retiredLinkedCancellationTokenSources[i].Source.Dispose();
}

_retiredLinkedCancellationTokenSources = null;
Expand Down
57 changes: 37 additions & 20 deletions src/TUnit.Engine/Helpers/TimeoutHelper.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using TUnit.Core;
using TUnit.Engine.Constants;

namespace TUnit.Engine.Helpers;
Expand Down Expand Up @@ -55,14 +56,16 @@ public static async Task ExecuteWithTimeoutAsync(
/// <param name="timeoutCts">Caller-owned CTS, already linked to <paramref name="externalToken"/>. Not disposed here.</param>
/// <param name="externalToken">The external token the CTS is linked to; used to distinguish timeout from external cancellation.</param>
/// <param name="timeoutMessage">Optional custom timeout message. If null, uses default message.</param>
/// <param name="testContext">Tracks execution tokens linked to the timeout token, including captured tokens replaced during execution.</param>
/// <exception cref="TimeoutException">Thrown when the timeout elapses before task completion.</exception>
/// <exception cref="OperationCanceledException">Thrown when cancellation is requested.</exception>
public static async Task ExecuteWithTimeoutAsync(
Func<CancellationToken, Task> taskFactory,
TimeSpan timeout,
CancellationTokenSource timeoutCts,
CancellationToken externalToken,
string? timeoutMessage = null)
string? timeoutMessage = null,
TestContext? testContext = null)
{
// Set up cancellation detection BEFORE scheduling timeout to avoid race condition
// where timeout fires before registration completes (with very small timeouts)
Expand All @@ -78,39 +81,53 @@ public static async Task ExecuteWithTimeoutAsync(

var winner = await Task.WhenAny(executionTask, cancelledTcs.Task).ConfigureAwait(false);

if (winner == cancelledTcs.Task)
if (winner == executionTask)
{
// Determine if it was external cancellation or timeout
if (externalToken.IsCancellationRequested)
try
{
await executionTask.ConfigureAwait(false);
return;
}
catch (OperationCanceledException exception)
when (timeoutCts.IsCancellationRequested
&& IsTimeoutCancellationToken(exception.CancellationToken, timeoutCts.Token, testContext))
{
throw new OperationCanceledException(externalToken);
// The operation can observe cancellation before the detection task wins WhenAny.
// Classify that cancellation through the same timeout/external-cancellation path.
}
}

// Timeout occurred - give the execution task a brief grace period to clean up
var executionException = await ObserveExceptionDuringGracePeriodAsync(executionTask).ConfigureAwait(false);
// Determine if it was external cancellation or timeout
if (externalToken.IsCancellationRequested)
{
throw new OperationCanceledException(externalToken);
}

// Routine cancellation adds no useful context; preserve exceptions explicitly
// thrown while handling cancellation, such as Aspire's diagnostic exception.
var exceptionToPreserve = IsRoutineCancellation(executionException, timeoutCts.Token)
? null
: executionException;
// Timeout occurred - give the execution task a brief grace period to clean up
var executionException = await ObserveExceptionDuringGracePeriodAsync(executionTask).ConfigureAwait(false);

// 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, exceptionToPreserve);
throw new TimeoutException(diagnosticMessage, exceptionToPreserve);
}
// Routine cancellation adds no useful context; preserve exceptions explicitly
// thrown while handling cancellation, such as Aspire's diagnostic exception.
var exceptionToPreserve = IsRoutineCancellation(executionException, timeoutCts.Token, testContext)
? null
: executionException;

await executionTask.ConfigureAwait(false);
// 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, exceptionToPreserve);
throw new TimeoutException(diagnosticMessage, exceptionToPreserve);
}

private static bool IsRoutineCancellation(Exception? exception, CancellationToken timeoutToken)
private static bool IsTimeoutCancellationToken(CancellationToken token, CancellationToken timeoutToken, TestContext? testContext)
=> token == timeoutToken || testContext?.IsLinkedCancellationToken(token, timeoutToken) == true;

private static bool IsRoutineCancellation(Exception? exception, CancellationToken timeoutToken, TestContext? testContext)
{
if (exception is not OperationCanceledException
{
InnerException: null
} operationCanceledException
|| operationCanceledException.CancellationToken != timeoutToken)
|| !IsTimeoutCancellationToken(operationCanceledException.CancellationToken, timeoutToken, testContext))
{
return false;
}
Expand Down
34 changes: 27 additions & 7 deletions src/TUnit.Engine/Services/TestFilterService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,26 @@ public IReadOnlyCollection<AbstractExecutableTest> FilterTests(ITestExecutionFil
return filteredTests;
}

private async Task InvokeTestRegisteredReceiver(ITestRegisteredEventReceiver receiver, TestRegisteredContext context,
HashSet<ITestRegisteredEventReceiver> invokedReceivers)
{
// Record identity before invoking: a receiver can install itself as an executor.
if (!invokedReceivers.Add(receiver))
{
return;
}

try
{
await receiver.OnTestRegistered(context).ConfigureAwait(false);
}
catch (Exception ex)
{
await logger.LogErrorAsync($"Error in test registered event receiver: {ex.Message}").ConfigureAwait(false);
throw;
}
}

private async Task RegisterTest(AbstractExecutableTest test, bool isForExecution)
{
var registeredReceivers = test.Context.GetTestRegisteredReceivers();
Expand All @@ -83,17 +103,17 @@ private async Task RegisterTest(AbstractExecutableTest test, bool isForExecution
};

test.Context.InternalDiscoveredTest = discoveredTest;
var invokedReceivers = new HashSet<ITestRegisteredEventReceiver>(Core.Helpers.ReferenceEqualityComparer.Instance);

foreach (var receiver in registeredReceivers)
{
try
{
await receiver.OnTestRegistered(registeredContext).ConfigureAwait(false);
}
catch (Exception ex)
await InvokeTestRegisteredReceiver(receiver, registeredContext, invokedReceivers).ConfigureAwait(false);

// Dynamically installed executor callbacks run after their installer, regardless of their
// own Order. The shared identity set also covers executors in the original receiver list.
while (registeredContext.TryDequeueExecutorEventReceiver(out var executorReceiver))
{
await logger.LogErrorAsync($"Error in test registered event receiver: {ex.Message}").ConfigureAwait(false);
throw;
await InvokeTestRegisteredReceiver(executorReceiver, registeredContext, invokedReceivers).ConfigureAwait(false);
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/TUnit.Engine/TestExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,8 @@ await TimeoutHelper.ExecuteWithTimeoutAsync(
testTimeout.Value,
testBodyTimeoutCts,
testCancellationToken,
timeoutMessage).ConfigureAwait(false);
timeoutMessage,
context).ConfigureAwait(false);
}
else
{
Expand Down
Loading
Loading