Skip to content

Fix DisposeAsync() ordering for nested property injection#5337

Merged
thomhurst merged 2 commits intomainfrom
copilot/fix-disposeasync-ordering
Apr 1, 2026
Merged

Fix DisposeAsync() ordering for nested property injection#5337
thomhurst merged 2 commits intomainfrom
copilot/fix-disposeasync-ordering

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 1, 2026

  • Investigate the issue: DisposeAsync ordering is wrong for nested property injection
  • Identify root cause: ObjectTracker.UntrackObjectsAsync iterates deepest-to-shallowest (same as initialization), should be shallowest-to-deepest (reverse of initialization)
  • Fix the disposal ordering in ObjectTracker.UntrackObjectsAsync to iterate ascending (shallowest first)
  • Add a regression test for nested dispose ordering
  • Run existing tests to verify no regressions
  • Run code review and CodeQL

Copilot AI linked an issue Apr 1, 2026 that may be closed by this pull request
1 task
@codacy-production
Copy link
Copy Markdown

codacy-production bot commented Apr 1, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity

Metric Results
Complexity 0

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

Copilot AI requested a review from thomhurst April 1, 2026 11:00
Copilot stopped work on behalf of thomhurst due to an error April 1, 2026 11:00
@thomhurst
Copy link
Copy Markdown
Owner

@copilot continue

Reverse the iteration order in ObjectTracker.UntrackObjectsAsync to
iterate shallowest-to-deepest (ascending), so disposal happens in
reverse order of initialization (which goes deepest-to-shallowest).

This fixes a regression where nested IAsyncDisposable fixtures were
disposed in initialization order instead of reverse order, breaking
fixture isolation for multi-level nested property injection.

Agent-Logs-Url: https://github.com/thomhurst/TUnit/sessions/2ea26864-60aa-48f4-aca7-d32f86ca96c0

Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com>
Copilot stopped work on behalf of thomhurst due to an error April 1, 2026 12:02
Copilot AI temporarily deployed to Pull Requests April 1, 2026 14:18 Inactive
Copilot AI temporarily deployed to Pull Requests April 1, 2026 14:18 Inactive
Copilot AI temporarily deployed to Pull Requests April 1, 2026 14:18 Inactive
@thomhurst thomhurst marked this pull request as ready for review April 1, 2026 15:18
@thomhurst thomhurst changed the title [WIP] Fix DisposeAsync() ordering for nested property injection Fix DisposeAsync() ordering for nested property injection Apr 1, 2026
@thomhurst thomhurst merged commit 5063783 into main Apr 1, 2026
14 of 15 checks passed
@thomhurst thomhurst deleted the copilot/fix-disposeasync-ordering branch April 1, 2026 15:18
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: Fix DisposeAsync() ordering for nested property injection

Summary

This PR fixes a real bug: ObjectTracker.UntrackObjectsAsync was iterating depth buckets from deepest-to-shallowest (reverse), but because depth is assigned such that shallow objects get lower depth numbers (depth 0 = root, depth 1+ = nested dependencies), disposal was actually happening in the wrong order — deep objects (dependencies) were being disposed before shallow ones (dependents). The fix correctly changes the iteration to ascending order.

The core fix is minimal and correct. The regression test is a welcome addition. A few observations worth raising:


Issue 1: Depth Semantics Are Counterintuitive — and the Fix Inverts Them

The comment in the fix says "iterate in ascending order (shallowest depth first)", and that's true. But looking at ObjectGraphDiscoverer, depth 0 is the test class's directly injected properties (e.g. AppServiceFixture), and deeper numbers represent their dependencies (e.g. ContextFactoryFixture ends up at depth 2). So "ascending order" means "dependents first, dependencies last", which is the correct disposal order.

The issue is that the naming is backwards from how most people think about depth: depth 0 is the "shallowest" injected object (a leaf from the test's perspective), but it depends on depth-1 objects. This creates a mental model mismatch where "deeper depth = more fundamental dependency" is the opposite of what "deeper" usually implies.

This is a pre-existing design issue, not introduced by this PR — but if this area is touched again in the future, consider renaming to something like injectionLevel or initializationOrder to make the intent clearer. The old comment "iterate by index in reverse for descending depth" was accurate (it did iterate reverse), but the effect was the bug: it was disposing dependencies first.

Suggestion: Add a brief explanatory note to TestContext.TrackedObjects or ObjectGraphDiscoverer.CollectRootObjects explaining the depth convention (0 = test class's direct deps, higher = deeper transitive deps that are initialized first).


Issue 2: Regression Test Uses [After(TestSession)] with a Verification Guard That May Miss Failures

[After(TestSession)]
public static async Task VerifyDisposalOrder(TestSessionContext context)
{
    var initOrder = NestedDisposalOrderTracker.GetInitOrder();
    var disposeOrder = NestedDisposalOrderTracker.GetDisposeOrder();

    // Guard: skip assertions if this test class was not part of the test run
    if (initOrder.Count == 0)
    {
        return;
    }
    ...
}

The [After(TestSession)] hook runs for the entire test session. The guard if (initOrder.Count == 0) return; silently skips verification if the fixtures were never initialized — which could happen if the test was filtered out, or if the test is run in isolation. In those cases, the regression guard is a no-op, giving false confidence.

Suggestion: Consider structuring the verification as an [After(Class)] hook on NestedDisposalOrderTests instead of [After(TestSession)]. That way the verification is tightly scoped to the test class, runs only when the class ran, and doesn't require the silent-skip guard. Alternatively, if [After(TestSession)] is required for timing reasons (disposal happens after the session ends), document why explicitly.


Issue 3: NestedDisposalOrderTracker is a Static Global — Fragile in Parallel Runs

NestedDisposalOrderTracker uses static List<string> fields guarded by a Lock. The [Before(Class)] hook calls Reset() before each test class run. This pattern is fragile:

  • If tests from NestedDisposalOrderTests run in parallel with other test classes that happen to init/dispose the same shared fixtures, the recorded orders could interleave.
  • The [NotInParallel] attribute helps, but only prevents parallelism between tests within this class. Other classes sharing PerTestSession fixtures could still interfere.
  • More importantly, the static tracker is reset in [Before(Class)] but the verification happens [After(TestSession)] — so if anything resets it again between class teardown and session end, the verification would silently pass via the guard.

Since SharedType.PerTestSession is used, the fixtures are shared across the session anyway, so the init/dispose ordering applies to the whole session. The test design is probably fine in practice, but the static global state pattern is fragile and could cause false passes or test pollution if the structure changes.

Suggestion: Consider whether the tracking could be done via the fixture constructors/disposers themselves (e.g. via a shared InjectedOrderRecorder fixture), rather than a static tracker. That would be more aligned with how TUnit fixtures are meant to be used.


Minor: Unused Variable Removal is Clean

The removal of the now-unused keys variable (var keys = trackedObjects.Keys;) is correct — values is accessed directly by index, so keys is no longer needed. Good cleanup.


Overall Assessment

The core fix is correct and addresses a real disposal ordering bug. The test is valuable. The concerns above are about test robustness and a pre-existing naming/documentation issue — they shouldn't block merging, but Issue 2 in particular (the silent-skip guard in the session-level hook) could mean the regression test doesn't actually catch regressions in certain execution contexts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: DisposeAsync() ordering since 1.21.0

2 participants