Repository navigation
fix: never hand out a disposed shared data-source instance - #7248
Conversation
When the last test holding a shared instance (PerTestSession, PerClass, PerAssembly or Keyed) ends, the instance is disposed but stayed cached in TestDataContainer. Any later request for the same scope got the disposed instance: a dynamic test registered later in the run, or code calling SharedDataSources.GetOrCreate. - TestDataContainer indexes cached disposable instances by identity, and ObjectTracker evicts an instance when it retires it, so the next request creates a new one. - A running test that fetches a shared instance through SharedDataSources now holds it until the test ends, so it is not disposed under the test and is disposed afterwards if no other test holds it. Retiring and holding are serialized, so an instance is either retired or held. Fixes #7204
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Cache and retirement src/TUnit.Core/TestDataContainer.cs, src/TUnit.Core/Tracking/ObjectTracker.cs, tests/TUnit.UnitTests/SharedInstanceLifetimeTests.cs |
A concurrent cache stores shared instances by scope and type. ObjectTracker coordinates reference acquisition, disposal, and cache eviction. Unit tests cover eviction, multiple keys, and retirement races. |
Running-test reference holds src/TUnit.Core/TestContext.cs, src/TUnit.Engine/Services/TestExecution/TestCoordinator.cs, src/TUnit.Core/SharedDataSources.cs, src/TUnit.Core/Tracking/ObjectTracker.cs, tests/TUnit.UnitTests/SharedInstanceLifetimeTests.cs |
Test execution defines when shared references can be held. SharedDataSources holds resolved instances for running tests and retries resolution if holding fails. Unit tests cover hold behavior and concurrent fetches. |
Lifetime tests and documentation tests/TUnit.TestProject/Bugs/_7204/*, tests/TUnit.Engine.Tests/Issue7204Tests.cs, docs/docs/writing-tests/class-data-source.md |
Regression tests cover retrieval after disposal, dynamic registration, and constructor-time fetching. Documentation describes shared-instance lifetime and access during and outside a test. |
Priority:
Estimated code review effort: 4 (Complex) | ~45 minutes
Change: Bug fix · Severity of issue fixed: Medium
Merge Risk: 
Low · up to c9915
-
Concurrent reuse of one shared instance under multiple keys can occasionally return a disposed instance. This is a bounded risk that can be accepted with owner awareness.
Pre-merge checks: 
4 · 
1
-
Failed checks: 1 warning Passed checks: 4 passed-
Full details: Linked Issues check
-
Explanation
Directly linked issue [
#7204] requires that a test registered after the last holder of a shared instance gets a new instance, not the disposed cached one. Scopes: Keyed, PerClass, PerAssembly, PerTestSession.Evidence at the reviewed head (c991548):
- src/TUnit.Core/Tracking/ObjectTracker.cs, UntrackObject: when the count of a cached instance reaches zero (TestDataContainer.IsCached), TryRetireSharedInstance runs. Under s_sharedInstanceLock it checks CurrentCount == 0, removes the counter, and calls TestDataContainer.Evict. Only then is the instance disposed. The outcome of the test does not affect this path.
- TryRetireSharedInstance and TryHoldForRunningTest use the same lock, so an instance is either retired or held. A held instance is not disposed. Re-hold after zero count is handled by the CurrentCount re-check.
- src/TUnit.Core/SharedDataSources.cs, GetShared: when TryHoldForRunningTest returns false (instance retired), the loop calls Resolve again. The cache then creates a new instance.
- Non-disposable instances are skipped by ShouldSkipTracking and are not indexed. Their caching behavior is unchanged.
Tests: the PR adds tests/TUnit.TestProject/Bugs/_7204 and Issue7204Tests (engine), plus SharedInstanceLifetimeTests (unit). I did not execute them. The reported pass results are author claims. The source evidence supports the fix for the reproduction in
#7204.Not covered by this PR and not required by the issue text: registration at the exact moment the last holder ends (documented by the PR as a pre-existing race), and async initialization through GetOrCreate. The issue does not request either.
Full details: Out of Scope Changes check
-
Explanation
Changes and their link to
#7204:- ObjectTracker.cs, TryRetireSharedInstance and RemoveCounter: eviction on retirement. This is the core fix.
- ObjectTracker.cs, TryHoldForRunningTest: holds instances fetched by a running test through SharedDataSources. This covers the same defect (a disposed shared instance handed out after disposal) through a second entry point. It is a related extension, not unrelated work.
- ObjectTracker.cs, UntrackObjectsAsync and UntrackBucketAsync: release test-held references last, and batch disposal per bucket with error collection. The release ordering supports the held-instance design. The batching is a refactor of the untracking path. The PR does not explain it separately, but it is part of the same untracking flow and is not a separate feature.
- ObjectTracker.TrackObjects, RemoveObjectsNotIn on discovery failure: removes only the entries added by the failed call. This prevents a test from releasing references it never took, which the eviction logic depends on.
- TestDataContainer.cs: identity index, IsCached, Evict, and Reset. These support eviction.
- SharedDataSources.cs, GetShared and Resolve: routes non-None sharing through one path that holds the instance for a running test. The old generic helpers were replaced by type-based helpers. Behavior for None is unchanged.
- TestContext.cs and TestCoordinator.cs: the hold window opens after Before(Class/Assembly/Session) hooks and closes before untracking. This bounds the hold behavior. The summaries describe these boundaries. I did not read these files directly.
- Docs (class-data-source.md): describes the new lifetime behavior.
- Tests (UnitTests, TestProject _7204, Issue7204Tests, ConstructorFetchedSharedInstanceTests): support the change.
I found no change that is demonstrably unrelated to shared-instance lifetime. The PR states that it makes no public API changes, and the summaries list none.
-
Full details: Docstring Coverage
-
Explanation
Docstring coverage is 26.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 60 functions across 9 files. (1 skipped: 1 unsupported.)
Resolution
Write docstrings for the functions missing them to satisfy the coverage threshold.
-
Finishing Touches: 💡 1
-
📝 Generate docstrings: 💡
-
- Commit to this branch
- Create a new PR
🧪 Generate unit tests (beta)
-
- Commit to this branch
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
A rabbit checks the cache at dawn,
And finds old shared instances gone.
While tests are running, holds stay tight,
Then disposal follows test-end night.
Fresh keys bring fresh instances to light,
The rabbit hops away, all right.
Comment @coderabbitai help to get the list of available commands.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e3eb82d826
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Review of #7248I read the diff. I did not build or run the tests, and the Overall: the fix looks sound and well-scoped. It addresses #7204 at the cause: the cache kept handing out an instance that What I checked
Suggestions (non-blocking)
No blocking issues found. Nice work. |
[Medium impact] The PR appears safe to merge; all four previous findings are addressed. SummaryShared disposable instances are evicted when their last reference is released. Running tests hold instances they fetch until cleanup.
No new actionable issues were found. Tests were inspected, not run.
|
- Evict every cache entry that holds a retired instance, not only the last one recorded; a factory returning one instance for several keys made a running test's fetch loop forever. - Dispose only when this caller removes the instance's own counter, in both the cached and fallback paths, so a holder that saw the count reach zero cannot dispose again after a running test re-held, released and disposed the instance. - Keep instances a running test fetches in a separate held set instead of TrackedObjects, so the engine does not initialize them and an initializer fetching another instance cannot modify the set the initialization walk is enumerating. - Docs: name which hooks are inside and outside the hold window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #7248 (shared instance eviction on retirement)I read the core changes in Concerns, in order of importance
Verdict: a good fix with solid tests. I'd address item 1 or document its limit, and the rest are optional. |
Fixes #7204.
Problem
When the last test holding a shared data-source instance (
PerTestSession,PerClass,PerAssemblyorKeyed) ends,ObjectTrackerdisposes the instance, butTestDataContainerkeeps it cached until the session ends. Any later request for the same scope gets the disposed instance back:SharedDataSources.GetOrCreateitself, for example inside a test, after the holders have finished. That call also never took a reference, so the instance could be disposed while the test was still using it.Fix
TestDataContainernow keeps disposable cached instances in an identity index. When a cached instance's count reaches zero,ObjectTrackerretires it: it removes the counter and evicts the instance under one lock, then disposes it. The next request creates a new instance.TestContextgets a hold window: it opens after the Before(Class/Assembly/Session) hooks and closes just before the test's tracked objects are untracked. ASharedDataSources.GetOrCreatecall made by a test inside that window adds the instance to that test's tracked objects and takes one reference. The test then releases it when it ends, like an injected instance.SharedDataSourcesare still counted only by the object-graph walk. No reference is counted twice.ClassDataSource, is not counted again.GetOrCreatefetches again and gets a new instance.class-data-source.md.No public API changes (
ScopedDictionaryis untouched;TestDataContaineris internal).Not covered
GetOrCreatestill does not take a reference. The docs now say so and point to static-property injection for session lifetime.GetOrCreatestill does not callIAsyncInitializer.InitializeAsync; that would need a separate async API.SharedDataSources) at the same moment as the last holder ends can still race. This window existed before and is unchanged.Tests
tests/TUnit.UnitTests/SharedInstanceLifetimeTests.cs: eviction after disposal, holding until the test ends, a new instance after disposal, no double count, no hold outside the window, 32 concurrent fetches in one test. 4 of the 6 fail with the fix disabled; the other 2 guard against overcounting.tests/TUnit.TestProject/Bugs/_7204+tests/TUnit.Engine.Tests/Issue7204Tests.cs: a dynamic test registered after disposal, and a running test fetching after disposal. Both fail without the fix, in source-gen and reflection modes.TUnit.UnitTests(623 passed, 4 skipped). Engine testsIssue7204,Issue7156,FilteredSharedFixtureDisposal,KeyedDataSource,DynamicTests,DynamicTestIndex,DerivedSkipWithClassDataSource,PartialInjectionFailure6554andPropertyInjectionInitFailurepass.*Shared*,*Dispos*,*Inject*and*Keyed*subsets, both modes: failures are identical tomain(intentional failures, plus the order-dependentClassDataSourceSharedNoneNoLeakageTestscounters, which also fail onmainunder these broad filters).Found along the way
While writing the dynamic-test repro:
TestRegistry.CreateRuntimeTestInvokercompiles theExpression<Action<T>>, so anasync Tasktest method's task is discarded and its failures are never reported. Filed separately.Summary by CodeRabbit
Bug Fixes
Documentation