diff --git a/TUnit.Engine/Services/EventReceiverOrchestrator.cs b/TUnit.Engine/Services/EventReceiverOrchestrator.cs index c3417ace96..b0bfec5c67 100644 --- a/TUnit.Engine/Services/EventReceiverOrchestrator.cs +++ b/TUnit.Engine/Services/EventReceiverOrchestrator.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics; using System.Reflection; using System.Runtime.CompilerServices; using TUnit.Core; @@ -42,7 +43,7 @@ public EventReceiverOrchestrator(TUnitFrameworkLogger logger) _logger = logger; } - public void RegisterReceivers(TestContext context, CancellationToken cancellationToken) + public void RegisterReceivers(TestContext context) { var vlb = new ValueListBuilder([null, null, null, null]); @@ -80,6 +81,41 @@ obj is IFirstTestInAssemblyEventReceiver || vlb.Dispose(); } + // Precondition: ClassInstance has just been assigned; the initial RegisterReceivers call (with ClassInstance still null) already covered every other eligible object. + public void RegisterClassInstanceReceiver(TestContext context) + { + var classInstance = context.Metadata.TestDetails.ClassInstance; + Debug.Assert(classInstance is not null, "RegisterClassInstanceReceiver should only be called after ClassInstance is assigned."); + if (classInstance is null) + { + return; + } + + // Defense-in-depth: SkippedTestInstance is a sentinel singleton for tests skipped + // at registration time and should never be treated as an event receiver. Callers + // already short-circuit on this sentinel, but guard here too. + if (classInstance is SkippedTestInstance) + { + return; + } + + if (!_initializedObjects.Add(classInstance)) + { + return; + } + + if (classInstance is IFirstTestInTestSessionEventReceiver + or IFirstTestInAssemblyEventReceiver + or IFirstTestInClassEventReceiver) + { + if (!_registeredFirstEventReceiverTypes.Add(classInstance.GetType())) + { + return; + } + } + + _registry.RegisterReceiver(classInstance); + } // Fast-path checks with inlining [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index a39f2fc516..9ff9277b2a 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -55,14 +55,16 @@ private async ValueTask ExecuteTestInternalAsync(AbstractExecutableTest test, Ca try { _stateManager.MarkRunning(test); - // Fire-and-forget InProgress - it's informational and doesn't need to block test execution - _ = _messageBus.InProgress(test.Context); + // Await InProgress so back-pressure from MTP's bounded channel spreads publishes + // across each test task rather than fanning 1000+ fire-and-forget writers into + // the channel at once. + await _messageBus.InProgress(test.Context).ConfigureAwait(false); _contextRestorer.RestoreContext(test); // Register event receivers early so that skip event receivers work // even when the test is skipped before full initialization. - _eventReceiverOrchestrator.RegisterReceivers(test.Context, cancellationToken); + _eventReceiverOrchestrator.RegisterReceivers(test.Context); // Check if test was already marked as skipped during registration // (e.g., by a derived SkipAttribute evaluated in OnTestRegistered). @@ -290,7 +292,7 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C test.Context.Metadata.TestDetails.ClassInstance = await test.CreateInstanceAsync().ConfigureAwait(false); - // Invalidate cached eligible event objects since ClassInstance changed + // Drop the cached eligible-objects list so any later consumer rebuilds it with the new ClassInstance included — the initial list was built before the instance existed. test.Context.CachedEligibleEventObjects = null; // Check if this test should be skipped (after creating instance). @@ -310,7 +312,7 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C try { - _testInitializer.PrepareTest(test, cancellationToken); + _testInitializer.PrepareTest(test); test.Context.RestoreExecutionContext(); var testTimeout = test.Context.Metadata.TestDetails.Timeout; await _testExecutor.ExecuteAsync(test, _testInitializer, cancellationToken, testTimeout).ConfigureAwait(false); diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs index 7aa378a844..9665e0112f 100644 --- a/TUnit.Engine/TestInitializer.cs +++ b/TUnit.Engine/TestInitializer.cs @@ -20,13 +20,15 @@ public TestInitializer( _objectLifecycleService = objectLifecycleService; } - public void PrepareTest(AbstractExecutableTest test, CancellationToken cancellationToken) + public void PrepareTest(AbstractExecutableTest test) { - // Register event receivers - _eventReceiverOrchestrator.RegisterReceivers(test.Context, cancellationToken); + // Register the freshly created ClassInstance as an event receiver. The initial + // registration happens before instance creation, so re-iterating the full + // eligible-event-object set would duplicate work — only the ClassInstance is new. + _eventReceiverOrchestrator.RegisterClassInstanceReceiver(test.Context); - // Prepare test: set cached property values on the instance - // Does NOT call IAsyncInitializer - that is deferred until after BeforeClass hooks + // Prepare test: set cached property values on the instance. + // Does NOT call IAsyncInitializer - that is deferred until after BeforeClass hooks. _objectLifecycleService.PrepareTest(test.Context); }