From cada3564859a56f280bff66d297a5c74c339e58e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 07:42:13 +0000 Subject: [PATCH 1/4] Initial plan From ccbceccbb0dcb4fb7af919cfbe1e8c8b524f7fb4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 08:23:28 +0000 Subject: [PATCH 2/4] Fix MaxExternalSpansPerTest cap bypass when Activity.Parent chain is broken When libraries like Npgsql use async connection pooling, Activity.Parent may be null/broken by the time OnActivityStopped fires, causing FindTestCaseAncestor to return null and external spans to bypass the cap. Three-layer fix: 1. Register test case span IDs on ActivityStarted (before children stop) 2. Fallback in FindTestCaseAncestor to check ParentSpanId against known test case span IDs when Activity.Parent walk fails 3. Per-trace cap as ultimate safety net for completely broken chains Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/1c7f9e0c-eca2-4452-a1b9-b94f99ac238c Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --- .../Reporters/Html/ActivityCollector.cs | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs index cae6be94c1..fdd7817075 100644 --- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs +++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs @@ -15,6 +15,12 @@ internal sealed class ActivityCollector : IDisposable private readonly ConcurrentDictionary> _spansByTrace = new(); // Track external span count per test case (keyed by test case span ID) private readonly ConcurrentDictionary _externalSpanCountsByTest = new(); + // Fallback: per-trace cap for external spans whose parent chain is broken + // (e.g. Npgsql async pooling where Activity.Parent is null but traceId is correct) + private readonly ConcurrentDictionary _externalSpanCountsByTrace = new(); + // Known test case span IDs, populated at activity start time so they're available + // before child spans stop (children stop before parents in Activity ordering). + private readonly ConcurrentDictionary _testCaseSpanIds = new(); // Fast-path cache of trace IDs that should be collected. Subsumes TraceRegistry lookups // so that subsequent activities on the same trace avoid cross-class dictionary checks. private readonly ConcurrentDictionary _knownTraceIds = new(StringComparer.OrdinalIgnoreCase); @@ -31,6 +37,7 @@ public void Start() ShouldListenTo = static _ => true, Sample = SampleActivity, SampleUsingParentId = SampleActivityUsingParentId, + ActivityStarted = OnActivityStarted, ActivityStopped = OnActivityStopped }; @@ -141,8 +148,20 @@ public SpanData[] GetAllSpans() return lookup; } - private static string? FindTestCaseAncestor(Activity activity) + private void OnActivityStarted(Activity activity) { + // Register test case span IDs early so they're available for child span lookups. + // Children stop before parents in Activity ordering, so we need this pre-registered. + if (IsTUnitSource(activity.Source.Name) && + activity.GetTagItem("tunit.test.node_uid") is not null) + { + _testCaseSpanIds.TryAdd(activity.SpanId.ToString(), 0); + } + } + + private string? FindTestCaseAncestor(Activity activity) + { + // First: walk in-memory parent chain (works when parent Activity is alive) var current = activity.Parent; while (current is not null) { @@ -155,6 +174,18 @@ public SpanData[] GetAllSpans() current = current.Parent; } + // Fallback: check if ParentSpanId is a known test case span. + // This handles broken Activity.Parent chains (e.g. Npgsql async pooling) + // where the W3C ParentSpanId is still correct. + if (activity.ParentSpanId != default) + { + var parentSpanId = activity.ParentSpanId.ToString(); + if (_testCaseSpanIds.ContainsKey(parentSpanId)) + { + return parentSpanId; + } + } + return null; } @@ -217,7 +248,16 @@ private void OnActivityStopped(Activity activity) return; } } - // External spans not under any test (e.g., fixture/infrastructure setup) are uncapped + else + { + // Fallback cap by trace ID to prevent unbounded growth for spans + // with broken parent chains (e.g., Npgsql async connection pooling). + var count = _externalSpanCountsByTrace.AddOrUpdate(traceId, 1, (_, c) => c + 1); + if (count > MaxExternalSpansPerTest) + { + return; + } + } } var queue = _spansByTrace.GetOrAdd(traceId, _ => new ConcurrentQueue()); From 947392dc5a32f54c8e556c8f78806bf8043591e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:25:39 +0000 Subject: [PATCH 3/4] Address review: fix memory leak, add MaxExternalSpansPerTrace constant, clarify fallback comment - Clean up _testCaseSpanIds and _externalSpanCountsByTrace when test case span stops (children stop before parents, so cleanup is safe) - Add separate MaxExternalSpansPerTrace constant for the per-trace fallback cap instead of reusing MaxExternalSpansPerTest - Update FindTestCaseAncestor comment to note single-level limitation Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/e846703a-5b5a-4f60-afc3-cffadcc724c5 Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --- .../Reporters/Html/ActivityCollector.cs | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs index fdd7817075..2eff201c6c 100644 --- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs +++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs @@ -11,6 +11,9 @@ internal sealed class ActivityCollector : IDisposable // TUnit's own spans are always captured regardless of caps. // Soft cap — intentionally racy for performance; may be slightly exceeded under high concurrency. private const int MaxExternalSpansPerTest = 100; + // Fallback cap applied per trace when the test case association cannot be determined + // (e.g. broken Activity.Parent chains from async connection pooling). + private const int MaxExternalSpansPerTrace = 100; private readonly ConcurrentDictionary> _spansByTrace = new(); // Track external span count per test case (keyed by test case span ID) @@ -174,9 +177,10 @@ private void OnActivityStarted(Activity activity) current = current.Parent; } - // Fallback: check if ParentSpanId is a known test case span. - // This handles broken Activity.Parent chains (e.g. Npgsql async pooling) - // where the W3C ParentSpanId is still correct. + // Fallback: check if the direct ParentSpanId is a known test case span. + // Note: only one level — deeper broken chains fall through to the per-trace cap. + // This handles Npgsql async pooling where the direct parent reference is broken + // but W3C ParentSpanId is still correct. if (activity.ParentSpanId != default) { var parentSpanId = activity.ParentSpanId.ToString(); @@ -253,7 +257,7 @@ private void OnActivityStopped(Activity activity) // Fallback cap by trace ID to prevent unbounded growth for spans // with broken parent chains (e.g., Npgsql async connection pooling). var count = _externalSpanCountsByTrace.AddOrUpdate(traceId, 1, (_, c) => c + 1); - if (count > MaxExternalSpansPerTest) + if (count > MaxExternalSpansPerTrace) { return; } @@ -336,6 +340,14 @@ private void OnActivityStopped(Activity activity) }; queue.Enqueue(spanData); + + // Cleanup: remove test case span from tracking sets once it stops. + // All child spans will have already stopped by this point (children stop before parents). + if (isTUnit && activity.GetTagItem("tunit.test.node_uid") is not null) + { + _testCaseSpanIds.TryRemove(activity.SpanId.ToString(), out _); + _externalSpanCountsByTrace.TryRemove(traceId, out _); + } } public void Dispose() From 59a4217612d60655d234408d5184dbd6258653c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:46:47 +0000 Subject: [PATCH 4/4] Also clean up _externalSpanCountsByTest in OnActivityStopped to fix pre-existing leak Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/99eadeb1-f26a-4f94-8341-dc05e1ad3fe9 Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --- TUnit.Engine/Reporters/Html/ActivityCollector.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs index 2eff201c6c..838f5d7bd6 100644 --- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs +++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs @@ -345,7 +345,9 @@ private void OnActivityStopped(Activity activity) // All child spans will have already stopped by this point (children stop before parents). if (isTUnit && activity.GetTagItem("tunit.test.node_uid") is not null) { - _testCaseSpanIds.TryRemove(activity.SpanId.ToString(), out _); + var spanId = activity.SpanId.ToString(); + _testCaseSpanIds.TryRemove(spanId, out _); + _externalSpanCountsByTest.TryRemove(spanId, out _); _externalSpanCountsByTrace.TryRemove(traceId, out _); } }