diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index 7eb019614d..504f03e54c 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -267,6 +267,7 @@ public TUnitServiceProvider(IExtension extension, circularDependencyDetector, constraintKeyScheduler, hookExecutor, + afterHookPairTracker, staticPropertyHandler, dynamicTestQueue)); diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index b2d3d8a208..091e8cfeb9 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -23,6 +23,7 @@ internal sealed class TestScheduler : ITestScheduler private readonly CircularDependencyDetector _circularDependencyDetector; private readonly IConstraintKeyScheduler _constraintKeyScheduler; private readonly HookExecutor _hookExecutor; + private readonly AfterHookPairTracker _afterHookPairTracker; private readonly StaticPropertyHandler _staticPropertyHandler; private readonly IDynamicTestQueue _dynamicTestQueue; private readonly int _maxParallelism; @@ -39,6 +40,7 @@ public TestScheduler( CircularDependencyDetector circularDependencyDetector, IConstraintKeyScheduler constraintKeyScheduler, HookExecutor hookExecutor, + AfterHookPairTracker afterHookPairTracker, StaticPropertyHandler staticPropertyHandler, IDynamicTestQueue dynamicTestQueue) { @@ -51,6 +53,7 @@ public TestScheduler( _circularDependencyDetector = circularDependencyDetector; _constraintKeyScheduler = constraintKeyScheduler; _hookExecutor = hookExecutor; + _afterHookPairTracker = afterHookPairTracker; _staticPropertyHandler = staticPropertyHandler; _dynamicTestQueue = dynamicTestQueue; @@ -136,7 +139,8 @@ public async Task ScheduleAndExecuteAsync( // Execute tests according to their grouping await ExecuteGroupedTestsAsync(groupedTests, cancellationToken).ConfigureAwait(false); - var sessionHookExceptions = await _hookExecutor.ExecuteAfterTestSessionHooksAsync(cancellationToken).ConfigureAwait(false) ?? []; + var sessionHookExceptions = await _afterHookPairTracker.GetOrCreateAfterTestSessionTask( + () => _hookExecutor.ExecuteAfterTestSessionHooksAsync(cancellationToken)).ConfigureAwait(false) ?? []; await _staticPropertyHandler.DisposeStaticPropertiesAsync(sessionHookExceptions).ConfigureAwait(false); diff --git a/TUnit.Engine/Services/AfterHookPairTracker.cs b/TUnit.Engine/Services/AfterHookPairTracker.cs index 23a35254d2..a703e2bfcc 100644 --- a/TUnit.Engine/Services/AfterHookPairTracker.cs +++ b/TUnit.Engine/Services/AfterHookPairTracker.cs @@ -20,17 +20,30 @@ internal sealed class AfterHookPairTracker private readonly Lock _testSessionLock = new(); private readonly Lock _classLock = new(); + // Ensure only the first call to RegisterAfterTestSessionHook registers a callback. + // Subsequent calls (e.g. from per-test timeout tokens) are ignored so that + // a test timeout cannot prematurely trigger session-level After hooks. + private volatile bool _sessionHookRegistered; + // Track cancellation registrations for cleanup private readonly ConcurrentBag _registrations = []; /// /// Registers Session After hooks to run on cancellation or normal completion. - /// Ensures After hooks run exactly once even if called both ways. + /// Only the first call registers a callback; subsequent calls are no-ops. + /// This prevents per-test timeout tokens from prematurely firing session hooks. /// public void RegisterAfterTestSessionHook( CancellationToken cancellationToken, Func>> afterHookExecutor) { + if (_sessionHookRegistered) + { + return; + } + + _sessionHookRegistered = true; + // Register callback to run After hook on cancellation var registration = cancellationToken.Register(() => {