From cde8bc2a37c1f8df8b0d72f0c42d1c493ea76b66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:01:36 +0000 Subject: [PATCH 01/28] Track initialization and disposal times with OpenTelemetry activity spans - Move "test case" activity span to start before data source initialization - Add "data source initialization" child activity span in TestExecutor - Add "test instance disposal" activity span in TestCoordinator - Set TestStart before initialization so HTML report duration includes init time - Simplify TestInitializer now that test case span covers initialization Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/7e7dc4cb-23dd-4723-ba82-6355cfc6d354 Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --- .../Services/TestExecution/TestCoordinator.cs | 23 +++++++++++ TUnit.Engine/TestExecutor.cs | 39 +++++++++++++++---- TUnit.Engine/TestInitializer.cs | 28 ++----------- 3 files changed, 59 insertions(+), 31 deletions(-) diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index cc79d87fb7..e006e6ef11 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -6,6 +6,9 @@ using TUnit.Engine.Helpers; using TUnit.Engine.Interfaces; using TUnit.Engine.Logging; +#if NET +using System.Diagnostics; +#endif namespace TUnit.Engine.Services.TestExecution; @@ -301,6 +304,19 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C } finally { +#if NET + Activity? disposalActivity = null; + if (TUnitActivitySource.Source.HasListeners()) + { + disposalActivity = TUnitActivitySource.StartActivity( + "test instance disposal", + ActivityKind.Internal, + test.Context.ClassContext.Activity?.Context ?? default, + [new("tunit.test.id", test.Context.Id)]); + } + try + { +#endif // Dispose test instance and fire OnDispose after each attempt // This ensures each retry gets a fresh instance var onDispose = test.Context.InternalEvents.OnDispose; @@ -327,6 +343,13 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C { await _logger.LogErrorAsync($"Error disposing test instance for {test.TestId}: {disposeEx}").ConfigureAwait(false); } +#if NET + } + finally + { + TUnitActivitySource.StopActivity(disposalActivity); + } +#endif } } } diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs index 4f1336b6ab..34582085b9 100644 --- a/TUnit.Engine/TestExecutor.cs +++ b/TUnit.Engine/TestExecutor.cs @@ -116,10 +116,6 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( executableTest.Context.ClassContext.RestoreExecutionContext(); - // Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks - // This ensures resources like Docker containers are not started until needed - await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false); - #if NET if (TUnitActivitySource.Source.HasListeners()) { @@ -140,6 +136,38 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( } #endif + // Set test start time before initialization so the HTML report duration + // includes data source initialization time + executableTest.Context.TestStart = DateTimeOffset.UtcNow; + + // Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks + // This ensures resources like Docker containers are not started until needed +#if NET + Activity? initActivity = null; + if (TUnitActivitySource.Source.HasListeners()) + { + initActivity = TUnitActivitySource.StartActivity( + "data source initialization", + ActivityKind.Internal, + executableTest.Context.Activity?.Context ?? default); + } + try + { + await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + TUnitActivitySource.RecordException(initActivity, ex); + throw; + } + finally + { + TUnitActivitySource.StopActivity(initActivity); + } +#else + await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false); +#endif + executableTest.Context.RestoreExecutionContext(); // Early stage test start receivers run before instance-level hooks @@ -320,9 +348,6 @@ private static async ValueTask ExecuteTestAsync(AbstractExecutableTest executabl return; } - // Set the test start time when we actually begin executing the test - executableTest.Context.TestStart = DateTimeOffset.UtcNow; - // Set the cancellation token on the context so source-generated tests can access it executableTest.Context.CancellationToken = cancellationToken; diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs index ad6c1f161b..aa98cef708 100644 --- a/TUnit.Engine/TestInitializer.cs +++ b/TUnit.Engine/TestInitializer.cs @@ -1,8 +1,5 @@ using TUnit.Core; using TUnit.Engine.Services; -#if NET -using System.Diagnostics; -#endif namespace TUnit.Engine; @@ -35,27 +32,10 @@ public void PrepareTest(AbstractExecutableTest test, CancellationToken cancellat public async ValueTask InitializeTestObjectsAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { - // Data source initialization runs before the test case span starts, so any spans it - // creates (container startup, auth calls, connection pools, etc.) do not appear nested - // inside the individual test's trace timeline. We briefly set Activity.Current to the - // session span so those spans are parented there instead. -#if NET - var sessionActivity = test.Context.ClassContext.AssemblyContext.TestSessionContext.Activity; - var previousActivity = Activity.Current; - if (sessionActivity is not null) - { - Activity.Current = sessionActivity; - } - try - { - await _objectLifecycleService.InitializeTestObjectsAsync(test.Context, cancellationToken); - } - finally - { - Activity.Current = previousActivity; - } -#else + // Data source initialization now runs inside the test case span, so any spans it + // creates (container startup, auth calls, connection pools, etc.) will appear nested + // inside the test's trace timeline via the "data source initialization" child activity + // created by TestExecutor. await _objectLifecycleService.InitializeTestObjectsAsync(test.Context, cancellationToken); -#endif } } From d8ee5eb5c4db391a73d7db466d804ffa5e4cf363 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:10:37 +0000 Subject: [PATCH 02/28] Address code review: rename activity, add explanatory comments - Rename "data source initialization" to "test object initialization" for accuracy - Add comment explaining TestStart semantic change (now includes init time) - Add comment explaining why disposal activity is parented under class activity Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/7e7dc4cb-23dd-4723-ba82-6355cfc6d354 Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --- TUnit.Engine/Services/TestExecution/TestCoordinator.cs | 3 +++ TUnit.Engine/TestExecutor.cs | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index e006e6ef11..0fffe17156 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -308,6 +308,9 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C Activity? disposalActivity = null; if (TUnitActivitySource.Source.HasListeners()) { + // Parented under the class activity because the test case activity has already + // been stopped by this point (disposal runs after TestExecutor.ExecuteAsync completes). + // The initialization activity is a child of the test case span since it runs within it. disposalActivity = TUnitActivitySource.StartActivity( "test instance disposal", ActivityKind.Internal, diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs index 34582085b9..e3d0cfdcae 100644 --- a/TUnit.Engine/TestExecutor.cs +++ b/TUnit.Engine/TestExecutor.cs @@ -137,7 +137,9 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( #endif // Set test start time before initialization so the HTML report duration - // includes data source initialization time + // includes data source initialization time. This means "test start" reflects + // the beginning of the full test lifecycle (init + hooks + body), not just + // the test body execution. executableTest.Context.TestStart = DateTimeOffset.UtcNow; // Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks @@ -147,7 +149,7 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( if (TUnitActivitySource.Source.HasListeners()) { initActivity = TUnitActivitySource.StartActivity( - "data source initialization", + "test object initialization", ActivityKind.Internal, executableTest.Context.Activity?.Context ?? default); } From 7657adf6631c29b29ec16457484b0cf3502f3a71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:09:14 +0000 Subject: [PATCH 03/28] Address PR review: extract disposal helper, add error recording, fix stale comment - Extract disposal span logic into DisposeTestInstanceWithSpanAsync() helper to reduce #if NET nesting in the finally block (review issue #1) - Add TUnitActivitySource.RecordException() calls on disposal span when OnDispose or DisposeTestInstance throws (review issue #2) - Fix stale comment in TestInitializer.cs referencing "data source initialization" instead of "test object initialization" (review issue #4) Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/3802acb9-a7fe-4976-8c3f-5bb246c098d2 Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --- .../Services/TestExecution/TestCoordinator.cs | 94 +++++++++++-------- TUnit.Engine/TestInitializer.cs | 2 +- 2 files changed, 55 insertions(+), 41 deletions(-) diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 0fffe17156..a953a24b01 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -304,55 +304,69 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C } finally { + await DisposeTestInstanceWithSpanAsync(test).ConfigureAwait(false); + } + } + + /// + /// Disposes the test instance and fires OnDispose callbacks, wrapped in an OpenTelemetry + /// activity span for trace timeline visibility. + /// Parented under the class activity because the test case activity has already been stopped + /// by this point (disposal runs after TestExecutor.ExecuteAsync completes). + /// + private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest test) + { #if NET - Activity? disposalActivity = null; - if (TUnitActivitySource.Source.HasListeners()) - { - // Parented under the class activity because the test case activity has already - // been stopped by this point (disposal runs after TestExecutor.ExecuteAsync completes). - // The initialization activity is a child of the test case span since it runs within it. - disposalActivity = TUnitActivitySource.StartActivity( - "test instance disposal", - ActivityKind.Internal, - test.Context.ClassContext.Activity?.Context ?? default, - [new("tunit.test.id", test.Context.Id)]); - } - try - { + Activity? disposalActivity = null; + if (TUnitActivitySource.Source.HasListeners()) + { + disposalActivity = TUnitActivitySource.StartActivity( + "test instance disposal", + ActivityKind.Internal, + test.Context.ClassContext.Activity?.Context ?? default, + [new("tunit.test.id", test.Context.Id)]); + } + try + { #endif - // Dispose test instance and fire OnDispose after each attempt - // This ensures each retry gets a fresh instance - var onDispose = test.Context.InternalEvents.OnDispose; - if (onDispose?.InvocationList != null) + // Dispose test instance and fire OnDispose after each attempt + // This ensures each retry gets a fresh instance + var onDispose = test.Context.InternalEvents.OnDispose; + if (onDispose?.InvocationList != null) + { + foreach (var invocation in onDispose.InvocationList) { - foreach (var invocation in onDispose.InvocationList) + try { - try - { - await invocation.InvokeAsync(test.Context, test.Context).ConfigureAwait(false); - } - catch (Exception disposeEx) - { - await _logger.LogErrorAsync($"Error during OnDispose for {test.TestId}: {disposeEx}").ConfigureAwait(false); - } + await invocation.InvokeAsync(test.Context, test.Context).ConfigureAwait(false); + } + catch (Exception disposeEx) + { +#if NET + TUnitActivitySource.RecordException(disposalActivity, disposeEx); +#endif + await _logger.LogErrorAsync($"Error during OnDispose for {test.TestId}: {disposeEx}").ConfigureAwait(false); } } + } - try - { - await TestExecutor.DisposeTestInstance(test).ConfigureAwait(false); - } - catch (Exception disposeEx) - { - await _logger.LogErrorAsync($"Error disposing test instance for {test.TestId}: {disposeEx}").ConfigureAwait(false); - } + try + { + await TestExecutor.DisposeTestInstance(test).ConfigureAwait(false); + } + catch (Exception disposeEx) + { #if NET - } - finally - { - TUnitActivitySource.StopActivity(disposalActivity); - } + TUnitActivitySource.RecordException(disposalActivity, disposeEx); #endif + await _logger.LogErrorAsync($"Error disposing test instance for {test.TestId}: {disposeEx}").ConfigureAwait(false); + } +#if NET } + finally + { + TUnitActivitySource.StopActivity(disposalActivity); + } +#endif } } diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs index aa98cef708..da45407880 100644 --- a/TUnit.Engine/TestInitializer.cs +++ b/TUnit.Engine/TestInitializer.cs @@ -34,7 +34,7 @@ public async ValueTask InitializeTestObjectsAsync(AbstractExecutableTest test, C { // Data source initialization now runs inside the test case span, so any spans it // creates (container startup, auth calls, connection pools, etc.) will appear nested - // inside the test's trace timeline via the "data source initialization" child activity + // inside the test's trace timeline via the "test object initialization" child activity // created by TestExecutor. await _objectLifecycleService.InitializeTestObjectsAsync(test.Context, cancellationToken); } From f4e70bb31f6899e2c0df5088367bf5a8c0623755 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:40:40 +0000 Subject: [PATCH 04/28] Move init/disposal spans outside test case, add type names, restore TestStart - Initialization span now parented under session activity (not test case) so slow infrastructure setup doesn't inflate the test's reported duration - Restore TestStart to just before test body execution - Span names include the class type: "initialize {TypeName}", "dispose {TypeName}" - Restore Activity.Current swapping in TestInitializer.cs for session-level parenting - Error recording preserved on both init and disposal spans Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/26cf2c58-e50d-44f7-b5d4-34f5bdc54f52 Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --- .../Services/TestExecution/TestCoordinator.cs | 3 +- TUnit.Engine/TestExecutor.cs | 43 ++++------------- TUnit.Engine/TestInitializer.cs | 48 +++++++++++++++++-- 3 files changed, 55 insertions(+), 39 deletions(-) diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index a953a24b01..07803ee940 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -320,8 +320,9 @@ private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest Activity? disposalActivity = null; if (TUnitActivitySource.Source.HasListeners()) { + var typeName = test.Context.Metadata.TestDetails.ClassType.Name; disposalActivity = TUnitActivitySource.StartActivity( - "test instance disposal", + $"dispose {typeName}", ActivityKind.Internal, test.Context.ClassContext.Activity?.Context ?? default, [new("tunit.test.id", test.Context.Id)]); diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs index e3d0cfdcae..9e62882d1a 100644 --- a/TUnit.Engine/TestExecutor.cs +++ b/TUnit.Engine/TestExecutor.cs @@ -116,6 +116,12 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( executableTest.Context.ClassContext.RestoreExecutionContext(); + // Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks + // This ensures resources like Docker containers are not started until needed. + // The init span is parented under the session activity (not the test case span) + // so that slow infrastructure setup doesn't inflate the test's reported duration. + await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false); + #if NET if (TUnitActivitySource.Source.HasListeners()) { @@ -136,40 +142,6 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( } #endif - // Set test start time before initialization so the HTML report duration - // includes data source initialization time. This means "test start" reflects - // the beginning of the full test lifecycle (init + hooks + body), not just - // the test body execution. - executableTest.Context.TestStart = DateTimeOffset.UtcNow; - - // Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks - // This ensures resources like Docker containers are not started until needed -#if NET - Activity? initActivity = null; - if (TUnitActivitySource.Source.HasListeners()) - { - initActivity = TUnitActivitySource.StartActivity( - "test object initialization", - ActivityKind.Internal, - executableTest.Context.Activity?.Context ?? default); - } - try - { - await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - TUnitActivitySource.RecordException(initActivity, ex); - throw; - } - finally - { - TUnitActivitySource.StopActivity(initActivity); - } -#else - await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false); -#endif - executableTest.Context.RestoreExecutionContext(); // Early stage test start receivers run before instance-level hooks @@ -350,6 +322,9 @@ private static async ValueTask ExecuteTestAsync(AbstractExecutableTest executabl return; } + // Set the test start time when we actually begin executing the test + executableTest.Context.TestStart = DateTimeOffset.UtcNow; + // Set the cancellation token on the context so source-generated tests can access it executableTest.Context.CancellationToken = cancellationToken; diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs index da45407880..9bd60ab1fb 100644 --- a/TUnit.Engine/TestInitializer.cs +++ b/TUnit.Engine/TestInitializer.cs @@ -1,5 +1,8 @@ using TUnit.Core; using TUnit.Engine.Services; +#if NET +using System.Diagnostics; +#endif namespace TUnit.Engine; @@ -32,10 +35,47 @@ public void PrepareTest(AbstractExecutableTest test, CancellationToken cancellat public async ValueTask InitializeTestObjectsAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { - // Data source initialization now runs inside the test case span, so any spans it - // creates (container startup, auth calls, connection pools, etc.) will appear nested - // inside the test's trace timeline via the "test object initialization" child activity - // created by TestExecutor. + // Object initialization runs before the test case span starts, so any spans it + // creates (container startup, auth calls, connection pools, etc.) do not appear nested + // inside the individual test's trace timeline. We briefly set Activity.Current to the + // session span so those spans are parented there instead. +#if NET + var sessionActivity = test.Context.ClassContext.AssemblyContext.TestSessionContext.Activity; + var previousActivity = Activity.Current; + if (sessionActivity is not null) + { + Activity.Current = sessionActivity; + } + + var typeName = test.Context.Metadata.TestDetails.ClassType.Name; + Activity? initActivity = null; + if (TUnitActivitySource.Source.HasListeners()) + { + initActivity = TUnitActivitySource.StartActivity( + $"initialize {typeName}", + ActivityKind.Internal, + sessionActivity?.Context ?? default, + [ + new("tunit.test.id", test.Context.Id), + new("tunit.test.class", test.Context.Metadata.TestDetails.ClassType.FullName) + ]); + } + try + { + await _objectLifecycleService.InitializeTestObjectsAsync(test.Context, cancellationToken); + } + catch (Exception ex) + { + TUnitActivitySource.RecordException(initActivity, ex); + throw; + } + finally + { + TUnitActivitySource.StopActivity(initActivity); + Activity.Current = previousActivity; + } +#else await _objectLifecycleService.InitializeTestObjectsAsync(test.Context, cancellationToken); +#endif } } From f905adf2b8e0586dd377c6cc97aa31dd3726807d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 23:04:05 +0000 Subject: [PATCH 05/28] Address latest review: fix dead code, add missing tags, set Activity.Current, use FullName - Remove dead RecordException for DisposeTestInstance (swallows all exceptions) - Add tunit.test.class tag to dispose span for consistent trace filtering - Set Activity.Current = disposalActivity during disposal for child span parenting - Use FullName ?? Name for span names to handle nested/generic types - Restore Activity.Current after disposal completes Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/172f0096-6317-4169-8b89-b8bc122de149 Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --- .../Services/TestExecution/TestCoordinator.cs | 29 ++++++++++--------- TUnit.Engine/TestInitializer.cs | 2 +- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 07803ee940..1ff324632b 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -318,15 +318,25 @@ private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest { #if NET Activity? disposalActivity = null; + var previousActivity = Activity.Current; if (TUnitActivitySource.Source.HasListeners()) { - var typeName = test.Context.Metadata.TestDetails.ClassType.Name; + var typeName = test.Context.Metadata.TestDetails.ClassType.FullName ?? test.Context.Metadata.TestDetails.ClassType.Name; disposalActivity = TUnitActivitySource.StartActivity( $"dispose {typeName}", ActivityKind.Internal, test.Context.ClassContext.Activity?.Context ?? default, - [new("tunit.test.id", test.Context.Id)]); + [ + new("tunit.test.id", test.Context.Id), + new("tunit.test.class", test.Context.Metadata.TestDetails.ClassType.FullName) + ]); } + + if (disposalActivity is not null) + { + Activity.Current = disposalActivity; + } + try { #endif @@ -351,22 +361,15 @@ private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest } } - try - { - await TestExecutor.DisposeTestInstance(test).ConfigureAwait(false); - } - catch (Exception disposeEx) - { -#if NET - TUnitActivitySource.RecordException(disposalActivity, disposeEx); -#endif - await _logger.LogErrorAsync($"Error disposing test instance for {test.TestId}: {disposeEx}").ConfigureAwait(false); - } + // Note: DisposeTestInstance swallows all exceptions internally (bare catch {}), + // so no error recording is needed here — exceptions never propagate. + await TestExecutor.DisposeTestInstance(test).ConfigureAwait(false); #if NET } finally { TUnitActivitySource.StopActivity(disposalActivity); + Activity.Current = previousActivity; } #endif } diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs index 9bd60ab1fb..a10445b904 100644 --- a/TUnit.Engine/TestInitializer.cs +++ b/TUnit.Engine/TestInitializer.cs @@ -47,7 +47,7 @@ public async ValueTask InitializeTestObjectsAsync(AbstractExecutableTest test, C Activity.Current = sessionActivity; } - var typeName = test.Context.Metadata.TestDetails.ClassType.Name; + var typeName = test.Context.Metadata.TestDetails.ClassType.FullName ?? test.Context.Metadata.TestDetails.ClassType.Name; Activity? initActivity = null; if (TUnitActivitySource.Source.HasListeners()) { From fa7d05b59f131c68b43186f0b14a631316a180a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 06:57:20 +0000 Subject: [PATCH 06/28] Add initialization and disposal spans to HTML report execution timeline Include 'initialize {TypeName}' and 'dispose {TypeName}' spans in the global Execution Timeline alongside session, assembly, and suite spans. Extracted isGlobalTimelineSpan() helper for readability. Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/472d120e-6d02-435d-bc01-4fbeb030c0ff Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --- TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs index 887df5d4a2..30ca6ccb5a 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs @@ -1476,9 +1476,13 @@ function renderSuiteTrace(className) { return '
' + tlArrow + 'Class Timeline
' + renderSpanRows(filtered, 'suite-' + className) + '
'; } -// Global timeline: session + assembly + suite spans +// Global timeline: session + assembly + suite + initialize/dispose spans +function isGlobalTimelineSpan(s) { + var t = s.spanType; + return t === 'test session' || t === 'test assembly' || t === 'test suite' || (t && (t.startsWith('initialize ') || t.startsWith('dispose '))); +} function renderGlobalTimeline() { - const topSpans = spans.filter(s => s.spanType === 'test session' || s.spanType === 'test assembly' || s.spanType === 'test suite'); + const topSpans = spans.filter(isGlobalTimelineSpan); if (!topSpans.length) return ''; return '
' + tlArrow + 'Execution Timeline
' + renderSpanRows(topSpans, 'global') + '
'; } From d9662364f35cbab82ea02ecd8d7bc90c9aaca0d5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:00:12 +0100 Subject: [PATCH 07/28] Remove full name from class type in TestInitializer --- TUnit.Engine/TestInitializer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs index a10445b904..9bd60ab1fb 100644 --- a/TUnit.Engine/TestInitializer.cs +++ b/TUnit.Engine/TestInitializer.cs @@ -47,7 +47,7 @@ public async ValueTask InitializeTestObjectsAsync(AbstractExecutableTest test, C Activity.Current = sessionActivity; } - var typeName = test.Context.Metadata.TestDetails.ClassType.FullName ?? test.Context.Metadata.TestDetails.ClassType.Name; + var typeName = test.Context.Metadata.TestDetails.ClassType.Name; Activity? initActivity = null; if (TUnitActivitySource.Source.HasListeners()) { From 6f8e89b313861683d2f807008e480b8fd402576f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:01:04 +0000 Subject: [PATCH 08/28] Restore FullName ?? Name in TestInitializer.cs to fix regression Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/ad55c3d7-39fe-46bd-b02e-b217cf7ec4c8 Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --- TUnit.Engine/TestInitializer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs index 9bd60ab1fb..a10445b904 100644 --- a/TUnit.Engine/TestInitializer.cs +++ b/TUnit.Engine/TestInitializer.cs @@ -47,7 +47,7 @@ public async ValueTask InitializeTestObjectsAsync(AbstractExecutableTest test, C Activity.Current = sessionActivity; } - var typeName = test.Context.Metadata.TestDetails.ClassType.Name; + var typeName = test.Context.Metadata.TestDetails.ClassType.FullName ?? test.Context.Metadata.TestDetails.ClassType.Name; Activity? initActivity = null; if (TUnitActivitySource.Source.HasListeners()) { From 3c3097b2d730eb30cb1997b09d385de9e6d3962b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:51:20 +0000 Subject: [PATCH 09/28] Implement scope-aware init/dispose tracing based on SharedType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TraceScopeRegistry to map data source objects to their SharedType - Register scope in ClassDataSources.Get based on SharedType - Move test case activity start before initialization so per-test init spans can be children of the test case - Create per-object init spans with scope-aware parent selection: - PerTestSession → session activity - PerAssembly → assembly activity - PerClass → class activity - None/Keyed/default → test case activity - Add tunit.trace.scope tag to init/dispose spans - Update HTML report isGlobalTimelineSpan() to show only shared init spans in global timeline (not per-test ones) - Simplify TestInitializer (tracing now in ObjectLifecycleService) Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/ccdbc811-2561-405a-87e0-7152014e4e69 Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --- .../Attributes/TestData/ClassDataSources.cs | 19 ++- TUnit.Core/TraceScopeRegistry.cs | 56 +++++++++ .../Reporters/Html/HtmlReportGenerator.cs | 10 +- .../Services/ObjectLifecycleService.cs | 114 +++++++++++++++++- .../Services/TestExecution/TestCoordinator.cs | 3 +- TUnit.Engine/TestExecutor.cs | 15 ++- TUnit.Engine/TestInitializer.cs | 48 +------- 7 files changed, 205 insertions(+), 60 deletions(-) create mode 100644 TUnit.Core/TraceScopeRegistry.cs diff --git a/TUnit.Core/Attributes/TestData/ClassDataSources.cs b/TUnit.Core/Attributes/TestData/ClassDataSources.cs index b90b8a3cd4..83d86e7b69 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSources.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSources.cs @@ -42,7 +42,7 @@ private string GetKey(int index, SharedType[] sharedTypes, string[] keys) public T Get<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T>(SharedType sharedType, Type testClassType, string key, DataGeneratorMetadata dataGeneratorMetadata) { - return sharedType switch + var instance = sharedType switch { SharedType.None => Create(), SharedType.PerTestSession => (T) TestDataContainer.GetGlobalInstance(typeof(T), _ => Create(typeof(T)))!, @@ -51,11 +51,17 @@ private string GetKey(int index, SharedType[] sharedTypes, string[] keys) SharedType.PerAssembly => (T) TestDataContainer.GetInstanceForAssembly(testClassType.Assembly, typeof(T), _ => Create(typeof(T)))!, _ => throw new ArgumentOutOfRangeException() }; + +#if NET + TraceScopeRegistry.Register(instance!, sharedType); +#endif + + return instance; } public object? Get(SharedType sharedType, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] Type type, Type testClassType, string? key, DataGeneratorMetadata dataGeneratorMetadata) { - return sharedType switch + var instance = sharedType switch { SharedType.None => Create(type), SharedType.PerTestSession => TestDataContainer.GetGlobalInstance(type, _ => Create(type)), @@ -64,6 +70,15 @@ private string GetKey(int index, SharedType[] sharedTypes, string[] keys) SharedType.PerAssembly => TestDataContainer.GetInstanceForAssembly(testClassType.Assembly, type, _ => Create(type)), _ => throw new ArgumentOutOfRangeException() }; + +#if NET + if (instance is not null) + { + TraceScopeRegistry.Register(instance, sharedType); + } +#endif + + return instance; } private static object CreateWithKey([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type type, string key) diff --git a/TUnit.Core/TraceScopeRegistry.cs b/TUnit.Core/TraceScopeRegistry.cs new file mode 100644 index 0000000000..5d9ff2b9c7 --- /dev/null +++ b/TUnit.Core/TraceScopeRegistry.cs @@ -0,0 +1,56 @@ +#if NET +using System.Collections.Concurrent; +using TUnit.Core.Helpers; + +namespace TUnit.Core; + +/// +/// Thread-safe registry that maps data source objects to their trace scope. +/// Used by the engine to parent initialization and disposal OpenTelemetry spans +/// under the correct activity (session, assembly, class, or test). +/// +/// +/// Objects are registered in based on their +/// . The engine reads the scope during initialization +/// to determine the parent activity for each object's trace span. +/// Uses reference equality to distinguish distinct instances that may compare equal. +/// +internal static class TraceScopeRegistry +{ + private static readonly ConcurrentDictionary Scopes = + new(Helpers.ReferenceEqualityComparer.Instance); + + /// + /// Records the for an object created by a class data source. + /// + internal static void Register(object obj, SharedType sharedType) + { + Scopes[obj] = sharedType; + } + + /// + /// Returns the for an object, or null if unregistered. + /// Unregistered objects (e.g., the test class instance) default to per-test scope. + /// + internal static SharedType? GetSharedType(object obj) + { + return Scopes.TryGetValue(obj, out var scope) ? scope : null; + } + + /// + /// Removes an object from the registry. Called during cleanup. + /// + internal static void Unregister(object obj) + { + Scopes.TryRemove(obj, out _); + } + + /// + /// Clears all entries. Called at end of test session. + /// + internal static void Clear() + { + Scopes.Clear(); + } +} +#endif diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs index 30ca6ccb5a..6527439c14 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs @@ -1476,10 +1476,16 @@ function renderSuiteTrace(className) { return '
' + tlArrow + 'Class Timeline
' + renderSpanRows(filtered, 'suite-' + className) + '
'; } -// Global timeline: session + assembly + suite + initialize/dispose spans +// Global timeline: session + assembly + suite + shared init/dispose spans (not per-test) function isGlobalTimelineSpan(s) { var t = s.spanType; - return t === 'test session' || t === 'test assembly' || t === 'test suite' || (t && (t.startsWith('initialize ') || t.startsWith('dispose '))); + if (t === 'test session' || t === 'test assembly' || t === 'test suite') return true; + // Include init/dispose spans that are NOT per-test scope (shared prerequisites) + if (t && (t.startsWith('initialize ') || t.startsWith('dispose '))) { + var scopeTag = (s.tags||[]).find(function(tag){ return tag.key === 'tunit.trace.scope'; }); + return scopeTag && scopeTag.value !== 'test'; + } + return false; } function renderGlobalTimeline() { const topSpans = spans.filter(isGlobalTimelineSpan); diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index a582ffa897..51ba58bab9 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -5,6 +5,9 @@ using TUnit.Core.Interfaces; using TUnit.Core.PropertyInjection; using TUnit.Core.Tracking; +#if NET +using System.Diagnostics; +#endif namespace TUnit.Engine.Services; @@ -220,6 +223,8 @@ private void SetCachedPropertiesOnInstance(object instance, TestContext testCont /// Initializes all tracked objects depth-first (deepest objects first). /// This is called during test execution (after BeforeClass hooks) to initialize IAsyncInitializer objects. /// Objects at the same level are initialized in parallel. + /// Each object gets its own OpenTelemetry span parented under the appropriate scope activity + /// based on its (registered in ). /// private async Task InitializeTrackedObjectsAsync(TestContext testContext, CancellationToken cancellationToken) { @@ -235,7 +240,7 @@ private async Task InitializeTrackedObjectsAsync(TestContext testContext, Cancel var tasks = new List(objectsAtLevel.Count); foreach (var obj in objectsAtLevel) { - tasks.Add(InitializeObjectWithNestedAsync(obj, cancellationToken)); + tasks.Add(InitializeObjectWithSpanAsync(obj, testContext, cancellationToken)); } if (tasks.Count > 0) @@ -244,24 +249,123 @@ private async Task InitializeTrackedObjectsAsync(TestContext testContext, Cancel } } - // Finally initialize the test class and its nested objects + // Finally initialize the test class and its nested objects. + // The test class instance is always per-test scope. var classInstance = testContext.Metadata.TestDetails.ClassInstance; await InitializeNestedObjectsForExecutionAsync(classInstance, cancellationToken); + +#if NET + if (classInstance is IAsyncInitializer) + { + await InitializeWithSpanAsync(classInstance, testContext, SharedType.None, cancellationToken); + } + else + { + await ObjectInitializer.InitializeAsync(classInstance, cancellationToken); + } +#else await ObjectInitializer.InitializeAsync(classInstance, cancellationToken); +#endif } /// - /// Initializes an object and its nested objects. + /// Initializes an object and its nested objects, wrapped in a scope-aware OpenTelemetry span. /// - private async Task InitializeObjectWithNestedAsync(object obj, CancellationToken cancellationToken) + private async Task InitializeObjectWithSpanAsync(object obj, TestContext testContext, CancellationToken cancellationToken) { // First initialize nested objects depth-first await InitializeNestedObjectsForExecutionAsync(obj, cancellationToken); - // Then initialize the object itself +#if NET + if (obj is IAsyncInitializer) + { + var sharedType = TraceScopeRegistry.GetSharedType(obj); + await InitializeWithSpanAsync(obj, testContext, sharedType, cancellationToken); + } + else + { + await ObjectInitializer.InitializeAsync(obj, cancellationToken); + } +#else await ObjectInitializer.InitializeAsync(obj, cancellationToken); +#endif } +#if NET + /// + /// Initializes an object within an OpenTelemetry span parented under the appropriate scope activity. + /// + private static async Task InitializeWithSpanAsync( + object obj, + TestContext testContext, + SharedType? sharedType, + CancellationToken cancellationToken) + { + Activity? initActivity = null; + var previousActivity = Activity.Current; + + if (TUnitActivitySource.Source.HasListeners()) + { + var parentContext = GetParentActivityContext(testContext, sharedType); + var typeName = obj.GetType().FullName ?? obj.GetType().Name; + var scopeTag = sharedType switch + { + SharedType.PerTestSession => "session", + SharedType.PerAssembly => "assembly", + SharedType.PerClass => "class", + SharedType.Keyed => "keyed", + _ => "test" + }; + + initActivity = TUnitActivitySource.StartActivity( + $"initialize {typeName}", + ActivityKind.Internal, + parentContext, + [ + new("tunit.test.id", testContext.Id), + new("tunit.test.class", testContext.Metadata.TestDetails.ClassType.FullName), + new("tunit.trace.scope", scopeTag) + ]); + + if (initActivity is not null) + { + Activity.Current = initActivity; + } + } + + try + { + await ObjectInitializer.InitializeAsync(obj, cancellationToken); + } + catch (Exception ex) + { + TUnitActivitySource.RecordException(initActivity, ex); + throw; + } + finally + { + TUnitActivitySource.StopActivity(initActivity); + Activity.Current = previousActivity; + } + } + + /// + /// Returns the parent activity context for an initialization span based on the object's shared type. + /// + private static ActivityContext GetParentActivityContext(TestContext testContext, SharedType? sharedType) + { + return sharedType switch + { + SharedType.PerTestSession => testContext.ClassContext.AssemblyContext.TestSessionContext.Activity?.Context ?? default, + SharedType.PerAssembly => testContext.ClassContext.AssemblyContext.Activity?.Context ?? default, + SharedType.PerClass => testContext.ClassContext.Activity?.Context ?? default, + SharedType.Keyed => testContext.ClassContext.AssemblyContext.TestSessionContext.Activity?.Context ?? default, + // Per-test objects and the test class instance go under the test case activity + _ => testContext.Activity?.Context ?? testContext.ClassContext.Activity?.Context ?? default + }; + } +#endif + /// /// Initializes nested objects during execution phase - all IAsyncInitializer objects. /// diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 1ff324632b..96a3d517b1 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -328,7 +328,8 @@ private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest test.Context.ClassContext.Activity?.Context ?? default, [ new("tunit.test.id", test.Context.Id), - new("tunit.test.class", test.Context.Metadata.TestDetails.ClassType.FullName) + new("tunit.test.class", test.Context.Metadata.TestDetails.ClassType.FullName), + new("tunit.trace.scope", "test") ]); } diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs index 9e62882d1a..4fd4d2166c 100644 --- a/TUnit.Engine/TestExecutor.cs +++ b/TUnit.Engine/TestExecutor.cs @@ -116,13 +116,11 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( executableTest.Context.ClassContext.RestoreExecutionContext(); - // Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks - // This ensures resources like Docker containers are not started until needed. - // The init span is parented under the session activity (not the test case span) - // so that slow infrastructure setup doesn't inflate the test's reported duration. - await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false); - #if NET + // Start the test case activity BEFORE initialization so that per-test + // object init spans can be parented under the test case. Shared objects + // (PerSession/PerAssembly/PerClass) are parented under their respective + // scope activities via explicit parentContext in ObjectLifecycleService. if (TUnitActivitySource.Source.HasListeners()) { var classActivity = executableTest.Context.ClassContext.Activity; @@ -142,6 +140,11 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( } #endif + // Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks + // and after the test case activity starts. Per-test objects are traced + // under the test case; shared objects under session/assembly/class. + await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false); + executableTest.Context.RestoreExecutionContext(); // Early stage test start receivers run before instance-level hooks diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs index a10445b904..7aa378a844 100644 --- a/TUnit.Engine/TestInitializer.cs +++ b/TUnit.Engine/TestInitializer.cs @@ -1,8 +1,5 @@ using TUnit.Core; using TUnit.Engine.Services; -#if NET -using System.Diagnostics; -#endif namespace TUnit.Engine; @@ -35,47 +32,10 @@ public void PrepareTest(AbstractExecutableTest test, CancellationToken cancellat public async ValueTask InitializeTestObjectsAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { - // Object initialization runs before the test case span starts, so any spans it - // creates (container startup, auth calls, connection pools, etc.) do not appear nested - // inside the individual test's trace timeline. We briefly set Activity.Current to the - // session span so those spans are parented there instead. -#if NET - var sessionActivity = test.Context.ClassContext.AssemblyContext.TestSessionContext.Activity; - var previousActivity = Activity.Current; - if (sessionActivity is not null) - { - Activity.Current = sessionActivity; - } - - var typeName = test.Context.Metadata.TestDetails.ClassType.FullName ?? test.Context.Metadata.TestDetails.ClassType.Name; - Activity? initActivity = null; - if (TUnitActivitySource.Source.HasListeners()) - { - initActivity = TUnitActivitySource.StartActivity( - $"initialize {typeName}", - ActivityKind.Internal, - sessionActivity?.Context ?? default, - [ - new("tunit.test.id", test.Context.Id), - new("tunit.test.class", test.Context.Metadata.TestDetails.ClassType.FullName) - ]); - } - try - { - await _objectLifecycleService.InitializeTestObjectsAsync(test.Context, cancellationToken); - } - catch (Exception ex) - { - TUnitActivitySource.RecordException(initActivity, ex); - throw; - } - finally - { - TUnitActivitySource.StopActivity(initActivity); - Activity.Current = previousActivity; - } -#else + // ObjectLifecycleService creates per-object initialization spans with scope-aware + // parent activity selection. Shared objects (PerSession/PerAssembly/PerClass) are + // parented under session/assembly/class activities; per-test objects and the test + // class itself are parented under the test case activity. await _objectLifecycleService.InitializeTestObjectsAsync(test.Context, cancellationToken); -#endif } } From a074b30b32ce2720ad903aa4751e0fc1c359b9f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 08:54:48 +0000 Subject: [PATCH 10/28] Address code review: extract GetScopeTag helper, fix null safety, document Keyed scope Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/ccdbc811-2561-405a-87e0-7152014e4e69 Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --- TUnit.Core/Attributes/TestData/ClassDataSources.cs | 5 ++++- TUnit.Core/TUnitActivitySource.cs | 14 ++++++++++++++ TUnit.Core/TraceScopeRegistry.cs | 1 + TUnit.Engine/Services/ObjectLifecycleService.cs | 12 +++--------- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/TUnit.Core/Attributes/TestData/ClassDataSources.cs b/TUnit.Core/Attributes/TestData/ClassDataSources.cs index 83d86e7b69..c8a9cdb74b 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSources.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSources.cs @@ -53,7 +53,10 @@ private string GetKey(int index, SharedType[] sharedTypes, string[] keys) }; #if NET - TraceScopeRegistry.Register(instance!, sharedType); + if (instance is not null) + { + TraceScopeRegistry.Register(instance, sharedType); + } #endif return instance; diff --git a/TUnit.Core/TUnitActivitySource.cs b/TUnit.Core/TUnitActivitySource.cs index 2505f814d3..8e809dc9ca 100644 --- a/TUnit.Core/TUnitActivitySource.cs +++ b/TUnit.Core/TUnitActivitySource.cs @@ -51,6 +51,20 @@ internal static void StopActivity(Activity? activity) activity.Stop(); activity.Dispose(); } + + /// + /// Maps a to its trace scope tag value. + /// Keyed objects use session-level scope because they can be shared across + /// classes and assemblies via matching keys, so session is the broadest safe parent. + /// + internal static string GetScopeTag(SharedType? sharedType) => sharedType switch + { + SharedType.PerTestSession => "session", + SharedType.PerAssembly => "assembly", + SharedType.PerClass => "class", + SharedType.Keyed => "keyed", + _ => "test" + }; } #endif diff --git a/TUnit.Core/TraceScopeRegistry.cs b/TUnit.Core/TraceScopeRegistry.cs index 5d9ff2b9c7..6336729dd2 100644 --- a/TUnit.Core/TraceScopeRegistry.cs +++ b/TUnit.Core/TraceScopeRegistry.cs @@ -17,6 +17,7 @@ namespace TUnit.Core; /// internal static class TraceScopeRegistry { + // Fully qualify to disambiguate from System.Collections.Generic.ReferenceEqualityComparer private static readonly ConcurrentDictionary Scopes = new(Helpers.ReferenceEqualityComparer.Instance); diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index 51ba58bab9..962e0e9c37 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -308,14 +308,6 @@ private static async Task InitializeWithSpanAsync( { var parentContext = GetParentActivityContext(testContext, sharedType); var typeName = obj.GetType().FullName ?? obj.GetType().Name; - var scopeTag = sharedType switch - { - SharedType.PerTestSession => "session", - SharedType.PerAssembly => "assembly", - SharedType.PerClass => "class", - SharedType.Keyed => "keyed", - _ => "test" - }; initActivity = TUnitActivitySource.StartActivity( $"initialize {typeName}", @@ -324,7 +316,7 @@ private static async Task InitializeWithSpanAsync( [ new("tunit.test.id", testContext.Id), new("tunit.test.class", testContext.Metadata.TestDetails.ClassType.FullName), - new("tunit.trace.scope", scopeTag) + new("tunit.trace.scope", TUnitActivitySource.GetScopeTag(sharedType)) ]); if (initActivity is not null) @@ -351,6 +343,8 @@ private static async Task InitializeWithSpanAsync( /// /// Returns the parent activity context for an initialization span based on the object's shared type. + /// Keyed objects use session-level scope because they can be shared across classes and assemblies + /// via matching keys, making session the broadest safe parent. /// private static ActivityContext GetParentActivityContext(TestContext testContext, SharedType? sharedType) { From c8167bde6360cc3d814310eda76b319cd62015d4 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:32:11 +0100 Subject: [PATCH 11/28] Fix TraceScopeRegistry memory leak and redundant registration - Call TraceScopeRegistry.Clear() in TUnitServiceProvider.DisposeAsync() so per-test entries are released at end of session - Use TryAdd instead of indexer in Register() to skip redundant writes for shared objects that are re-registered on every test - Remove unused Unregister() method (cleanup handled by Clear()) --- TUnit.Core/TraceScopeRegistry.cs | 10 +--------- TUnit.Engine/Framework/TUnitServiceProvider.cs | 4 ++++ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/TUnit.Core/TraceScopeRegistry.cs b/TUnit.Core/TraceScopeRegistry.cs index 6336729dd2..94f1d38d09 100644 --- a/TUnit.Core/TraceScopeRegistry.cs +++ b/TUnit.Core/TraceScopeRegistry.cs @@ -26,7 +26,7 @@ internal static class TraceScopeRegistry /// internal static void Register(object obj, SharedType sharedType) { - Scopes[obj] = sharedType; + Scopes.TryAdd(obj, sharedType); } /// @@ -38,14 +38,6 @@ internal static void Register(object obj, SharedType sharedType) return Scopes.TryGetValue(obj, out var scope) ? scope : null; } - /// - /// Removes an object from the registry. Called during cleanup. - /// - internal static void Unregister(object obj) - { - Scopes.TryRemove(obj, out _); - } - /// /// Clears all entries. Called at end of test session. /// diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index 92e6a5ac82..d4b99205f3 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -338,5 +338,9 @@ public async ValueTask DisposeAsync() await TUnitLoggerFactory.DisposeAllAsync().ConfigureAwait(false); TestExtensions.ClearCaches(); + +#if NET + TraceScopeRegistry.Clear(); +#endif } } From 1e5d49d381d6b18dbe5cbe43ed8ed92353da8f66 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:40:04 +0100 Subject: [PATCH 12/28] Move TraceScopeRegistry registration from ClassDataSources to engine layer - Add ITraceScopeProvider interface for data source attributes to declare their SharedType per generated object - ClassDataSourceAttribute (all variants) implement ITraceScopeProvider - Remove TraceScopeRegistry.Register calls from ClassDataSources.Get, keeping it as pure user-facing data generation code - Add TraceScopeRegistry.RegisterFromDataSource helper that checks if a data source implements ITraceScopeProvider and registers objects - Call RegisterFromDataSource in TestBuilder (class + method data) and PropertyInjector (property data) after objects are produced --- .../TestData/ClassDataSourceAttribute.cs | 4 +-- .../TestData/ClassDataSourceAttribute_2.cs | 2 +- .../TestData/ClassDataSourceAttribute_3.cs | 2 +- .../TestData/ClassDataSourceAttribute_4.cs | 2 +- .../TestData/ClassDataSourceAttribute_5.cs | 2 +- .../Attributes/TestData/ClassDataSources.cs | 14 -------- .../TestData/ITraceScopeProvider.cs | 14 ++++++++ TUnit.Core/TraceScopeRegistry.cs | 34 +++++++++++++++++-- TUnit.Engine/Building/TestBuilder.cs | 9 +++++ TUnit.Engine/Services/PropertyInjector.cs | 3 ++ 10 files changed, 63 insertions(+), 23 deletions(-) create mode 100644 TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs index 2d87437f55..38e22bfbb3 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs @@ -31,7 +31,7 @@ namespace TUnit.Core; /// /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)] -public sealed class ClassDataSourceAttribute : UntypedDataSourceGeneratorAttribute +public sealed class ClassDataSourceAttribute : UntypedDataSourceGeneratorAttribute, ITraceScopeProvider { private Type[] _types; @@ -180,7 +180,7 @@ public ClassDataSourceAttribute(params Type[] types) /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)] public sealed class ClassDataSourceAttribute<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T> - : DataSourceGeneratorAttribute + : DataSourceGeneratorAttribute, ITraceScopeProvider { public SharedType Shared { get; set; } = SharedType.None; public string Key { get; set; } = string.Empty; diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs index b6a06a98d9..020d613a37 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs @@ -7,7 +7,7 @@ namespace TUnit.Core; public sealed class ClassDataSourceAttribute< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T1, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T2> - : DataSourceGeneratorAttribute + : DataSourceGeneratorAttribute, ITraceScopeProvider where T1 : new() where T2 : new() { diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs index 010a7dbde6..1c46daab48 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs @@ -8,7 +8,7 @@ public sealed class ClassDataSourceAttribute< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T1, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T2, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T3> - : DataSourceGeneratorAttribute + : DataSourceGeneratorAttribute, ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs index ed4c73fa1c..fb7cb9dd94 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs @@ -9,7 +9,7 @@ public sealed class ClassDataSourceAttribute< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T2, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T3, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T4> - : DataSourceGeneratorAttribute + : DataSourceGeneratorAttribute, ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs index f65b362fd5..0e2c67075b 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs @@ -10,7 +10,7 @@ public sealed class ClassDataSourceAttribute< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T3, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T4, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T5> - : DataSourceGeneratorAttribute + : DataSourceGeneratorAttribute, ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() diff --git a/TUnit.Core/Attributes/TestData/ClassDataSources.cs b/TUnit.Core/Attributes/TestData/ClassDataSources.cs index c8a9cdb74b..8f8389d0da 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSources.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSources.cs @@ -52,13 +52,6 @@ private string GetKey(int index, SharedType[] sharedTypes, string[] keys) _ => throw new ArgumentOutOfRangeException() }; -#if NET - if (instance is not null) - { - TraceScopeRegistry.Register(instance, sharedType); - } -#endif - return instance; } @@ -74,13 +67,6 @@ private string GetKey(int index, SharedType[] sharedTypes, string[] keys) _ => throw new ArgumentOutOfRangeException() }; -#if NET - if (instance is not null) - { - TraceScopeRegistry.Register(instance, sharedType); - } -#endif - return instance; } diff --git a/TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs b/TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs new file mode 100644 index 0000000000..57392816de --- /dev/null +++ b/TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs @@ -0,0 +1,14 @@ +namespace TUnit.Core; + +/// +/// Implemented by data source attributes that provide trace scope information. +/// The engine uses this to parent OpenTelemetry initialization and disposal spans +/// under the correct activity (session, assembly, class, or test). +/// +public interface ITraceScopeProvider +{ + /// + /// Returns the for each generated object, in parameter order. + /// + IEnumerable GetSharedTypes(); +} diff --git a/TUnit.Core/TraceScopeRegistry.cs b/TUnit.Core/TraceScopeRegistry.cs index 94f1d38d09..9326589ff5 100644 --- a/TUnit.Core/TraceScopeRegistry.cs +++ b/TUnit.Core/TraceScopeRegistry.cs @@ -10,9 +10,9 @@ namespace TUnit.Core; /// under the correct activity (session, assembly, class, or test). /// /// -/// Objects are registered in based on their -/// . The engine reads the scope during initialization -/// to determine the parent activity for each object's trace span. +/// Objects are registered by the engine (TestBuilder, PropertyInjector) when data source +/// attributes implement . The engine reads the scope +/// during initialization to determine the parent activity for each object's trace span. /// Uses reference equality to distinguish distinct instances that may compare equal. /// internal static class TraceScopeRegistry @@ -29,6 +29,34 @@ internal static void Register(object obj, SharedType sharedType) Scopes.TryAdd(obj, sharedType); } + /// + /// Registers trace scopes for data objects produced by a data source attribute. + /// If the attribute implements , each object is + /// paired with the corresponding from the provider. + /// + internal static void RegisterFromDataSource(IDataSourceAttribute dataSource, object?[]? objects) + { + if (objects is null || objects.Length == 0) + { + return; + } + + if (dataSource is not ITraceScopeProvider traceScopeProvider) + { + return; + } + + using var enumerator = traceScopeProvider.GetSharedTypes().GetEnumerator(); + for (var i = 0; i < objects.Length; i++) + { + var sharedType = enumerator.MoveNext() ? enumerator.Current : SharedType.None; + if (objects[i] is not null) + { + Scopes.TryAdd(objects[i]!, sharedType); + } + } + } + /// /// Returns the for an object, or null if unregistered. /// Unregistered objects (e.g., the test class instance) default to per-test scope. diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index f6f0a859fb..02dce9e428 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -210,6 +210,10 @@ public async Task> BuildTestsFromMetadataAsy var classDataResult = await classDataFactory() ?? []; var classData = DataUnwrapper.Unwrap(classDataResult); +#if NET + TraceScopeRegistry.RegisterFromDataSource(classDataSource, classData); +#endif + // Initialize objects before method data sources are evaluated. // ObjectInitializer is phase-aware and will only initialize IAsyncDiscoveryInitializer during Discovery. await InitializeClassDataAsync(classData); @@ -293,6 +297,11 @@ await _objectLifecycleService.RegisterObjectAsync( classData = classDataUnwrapped; var (methodData, methodRowMetadata) = DataUnwrapper.UnwrapWithTypesAndMetadata(await methodDataFactory() ?? [], metadata.MethodMetadata.Parameters); +#if NET + TraceScopeRegistry.RegisterFromDataSource(classDataSource, classData); + TraceScopeRegistry.RegisterFromDataSource(methodDataSource, methodData); +#endif + // Extract and merge metadata from data source attributes and TestDataRow wrappers var classAttrMetadata = DataSourceMetadataExtractor.ExtractFromAttribute(classDataSource); var methodAttrMetadata = DataSourceMetadataExtractor.ExtractFromAttribute(methodDataSource); diff --git a/TUnit.Engine/Services/PropertyInjector.cs b/TUnit.Engine/Services/PropertyInjector.cs index f2df872ea6..58a63a5450 100644 --- a/TUnit.Engine/Services/PropertyInjector.cs +++ b/TUnit.Engine/Services/PropertyInjector.cs @@ -537,6 +537,9 @@ private async Task ResolveAndCacheReflectionPropertyAsync( if (value != null) { +#if NET + TraceScopeRegistry.RegisterFromDataSource(dataSource, args); +#endif // EnsureInitializedAsync handles property injection and initialization. // ObjectInitializer is phase-aware: during Discovery phase, only IAsyncDiscoveryInitializer // objects are initialized; regular IAsyncInitializer objects are deferred to Execution phase. From fe8e35ffc4450ecff8b6cd30f113c4ba872a4978 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:43:35 +0100 Subject: [PATCH 13/28] Simplify: remove dead code, fix stringly-typed tag, cache type lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused TraceScopeRegistry.Register(object, SharedType) — only RegisterFromDataSource is called - Restore direct return in ClassDataSources.Get (unnecessary local var left from removing TraceScopeRegistry.Register) - Use GetScopeTag(SharedType.None) instead of hardcoded "test" string in DisposeTestInstanceWithSpanAsync for consistency with init spans - Cache obj.GetType() and ClassType to avoid repeated property access in span creation paths --- TUnit.Core/Attributes/TestData/ClassDataSources.cs | 8 ++------ TUnit.Core/TraceScopeRegistry.cs | 8 -------- TUnit.Engine/Services/ObjectLifecycleService.cs | 3 ++- TUnit.Engine/Services/TestExecution/TestCoordinator.cs | 7 ++++--- 4 files changed, 8 insertions(+), 18 deletions(-) diff --git a/TUnit.Core/Attributes/TestData/ClassDataSources.cs b/TUnit.Core/Attributes/TestData/ClassDataSources.cs index 8f8389d0da..b90b8a3cd4 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSources.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSources.cs @@ -42,7 +42,7 @@ private string GetKey(int index, SharedType[] sharedTypes, string[] keys) public T Get<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T>(SharedType sharedType, Type testClassType, string key, DataGeneratorMetadata dataGeneratorMetadata) { - var instance = sharedType switch + return sharedType switch { SharedType.None => Create(), SharedType.PerTestSession => (T) TestDataContainer.GetGlobalInstance(typeof(T), _ => Create(typeof(T)))!, @@ -51,13 +51,11 @@ private string GetKey(int index, SharedType[] sharedTypes, string[] keys) SharedType.PerAssembly => (T) TestDataContainer.GetInstanceForAssembly(testClassType.Assembly, typeof(T), _ => Create(typeof(T)))!, _ => throw new ArgumentOutOfRangeException() }; - - return instance; } public object? Get(SharedType sharedType, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] Type type, Type testClassType, string? key, DataGeneratorMetadata dataGeneratorMetadata) { - var instance = sharedType switch + return sharedType switch { SharedType.None => Create(type), SharedType.PerTestSession => TestDataContainer.GetGlobalInstance(type, _ => Create(type)), @@ -66,8 +64,6 @@ private string GetKey(int index, SharedType[] sharedTypes, string[] keys) SharedType.PerAssembly => TestDataContainer.GetInstanceForAssembly(testClassType.Assembly, type, _ => Create(type)), _ => throw new ArgumentOutOfRangeException() }; - - return instance; } private static object CreateWithKey([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type type, string key) diff --git a/TUnit.Core/TraceScopeRegistry.cs b/TUnit.Core/TraceScopeRegistry.cs index 9326589ff5..d82d42f5cd 100644 --- a/TUnit.Core/TraceScopeRegistry.cs +++ b/TUnit.Core/TraceScopeRegistry.cs @@ -21,14 +21,6 @@ internal static class TraceScopeRegistry private static readonly ConcurrentDictionary Scopes = new(Helpers.ReferenceEqualityComparer.Instance); - /// - /// Records the for an object created by a class data source. - /// - internal static void Register(object obj, SharedType sharedType) - { - Scopes.TryAdd(obj, sharedType); - } - /// /// Registers trace scopes for data objects produced by a data source attribute. /// If the attribute implements , each object is diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index 962e0e9c37..12f203f67e 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -307,7 +307,8 @@ private static async Task InitializeWithSpanAsync( if (TUnitActivitySource.Source.HasListeners()) { var parentContext = GetParentActivityContext(testContext, sharedType); - var typeName = obj.GetType().FullName ?? obj.GetType().Name; + var objType = obj.GetType(); + var typeName = objType.FullName ?? objType.Name; initActivity = TUnitActivitySource.StartActivity( $"initialize {typeName}", diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 96a3d517b1..3d317de88b 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -321,15 +321,16 @@ private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest var previousActivity = Activity.Current; if (TUnitActivitySource.Source.HasListeners()) { - var typeName = test.Context.Metadata.TestDetails.ClassType.FullName ?? test.Context.Metadata.TestDetails.ClassType.Name; + var classType = test.Context.Metadata.TestDetails.ClassType; + var typeName = classType.FullName ?? classType.Name; disposalActivity = TUnitActivitySource.StartActivity( $"dispose {typeName}", ActivityKind.Internal, test.Context.ClassContext.Activity?.Context ?? default, [ new("tunit.test.id", test.Context.Id), - new("tunit.test.class", test.Context.Metadata.TestDetails.ClassType.FullName), - new("tunit.trace.scope", "test") + new("tunit.test.class", classType.FullName), + new("tunit.trace.scope", TUnitActivitySource.GetScopeTag(SharedType.None)) ]); } From b291d2f582ff5b6817b393686b1e6480daf81a4e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:13:06 +0100 Subject: [PATCH 14/28] Address PR review: fix memory leak, add robustness, tests, and snapshots - Replace ConcurrentDictionary with ConditionalWeakTable in TraceScopeRegistry so per-test data source objects can be GC'd after tests complete - Remove TraceScopeRegistry.Clear() from TUnitServiceProvider (no longer needed) - Wrap DisposeTestInstance in try/catch with error recording on disposal span - Add Activity.Current thread-static limitation comments at restore sites - Add comments explaining dual RegisterFromDataSource calls in TestBuilder - Update all 4 public API snapshot files with ITraceScopeProvider interface - Add 8 unit tests for TraceScopeRegistry covering registration, lookup, null handling, duplicate semantics, and reference equality --- TUnit.Core/TraceScopeRegistry.cs | 28 ++- TUnit.Engine/Building/TestBuilder.cs | 5 + .../Framework/TUnitServiceProvider.cs | 4 - .../Services/ObjectLifecycleService.cs | 4 + .../Services/TestExecution/TestCoordinator.cs | 18 +- ...Has_No_API_Changes.DotNet10_0.verified.txt | 16 +- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 16 +- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 16 +- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 16 +- TUnit.UnitTests/TraceScopeRegistryTests.cs | 169 ++++++++++++++++++ 10 files changed, 244 insertions(+), 48 deletions(-) create mode 100644 TUnit.UnitTests/TraceScopeRegistryTests.cs diff --git a/TUnit.Core/TraceScopeRegistry.cs b/TUnit.Core/TraceScopeRegistry.cs index d82d42f5cd..b58fc980e6 100644 --- a/TUnit.Core/TraceScopeRegistry.cs +++ b/TUnit.Core/TraceScopeRegistry.cs @@ -1,6 +1,5 @@ #if NET -using System.Collections.Concurrent; -using TUnit.Core.Helpers; +using System.Runtime.CompilerServices; namespace TUnit.Core; @@ -13,18 +12,19 @@ namespace TUnit.Core; /// Objects are registered by the engine (TestBuilder, PropertyInjector) when data source /// attributes implement . The engine reads the scope /// during initialization to determine the parent activity for each object's trace span. -/// Uses reference equality to distinguish distinct instances that may compare equal. +/// Uses so that per-test data source objects +/// can be garbage-collected after their tests complete, rather than being held alive for the +/// entire session. ConditionalWeakTable inherently uses reference equality. /// internal static class TraceScopeRegistry { - // Fully qualify to disambiguate from System.Collections.Generic.ReferenceEqualityComparer - private static readonly ConcurrentDictionary Scopes = - new(Helpers.ReferenceEqualityComparer.Instance); + private static readonly ConditionalWeakTable> Scopes = new(); /// /// Registers trace scopes for data objects produced by a data source attribute. /// If the attribute implements , each object is /// paired with the corresponding from the provider. + /// First registration wins — subsequent calls for the same instance are no-ops. /// internal static void RegisterFromDataSource(IDataSourceAttribute dataSource, object?[]? objects) { @@ -42,9 +42,11 @@ internal static void RegisterFromDataSource(IDataSourceAttribute dataSource, obj for (var i = 0; i < objects.Length; i++) { var sharedType = enumerator.MoveNext() ? enumerator.Current : SharedType.None; - if (objects[i] is not null) + if (objects[i] is not null && !Scopes.TryGetValue(objects[i]!, out _)) { - Scopes.TryAdd(objects[i]!, sharedType); + // GetValue is atomic: returns existing entry or creates a new one. + // TryGetValue fast-path above avoids allocating the closure on re-registration. + Scopes.GetValue(objects[i]!, _ => new StrongBox(sharedType)); } } } @@ -55,15 +57,7 @@ internal static void RegisterFromDataSource(IDataSourceAttribute dataSource, obj /// internal static SharedType? GetSharedType(object obj) { - return Scopes.TryGetValue(obj, out var scope) ? scope : null; - } - - /// - /// Clears all entries. Called at end of test session. - /// - internal static void Clear() - { - Scopes.Clear(); + return Scopes.TryGetValue(obj, out var box) ? box.Value : null; } } #endif diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 02dce9e428..0154b3301b 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -211,6 +211,8 @@ public async Task> BuildTestsFromMetadataAsy var classData = DataUnwrapper.Unwrap(classDataResult); #if NET + // Register scope for class data objects from the simple path. + // Also registered in the repeat loop below for re-fetched instances. TraceScopeRegistry.RegisterFromDataSource(classDataSource, classData); #endif @@ -298,6 +300,9 @@ await _objectLifecycleService.RegisterObjectAsync( var (methodData, methodRowMetadata) = DataUnwrapper.UnwrapWithTypesAndMetadata(await methodDataFactory() ?? [], metadata.MethodMetadata.Parameters); #if NET + // Re-register: classDataFactory() was called again above and may return + // a different instance for non-shared objects. First-registration-wins + // semantics make this a no-op for the same instance. TraceScopeRegistry.RegisterFromDataSource(classDataSource, classData); TraceScopeRegistry.RegisterFromDataSource(methodDataSource, methodData); #endif diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index d4b99205f3..92e6a5ac82 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -338,9 +338,5 @@ public async ValueTask DisposeAsync() await TUnitLoggerFactory.DisposeAllAsync().ConfigureAwait(false); TestExtensions.ClearCaches(); - -#if NET - TraceScopeRegistry.Clear(); -#endif } } diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index 12f203f67e..d78731ec2b 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -337,6 +337,10 @@ private static async Task InitializeWithSpanAsync( } finally { + // Activity.Current is thread-static; this restore only affects the current thread. + // Async continuations that ran on other threads during initialization will have + // already captured Activity.Current at their point of execution — this is an + // inherent limitation of System.Diagnostics.Activity's threading model. TUnitActivitySource.StopActivity(initActivity); Activity.Current = previousActivity; } diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 3d317de88b..09b82765e7 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -363,13 +363,25 @@ private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest } } - // Note: DisposeTestInstance swallows all exceptions internally (bare catch {}), - // so no error recording is needed here — exceptions never propagate. - await TestExecutor.DisposeTestInstance(test).ConfigureAwait(false); + try + { + await TestExecutor.DisposeTestInstance(test).ConfigureAwait(false); + } + catch (Exception disposeEx) + { +#if NET + TUnitActivitySource.RecordException(disposalActivity, disposeEx); +#endif + await _logger.LogErrorAsync($"Error disposing test instance for {test.TestId}: {disposeEx}").ConfigureAwait(false); + } #if NET } finally { + // Activity.Current is thread-static; this restore only affects the current thread. + // Async continuations that ran on other threads during disposal will have already + // captured Activity.Current at their point of execution — this is an inherent + // limitation of System.Diagnostics.Activity's threading model. TUnitActivitySource.StopActivity(disposalActivity); Activity.Current = previousActivity; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index a750c49483..a85bb0e28d 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -244,7 +244,7 @@ namespace public required string TestSessionId { get; init; } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute, .ITraceScopeProvider { public ClassDataSourceAttribute() { } [.("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic" + @@ -274,7 +274,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T> : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T> : .DataSourceGeneratorAttribute, .ITraceScopeProvider { public ClassDataSourceAttribute() { } public ClassType { get; } @@ -285,7 +285,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2> : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2> : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() { @@ -297,7 +297,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3> : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3> : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() @@ -310,7 +310,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4> : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4> : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() @@ -324,7 +324,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T5> : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T5> : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() @@ -910,6 +910,10 @@ namespace .TestEntryFilterData GetFilterData(int index); .<.TestMetadata> Materialize(int index, string testSessionId); } + public interface ITraceScopeProvider + { + .<.SharedType> GetSharedTypes(); + } public interface ITypedDataSourceAttribute : .IDataSourceAttribute { .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 348b7a337e..78b2fd7b17 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -244,7 +244,7 @@ namespace public required string TestSessionId { get; init; } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute, .ITraceScopeProvider { public ClassDataSourceAttribute() { } [.("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic" + @@ -274,7 +274,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T> : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T> : .DataSourceGeneratorAttribute, .ITraceScopeProvider { public ClassDataSourceAttribute() { } public ClassType { get; } @@ -285,7 +285,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2> : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2> : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() { @@ -297,7 +297,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3> : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3> : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() @@ -310,7 +310,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4> : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4> : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() @@ -324,7 +324,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T5> : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T5> : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() @@ -910,6 +910,10 @@ namespace .TestEntryFilterData GetFilterData(int index); .<.TestMetadata> Materialize(int index, string testSessionId); } + public interface ITraceScopeProvider + { + .<.SharedType> GetSharedTypes(); + } public interface ITypedDataSourceAttribute : .IDataSourceAttribute { .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index f3a7961d03..349549a791 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -244,7 +244,7 @@ namespace public required string TestSessionId { get; init; } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute, .ITraceScopeProvider { public ClassDataSourceAttribute() { } [.("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic" + @@ -274,7 +274,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T> : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T> : .DataSourceGeneratorAttribute, .ITraceScopeProvider { public ClassDataSourceAttribute() { } public ClassType { get; } @@ -285,7 +285,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2> : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2> : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() { @@ -297,7 +297,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3> : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3> : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() @@ -310,7 +310,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4> : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4> : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() @@ -324,7 +324,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T5> : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T5> : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() @@ -910,6 +910,10 @@ namespace .TestEntryFilterData GetFilterData(int index); .<.TestMetadata> Materialize(int index, string testSessionId); } + public interface ITraceScopeProvider + { + .<.SharedType> GetSharedTypes(); + } public interface ITypedDataSourceAttribute : .IDataSourceAttribute { .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 685adf2d3a..fc1eda723e 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -240,7 +240,7 @@ namespace public required string TestSessionId { get; init; } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute, .ITraceScopeProvider { public ClassDataSourceAttribute() { } public ClassDataSourceAttribute( type) { } @@ -256,7 +256,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute, .ITraceScopeProvider { public ClassDataSourceAttribute() { } public ClassType { get; } @@ -267,7 +267,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() { @@ -279,7 +279,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() @@ -292,7 +292,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() @@ -306,7 +306,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute + public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute, .ITraceScopeProvider where T1 : new() where T2 : new() where T3 : new() @@ -886,6 +886,10 @@ namespace .TestEntryFilterData GetFilterData(int index); .<.TestMetadata> Materialize(int index, string testSessionId); } + public interface ITraceScopeProvider + { + .<.SharedType> GetSharedTypes(); + } public interface ITypedDataSourceAttribute : .IDataSourceAttribute { .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); diff --git a/TUnit.UnitTests/TraceScopeRegistryTests.cs b/TUnit.UnitTests/TraceScopeRegistryTests.cs new file mode 100644 index 0000000000..e56bf4920b --- /dev/null +++ b/TUnit.UnitTests/TraceScopeRegistryTests.cs @@ -0,0 +1,169 @@ +#if NET +using System.Runtime.CompilerServices; +using TUnit.Assertions.Extensions; +using TUnit.Core; + +namespace TUnit.UnitTests; + +public class TraceScopeRegistryTests +{ + [Test] + public async Task RegisterFromDataSource_WithTraceScopeProvider_RegistersSharedTypes() + { + var obj1 = new object(); + var obj2 = new object(); + var dataSource = new FakeTraceScopeDataSource( + [SharedType.PerAssembly, SharedType.PerClass]); + + TraceScopeRegistry.RegisterFromDataSource(dataSource, [obj1, obj2]); + + await Assert.That(TraceScopeRegistry.GetSharedType(obj1)).IsEqualTo(SharedType.PerAssembly); + await Assert.That(TraceScopeRegistry.GetSharedType(obj2)).IsEqualTo(SharedType.PerClass); + } + + [Test] + public async Task RegisterFromDataSource_WithNonTraceScopeProvider_DoesNotRegister() + { + var obj = new object(); + var dataSource = new FakeNonTraceScopeDataSource(); + + TraceScopeRegistry.RegisterFromDataSource(dataSource, [obj]); + + await Assert.That(TraceScopeRegistry.GetSharedType(obj)).IsNull(); + } + + [Test] + public async Task GetSharedType_UnregisteredObject_ReturnsNull() + { + var unregistered = new object(); + + await Assert.That(TraceScopeRegistry.GetSharedType(unregistered)).IsNull(); + } + + [Test] + public async Task RegisterFromDataSource_WithNullObjectsArray_DoesNotThrow() + { + var dataSource = new FakeTraceScopeDataSource([SharedType.None]); + + // Should not throw — just a no-op + TraceScopeRegistry.RegisterFromDataSource(dataSource, null); + + // Verify no side effects — a new object should still be unregistered + var probe = new object(); + await Assert.That(TraceScopeRegistry.GetSharedType(probe)).IsNull(); + } + + [Test] + public async Task RegisterFromDataSource_WithEmptyObjectsArray_DoesNotThrow() + { + var dataSource = new FakeTraceScopeDataSource([SharedType.None]); + + TraceScopeRegistry.RegisterFromDataSource(dataSource, []); + + // Verify no side effects + var probe = new object(); + await Assert.That(TraceScopeRegistry.GetSharedType(probe)).IsNull(); + } + + [Test] + public async Task RegisterFromDataSource_WithNullElementsInArray_SkipsNulls() + { + var realObj = new object(); + var dataSource = new FakeTraceScopeDataSource( + [SharedType.PerTestSession, SharedType.PerAssembly]); + + TraceScopeRegistry.RegisterFromDataSource(dataSource, [null, realObj]); + + // null element is skipped, realObj gets the second SharedType + await Assert.That(TraceScopeRegistry.GetSharedType(realObj)).IsEqualTo(SharedType.PerAssembly); + } + + [Test] + public async Task RegisterFromDataSource_DuplicateObject_KeepsFirstRegistration() + { + var obj = new object(); + var firstSource = new FakeTraceScopeDataSource([SharedType.PerTestSession]); + var secondSource = new FakeTraceScopeDataSource([SharedType.PerClass]); + + TraceScopeRegistry.RegisterFromDataSource(firstSource, [obj]); + TraceScopeRegistry.RegisterFromDataSource(secondSource, [obj]); + + // First registration wins + await Assert.That(TraceScopeRegistry.GetSharedType(obj)).IsEqualTo(SharedType.PerTestSession); + } + + [Test] + public async Task RegisterFromDataSource_FewerSharedTypesThanObjects_DefaultsToNone() + { + var obj1 = new object(); + var obj2 = new object(); + // Only one SharedType for two objects + var dataSource = new FakeTraceScopeDataSource([SharedType.PerAssembly]); + + TraceScopeRegistry.RegisterFromDataSource(dataSource, [obj1, obj2]); + + await Assert.That(TraceScopeRegistry.GetSharedType(obj1)).IsEqualTo(SharedType.PerAssembly); + await Assert.That(TraceScopeRegistry.GetSharedType(obj2)).IsEqualTo(SharedType.None); + } + + [Test] + public async Task RegisterFromDataSource_UsesReferenceEquality() + { + var obj1 = new EquatableObject(42); + var obj2 = new EquatableObject(42); // Same value but different reference + var dataSource = new FakeTraceScopeDataSource( + [SharedType.PerTestSession, SharedType.PerClass]); + + TraceScopeRegistry.RegisterFromDataSource(dataSource, [obj1, obj2]); + + // Should track separately despite Equals returning true + await Assert.That(TraceScopeRegistry.GetSharedType(obj1)).IsEqualTo(SharedType.PerTestSession); + await Assert.That(TraceScopeRegistry.GetSharedType(obj2)).IsEqualTo(SharedType.PerClass); + } + + /// + /// Fake data source that implements both IDataSourceAttribute and ITraceScopeProvider. + /// + private sealed class FakeTraceScopeDataSource : IDataSourceAttribute, ITraceScopeProvider + { + private readonly SharedType[] _sharedTypes; + + public FakeTraceScopeDataSource(SharedType[] sharedTypes) + { + _sharedTypes = sharedTypes; + } + + public bool SkipIfEmpty { get; set; } + + public IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) + => throw new NotSupportedException("Not needed for registry tests"); + + public IEnumerable GetSharedTypes() => _sharedTypes; + } + + /// + /// Fake data source that does NOT implement ITraceScopeProvider. + /// + private sealed class FakeNonTraceScopeDataSource : IDataSourceAttribute + { + public bool SkipIfEmpty { get; set; } + + public IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) + => throw new NotSupportedException("Not needed for registry tests"); + } + + /// + /// Object with value-based equality to test that the registry uses reference equality. + /// + private sealed class EquatableObject : IEquatable + { + private readonly int _value; + + public EquatableObject(int value) => _value = value; + + public bool Equals(EquatableObject? other) => other is not null && _value == other._value; + public override bool Equals(object? obj) => Equals(obj as EquatableObject); + public override int GetHashCode() => _value; + } +} +#endif From 165d800d3825faf711478f13c400fb7723939a72 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:27:29 +0100 Subject: [PATCH 15/28] Address code review: unify class init path, fix keyed scope tag - Remove duplicated #if NET class instance init in ObjectLifecycleService; call InitializeObjectWithSpanAsync instead (unregistered objects default to per-test scope via null SharedType) - Map SharedType.Keyed to "session" scope tag to match its parent activity selection, eliminating the inconsistency where "keyed" was a non-structural scope name --- TUnit.Core/TUnitActivitySource.cs | 6 +++--- .../Services/ObjectLifecycleService.cs | 18 +++--------------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/TUnit.Core/TUnitActivitySource.cs b/TUnit.Core/TUnitActivitySource.cs index 8e809dc9ca..3c31b386b9 100644 --- a/TUnit.Core/TUnitActivitySource.cs +++ b/TUnit.Core/TUnitActivitySource.cs @@ -54,15 +54,15 @@ internal static void StopActivity(Activity? activity) /// /// Maps a to its trace scope tag value. - /// Keyed objects use session-level scope because they can be shared across - /// classes and assemblies via matching keys, so session is the broadest safe parent. + /// Keyed objects map to "session" because they are parented under the session activity + /// (shared across classes and assemblies via matching keys). /// internal static string GetScopeTag(SharedType? sharedType) => sharedType switch { SharedType.PerTestSession => "session", SharedType.PerAssembly => "assembly", SharedType.PerClass => "class", - SharedType.Keyed => "keyed", + SharedType.Keyed => "session", _ => "test" }; } diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index d78731ec2b..7e8c9929aa 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -250,22 +250,10 @@ private async Task InitializeTrackedObjectsAsync(TestContext testContext, Cancel } // Finally initialize the test class and its nested objects. - // The test class instance is always per-test scope. + // The test class instance is unregistered in TraceScopeRegistry, so + // GetSharedType returns null → defaults to per-test scope automatically. var classInstance = testContext.Metadata.TestDetails.ClassInstance; - await InitializeNestedObjectsForExecutionAsync(classInstance, cancellationToken); - -#if NET - if (classInstance is IAsyncInitializer) - { - await InitializeWithSpanAsync(classInstance, testContext, SharedType.None, cancellationToken); - } - else - { - await ObjectInitializer.InitializeAsync(classInstance, cancellationToken); - } -#else - await ObjectInitializer.InitializeAsync(classInstance, cancellationToken); -#endif + await InitializeObjectWithSpanAsync(classInstance, testContext, cancellationToken); } /// From 3009a26172c5e5e08af11a15f771e74731b4f27e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:36:22 +0100 Subject: [PATCH 16/28] Improve docs: fix misleading TOCTOU comment, document ITraceScopeProvider contract --- .../Attributes/TestData/ITraceScopeProvider.cs | 12 +++++++++++- TUnit.Core/TraceScopeRegistry.cs | 5 +++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs b/TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs index 57392816de..75fce171b3 100644 --- a/TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs +++ b/TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs @@ -5,10 +5,20 @@ namespace TUnit.Core; /// The engine uses this to parent OpenTelemetry initialization and disposal spans /// under the correct activity (session, assembly, class, or test). /// +/// +/// This interface is intended as a public extension point. Third-party +/// implementations may implement it to +/// participate in scope-aware tracing. +/// public interface ITraceScopeProvider { /// - /// Returns the for each generated object, in parameter order. + /// Returns the for each generated object. + /// The sequence must be in the same order as the object array produced by + /// ; position i + /// in this sequence corresponds to objects[i] in the data row. + /// If the sequence is shorter than the object array, remaining objects + /// default to (per-test scope). /// IEnumerable GetSharedTypes(); } diff --git a/TUnit.Core/TraceScopeRegistry.cs b/TUnit.Core/TraceScopeRegistry.cs index b58fc980e6..3bfdb774bb 100644 --- a/TUnit.Core/TraceScopeRegistry.cs +++ b/TUnit.Core/TraceScopeRegistry.cs @@ -44,8 +44,9 @@ internal static void RegisterFromDataSource(IDataSourceAttribute dataSource, obj var sharedType = enumerator.MoveNext() ? enumerator.Current : SharedType.None; if (objects[i] is not null && !Scopes.TryGetValue(objects[i]!, out _)) { - // GetValue is atomic: returns existing entry or creates a new one. - // TryGetValue fast-path above avoids allocating the closure on re-registration. + // TryGetValue above is a fast-path to avoid allocating the closure below + // on re-registration. GetValue is the atomic operation that enforces + // first-registration-wins semantics. Scopes.GetValue(objects[i]!, _ => new StrongBox(sharedType)); } } From 8f33ecf0adbf31924bb19fa5e5f7ac78dd574348 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:41:11 +0100 Subject: [PATCH 17/28] Return defensive copies from GetSharedTypes() and GetKeys() --- TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs | 4 ++-- TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs | 4 ++-- TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs | 4 ++-- TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs | 4 ++-- TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs index 38e22bfbb3..9fb3daec7d 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs @@ -151,9 +151,9 @@ public ClassDataSourceAttribute(params Type[] types) }; } - public IEnumerable GetSharedTypes() => Shared; + public IEnumerable GetSharedTypes() => [..Shared]; - public IEnumerable GetKeys() => Keys; + public IEnumerable GetKeys() => [..Keys]; } diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs index 020d613a37..132a794ab4 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs @@ -28,7 +28,7 @@ public sealed class ClassDataSourceAttribute< }; } - public IEnumerable GetSharedTypes() => Shared; + public IEnumerable GetSharedTypes() => [..Shared]; - public IEnumerable GetKeys() => Keys; + public IEnumerable GetKeys() => [..Keys]; } diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs index 1c46daab48..c1038010c9 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs @@ -32,7 +32,7 @@ public sealed class ClassDataSourceAttribute< }; } - public IEnumerable GetSharedTypes() => Shared; + public IEnumerable GetSharedTypes() => [..Shared]; - public IEnumerable GetKeys() => Keys; + public IEnumerable GetKeys() => [..Keys]; } diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs index fb7cb9dd94..af215eef30 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs @@ -36,7 +36,7 @@ public sealed class ClassDataSourceAttribute< }; } - public IEnumerable GetSharedTypes() => Shared; + public IEnumerable GetSharedTypes() => [..Shared]; - public IEnumerable GetKeys() => Keys; + public IEnumerable GetKeys() => [..Keys]; } diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs index 0e2c67075b..546fd2d503 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs @@ -40,7 +40,7 @@ public sealed class ClassDataSourceAttribute< }; } - public IEnumerable GetSharedTypes() => Shared; + public IEnumerable GetSharedTypes() => [..Shared]; - public IEnumerable GetKeys() => Keys; + public IEnumerable GetKeys() => [..Keys]; } From 1cfa90f0916cde8c9af169a253dd16287e103fc3 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:48:46 +0100 Subject: [PATCH 18/28] Use simple type names in init/dispose span labels for readability --- TUnit.Engine/Services/ObjectLifecycleService.cs | 4 +--- TUnit.Engine/Services/TestExecution/TestCoordinator.cs | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index 7e8c9929aa..2ef691cd02 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -296,10 +296,8 @@ private static async Task InitializeWithSpanAsync( { var parentContext = GetParentActivityContext(testContext, sharedType); var objType = obj.GetType(); - var typeName = objType.FullName ?? objType.Name; - initActivity = TUnitActivitySource.StartActivity( - $"initialize {typeName}", + $"initialize {objType.Name}", ActivityKind.Internal, parentContext, [ diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 09b82765e7..39680d1c75 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -322,9 +322,8 @@ private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest if (TUnitActivitySource.Source.HasListeners()) { var classType = test.Context.Metadata.TestDetails.ClassType; - var typeName = classType.FullName ?? classType.Name; disposalActivity = TUnitActivitySource.StartActivity( - $"dispose {typeName}", + $"dispose {classType.Name}", ActivityKind.Internal, test.Context.ClassContext.Activity?.Context ?? default, [ From b98e7f2846bcaca64ef64477a00a2f09a6aec5bc Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:51:43 +0100 Subject: [PATCH 19/28] Deduplicate init spans: only the first caller for a shared object creates a trace span --- TUnit.Engine/Services/ObjectLifecycleService.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index 2ef691cd02..7b127a24c4 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -35,6 +35,14 @@ internal sealed class ObjectLifecycleService : IObjectRegistry, IInitializationC private readonly ConcurrentDictionary> _initializationTasks = new(Core.Helpers.ReferenceEqualityComparer.Instance); +#if NET + // Gates span creation so only the first caller for a given object creates a trace span. + // Subsequent callers (concurrent tests sharing the same object) skip span creation + // and just await ObjectInitializer's deduplicated Lazy. + private readonly ConcurrentDictionary _spannedObjects = + new(Core.Helpers.ReferenceEqualityComparer.Instance); +#endif + public ObjectLifecycleService( Lazy propertyInjector, ObjectGraphDiscoveryService objectGraphDiscoveryService, @@ -265,7 +273,11 @@ private async Task InitializeObjectWithSpanAsync(object obj, TestContext testCon await InitializeNestedObjectsForExecutionAsync(obj, cancellationToken); #if NET - if (obj is IAsyncInitializer) + // Only the first caller for a given object creates a trace span. + // TryAdd is atomic — exactly one concurrent caller wins the gate. + // Subsequent callers skip span creation and go straight to ObjectInitializer, + // which deduplicates via Lazy. + if (obj is IAsyncInitializer && _spannedObjects.TryAdd(obj, 0)) { var sharedType = TraceScopeRegistry.GetSharedType(obj); await InitializeWithSpanAsync(obj, testContext, sharedType, cancellationToken); From 700733f9a909152f1fac2efbec32cecbd0392e09 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:58:30 +0100 Subject: [PATCH 20/28] Simplify: extract RunWithSpanAsync helper, add tag constants, remove #if NET nesting --- TUnit.Core/TUnitActivitySource.cs | 47 +++++++++++++- .../Services/ObjectLifecycleService.cs | 52 ++++----------- .../Services/TestExecution/TestCoordinator.cs | 63 +++++-------------- 3 files changed, 73 insertions(+), 89 deletions(-) diff --git a/TUnit.Core/TUnitActivitySource.cs b/TUnit.Core/TUnitActivitySource.cs index 3c31b386b9..38e65402a0 100644 --- a/TUnit.Core/TUnitActivitySource.cs +++ b/TUnit.Core/TUnitActivitySource.cs @@ -11,17 +11,60 @@ internal static class TUnitActivitySource internal static readonly ActivitySource Source = new("TUnit", Version); + // Tag keys used across init/dispose spans and the HTML report. + internal const string TagTestId = "tunit.test.id"; + internal const string TagTestClass = "tunit.test.class"; + internal const string TagTraceScope = "tunit.trace.scope"; + internal static Activity? StartActivity( string name, ActivityKind kind = ActivityKind.Internal, ActivityContext parentContext = default, IEnumerable>? tags = null) { - // StartActivity returns null when no listener is sampling this source, - // so the HasListeners() check is implicit. We rely on the framework behavior. return Source.StartActivity(name, kind, parentContext, tags); } + /// + /// Runs inside an OpenTelemetry span, handling + /// Activity.Current save/restore, error recording, and span stop/dispose. + /// + internal static async Task RunWithSpanAsync( + string name, + ActivityContext parentContext, + IEnumerable> tags, + Func action) + { + Activity? activity = null; + var previousActivity = Activity.Current; + + if (Source.HasListeners()) + { + activity = Source.StartActivity(name, ActivityKind.Internal, parentContext, tags); + if (activity is not null) + { + Activity.Current = activity; + } + } + + try + { + await action(); + } + catch (Exception ex) + { + RecordException(activity, ex); + throw; + } + finally + { + // Activity.Current is thread-static; this restore only affects the current thread. + // Async continuations on other threads will have already captured their own value. + StopActivity(activity); + Activity.Current = previousActivity; + } + } + internal static void RecordException(Activity? activity, Exception exception) { if (activity is null) diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index 7b127a24c4..c5f1f1cf9f 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -301,47 +301,17 @@ private static async Task InitializeWithSpanAsync( SharedType? sharedType, CancellationToken cancellationToken) { - Activity? initActivity = null; - var previousActivity = Activity.Current; - - if (TUnitActivitySource.Source.HasListeners()) - { - var parentContext = GetParentActivityContext(testContext, sharedType); - var objType = obj.GetType(); - initActivity = TUnitActivitySource.StartActivity( - $"initialize {objType.Name}", - ActivityKind.Internal, - parentContext, - [ - new("tunit.test.id", testContext.Id), - new("tunit.test.class", testContext.Metadata.TestDetails.ClassType.FullName), - new("tunit.trace.scope", TUnitActivitySource.GetScopeTag(sharedType)) - ]); - - if (initActivity is not null) - { - Activity.Current = initActivity; - } - } - - try - { - await ObjectInitializer.InitializeAsync(obj, cancellationToken); - } - catch (Exception ex) - { - TUnitActivitySource.RecordException(initActivity, ex); - throw; - } - finally - { - // Activity.Current is thread-static; this restore only affects the current thread. - // Async continuations that ran on other threads during initialization will have - // already captured Activity.Current at their point of execution — this is an - // inherent limitation of System.Diagnostics.Activity's threading model. - TUnitActivitySource.StopActivity(initActivity); - Activity.Current = previousActivity; - } + var parentContext = GetParentActivityContext(testContext, sharedType); + + await TUnitActivitySource.RunWithSpanAsync( + $"initialize {obj.GetType().Name}", + parentContext, + [ + new(TUnitActivitySource.TagTestId, testContext.Id), + new(TUnitActivitySource.TagTestClass, testContext.Metadata.TestDetails.ClassType.FullName), + new(TUnitActivitySource.TagTraceScope, TUnitActivitySource.GetScopeTag(sharedType)) + ], + () => ObjectInitializer.InitializeAsync(obj, cancellationToken).AsTask()); } /// diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 39680d1c75..da57f86b5b 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -6,9 +6,6 @@ using TUnit.Engine.Helpers; using TUnit.Engine.Interfaces; using TUnit.Engine.Logging; -#if NET -using System.Diagnostics; -#endif namespace TUnit.Engine.Services.TestExecution; @@ -317,32 +314,24 @@ private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, C private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest test) { #if NET - Activity? disposalActivity = null; - var previousActivity = Activity.Current; - if (TUnitActivitySource.Source.HasListeners()) - { - var classType = test.Context.Metadata.TestDetails.ClassType; - disposalActivity = TUnitActivitySource.StartActivity( - $"dispose {classType.Name}", - ActivityKind.Internal, - test.Context.ClassContext.Activity?.Context ?? default, - [ - new("tunit.test.id", test.Context.Id), - new("tunit.test.class", classType.FullName), - new("tunit.trace.scope", TUnitActivitySource.GetScopeTag(SharedType.None)) - ]); - } - - if (disposalActivity is not null) - { - Activity.Current = disposalActivity; - } - - try - { + var classType = test.Context.Metadata.TestDetails.ClassType; + await TUnitActivitySource.RunWithSpanAsync( + $"dispose {classType.Name}", + 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); #endif - // Dispose test instance and fire OnDispose after each attempt - // This ensures each retry gets a fresh instance + } + + private async Task DisposeTestInstanceCoreAsync(AbstractExecutableTest test) + { + // Fire OnDispose callbacks — each retry gets a fresh instance var onDispose = test.Context.InternalEvents.OnDispose; if (onDispose?.InvocationList != null) { @@ -354,9 +343,6 @@ private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest } catch (Exception disposeEx) { -#if NET - TUnitActivitySource.RecordException(disposalActivity, disposeEx); -#endif await _logger.LogErrorAsync($"Error during OnDispose for {test.TestId}: {disposeEx}").ConfigureAwait(false); } } @@ -368,22 +354,7 @@ private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest } catch (Exception disposeEx) { -#if NET - TUnitActivitySource.RecordException(disposalActivity, disposeEx); -#endif await _logger.LogErrorAsync($"Error disposing test instance for {test.TestId}: {disposeEx}").ConfigureAwait(false); } -#if NET - } - finally - { - // Activity.Current is thread-static; this restore only affects the current thread. - // Async continuations that ran on other threads during disposal will have already - // captured Activity.Current at their point of execution — this is an inherent - // limitation of System.Diagnostics.Activity's threading model. - TUnitActivitySource.StopActivity(disposalActivity); - Activity.Current = previousActivity; - } -#endif } } From 45409669e3d30719e2351e4a7088478be2feb61b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:07:30 +0100 Subject: [PATCH 21/28] Parent session/keyed init spans under assembly, not session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session-scoped and keyed init spans were parented under the session activity, putting them at the same depth as assemblies. This made suite spans (at the next depth level) appear visually nested under init spans in both OTel backends and the HTML timeline. Now all shared init spans (session, assembly, keyed) are parented under the assembly activity — siblings of suites. The tunit.trace.scope tag still carries the precise lifetime semantics. This removes the need for the JS reparenting workaround in the global timeline. --- TUnit.Core/TUnitActivitySource.cs | 2 +- TUnit.Engine/Services/ObjectLifecycleService.cs | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/TUnit.Core/TUnitActivitySource.cs b/TUnit.Core/TUnitActivitySource.cs index 38e65402a0..4805e1d28a 100644 --- a/TUnit.Core/TUnitActivitySource.cs +++ b/TUnit.Core/TUnitActivitySource.cs @@ -97,7 +97,7 @@ internal static void StopActivity(Activity? activity) /// /// Maps a to its trace scope tag value. - /// Keyed objects map to "session" because they are parented under the session activity + /// Keyed objects map to "session" because their lifetime is session-scoped /// (shared across classes and assemblies via matching keys). /// internal static string GetScopeTag(SharedType? sharedType) => sharedType switch diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index c5f1f1cf9f..3ea71019be 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -315,18 +315,18 @@ await TUnitActivitySource.RunWithSpanAsync( } /// - /// Returns the parent activity context for an initialization span based on the object's shared type. - /// Keyed objects use session-level scope because they can be shared across classes and assemblies - /// via matching keys, making session the broadest safe parent. + /// Returns the parent activity context for an initialization span. + /// Session/keyed/assembly-scoped objects are parented under the assembly activity so they + /// appear as siblings of suite spans in both OTel backends and the HTML timeline. + /// The tunit.trace.scope tag carries the precise lifetime semantics. /// private static ActivityContext GetParentActivityContext(TestContext testContext, SharedType? sharedType) { return sharedType switch { - SharedType.PerTestSession => testContext.ClassContext.AssemblyContext.TestSessionContext.Activity?.Context ?? default, - SharedType.PerAssembly => testContext.ClassContext.AssemblyContext.Activity?.Context ?? default, + SharedType.PerTestSession or SharedType.PerAssembly or SharedType.Keyed + => testContext.ClassContext.AssemblyContext.Activity?.Context ?? default, SharedType.PerClass => testContext.ClassContext.Activity?.Context ?? default, - SharedType.Keyed => testContext.ClassContext.AssemblyContext.TestSessionContext.Activity?.Context ?? default, // Per-test objects and the test class instance go under the test case activity _ => testContext.Activity?.Context ?? testContext.ClassContext.Activity?.Context ?? default }; From 91d2fa8560fdb9e768382fdf810bb5dd3174ecd6 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:20:36 +0100 Subject: [PATCH 22/28] Make ITraceScopeProvider internal, revert defensive copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ITraceScopeProvider is an implementation detail of the tracing system — only ClassDataSourceAttribute variants implement it, all in the same assembly. Making it internal removes it from the public API surface. The defensive array copies ([..Shared]) are unnecessary since all callers are internal and iterate immediately. Revert to the original direct returns. --- .../TestData/ClassDataSourceAttribute.cs | 4 ++-- .../TestData/ClassDataSourceAttribute_2.cs | 4 ++-- .../TestData/ClassDataSourceAttribute_3.cs | 4 ++-- .../TestData/ClassDataSourceAttribute_4.cs | 4 ++-- .../TestData/ClassDataSourceAttribute_5.cs | 4 ++-- .../TestData/ITraceScopeProvider.cs | 19 ++++--------------- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 16 ++++++---------- 7 files changed, 20 insertions(+), 35 deletions(-) diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs index 9fb3daec7d..38e22bfbb3 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs @@ -151,9 +151,9 @@ public ClassDataSourceAttribute(params Type[] types) }; } - public IEnumerable GetSharedTypes() => [..Shared]; + public IEnumerable GetSharedTypes() => Shared; - public IEnumerable GetKeys() => [..Keys]; + public IEnumerable GetKeys() => Keys; } diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs index 132a794ab4..020d613a37 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_2.cs @@ -28,7 +28,7 @@ public sealed class ClassDataSourceAttribute< }; } - public IEnumerable GetSharedTypes() => [..Shared]; + public IEnumerable GetSharedTypes() => Shared; - public IEnumerable GetKeys() => [..Keys]; + public IEnumerable GetKeys() => Keys; } diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs index c1038010c9..1c46daab48 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_3.cs @@ -32,7 +32,7 @@ public sealed class ClassDataSourceAttribute< }; } - public IEnumerable GetSharedTypes() => [..Shared]; + public IEnumerable GetSharedTypes() => Shared; - public IEnumerable GetKeys() => [..Keys]; + public IEnumerable GetKeys() => Keys; } diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs index af215eef30..fb7cb9dd94 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_4.cs @@ -36,7 +36,7 @@ public sealed class ClassDataSourceAttribute< }; } - public IEnumerable GetSharedTypes() => [..Shared]; + public IEnumerable GetSharedTypes() => Shared; - public IEnumerable GetKeys() => [..Keys]; + public IEnumerable GetKeys() => Keys; } diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs index 546fd2d503..0e2c67075b 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute_5.cs @@ -40,7 +40,7 @@ public sealed class ClassDataSourceAttribute< }; } - public IEnumerable GetSharedTypes() => [..Shared]; + public IEnumerable GetSharedTypes() => Shared; - public IEnumerable GetKeys() => [..Keys]; + public IEnumerable GetKeys() => Keys; } diff --git a/TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs b/TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs index 75fce171b3..b3042515ae 100644 --- a/TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs +++ b/TUnit.Core/Attributes/TestData/ITraceScopeProvider.cs @@ -1,24 +1,13 @@ namespace TUnit.Core; /// -/// Implemented by data source attributes that provide trace scope information. -/// The engine uses this to parent OpenTelemetry initialization and disposal spans -/// under the correct activity (session, assembly, class, or test). +/// Implemented by data source attributes that expose information. +/// Used by the engine to parent initialization spans under the correct activity. /// -/// -/// This interface is intended as a public extension point. Third-party -/// implementations may implement it to -/// participate in scope-aware tracing. -/// -public interface ITraceScopeProvider +internal interface ITraceScopeProvider { /// - /// Returns the for each generated object. - /// The sequence must be in the same order as the object array produced by - /// ; position i - /// in this sequence corresponds to objects[i] in the data row. - /// If the sequence is shorter than the object array, remaining objects - /// default to (per-test scope). + /// Returns the for each generated object, in parameter order. /// IEnumerable GetSharedTypes(); } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index fc1eda723e..685adf2d3a 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -240,7 +240,7 @@ namespace public required string TestSessionId { get; init; } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute { public ClassDataSourceAttribute() { } public ClassDataSourceAttribute( type) { } @@ -256,7 +256,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute { public ClassDataSourceAttribute() { } public ClassType { get; } @@ -267,7 +267,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() { @@ -279,7 +279,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() where T3 : new() @@ -292,7 +292,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() where T3 : new() @@ -306,7 +306,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() where T3 : new() @@ -886,10 +886,6 @@ namespace .TestEntryFilterData GetFilterData(int index); .<.TestMetadata> Materialize(int index, string testSessionId); } - public interface ITraceScopeProvider - { - .<.SharedType> GetSharedTypes(); - } public interface ITypedDataSourceAttribute : .IDataSourceAttribute { .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); From e10249c9c532c0529a22744f92cbf7e50ae74b27 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:29:42 +0100 Subject: [PATCH 23/28] Use session span duration for header when available The header duration was calculated from test execution timing only, excluding initialization time. The session span captures the full wall-clock time. Falls back to test-based timing on non-.NET or when no session span exists. --- TUnit.Engine/Reporters/Html/HtmlReporter.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index f96a59f392..2d16516013 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -335,6 +335,15 @@ private ReportData BuildReportData() if (_activityCollector != null) { spans = _activityCollector.GetAllSpans(); + + // Use the session span duration as the header duration when available, + // since it captures the full wall-clock time including initialization. + // The test-timing-based duration only covers test execution. + var sessionSpan = spans?.FirstOrDefault(s => s.SpanType == "test session"); + if (sessionSpan != null) + { + totalDurationMs = sessionSpan.DurationMs; + } } #endif From 031e748c56d226877f02147dcd331906cd7ff982 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:40:31 +0100 Subject: [PATCH 24/28] Address review: span name constants, disposal error recording, memory leak fix - Extract span name constants (SpanTestSession, SpanTestAssembly, etc.) into TUnitActivitySource and replace all magic strings across C# call sites - Record disposal exceptions on the trace span before swallowing them, so disposal errors are visible in traces instead of always showing green - Clear _spannedObjects in ClearCache() to prevent memory leak for per-test objects that were kept alive by hard references - Gate _spannedObjects.TryAdd behind HasListeners() to skip work when no trace listener is attached - Update public API snapshots to remove internal ITraceScopeProvider from class declarations --- TUnit.Core/TUnitActivitySource.cs | 6 ++++++ TUnit.Engine/Reporters/Html/ActivityCollector.cs | 6 +++--- TUnit.Engine/Reporters/Html/HtmlReporter.cs | 2 +- TUnit.Engine/Services/HookExecutor.cs | 6 +++--- TUnit.Engine/Services/ObjectLifecycleService.cs | 5 ++++- .../Services/TestExecution/TestCoordinator.cs | 6 ++++++ TUnit.Engine/TestExecutor.cs | 2 +- ...ry_Has_No_API_Changes.DotNet10_0.verified.txt | 16 ++++++---------- ...ary_Has_No_API_Changes.DotNet8_0.verified.txt | 16 ++++++---------- ...ary_Has_No_API_Changes.DotNet9_0.verified.txt | 16 ++++++---------- 10 files changed, 42 insertions(+), 39 deletions(-) diff --git a/TUnit.Core/TUnitActivitySource.cs b/TUnit.Core/TUnitActivitySource.cs index 4805e1d28a..aa592dded3 100644 --- a/TUnit.Core/TUnitActivitySource.cs +++ b/TUnit.Core/TUnitActivitySource.cs @@ -11,6 +11,12 @@ internal static class TUnitActivitySource internal static readonly ActivitySource Source = new("TUnit", Version); + // Span names used across the engine and HTML report. + internal const string SpanTestSession = "test session"; + internal const string SpanTestAssembly = "test assembly"; + internal const string SpanTestSuite = "test suite"; + internal const string SpanTestCase = "test case"; + // Tag keys used across init/dispose spans and the HTML report. internal const string TagTestId = "tunit.test.id"; internal const string TagTestClass = "tunit.test.class"; diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs index cae6be94c1..f1b99e8d0a 100644 --- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs +++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs @@ -169,9 +169,9 @@ private static string EnrichSpanName(Activity activity) // Look up the semantic name tag to produce a more descriptive label var tagKey = displayName switch { - "test case" => "test.case.name", - "test suite" => "test.suite.name", - "test assembly" => "tunit.assembly.name", + TUnitActivitySource.SpanTestCase => "test.case.name", + TUnitActivitySource.SpanTestSuite => "test.suite.name", + TUnitActivitySource.SpanTestAssembly => "tunit.assembly.name", _ => null }; diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 2d16516013..a5961ed86d 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -339,7 +339,7 @@ private ReportData BuildReportData() // Use the session span duration as the header duration when available, // since it captures the full wall-clock time including initialization. // The test-timing-based duration only covers test execution. - var sessionSpan = spans?.FirstOrDefault(s => s.SpanType == "test session"); + var sessionSpan = spans?.FirstOrDefault(s => s.SpanType == TUnitActivitySource.SpanTestSession); if (sessionSpan != null) { totalDurationMs = sessionSpan.DurationMs; diff --git a/TUnit.Engine/Services/HookExecutor.cs b/TUnit.Engine/Services/HookExecutor.cs index 7dcab8564c..dc90566995 100644 --- a/TUnit.Engine/Services/HookExecutor.cs +++ b/TUnit.Engine/Services/HookExecutor.cs @@ -69,7 +69,7 @@ public async ValueTask ExecuteBeforeTestSessionHooksAsync(CancellationToken canc if (TUnitActivitySource.Source.HasListeners()) { sessionContext.Activity = TUnitActivitySource.StartActivity( - "test session", + TUnitActivitySource.SpanTestSession, System.Diagnostics.ActivityKind.Internal, default, [ @@ -153,7 +153,7 @@ public async ValueTask ExecuteBeforeAssemblyHooksAsync(Assembly assembly, Cancel { var sessionActivity = _contextProvider.TestSessionContext.Activity; assemblyContext.Activity = TUnitActivitySource.StartActivity( - "test assembly", + TUnitActivitySource.SpanTestAssembly, System.Diagnostics.ActivityKind.Internal, sessionActivity?.Context ?? default, [ @@ -311,7 +311,7 @@ public async ValueTask ExecuteBeforeClassHooksAsync( { var assemblyActivity = classContext.AssemblyContext.Activity; classContext.Activity = TUnitActivitySource.StartActivity( - "test suite", + TUnitActivitySource.SpanTestSuite, System.Diagnostics.ActivityKind.Internal, assemblyActivity?.Context ?? default, [ diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index 3ea71019be..814cdc0c4d 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -277,7 +277,7 @@ private async Task InitializeObjectWithSpanAsync(object obj, TestContext testCon // TryAdd is atomic — exactly one concurrent caller wins the gate. // Subsequent callers skip span creation and go straight to ObjectInitializer, // which deduplicates via Lazy. - if (obj is IAsyncInitializer && _spannedObjects.TryAdd(obj, 0)) + if (obj is IAsyncInitializer && TUnitActivitySource.Source.HasListeners() && _spannedObjects.TryAdd(obj, 0)) { var sharedType = TraceScopeRegistry.GetSharedType(obj); await InitializeWithSpanAsync(obj, testContext, sharedType, cancellationToken); @@ -539,5 +539,8 @@ public Task CleanupTestAsync(TestContext testContext, List cleanupExc public void ClearCache() { _initializationTasks.Clear(); +#if NET + _spannedObjects.Clear(); +#endif } } diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index da57f86b5b..f04083fb0d 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -343,6 +343,9 @@ private async Task DisposeTestInstanceCoreAsync(AbstractExecutableTest test) } catch (Exception disposeEx) { +#if NET + TUnitActivitySource.RecordException(System.Diagnostics.Activity.Current, disposeEx); +#endif await _logger.LogErrorAsync($"Error during OnDispose for {test.TestId}: {disposeEx}").ConfigureAwait(false); } } @@ -354,6 +357,9 @@ private async Task DisposeTestInstanceCoreAsync(AbstractExecutableTest test) } catch (Exception disposeEx) { +#if NET + TUnitActivitySource.RecordException(System.Diagnostics.Activity.Current, disposeEx); +#endif await _logger.LogErrorAsync($"Error disposing test instance for {test.TestId}: {disposeEx}").ConfigureAwait(false); } } diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs index 4fd4d2166c..59adb506cd 100644 --- a/TUnit.Engine/TestExecutor.cs +++ b/TUnit.Engine/TestExecutor.cs @@ -126,7 +126,7 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync( var classActivity = executableTest.Context.ClassContext.Activity; var testDetails = executableTest.Context.Metadata.TestDetails; executableTest.Context.Activity = TUnitActivitySource.StartActivity( - "test case", + TUnitActivitySource.SpanTestCase, ActivityKind.Internal, classActivity?.Context ?? default, [ diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index a85bb0e28d..a750c49483 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -244,7 +244,7 @@ namespace public required string TestSessionId { get; init; } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute { public ClassDataSourceAttribute() { } [.("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic" + @@ -274,7 +274,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T> : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T> : .DataSourceGeneratorAttribute { public ClassDataSourceAttribute() { } public ClassType { get; } @@ -285,7 +285,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2> : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2> : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() { @@ -297,7 +297,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3> : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3> : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() where T3 : new() @@ -310,7 +310,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4> : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4> : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() where T3 : new() @@ -324,7 +324,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T5> : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T5> : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() where T3 : new() @@ -910,10 +910,6 @@ namespace .TestEntryFilterData GetFilterData(int index); .<.TestMetadata> Materialize(int index, string testSessionId); } - public interface ITraceScopeProvider - { - .<.SharedType> GetSharedTypes(); - } public interface ITypedDataSourceAttribute : .IDataSourceAttribute { .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 78b2fd7b17..348b7a337e 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -244,7 +244,7 @@ namespace public required string TestSessionId { get; init; } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute { public ClassDataSourceAttribute() { } [.("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic" + @@ -274,7 +274,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T> : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T> : .DataSourceGeneratorAttribute { public ClassDataSourceAttribute() { } public ClassType { get; } @@ -285,7 +285,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2> : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2> : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() { @@ -297,7 +297,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3> : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3> : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() where T3 : new() @@ -310,7 +310,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4> : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4> : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() where T3 : new() @@ -324,7 +324,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T5> : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T5> : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() where T3 : new() @@ -910,10 +910,6 @@ namespace .TestEntryFilterData GetFilterData(int index); .<.TestMetadata> Materialize(int index, string testSessionId); } - public interface ITraceScopeProvider - { - .<.SharedType> GetSharedTypes(); - } public interface ITypedDataSourceAttribute : .IDataSourceAttribute { .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 349549a791..f3a7961d03 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -244,7 +244,7 @@ namespace public required string TestSessionId { get; init; } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute : .UntypedDataSourceGeneratorAttribute { public ClassDataSourceAttribute() { } [.("Trimming", "IL2026:Members annotated with \'RequiresUnreferencedCodeAttribute\' require dynamic" + @@ -274,7 +274,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method | .Property | .Parameter, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T> : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T> : .DataSourceGeneratorAttribute { public ClassDataSourceAttribute() { } public ClassType { get; } @@ -285,7 +285,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2> : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2> : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() { @@ -297,7 +297,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3> : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3> : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() where T3 : new() @@ -310,7 +310,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4> : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4> : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() where T3 : new() @@ -324,7 +324,7 @@ namespace public .<.SharedType> GetSharedTypes() { } } [(.Class | .Method, AllowMultiple=true)] - public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T5> : .DataSourceGeneratorAttribute, .ITraceScopeProvider + public sealed class ClassDataSourceAttribute<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T1, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T2, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T3, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T4, [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicProperties | ..NonPublicProperties)] T5> : .DataSourceGeneratorAttribute where T1 : new() where T2 : new() where T3 : new() @@ -910,10 +910,6 @@ namespace .TestEntryFilterData GetFilterData(int index); .<.TestMetadata> Materialize(int index, string testSessionId); } - public interface ITraceScopeProvider - { - .<.SharedType> GetSharedTypes(); - } public interface ITypedDataSourceAttribute : .IDataSourceAttribute { .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); From a3e504d0b9327d9405b9288fecdb1ccf8c059045 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:46:32 +0100 Subject: [PATCH 25/28] Fix Activity.Current comment: AsyncLocal, not ThreadStatic --- TUnit.Core/TUnitActivitySource.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TUnit.Core/TUnitActivitySource.cs b/TUnit.Core/TUnitActivitySource.cs index aa592dded3..213f60a481 100644 --- a/TUnit.Core/TUnitActivitySource.cs +++ b/TUnit.Core/TUnitActivitySource.cs @@ -64,8 +64,8 @@ internal static async Task RunWithSpanAsync( } finally { - // Activity.Current is thread-static; this restore only affects the current thread. - // Async continuations on other threads will have already captured their own value. + // Activity.Current is AsyncLocal — restore so the ambient context on this + // execution path is not permanently set to the span we just stopped. StopActivity(activity); Activity.Current = previousActivity; } From 6e15c917d078ba361365addb86eb061c3d1bb583 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:58:38 +0100 Subject: [PATCH 26/28] Use ConditionalWeakTable for span dedup gate, fix Activity.Current comment - Replace ConcurrentDictionary with ConditionalWeakTable for _spannedObjects so per-test objects can be GC'd after their test completes instead of being retained for the entire session - Use Interlocked.Exchange on StrongBox for atomic first-caller-wins gate - Fix Activity.Current comment: it is AsyncLocal, not ThreadStatic --- .../Services/ObjectLifecycleService.cs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index 814cdc0c4d..14ba3e4ace 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -7,6 +7,8 @@ using TUnit.Core.Tracking; #if NET using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Threading; #endif namespace TUnit.Engine.Services; @@ -39,8 +41,8 @@ internal sealed class ObjectLifecycleService : IObjectRegistry, IInitializationC // Gates span creation so only the first caller for a given object creates a trace span. // Subsequent callers (concurrent tests sharing the same object) skip span creation // and just await ObjectInitializer's deduplicated Lazy. - private readonly ConcurrentDictionary _spannedObjects = - new(Core.Helpers.ReferenceEqualityComparer.Instance); + // Uses ConditionalWeakTable so per-test objects can be GC'd after their test completes. + private readonly ConditionalWeakTable> _spannedObjects = new(); #endif public ObjectLifecycleService( @@ -274,10 +276,18 @@ private async Task InitializeObjectWithSpanAsync(object obj, TestContext testCon #if NET // Only the first caller for a given object creates a trace span. - // TryAdd is atomic — exactly one concurrent caller wins the gate. - // Subsequent callers skip span creation and go straight to ObjectInitializer, - // which deduplicates via Lazy. - if (obj is IAsyncInitializer && TUnitActivitySource.Source.HasListeners() && _spannedObjects.TryAdd(obj, 0)) + // GetValue is atomic — exactly one concurrent caller's factory runs. + // Interlocked.Exchange ensures exactly one caller wins the gate even if + // multiple threads pass the TryGetValue fast-path simultaneously. + var isFirstCaller = false; + if (obj is IAsyncInitializer && TUnitActivitySource.Source.HasListeners() + && !_spannedObjects.TryGetValue(obj, out _)) + { + var box = _spannedObjects.GetValue(obj, _ => new StrongBox(1)); + isFirstCaller = Interlocked.Exchange(ref box.Value, 0) == 1; + } + + if (isFirstCaller) { var sharedType = TraceScopeRegistry.GetSharedType(obj); await InitializeWithSpanAsync(obj, testContext, sharedType, cancellationToken); From 03354ce213802f13577cd0527801e5a1efa408f3 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:05:33 +0100 Subject: [PATCH 27/28] Polish: readable type names, omit test ID from shared spans, add disposal TODO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GetReadableTypeName helper that strips namespace but preserves nesting (Outer.Inner) and cleans generic arity suffixes (MySource`1 → MySource) - Use GetReadableTypeName for init and dispose span labels - Omit tunit.test.id tag from shared-scope init spans (the triggering test is arbitrary for shared objects, making the tag misleading) - Add TODO in ObjectTracker.UntrackObject for shared-object disposal spans - Add GetReadableTypeNameTests covering simple, nested, generic, and deeply nested types --- TUnit.Core/TUnitActivitySource.cs | 29 +++++++++++++ TUnit.Core/Tracking/ObjectTracker.cs | 3 ++ .../Services/ObjectLifecycleService.cs | 22 +++++++--- .../Services/TestExecution/TestCoordinator.cs | 2 +- TUnit.UnitTests/GetReadableTypeNameTests.cs | 43 +++++++++++++++++++ 5 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 TUnit.UnitTests/GetReadableTypeNameTests.cs diff --git a/TUnit.Core/TUnitActivitySource.cs b/TUnit.Core/TUnitActivitySource.cs index 213f60a481..d0c0d6448b 100644 --- a/TUnit.Core/TUnitActivitySource.cs +++ b/TUnit.Core/TUnitActivitySource.cs @@ -22,6 +22,35 @@ internal static class TUnitActivitySource internal const string TagTestClass = "tunit.test.class"; internal const string TagTraceScope = "tunit.trace.scope"; + /// + /// Returns a human-readable type name suitable for span labels. + /// Strips the namespace but preserves nesting (Outer.Inner) and cleans up + /// generic arity suffixes (MySource`1 → MySource<T>). + /// + internal static string GetReadableTypeName(Type type) + { + var name = type.FullName ?? type.Name; + + // Strip namespace: take everything after the last '.' that isn't part of nesting + var nsEnd = name.LastIndexOf('.'); + if (nsEnd >= 0 && type.Namespace is { Length: > 0 }) + { + name = name[type.Namespace.Length..].TrimStart('.'); + } + + // Replace '+' nesting separator with '.' + name = name.Replace('+', '.'); + + // Clean up generic arity suffixes: MySource`1 → MySource + var backtick = name.IndexOf('`'); + if (backtick >= 0) + { + name = name[..backtick]; + } + + return name; + } + internal static Activity? StartActivity( string name, ActivityKind kind = ActivityKind.Internal, diff --git a/TUnit.Core/Tracking/ObjectTracker.cs b/TUnit.Core/Tracking/ObjectTracker.cs index c58e163fed..50098c8a6e 100644 --- a/TUnit.Core/Tracking/ObjectTracker.cs +++ b/TUnit.Core/Tracking/ObjectTracker.cs @@ -220,6 +220,9 @@ private async ValueTask UntrackObject(object? obj) if (shouldDispose) { + // TODO: Add disposal span for shared objects here to match the init spans + // created in ObjectLifecycleService.InitializeWithSpanAsync. Currently only + // per-test disposal gets a span (via TestCoordinator.DisposeTestInstanceWithSpanAsync). await disposer.DisposeAsync(obj).ConfigureAwait(false); } } diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs index 14ba3e4ace..55b45bda5c 100644 --- a/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -313,14 +313,26 @@ private static async Task InitializeWithSpanAsync( { var parentContext = GetParentActivityContext(testContext, sharedType); - await TUnitActivitySource.RunWithSpanAsync( - $"initialize {obj.GetType().Name}", - parentContext, + var scopeTag = TUnitActivitySource.GetScopeTag(sharedType); + var isShared = sharedType is not null and not SharedType.None; + + KeyValuePair[] tags = isShared + ? + [ + new(TUnitActivitySource.TagTestClass, testContext.Metadata.TestDetails.ClassType.FullName), + new(TUnitActivitySource.TagTraceScope, scopeTag) + ] + : [ new(TUnitActivitySource.TagTestId, testContext.Id), new(TUnitActivitySource.TagTestClass, testContext.Metadata.TestDetails.ClassType.FullName), - new(TUnitActivitySource.TagTraceScope, TUnitActivitySource.GetScopeTag(sharedType)) - ], + new(TUnitActivitySource.TagTraceScope, scopeTag) + ]; + + await TUnitActivitySource.RunWithSpanAsync( + $"initialize {TUnitActivitySource.GetReadableTypeName(obj.GetType())}", + parentContext, + tags, () => ObjectInitializer.InitializeAsync(obj, cancellationToken).AsTask()); } diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index f04083fb0d..0bfc503c51 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -316,7 +316,7 @@ private async ValueTask DisposeTestInstanceWithSpanAsync(AbstractExecutableTest #if NET var classType = test.Context.Metadata.TestDetails.ClassType; await TUnitActivitySource.RunWithSpanAsync( - $"dispose {classType.Name}", + $"dispose {TUnitActivitySource.GetReadableTypeName(classType)}", test.Context.ClassContext.Activity?.Context ?? default, [ new(TUnitActivitySource.TagTestId, test.Context.Id), diff --git a/TUnit.UnitTests/GetReadableTypeNameTests.cs b/TUnit.UnitTests/GetReadableTypeNameTests.cs new file mode 100644 index 0000000000..d4ab63b6ee --- /dev/null +++ b/TUnit.UnitTests/GetReadableTypeNameTests.cs @@ -0,0 +1,43 @@ +using TUnit.Core; + +namespace TUnit.UnitTests; + +public class GetReadableTypeNameTests +{ + [Test] + public async Task Simple_Type_Returns_Name() + { + var name = TUnitActivitySource.GetReadableTypeName(typeof(string)); + await Assert.That(name).IsEqualTo("String"); + } + + [Test] + public async Task Nested_Type_Uses_Dot_Separator() + { + var name = TUnitActivitySource.GetReadableTypeName(typeof(OuterClass.InnerClass)); + await Assert.That(name).IsEqualTo("OuterClass.InnerClass"); + } + + [Test] + public async Task Generic_Type_Strips_Arity_Suffix() + { + var name = TUnitActivitySource.GetReadableTypeName(typeof(List)); + await Assert.That(name).IsEqualTo("List"); + } + + [Test] + public async Task Deeply_Nested_Type_Preserves_Chain() + { + var name = TUnitActivitySource.GetReadableTypeName(typeof(OuterClass.InnerClass.DeeplyNested)); + await Assert.That(name).IsEqualTo("OuterClass.InnerClass.DeeplyNested"); + } +} + +// Test fixtures for nested type tests +public class OuterClass +{ + public class InnerClass + { + public class DeeplyNested; + } +} From 643516eab5f2e37c590bc4790d4dd68b8dcf31f8 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 18:25:15 +0100 Subject: [PATCH 28/28] Wrap GetReadableTypeNameTests in #if NET for net472 compatibility TUnitActivitySource is guarded by #if NET in TUnit.Core, so these tests cannot compile on net472. Matches the conditional compilation of the class under test. --- TUnit.UnitTests/GetReadableTypeNameTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TUnit.UnitTests/GetReadableTypeNameTests.cs b/TUnit.UnitTests/GetReadableTypeNameTests.cs index d4ab63b6ee..769d4d6a12 100644 --- a/TUnit.UnitTests/GetReadableTypeNameTests.cs +++ b/TUnit.UnitTests/GetReadableTypeNameTests.cs @@ -1,3 +1,4 @@ +#if NET using TUnit.Core; namespace TUnit.UnitTests; @@ -41,3 +42,4 @@ public class InnerClass public class DeeplyNested; } } +#endif