fix: create fresh non-shared instances per CombinedDataSources combination#6179
Conversation
…ation [CombinedDataSources] materialized each parameter's data source values once and reused the same object reference across every cartesian combination. For reference values from non-shared sources (e.g. [ClassDataSource] with SharedType.None) this meant one instance was shared across multiple test cases, violating SharedType.None semantics. Since test registration runs in parallel, both tests would concurrently inject properties into the same instance (the check-resolve-set sequence in PropertyInjector is not atomic), each creating its own nested property value. The losing test then tracked an orphaned object and initialized that at execution, while the test body observed the winner - whose IAsyncInitializer only ran via the other test's initialization. This caused the intermittent AOT failure in CombinedDataSource_WithNestedPropertyInjectionAndMultipleIAsyncInitializers. Defer value materialization to per-combination factories so each test case invokes the data source row factory itself: non-shared sources get a fresh instance per test case, while shared sources still resolve to their cached shared instance. Fixes #6177
There was a problem hiding this comment.
Code Review
Overall: This is a well-reasoned fix that correctly addresses the root cause of the shared-instance race. The PR description is thorough and the approach is sound. A few observations below.
The Core Fix — Correct
The deferred-materialization pattern is the right solution. By wrapping each value in a Func<Task<object?>> factory instead of materializing eagerly, the cartesian product correctly invokes each data source once per combination. For SharedType.None this produces a fresh instance per test; for SharedType.PerClass/Keyed the factory resolves to the cached shared instance. This honours each SharedType's semantics correctly.
Subtle Behaviour Change — Null/Empty Row Handling
Old code (ProcessDataSourceAsync):
var dataRow = await dataRowFunc();
if (dataRow != null && dataRow.Length > 0)
{
values.Add(dataRow[0]); // null/empty rows are SKIPPED
}New code:
valueFactories.Add(async () =>
{
var dataRow = await dataRowFunc();
return dataRow is { Length: > 0 } ? dataRow[0] : null; // factory still added; null returned
});Previously a null/empty data row would silently filter the entry from the factory set entirely — it would not expand the cartesian product. Now it contributes a factory that returns null, which IS included in the product and grows the combination count. For any data source that legitimately yields an empty row (even as a sentinel), this changes test count and test data unexpectedly.
In practice this is unlikely to matter — ClassDataSource never yields empty rows — but since the filtering was intentional in the original design it's worth being explicit. Consider either preserving the skip behaviour:
// Only add factory if the row is non-empty
Func<Task<object?>>? factory = null;
var dataRow = await dataRowFunc();
if (dataRow is { Length: > 0 })
{
var captured = dataRow[0];
factory = () => Task.FromResult(captured);
}
if (factory != null) valueFactories.Add(factory);…or documenting that null-producing rows are intentionally now first-class values.
GetCartesianProduct<T> Generics — Good Clean-Up
Making this generic is a nice improvement. It avoids boxing/unboxing during the product construction phase and makes the method reusable. No concerns here.
Regression Test — Well Designed
Using ConcurrentDictionary.TryAdd to assert distinct-by-reference identity is concise and accurate. Running this in parallel (as the engine does) turns the bug into a deterministic test failure rather than a race — good choice. The comment linking to #6177 is also helpful.
The Residual Race (Acknowledged)
The PR note about shared instances (PerClass/Keyed) with injected properties still racing via RegisterObjectAsync is accurate and reasonable to track separately. Out of scope here.
Minor
No public API changes, all TFMs clean, both source-gen and reflection modes covered — nothing more to add there.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | -9 |
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.
CombinedDataSourcesAttribute.GetCartesianProduct and MatrixDataSourceAttribute.GetMatrixValues were byte-for-byte copies of the same odometer-style cartesian product. Extract a single generic helper and use it from both. Also use a using directive instead of a fully-qualified ConcurrentDictionary in the new regression test.
There was a problem hiding this comment.
Code Review (Follow-up)
Checking previous review items and assessing final state.
Previous Concern: Null/Empty Row Handling — Still Unaddressed
My earlier review flagged a subtle behaviour change in ProcessDataSourceAsync. The current code is still:
valueFactories.Add(async () =>
{
var dataRow = await dataRowFunc();
return dataRow is { Length: > 0 } ? dataRow[0] : null;
});The factory is always added to valueFactories, even when the underlying row is null/empty. In the original code, a null/empty row would silently skip that entry — it would never appear in the cartesian product at all. Now it contributes a factory that produces null, which does enter the product and expands the test case count.
This is still worth a deliberate call: either preserve the skip with an eager check, or explicitly document that null rows are now first-class values in CombinedDataSources. Given no built-in data sources produce empty rows in normal usage, this is low-risk in practice — but it's still a semantic change from the original design.
Overall Assessment
Everything else is solid:
- Core fix is correct — deferred materialization properly addresses the
SharedType.Noneinstance-sharing race during parallel cartesian expansion. CartesianProductHelper<T>is a clean, well-motivated extraction — eliminates duplication betweenMatrixDataSourceAttributeandCombinedDataSourcesAttribute, and the generic avoids boxing.- Regression test is well-designed —
ConcurrentDictionary.TryAddgives reference-identity assertion without manual locking, and running under the normal parallel engine makes the pre-fix bug deterministic. - PR description documents root cause, fix, and residual race clearly.
The only open item is the null/empty row question from the prior review. Depending on whether that's intentional, a one-line comment or a guard in ProcessDataSourceAsync would close it out.
Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.48.6 to 1.51.0. <details> <summary>Release notes</summary> _Sourced from [TUnit.Core's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.51.0 <!-- Release notes generated using configuration in .github/release.yml at v1.51.0 --> ## What's Changed ### Other Changes * fix(mocks): WasCalled/WasNeverCalled assertions via [GenerateAssertion] for all ICallVerification types by @thomhurst in thomhurst/TUnit#6176 * fix: create fresh non-shared instances per CombinedDataSources combination by @thomhurst in thomhurst/TUnit#6179 * fix: assign TestDetails before TestContext is published to ClassHookContext.Tests by @thomhurst in thomhurst/TUnit#6182 * fix: resolve inherited instance data source members for MethodDataSource by @thomhurst in thomhurst/TUnit#6178 * feat(mocks): per-element matchers for params array parameters by @thomhurst in thomhurst/TUnit#6181 * fix: invoke inner Func for TestDataRow<Func<T>> data sources (#6161) by @thomhurst in thomhurst/TUnit#6183 ### Dependencies * chore(deps): update _tunitpolyfillversion to 10.8.0 by @thomhurst in thomhurst/TUnit#6167 * chore(deps): update dependency azure.storage.blobs to 12.29.0 by @thomhurst in thomhurst/TUnit#6168 * chore(deps): update aspire by @thomhurst in thomhurst/TUnit#6165 * chore(deps): update dependency cliwrap to 3.10.2 by @thomhurst in thomhurst/TUnit#6166 * chore(deps): update dependency streamjsonrpc to 2.25.25 by @thomhurst in thomhurst/TUnit#6170 * chore(deps): update dependency polyfill to 10.8.0 by @thomhurst in thomhurst/TUnit#6169 * chore(deps): update tunit to 1.5* by @thomhurst in thomhurst/TUnit#6171 * chore(deps): update _tunitpolyfillversion to 10.8.1 by @thomhurst in thomhurst/TUnit#6174 * chore(deps): update dependency polyfill to 10.8.1 by @thomhurst in thomhurst/TUnit#6175 **Full Changelog**: thomhurst/TUnit@v1.50.0...v1.51.0 ## 1.50.0 <!-- Release notes generated using configuration in .github/release.yml at v1.50.0 --> ## What's Changed ### Other Changes * fix(analyzers): decouple code fixers from Rules to prevent MissingFieldException in VS by @thomhurst in thomhurst/TUnit#6158 * Fix mock wrappers for indexers and generic methods by @thomhurst in thomhurst/TUnit#6163 * Add global mock default mode by @thomhurst in thomhurst/TUnit#6164 **Full Changelog**: thomhurst/TUnit@v1.49.0...v1.50.0 ## 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.51.0). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Summary
Fixes #6177 —
CombinedDataSource_WithNestedPropertyInjectionAndMultipleIAsyncInitializersfailing intermittently in AOT.Root cause
CombinedDataSourcesAttributematerialized each parameter''s data source values once and reused the same object reference across every cartesian combination. With[ClassDataSource<T>](defaultSharedType.None), oneAddressinstance was shared by both test cases — violatingSharedType.Nonesemantics.The race (mode-agnostic, surfaced by parallel registration introduced in #5524):
TestFilterService.RegisterTestsAsyncusesParallel.ForEachAsyncfor ≥8 tests) and concurrently runPropertyInjector.InjectPropertiesAsyncon the same shared instance — this path has no per-instance dedup.IsPropertyAlreadyPopulated→ resolve →SetPropertysequence is not atomic, so both create their own nestedLocation; the lastSetPropertywins.TrackObjectsgraph holds the orphanedLocation, so at execution it initializes the orphan while the test body reads the winner — whoseInitializeAsynconly runs via the other test''s initialization. If the loser''s body runs first →IsGeolocated == false→ "Expected to be true", with exactly one (scheduling-dependent) case failing. Matches both CI failures.Fix
Defer value materialization to per-combination factories: each test case invokes the data source row factory itself, so non-shared sources produce a fresh instance per test case while shared sources (
PerClass/Keyed/etc.) still resolve to their cached shared instance.Testing
CombinedDataSource_NonSharedClassDataSource_CreatesDistinctInstancePerTestCase— verified RED before the fix (second case received the same instance), GREEN after.CombinedDataSourceTests, 30ClassLevelCombinedDataSources_*, and 6CombinedDataSourceEdgeCaseTestspass in both source-gen and reflection modes (net10.0).TUnit.Corebuilds clean across all TFMs; no public API changes.Note
A residual (narrower) race remains for genuinely shared instances (
PerClass/Keyed) with injected properties, sinceRegisterObjectAsyncbypassesEnsureInitializedAsync''s per-object dedup — can be addressed separately if desired.