diff --git a/TUnit.Core/Models/TestDiscoveryContext.cs b/TUnit.Core/Models/TestDiscoveryContext.cs index 5996e44e3c..c7ca7a9bb3 100644 --- a/TUnit.Core/Models/TestDiscoveryContext.cs +++ b/TUnit.Core/Models/TestDiscoveryContext.cs @@ -28,10 +28,41 @@ public void AddTests(IEnumerable tests) public required string? TestFilter { get; init; } [field: AllowNull, MaybeNull] - public IEnumerable Assemblies => field ??= TestClasses.Select(x => x.AssemblyContext).Distinct().ToArray(); + public IEnumerable Assemblies => field ??= BuildUniqueAssemblies(); [field: AllowNull, MaybeNull] - public IEnumerable TestClasses => field ??= AllTests.Where(x => x.ClassContext != null).Select(x => x.ClassContext!).Distinct().ToArray(); + public IEnumerable TestClasses => field ??= BuildUniqueTestClasses(); + + private AssemblyHookContext[] BuildUniqueAssemblies() + { + // HashSet iteration order is not contractually guaranteed; the parallel list + // preserves first-occurrence order so hook execution sequence stays deterministic. + var seen = new HashSet(); + var ordered = new List(); + foreach (var cls in TestClasses) + { + if (seen.Add(cls.AssemblyContext)) + { + ordered.Add(cls.AssemblyContext); + } + } + return ordered.ToArray(); + } + + private ClassHookContext[] BuildUniqueTestClasses() + { + var seen = new HashSet(); + var ordered = new List(); + foreach (var test in AllTests) + { + var cls = test.ClassContext; + if (cls != null && seen.Add(cls)) + { + ordered.Add(cls); + } + } + return ordered.ToArray(); + } public IReadOnlyList AllTests { get; private set; } = []; diff --git a/TUnit.Core/Models/TestSessionContext.cs b/TUnit.Core/Models/TestSessionContext.cs index dc44fc6927..098d9a9eb5 100644 --- a/TUnit.Core/Models/TestSessionContext.cs +++ b/TUnit.Core/Models/TestSessionContext.cs @@ -87,6 +87,17 @@ private void InvalidateCaches() internal bool FirstTestStarted { get; set; } + private int _failureCount; + + /// + /// True if any test in the session completed with Failed, Timeout, or Cancelled state. + /// Updated atomically by ; avoids an O(N) AllTests traversal + /// in after-session hook paths. + /// + internal bool HasFailures => Volatile.Read(ref _failureCount) > 0; + + internal void MarkFailure() => Interlocked.Increment(ref _failureCount); + internal readonly List Artifacts = []; public void AddArtifact(Artifact artifact) diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index 7b2ce7e49d..df1f07dbfd 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -31,8 +31,10 @@ namespace TUnit.Core; public partial class TestContext : Context, ITestExecution, ITestParallelization, ITestOutput, ITestMetadata, ITestDependencies, ITestStateBag, ITestEvents, ITestIsolation { - private static readonly ConcurrentDictionary _testContextsById = new(); + private static readonly ConcurrentDictionary _testContextsByGuid = new(); private readonly TestBuilderContext _testBuilderContext; + private readonly Guid _idGuid; + private string? _idString; private string? _cachedDisplayName; public TestContext(string testName, IServiceProvider serviceProvider, ClassHookContext classContext, TestBuilderContext testBuilderContext, CancellationToken cancellationToken) : base(classContext) @@ -42,17 +44,31 @@ public TestContext(string testName, IServiceProvider serviceProvider, ClassHookC ServiceProvider = serviceProvider; ClassContext = classContext; - // Generate unique ID for this test instance - Id = Guid.NewGuid().ToString(); + _idGuid = Guid.NewGuid(); IsolationUniqueId = Interlocked.Increment(ref _isolationIdCounter); - _testContextsById[Id] = this; + _testContextsByGuid[_idGuid] = this; } /// /// Gets the unique identifier for this test instance. + /// The string form is materialized lazily on first access — most tests never need it + /// unless OTel is active or user code queries the context by Id. /// - public string Id { get; } + public string Id + { + get + { + // Volatile read gives ARM/WASM acquire semantics without a volatile field. + if (Volatile.Read(ref _idString) is { } existing) + { + return existing; + } + + var materialized = _idGuid.ToString(); + return Interlocked.CompareExchange(ref _idString, materialized, null) ?? materialized; + } + } /// /// Gets access to test execution state, result management, cancellation, and retry information. @@ -203,12 +219,10 @@ public ContextScope MakeCurrent() /// /// The unique identifier of the test context. /// The matching , or null. - public static TestContext? GetById(string id) => _testContextsById.GetValueOrDefault(id); + public static TestContext? GetById(string id) => + Guid.TryParse(id, out var guid) ? _testContextsByGuid.GetValueOrDefault(guid) : null; - /// - /// Removes a test context from the static registry. Called when test execution completes. - /// - internal static void RemoveById(string id) => _testContextsById.TryRemove(id, out _); + internal void RemoveFromRegistry() => _testContextsByGuid.TryRemove(_idGuid, out _); /// /// Gets the dictionary of test parameters indexed by parameter name. diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index 5ddc80bbd8..fa0dd4313b 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -106,6 +106,7 @@ public async Task ScheduleAndExecuteAsync( if (testsInCircularDependencies.Add(chainTest)) { _testStateManager.MarkCircularDependencyFailed(chainTest, exception); + TestSessionContext.Current?.MarkFailure(); await _messageBus.Failed(chainTest.Context, exception, DateTimeOffset.UtcNow).ConfigureAwait(false); } } diff --git a/TUnit.Engine/Services/EventReceiverOrchestrator.cs b/TUnit.Engine/Services/EventReceiverOrchestrator.cs index 6b11e80024..c3417ace96 100644 --- a/TUnit.Engine/Services/EventReceiverOrchestrator.cs +++ b/TUnit.Engine/Services/EventReceiverOrchestrator.cs @@ -291,10 +291,17 @@ public async ValueTask InvokeHookRegistrationEventReceiversAsync(HookRegisteredC // Pre-sort by Order before filtering so that FilterScopedAttributes (which uses TryAdd // and keeps the first encountered item per ScopeType) retains the lowest-Order attribute. // After filtering, sort the result in-place for final invocation order. - var filteredReceivers = ScopedAttributeFilter.FilterScopedAttributes( - hookContext.HookMethod.Attributes - .OfType() - .OrderBy(static x => x.Order)); + var sorted = new List(); + foreach (var attr in hookContext.HookMethod.Attributes) + { + if (attr is IHookRegisteredEventReceiver receiver) + { + sorted.Add(receiver); + } + } + sorted.Sort(static (a, b) => a.Order.CompareTo(b.Order)); + + var filteredReceivers = ScopedAttributeFilter.FilterScopedAttributes(sorted); Array.Sort(filteredReceivers, static (a, b) => a.Order.CompareTo(b.Order)); @@ -548,24 +555,22 @@ private async ValueTask InvokeLastTestInClassEventReceiversCore( /// /// Initialize test counts for first/last event receivers /// - public void InitializeTestCounts(IEnumerable allTestContexts) + public void InitializeTestCounts(IReadOnlyList allTests) { - var contexts = allTestContexts as IList ?? allTestContexts.ToList(); - Interlocked.Exchange(ref _sessionTestCount, contexts.Count); + Interlocked.Exchange(ref _sessionTestCount, allTests.Count); // Clear first-event tracking to ensure clean state for each test execution _firstTestInAssemblyTasks = new ThreadSafeDictionary(); _firstTestInClassTasks = new ThreadSafeDictionary(); _firstTestInSessionTasks = new ThreadSafeDictionary(); - foreach (var context in contexts) + for (var i = 0; i < allTests.Count; i++) { - var assembly = context.ClassContext.AssemblyContext.Assembly; - var assemblyCounter = _assemblyTestCounts.GetOrAdd(assembly, static _ => new Counter()); + var classContext = allTests[i].Context.ClassContext; + var assemblyCounter = _assemblyTestCounts.GetOrAdd(classContext.AssemblyContext.Assembly, static _ => new Counter()); assemblyCounter.Add(1); - var classType = context.ClassContext.ClassType; - var classCounter = _classTestCounts.GetOrAdd(classType, static _ => new Counter()); + var classCounter = _classTestCounts.GetOrAdd(classContext.ClassType, static _ => new Counter()); classCounter.Add(1); } } diff --git a/TUnit.Engine/Services/HookDelegateBuilder.cs b/TUnit.Engine/Services/HookDelegateBuilder.cs index 4e75537f66..117a25dd97 100644 --- a/TUnit.Engine/Services/HookDelegateBuilder.cs +++ b/TUnit.Engine/Services/HookDelegateBuilder.cs @@ -104,11 +104,24 @@ private async Task>> BuildGlobalHooksA await _logger.LogTraceAsync($"Built {hooks.Count} {hookTypeName} hook delegate(s)").ConfigureAwait(false); } - return hooks - .OrderBy(static h => h.order) - .ThenBy(static h => h.registrationIndex) - .Select(static h => h.hook) - .ToList(); + return SortAndProject(hooks); + } + + private static List> SortAndProject( + List<(int order, int registrationIndex, NamedHookDelegate hook)> hooks) + { + hooks.Sort(static (a, b) => + { + var orderCompare = a.order.CompareTo(b.order); + return orderCompare != 0 ? orderCompare : a.registrationIndex.CompareTo(b.registrationIndex); + }); + + var result = new List>(hooks.Count); + for (var i = 0; i < hooks.Count; i++) + { + result.Add(hooks[i].hook); + } + return result; } private Task>> BuildGlobalBeforeEveryTestHooksAsync() @@ -471,11 +484,7 @@ private async Task>> BuildB allHooks.Add((hook.Order, hook.RegistrationIndex, namedHook)); } - return allHooks - .OrderBy(static h => h.order) - .ThenBy(static h => h.registrationIndex) - .Select(static h => h.hook) - .ToList(); + return SortAndProject(allHooks); } public async ValueTask>> CollectAfterAssemblyHooksAsync(Assembly assembly) @@ -505,11 +514,7 @@ private async Task>> BuildA allHooks.Add((hook.Order, hook.RegistrationIndex, namedHook)); } - return allHooks - .OrderBy(static h => h.order) - .ThenBy(static h => h.registrationIndex) - .Select(static h => h.hook) - .ToList(); + return SortAndProject(allHooks); } public ValueTask>> CollectBeforeTestSessionHooksAsync() diff --git a/TUnit.Engine/Services/HookExecutor.cs b/TUnit.Engine/Services/HookExecutor.cs index 49d9f93b89..79ebf7083a 100644 --- a/TUnit.Engine/Services/HookExecutor.cs +++ b/TUnit.Engine/Services/HookExecutor.cs @@ -76,9 +76,7 @@ public async ValueTask> ExecuteAfterTestSessionHooksAsync(Cancel // stopped the activity after hooks, the exporter would already be // gone and the root span would never be exported. #if NET - var hasTestFailures = _contextProvider.TestSessionContext.AllTests - .Any(t => t.Result is { State: TestState.Failed or TestState.Timeout or TestState.Cancelled }); - FinishSessionActivity(hasErrors: hasTestFailures); + FinishSessionActivity(hasErrors: _contextProvider.TestSessionContext.HasFailures); #endif var hooks = await _hookCollectionService.CollectAfterTestSessionHooksAsync().ConfigureAwait(false); diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index a54aedbf6a..26647fc1a7 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -238,6 +238,7 @@ await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync( break; case TestState.Timeout: case TestState.Failed: + TestSessionContext.Current?.MarkFailure(); await _messageBus.Failed(test.Context, test.Context.Execution.Result?.Exception!, test.StartTime.GetValueOrDefault()).ConfigureAwait(false); break; case TestState.Skipped: @@ -247,13 +248,14 @@ await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync( await _messageBus.Skipped(test.Context, skipReason).ConfigureAwait(false); break; case TestState.Cancelled: + TestSessionContext.Current?.MarkFailure(); await _messageBus.Cancelled(test.Context, test.StartTime.GetValueOrDefault()).ConfigureAwait(false); break; default: throw new ArgumentOutOfRangeException(); } - TestContext.RemoveById(test.Context.Id); + test.Context.RemoveFromRegistry(); } } diff --git a/TUnit.Engine/Services/TestGroupingService.cs b/TUnit.Engine/Services/TestGroupingService.cs index 8e4376d954..41d3a53cd7 100644 --- a/TUnit.Engine/Services/TestGroupingService.cs +++ b/TUnit.Engine/Services/TestGroupingService.cs @@ -30,8 +30,31 @@ private struct TestSortKey public int NotInParallelOrder { get; init; } public NotInParallelConstraint? NotInParallelConstraint { get; init; } } - - public async ValueTask GroupTestsByConstraintsAsync(IEnumerable tests) + + public ValueTask GroupTestsByConstraintsAsync(IEnumerable tests) + { + // Hot path: trace disabled → fully synchronous, no state machine, no allocation + // for a trace-message list. Only pay async cost when someone actually wants traces. + if (!_logger.IsTraceEnabled) + { + return new ValueTask(GroupTestsByConstraintsCore(tests, traceMessages: null)); + } + + return GroupTestsByConstraintsWithTracingAsync(tests); + } + + private async ValueTask GroupTestsByConstraintsWithTracingAsync(IEnumerable tests) + { + var traceMessages = new List(); + var result = GroupTestsByConstraintsCore(tests, traceMessages); + foreach (var msg in traceMessages) + { + await _logger.LogTraceAsync(msg).ConfigureAwait(false); + } + return result; + } + + private static GroupedTests GroupTestsByConstraintsCore(IEnumerable tests, List? traceMessages) { var testCount = tests is ICollection collection ? collection.Count : 0; var testsWithKeys = new List<(AbstractExecutableTest Test, TestSortKey Key)>(testCount > 0 ? testCount : 16); @@ -46,7 +69,7 @@ public async ValueTask GroupTestsByConstraintsAsync(IEnumerable GroupTestsByConstraintsAsync(IEnumerable { var priorityCompare = b.Key.ExecutionPriority.CompareTo(a.Key.ExecutionPriority); @@ -91,29 +114,26 @@ public async ValueTask GroupTestsByConstraintsAsync(IEnumerable 0 ? $" (keys: {string.Join(", ", notInParallel.NotInParallelConstraintKeys)})" : ""; - await _logger.LogTraceAsync($"Test '{test.TestId}': → NotInParallel{keys}{FormatParallelLimiterInfo(test)}").ConfigureAwait(false); + traceMessages.Add($"Test '{test.TestId}': → NotInParallel{keys}{FormatParallelLimiterInfo(test)}"); } ProcessNotInParallelConstraint(test, sortKey.ClassFullName, notInParallel, notInParallelList, keyedNotInParallelList); } else { - if (_logger.IsTraceEnabled) - await _logger.LogTraceAsync($"Test '{test.TestId}': → Parallel (no constraints){FormatParallelLimiterInfo(test)}").ConfigureAwait(false); + traceMessages?.Add($"Test '{test.TestId}': → Parallel (no constraints){FormatParallelLimiterInfo(test)}"); parallelTests.Add(test); } } @@ -160,7 +180,7 @@ public async ValueTask GroupTestsByConstraintsAsync(IEnumerable { var classCompare = string.CompareOrdinal(a.Item2, b.Item2); @@ -178,14 +198,14 @@ public async ValueTask GroupTestsByConstraintsAsync(IEnumerable GroupTestsByConstraintsAsync(IEnumerable test.Context.ParallelLimiter != null ? $" [ParallelLimiter: {test.Context.ParallelLimiter.GetType().Name} (limit: {test.Context.ParallelLimiter.Limit})]" diff --git a/TUnit.Engine/TestDiscoveryService.cs b/TUnit.Engine/TestDiscoveryService.cs index 391aa01937..c2ad65e556 100644 --- a/TUnit.Engine/TestDiscoveryService.cs +++ b/TUnit.Engine/TestDiscoveryService.cs @@ -381,6 +381,11 @@ await Parallel.ForEachAsync( public IEnumerable GetCachedTestContexts() { - return _cachedTests.Select(static t => t.Context); + var list = new List(_cachedTests.Count); + foreach (var test in _cachedTests) + { + list.Add(test.Context); + } + return list; } } diff --git a/TUnit.Engine/TestSessionCoordinator.cs b/TUnit.Engine/TestSessionCoordinator.cs index 4ee76b261a..24f409af40 100644 --- a/TUnit.Engine/TestSessionCoordinator.cs +++ b/TUnit.Engine/TestSessionCoordinator.cs @@ -68,8 +68,7 @@ public async Task ExecuteTests( private void InitializeEventReceivers(List testList, CancellationToken cancellationToken) { - var testContexts = testList.Select(static t => t.Context); - _eventReceiverOrchestrator.InitializeTestCounts(testContexts); + _eventReceiverOrchestrator.InitializeTestCounts(testList); } private async Task PrepareTestOrchestrator(List testList, CancellationToken cancellationToken)