diff --git a/src/TUnit.Core/TestContext.cs b/src/TUnit.Core/TestContext.cs index 7fb2c86961..fd9f21bfaa 100644 --- a/src/TUnit.Core/TestContext.cs +++ b/src/TUnit.Core/TestContext.cs @@ -223,7 +223,23 @@ public ContextScope MakeCurrent() public static TestContext? GetById(string id) => Guid.TryParse(id, out var guid) ? _testContextsByGuid.GetValueOrDefault(guid) : null; - internal void RemoveFromRegistry() => _testContextsByGuid.TryRemove(_idGuid, out _); + internal void RemoveFromRegistry() + { + _testContextsByGuid.TryRemove(_idGuid, out _); + // Reporting has completed. Do not retain its cached properties with a + // context that user code or session summaries keep alive after execution. + Volatile.Write(ref CachedReportingProperties, null); + } + + internal static void ClearReportingCaches() + { + // Discovery-only sessions and failures before execution can leave contexts + // registered. Release their reporting metadata when the engine resets. + foreach (var entry in _testContextsByGuid) + { + Volatile.Write(ref entry.Value.CachedReportingProperties, null); + } + } /// /// Gets the dictionary of test parameters indexed by parameter name. @@ -399,6 +415,9 @@ public void RegisterTrace(System.Diagnostics.ActivityTraceId traceId) internal object[]? CachedEligibleEventObjects { get; set; } + // Owned by the engine; object keeps Core independent of MTP reporting types. + internal object? CachedReportingProperties; + // Pre-computed typed event receivers (filtered, sorted, scoped-attribute filtered) // These are computed lazily on first access and cached #if NET diff --git a/src/TUnit.Engine/Extensions/TestExtensions.cs b/src/TUnit.Engine/Extensions/TestExtensions.cs index 844de857b0..e8a2fbc060 100644 --- a/src/TUnit.Engine/Extensions/TestExtensions.cs +++ b/src/TUnit.Engine/Extensions/TestExtensions.cs @@ -17,10 +17,13 @@ internal static class TestExtensions private static bool? _cachedIsTrxEnabled; private static readonly ConcurrentDictionary AssemblyFullNameCache = new(); - private static readonly ConcurrentDictionary TestNodePropertiesCache = new(); + // Changing the scope invalidates entries even when callers retain a context + // across service-provider resets, without a global per-test dictionary. + private static object _reportingCacheScope = new(); private sealed class CachedTestNodeProperties { + public required object Scope { get; init; } public required TestFileLocationProperty FileLocation { get; init; } public required TestMethodIdentifierProperty MethodIdentifier { get; init; } public TestMetadataProperty[]? CategoryProperties { get; init; } @@ -32,7 +35,8 @@ private sealed class CachedTestNodeProperties internal static void ClearCaches() { AssemblyFullNameCache.Clear(); - TestNodePropertiesCache.Clear(); + Volatile.Write(ref _reportingCacheScope, new object()); + TestContext.ClearReportingCaches(); _cachedIsTrxEnabled = null; } @@ -43,9 +47,24 @@ private static string GetCachedAssemblyFullName(Assembly assembly) private static CachedTestNodeProperties GetOrCreateCachedProperties(TestContext testContext) { - var testId = testContext.Metadata.TestDetails.TestId; + var scope = Volatile.Read(ref _reportingCacheScope); + if (Volatile.Read(ref testContext.CachedReportingProperties) is CachedTestNodeProperties cached && + ReferenceEquals(cached.Scope, scope)) + { + return cached; + } + + var properties = CreateCachedProperties(testContext, scope); + Volatile.Write(ref testContext.CachedReportingProperties, properties); + // A reset may have swept this context while its properties were being + // created. Do not retain an entry published after that sweep. + if (!ReferenceEquals(scope, Volatile.Read(ref _reportingCacheScope))) + { + Interlocked.CompareExchange(ref testContext.CachedReportingProperties, null, properties); + } + return properties; - return TestNodePropertiesCache.GetOrAdd(testId, static (_, testContext) => + static CachedTestNodeProperties CreateCachedProperties(TestContext testContext, object scope) { var testDetails = testContext.Metadata.TestDetails; @@ -106,6 +125,7 @@ private static CachedTestNodeProperties GetOrCreateCachedProperties(TestContext return new CachedTestNodeProperties { + Scope = scope, FileLocation = fileLocation, MethodIdentifier = methodIdentifier, CategoryProperties = categoryProps, @@ -113,7 +133,7 @@ private static CachedTestNodeProperties GetOrCreateCachedProperties(TestContext TrxFullyQualifiedTypeName = trxTypeName, TrxCategories = trxCategories }; - }, testContext); + } } internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateProperty stateProperty) @@ -226,6 +246,13 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP Properties = propertyBag }; + if (isFinalState) + { + // Placeholders and failures before execution do not reach the + // coordinator's registry cleanup. The node owns its property snapshot. + Volatile.Write(ref testContext.CachedReportingProperties, null); + } + return testNode; } diff --git a/tests/TUnit.Engine.Tests/TestNodeLocationTests.cs b/tests/TUnit.Engine.Tests/TestNodeLocationTests.cs index 40c6405627..d5304f6059 100644 --- a/tests/TUnit.Engine.Tests/TestNodeLocationTests.cs +++ b/tests/TUnit.Engine.Tests/TestNodeLocationTests.cs @@ -8,8 +8,104 @@ namespace TUnit.Engine.Tests; +[NotInParallel] public class TestNodeLocationTests { + [Test] + public void ClearCaches_Refreshes_Metadata_For_Existing_Contexts() + { + var context = CreateTestContext(Guid.NewGuid().ToString("N"), "Before.cs", 1, 0, 1, 0); + try + { + var before = context.ToTestNode(DiscoveredTestNodeStateProperty.CachedInstance); + context.CachedReportingProperties.ShouldNotBeNull(); + context.Metadata.TestDetails.TestFilePath = "After.cs"; + TestExtensions.ClearCaches(); + context.CachedReportingProperties.ShouldBeNull(); + var after = context.ToTestNode(InProgressTestNodeStateProperty.CachedInstance); + + before.Properties.AsEnumerable().OfType().Single().FilePath.ShouldBe("Before.cs"); + after.Properties.AsEnumerable().OfType().Single().FilePath.ShouldBe("After.cs"); + before.Properties.AsEnumerable().OfType().Count().ShouldBe(1); + after.Properties.AsEnumerable().OfType().Count().ShouldBe(1); + context.RemoveFromRegistry(); + context.CachedReportingProperties.ShouldBeNull(); + } + finally + { + context.RemoveFromRegistry(); + context.Dispose(); + } + } + + [Test] + [Arguments("Passed")] + [Arguments("Failed")] + [Arguments("Error")] + [Arguments("Timeout")] + [Arguments("Skipped")] + [Arguments("Cancelled")] + public void Final_Updates_Release_Cache_Without_Coordinator_Cleanup(string state) + { + var context = CreateTestContext(Guid.NewGuid().ToString("N"), "Tests.cs", 1, 0, 1, 0); + try + { + context.Metadata.TestDetails.Categories.Add("Category"); + var discovered = context.ToTestNode(DiscoveredTestNodeStateProperty.CachedInstance); + context.CachedReportingProperties.ShouldNotBeNull(); + +#pragma warning disable CS0618, MTP0001 // Exercise the engine's cancellation reporting path. + TestNodeStateProperty finalState = state switch + { + "Passed" => PassedTestNodeStateProperty.CachedInstance, + "Failed" => new FailedTestNodeStateProperty(new Exception("failure")), + "Error" => new ErrorTestNodeStateProperty(new Exception("error")), + "Timeout" => new TimeoutTestNodeStateProperty(), + "Skipped" => new SkippedTestNodeStateProperty("skipped"), + "Cancelled" => new CancelledTestNodeStateProperty(), + _ => throw new ArgumentOutOfRangeException(nameof(state)) + }; +#pragma warning restore CS0618, MTP0001 + var final = context.ToTestNode(finalState); + + context.CachedReportingProperties.ShouldBeNull(); + final.Properties.AsEnumerable().OfType().Single().ShouldBeSameAs(finalState); + final.Properties.AsEnumerable().OfType().Single().FilePath.ShouldBe("Tests.cs"); + final.Properties.AsEnumerable().OfType().Single() + .ShouldBeSameAs(discovered.Properties.AsEnumerable().OfType().Single()); + } + finally + { + context.RemoveFromRegistry(); + context.Dispose(); + } + } + + [Test] + public void Concurrent_Updates_Keep_Separate_Message_State() + { + var context = CreateTestContext(Guid.NewGuid().ToString("N"), "Tests.cs", 1, 0, 1, 0); + try + { + var nodes = new TestNode[64]; + Parallel.For(0, nodes.Length, i => nodes[i] = context.ToTestNode(i % 2 == 0 + ? DiscoveredTestNodeStateProperty.CachedInstance + : InProgressTestNodeStateProperty.CachedInstance)); + + for (var i = 0; i < nodes.Length; i++) + { + var state = nodes[i].Properties.AsEnumerable().OfType().Single(); + (state is DiscoveredTestNodeStateProperty).ShouldBe(i % 2 == 0); + nodes[i].Properties.AsEnumerable().OfType().Single().FilePath.ShouldBe("Tests.cs"); + } + } + finally + { + context.RemoveFromRegistry(); + context.Dispose(); + } + } + [Test] public void ToTestNode_Uses_Source_Span_For_Mtp_File_Location() {