feat: add [DetectLeaks] attribute for resource leak detection#4955
feat: add [DetectLeaks] attribute for resource leak detection#4955thomhurst wants to merge 1 commit into
Conversation
Add opt-in infrastructure for detecting resource leaks during test execution. The [DetectLeaks] attribute tracks thread pool threads and active timer counts before/after each test, warning when significant growth is detected (default threshold: 10). Closes #4904
There was a problem hiding this comment.
Code Review
Overall this is a well-structured feature that follows TUnit's event receiver pattern correctly. The ValueTask, ConcurrentDictionary, and #if NET6_0_OR_GREATER guard are all good choices. Two issues worth addressing before merge:
Issue 1 — Missing IScopedAttribute implementation (significant bug)
TUnit/TUnit.Core/Attributes/DetectLeaksAttribute.cs
Lines 38 to 42 in 75fb4f2
DetectLeaksAttribute is applicable at method, class, and assembly level. When a user applies it at more than one level simultaneously — a realistic scenario, e.g. [assembly: DetectLeaks(ThreadThreshold = 50)] as a baseline with [DetectLeaks(ThreadThreshold = 2)] on a specific test — the framework collects both attribute instances and invokes both as event receivers (verified by tracing ReflectionAttributeExtractor.GetAllAttributes → ScopedAttributeFilter.FilterScopedAttributes).
Because DetectLeaksAttribute does not implement IScopedAttribute, ScopedAttributeFilter does not deduplicate it. Both instances run OnTestStart, and since they share the same static Snapshots dictionary keyed by context.Id, the second OnTestStart overwrites the first's snapshot. When both OnTestEnd handlers run, only the first TryRemove succeeds; the second exits early and its threshold is silently never applied.
Every other TUnit attribute that supports multiple scopes (e.g. RetryAttribute, TimeoutAttribute, NotInParallelAttribute) implements IScopedAttribute so the innermost (most specific) declaration wins. DetectLeaksAttribute should follow the same pattern:
// Before
public sealed class DetectLeaksAttribute : TUnitAttribute, ITestStartEventReceiver, ITestEndEventReceiver
// After
public sealed class DetectLeaksAttribute : TUnitAttribute, ITestStartEventReceiver, ITestEndEventReceiver, IScopedAttribute
{
// Add:
public Type ScopeType => typeof(DetectLeaksAttribute);
// ...
}With IScopedAttribute, the method-level instance takes precedence and the assembly-level one is filtered out, eliminating the shared-dictionary race.
Issue 2 — I/O completion port delta checked against ThreadThreshold instead of its own threshold
TUnit/TUnit.Core/Attributes/DetectLeaksAttribute.cs
Lines 96 to 103 in 75fb4f2
if (completionPortDelta >= ThreadThreshold) // uses ThreadThreshold, not TimerThreshold or a dedicated property
{
context.Output.WriteError(
$"[LeakDetection] Test '{testName}' may be leaking I/O completion port threads. ...");
}The class exposes ThreadThreshold (worker threads) and TimerThreshold (timers) as independent knobs, and the ResourceSnapshot struct deliberately separates AvailableWorkerThreads from AvailableCompletionPortThreads. Yet the completion port check silently reuses ThreadThreshold. A user who sets [DetectLeaks(ThreadThreshold = 2, TimerThreshold = 5)] expecting to tune each resource type independently cannot configure the completion port threshold at all — it just follows ThreadThreshold with no documentation or API surface to indicate this.
The fix is either:
Option A — Add a dedicated property (most consistent with the current design):
/// <summary>The minimum completion port thread decrease to trigger a warning. Default is 10.</summary>
public int CompletionPortThreshold { get; set; } = 10;
// then:
if (completionPortDelta >= CompletionPortThreshold) { ... }Option B — Intentionally bundle both thread types under ThreadThreshold and simplify:
// Combine deltas, single message, single threshold check
var totalThreadDelta = threadDelta + completionPortDelta;
if (totalThreadDelta >= ThreadThreshold) { ... }Either is reasonable; the current code does neither and leaves the behavior undiscoverable from the public API.
Summary
[DetectLeaks]attribute inTUnit.Corethat enables opt-in resource leak detection for testsTimer.ActiveCounton .NET 6+) before and after each test executionITestStartEventReceiver+ITestEndEventReceiver, following existing TUnit event receiver patternsTimer.ActiveCountUsage
Test plan
dotnet build TUnit.Core/TUnit.Core.csprojsucceeds on all TFMsdotnet build TUnit.Engine/TUnit.Engine.csprojsucceeds on all TFMs[DetectLeaks]to a test that intentionally leaks threads and verify warning output[DetectLeaks]to a clean test and verify no warnings appearCloses #4904