Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/TUnit.Core/TestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
thomhurst marked this conversation as resolved.
}

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);
}
}

/// <summary>
/// Gets the dictionary of test parameters indexed by parameter name.
Expand Down Expand Up @@ -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
Expand Down
37 changes: 32 additions & 5 deletions src/TUnit.Engine/Extensions/TestExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ internal static class TestExtensions
private static bool? _cachedIsTrxEnabled;

private static readonly ConcurrentDictionary<Assembly, string> AssemblyFullNameCache = new();
private static readonly ConcurrentDictionary<string, CachedTestNodeProperties> 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; }
Expand All @@ -32,7 +35,8 @@ private sealed class CachedTestNodeProperties
internal static void ClearCaches()
{
AssemblyFullNameCache.Clear();
TestNodePropertiesCache.Clear();
Volatile.Write(ref _reportingCacheScope, new object());
Comment thread
thomhurst marked this conversation as resolved.
TestContext.ClearReportingCaches();
_cachedIsTrxEnabled = null;
}

Expand All @@ -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;

Expand Down Expand Up @@ -106,14 +125,15 @@ private static CachedTestNodeProperties GetOrCreateCachedProperties(TestContext

return new CachedTestNodeProperties
{
Scope = scope,
FileLocation = fileLocation,
MethodIdentifier = methodIdentifier,
CategoryProperties = categoryProps,
CustomProperties = customProps,
TrxFullyQualifiedTypeName = trxTypeName,
TrxCategories = trxCategories
};
}, testContext);
}
}

internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateProperty stateProperty)
Expand Down Expand Up @@ -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;
}

Expand Down
96 changes: 96 additions & 0 deletions tests/TUnit.Engine.Tests/TestNodeLocationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestFileLocationProperty>().Single().FilePath.ShouldBe("Before.cs");
after.Properties.AsEnumerable().OfType<TestFileLocationProperty>().Single().FilePath.ShouldBe("After.cs");
before.Properties.AsEnumerable().OfType<DiscoveredTestNodeStateProperty>().Count().ShouldBe(1);
after.Properties.AsEnumerable().OfType<InProgressTestNodeStateProperty>().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<TestNodeStateProperty>().Single().ShouldBeSameAs(finalState);
final.Properties.AsEnumerable().OfType<TestFileLocationProperty>().Single().FilePath.ShouldBe("Tests.cs");
final.Properties.AsEnumerable().OfType<TestMetadataProperty>().Single()
.ShouldBeSameAs(discovered.Properties.AsEnumerable().OfType<TestMetadataProperty>().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<TestNodeStateProperty>().Single();
(state is DiscoveredTestNodeStateProperty).ShouldBe(i % 2 == 0);
nodes[i].Properties.AsEnumerable().OfType<TestFileLocationProperty>().Single().FilePath.ShouldBe("Tests.cs");
}
}
finally
{
context.RemoveFromRegistry();
context.Dispose();
}
}

[Test]
public void ToTestNode_Uses_Source_Span_For_Mtp_File_Location()
{
Expand Down
Loading