fix: dispose shared fixtures when only a subset of consuming tests runs#6156
Conversation
Shared-object reference counts were incremented at build time for every built test, but only decremented when a test executed. Any test that was built but filtered out post-build (an [Explicit] sibling, a single [Arguments] case selected by an IDE uid filter, or all tests during a discovery-only request) left the count above zero, so PerTestSession / PerClass / PerAssembly / Keyed fixtures never had DisposeAsync called. - Move argument registration (shared-object creation + ref counting) from TestBuilder.BuildTestAsync to the post-filter TestFilterService.RegisterTest, so counts only include tests that will execute - Skip argument registration entirely for discovery-only requests; discovery no longer constructs fixtures as a side effect - Register dynamic tests (AddDynamicTest / CreateTestVariant) explicitly, since they bypass the discovery pipeline registration - Sweep-dispose any still-tracked objects and reset TestDataContainer at run-session end, so a later run request in the same process (IDE server mode) gets fresh fixtures instead of disposed ones Fixes #6151
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| CodeStyle | 1 minor |
🟢 Metrics 4 complexity
Metric Results Complexity 4
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Code Review
This is a well-motivated fix with a clear root cause analysis. The architecture change — moving argument registration from build-time to post-filter — is correct, and the TDD approach (red-first test) gives good confidence in the regression coverage. A few observations and concerns follow.
Double-disposal risk for static properties
The most significant concern is in TestSessionCoordinator.ExecuteTests. The session-end sweep calls DisposeAndClearStaticTrackingAsync, which iterates s_trackedObjects and disposes everything still tracked. Static-property objects are added to s_trackedObjects via StaticPropertyHandler.TrackStaticProperties (called from TestScheduler.ScheduleAndExecuteAsync), and they are explicitly disposed by StaticPropertyHandler.DisposeStaticPropertiesAsync later in that same ScheduleAndExecuteAsync call — but DisposeStaticPropertiesAsync disposes directly via _disposer.DisposeAsync, bypassing UntrackObject (i.e. it does not decrement the counter or remove the entry from s_trackedObjects).
That means static-property objects will still be present in s_trackedObjects when the sweep runs in the finally block of ExecuteTests, and DisposeAndClearStaticTrackingAsync will call Disposer.DisposeAsync on them a second time. Whether this causes harm depends entirely on whether user-provided disposable objects are idempotent — which the framework cannot assume.
If the ClearStaticTracking path existed previously and was never called, one might argue this was always a latent issue, but the PR is what introduces the first caller, so it owns the risk. The fix should either:
- Have
DisposeStaticPropertiesAsyncgo throughUntrackObjectinstead of calling_disposer.DisposeAsyncdirectly, so the counter reaches zero and the entry is removed before the sweep runs, or - Skip objects that are already zero-ref-counted (the counter exists but is at 0) in
DisposeAndClearStaticTrackingAsync.
DisposeAndClearStaticTrackingAsync is an instance method on a class with only static state
ObjectTracker.DisposeAndClearStaticTrackingAsync operates entirely on s_trackedObjects and s_asyncCallbackErrors, both of which are static. The method is non-static only because it captures the disposer constructor parameter. This is an existing asymmetry (ClearStaticTracking is already static), but the new method deepens the confusion: callers that hold an ObjectTracker instance see a method that mutates all instances' shared state. A brief comment on why this is intentionally an instance method (or making disposer static if that's feasible) would prevent future confusion.
TestContext.Current = null removal leaves a gap
In the original BuildTestAsync, after calling RegisterTestArgumentsAsync, TestContext.Current was cleared to null. That reset was inside the finally block so it ran regardless of success or failure. The PR removes RegisterTestArgumentsAsync from BuildTestAsync but also removes that TestContext.Current = null reset entirely — there is no longer a finally clause at all. If anything sets TestContext.Current during BuildTestAsync (e.g. the DiscoveredTest assignment at line 76 in the pre-PR code), the context could leak into the next build iteration. Worth verifying that TestContext.Current is reliably reset at the right point on the new code path.
DiscoverTestsFullyStreamingAsync does not call RegisterTestsAsync
TestDiscoveryService.DiscoverTestsFullyStreamingAsync (used by the streaming discovery path) calls InvokePostResolutionEventsInParallelAsync and then yields tests to the caller, but never calls _testFilterService.RegisterTestsAsync(...). DiscoverTests (the non-streaming path) does call it. If DiscoverTestsFullyStreamingAsync is used in any execution scenario (currently it appears to only be used for IDE streaming discovery, isForExecution: false), those tests would not get their argument registration. The PR comment in DiscoverTests line 106 notes isForExecution drives the guard, so this is probably fine today, but it's worth a defensive assertion or comment to make the invariant explicit.
SessionSharedFixture static counters are not reset between runs in the same process
Bug6151FilteredDisposalTests uses SessionSharedFixture.CreatedCount and DisposedCount as static int fields. TestDataContainer.Reset() clears the instance caches so fresh objects are created, but CreatedCount/DisposedCount are user-defined statics that are not reset. If the engine test runs against a long-lived process that has already executed these tests in a previous session, the marker file would contain cumulative counts rather than per-run counts, and the assertion "Created=1;Disposed=1" would fail. This is a testing-infrastructure concern rather than a production bug, but the test's reliability in IDE server mode depends on it.
Minor: RunOptions.EnvironmentVariables mutated via WithEnvironmentVariable
RunOptions is declared as a record with Dictionary<string, string?> EnvironmentVariables { get; init; } = []. WithEnvironmentVariable mutates EnvironmentVariables[key] = value on the existing dictionary rather than returning a new RunOptions with a new dictionary. For a record, this is inconsistent with the With-pattern convention (non-destructive mutation). It works for the current use, but callers who share a RunOptions instance would see side effects. AdditionalArguments has the same pattern in the existing WithArgument — so this is consistent with the existing style — just worth noting if the records are ever used in a more functional style.
What's done well
- The root-cause analysis in the PR description is precise and complete.
DisposeAndClearStaticTrackingAsyncusesTryRemoveto guard against races with lateUntrackObjectcalls — correct.- The
isForExecution: falseskip of argument registration inRegisterTestis the right place and correctly avoids leaking fixture construction into discovery-only requests. - Dynamic-test and test-variant paths now go through
RegisterTestsAsync, closing the registration gap those paths previously had. - The
[After(TestSession)]hook approach for the regression test is clean — inert by default, opt-in via env var. - Public API snapshots are updated for the new
Clear()members.
…arStaticTrackingAsync
Follow-up Review (updated after second commit)The second commit ( Status of issues raised in the previous reviewThe substantive concerns from the earlier review remain open. Summarising for visibility: [Open] Double-disposal of static-property objects [Open] [Open, lower priority] [Non-blocking] The core fix (moving registration to post-filter) is correct and the test coverage is good. The double-disposal concern is the only item I'd want addressed before merging. |
…double-dispose Review feedback on #6156: DisposeStaticPropertiesAsync disposed directly via Disposer, leaving the +1 tracking entry from TrackStaticProperties in s_trackedObjects — the new session-end sweep would then dispose the same object a second time. Untracking instead decrements to zero, disposes once, and removes the entry. Also: reset Bug6151 fixture static counters after writing the marker (per-run counts in long-lived server processes), document why DisposeAndClearStaticTrackingAsync is an instance method, and add a warning comment on the unregistered DiscoverTestsFullyStreamingAsync path.
|
Addressed the review in d4d6cdf: Double-disposal of static properties — confirmed real, fixed. Instance method on static state — documented: the method needs the injected
Static counters not reset between runs — fixed: the marker hook now resets
|
There was a problem hiding this comment.
Final Review — All Outstanding Issues Addressed
All concerns from the two prior review rounds have been resolved in d4d6cdf9b. The PR is ready to merge.
Confirmed addressed
Double-disposal of static-property objects (was the blocking concern)
DisposeStaticPropertiesAsync now calls ObjectTracker.UntrackObjectAsync instead of _disposer.DisposeAsync directly. The new public UntrackObjectAsync method decrements the ref count, disposes at zero, and removes the entry from s_trackedObjects in one operation — exactly the right fix. The session-end sweep will no longer see any entry to dispose a second time. The removal of Disposer from StaticPropertyHandler's constructor is a clean follow-through.
TestContext.Current clearing
Confirmed: only RegisterTestArgumentsAsync set TestContext.Current in the build path, and that call is now removed from BuildTestAsync. No context can leak. The explanation in the PR comment is correct.
Static counters reset between runs
The [After(TestSession)] marker hook now resets CreatedCount/DisposedCount via Interlocked.Exchange after writing the file, so a long-lived server-mode process reports per-session counts correctly.
Streaming discovery path
Defensive comment added to DiscoverTestsFullyStreamingAsync warning that any future execution use must register tests with isForExecution: true first. Good enough given the method has zero callers today.
isForExecution boolean flag
Left as-is and consistent with existing style. Acceptable.
Architecture recap
The fundamental design is sound: registration (shared-object creation + ref counting) moved from build-time to post-filter, with dynamic tests and variants wired into the same post-filter registration path via TestRegistry. The session-end sweep in TestSessionCoordinator handles cancellation/miscount stragglers, and TestDataContainer.Reset() ensures a fresh start for subsequent runs in the same process. The layering is correct and the comments throughout explain the invariants clearly.
Approve.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.48.6 to 1.49.0. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.49.0 <!-- Release notes generated using configuration in .github/release.yml at v1.49.0 --> ## What's Changed ### Other Changes * docs: benchmark page descriptions + promote Benchmarks in sidebar by @thomhurst in thomhurst/TUnit#6143 * feat(mocks): discriminate generic-method mocks by type argument by @thomhurst in thomhurst/TUnit#6153 * fix(source-gen): jagged array data fails to compile (#6150) by @thomhurst in thomhurst/TUnit#6152 * fix: dispose shared fixtures when only a subset of consuming tests runs by @thomhurst in thomhurst/TUnit#6156 ### Dependencies * chore(deps): update tunit to 1.48.6 by @thomhurst in thomhurst/TUnit#6142 * chore(deps): update react to ^19.2.7 by @thomhurst in thomhurst/TUnit#6144 * chore(deps): update aspire to 13.4.0 by @thomhurst in thomhurst/TUnit#6145 * chore(deps): update dependency nunit.analyzers to 4.14.0 by @thomhurst in thomhurst/TUnit#6146 * chore(deps): update dependency polyfill to 10.7.2 by @thomhurst in thomhurst/TUnit#6148 * chore(deps): update dependency polyfill to 10.7.2 by @thomhurst in thomhurst/TUnit#6149 * chore(deps): update dependency dompurify to v3.4.8 by @thomhurst in thomhurst/TUnit#6155 **Full Changelog**: thomhurst/TUnit@v1.48.6...v1.49.0 Commits viewable in [compare view](thomhurst/TUnit@v1.48.6...v1.49.0). </details> Updated [TUnit.AspNetCore](https://github.com/thomhurst/TUnit) from 1.48.6 to 1.49.0. <details> <summary>Release notes</summary> _Sourced from [TUnit.AspNetCore's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.49.0 <!-- Release notes generated using configuration in .github/release.yml at v1.49.0 --> ## What's Changed ### Other Changes * docs: benchmark page descriptions + promote Benchmarks in sidebar by @thomhurst in thomhurst/TUnit#6143 * feat(mocks): discriminate generic-method mocks by type argument by @thomhurst in thomhurst/TUnit#6153 * fix(source-gen): jagged array data fails to compile (#6150) by @thomhurst in thomhurst/TUnit#6152 * fix: dispose shared fixtures when only a subset of consuming tests runs by @thomhurst in thomhurst/TUnit#6156 ### Dependencies * chore(deps): update tunit to 1.48.6 by @thomhurst in thomhurst/TUnit#6142 * chore(deps): update react to ^19.2.7 by @thomhurst in thomhurst/TUnit#6144 * chore(deps): update aspire to 13.4.0 by @thomhurst in thomhurst/TUnit#6145 * chore(deps): update dependency nunit.analyzers to 4.14.0 by @thomhurst in thomhurst/TUnit#6146 * chore(deps): update dependency polyfill to 10.7.2 by @thomhurst in thomhurst/TUnit#6148 * chore(deps): update dependency polyfill to 10.7.2 by @thomhurst in thomhurst/TUnit#6149 * chore(deps): update dependency dompurify to v3.4.8 by @thomhurst in thomhurst/TUnit#6155 **Full Changelog**: thomhurst/TUnit@v1.48.6...v1.49.0 Commits viewable in [compare view](thomhurst/TUnit@v1.48.6...v1.49.0). </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Problem
Discussion #6151: a
[ClassDataSource<T>(Shared = SharedType.PerTestSession)]fixture implementingIAsyncDisposablenever getsDisposeAsynccalled when running a single test from Rider (one of two[Arguments]cases).Root cause
Shared-object disposal is pure ref-counting (
ObjectTracker): +1 per test that registers the object, -1 when a test finishes, dispose at 0.The increment happened at build time (
TestBuilder.BuildTestAsync->RegisterTestArgumentsAsync), but filtering happens after building, and decrements only happen for executed tests. Any built-but-not-run test permanently inflated the count, so it never reached zero:[Arguments]case selected by an IDE uid filter (both cases share one metadata entry, so both are always built) - the reported repro[Explicit]sibling matched-then-excluded by a filterFix
RegisterTestArgumentsAsynccall;TestFilterService.RegisterTest(which already performed the same call with identical failure semantics) is now the single registration point, running only for tests that will execute (incl. dependency-expanded ones).AddDynamicTest/CreateTestVariantbypass the discovery pipeline, soTestRegistrynow registers built tests before enqueueing them (this also gives themOnTestRegisteredreceivers, which previously never fired for dynamic tests).TestDataContaineris cleared, so a subsequent run request in the same process (IDE server mode) creates fresh fixtures instead of receiving disposed ones. Runs afterAfter(TestSession)hooks and static-property disposal - existing ordering preserved.ObjectTracker.ClearStaticTrackingpreviously had zero callers; the new sweep wires that lifecycle up properly.Tests (TDD - red first, then green)
TUnit.TestProject/Bugs/6151/Bug6151FilteredDisposalTests.cs- PerTestSession fixture with a normal test +[Explicit]sibling (same built-but-not-run shape as the IDE uid-filter case, reproducible via--treenode-filter). A guarded[After(TestSession)]hook writes created/disposed counts to a marker file (opt-in via env var, inert otherwise).TUnit.Engine.Tests/FilteredSharedFixtureDisposalTests.cs- assertsCreated=1;Disposed=1for the filtered-subset run (failed withDisposed=0before the fix) plus a direct-single-test sanity case.InvokableTestBase.RunOptionsgainedWithEnvironmentVariableto support the marker handshake.Verification
Created=1;Disposed=0), green after - both source-gen and reflection modesTUnit.UnitTests: 218/218TUnit.PublicAPIsnapshots updated for the two newClear()members (ScopedDictionary,ThreadSafeDictionary)Note:
ClassDataSourceTupleTests.Test_ClassDataSource_UnwrapsTuplesfails identically with and without this change (verified via stash) - pre-existing, unrelated.Fixes #6151