Skip to content

fix: create fresh non-shared instances per CombinedDataSources combination#6179

Merged
thomhurst merged 2 commits into
mainfrom
fix/6177-combined-datasources-fresh-instance-per-combination
Jun 7, 2026
Merged

fix: create fresh non-shared instances per CombinedDataSources combination#6179
thomhurst merged 2 commits into
mainfrom
fix/6177-combined-datasources-fresh-instance-per-combination

Conversation

@thomhurst

Copy link
Copy Markdown
Owner

Summary

Fixes #6177CombinedDataSource_WithNestedPropertyInjectionAndMultipleIAsyncInitializers failing intermittently in AOT.

Root cause

CombinedDataSourcesAttribute materialized each parameter''s data source values once and reused the same object reference across every cartesian combination. With [ClassDataSource<T>] (default SharedType.None), one Address instance was shared by both test cases — violating SharedType.None semantics.

The race (mode-agnostic, surfaced by parallel registration introduced in #5524):

  1. Both test cases register in parallel (TestFilterService.RegisterTestsAsync uses Parallel.ForEachAsync for ≥8 tests) and concurrently run PropertyInjector.InjectPropertiesAsync on the same shared instance — this path has no per-instance dedup.
  2. The IsPropertyAlreadyPopulated → resolve → SetProperty sequence is not atomic, so both create their own nested Location; the last SetProperty wins.
  3. The losing test''s TrackObjects graph holds the orphaned Location, so at execution it initializes the orphan while the test body reads the winner — whose InitializeAsync only 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

  • New regression test CombinedDataSource_NonSharedClassDataSource_CreatesDistinctInstancePerTestCase — verified RED before the fix (second case received the same instance), GREEN after.
  • All 200 CombinedDataSourceTests, 30 ClassLevelCombinedDataSources_*, and 6 CombinedDataSourceEdgeCaseTests pass in both source-gen and reflection modes (net10.0).
  • TUnit.Core builds clean across all TFMs; no public API changes.

Note

A residual (narrower) race remains for genuinely shared instances (PerClass/Keyed) with injected properties, since RegisterObjectAsync bypasses EnsureInitializedAsync''s per-object dedup — can be addressed separately if desired.

…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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@codacy-production

codacy-production Bot commented Jun 7, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics -9 complexity

Metric Results
Complexity -9

View in Codacy

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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.None instance-sharing race during parallel cartesian expansion.
  • CartesianProductHelper<T> is a clean, well-motivated extraction — eliminates duplication between MatrixDataSourceAttribute and CombinedDataSourcesAttribute, and the generic avoids boxing.
  • Regression test is well-designed — ConcurrentDictionary.TryAdd gives 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.

@thomhurst thomhurst enabled auto-merge (squash) June 7, 2026 18:13
@thomhurst thomhurst merged commit a5416c8 into main Jun 7, 2026
14 checks passed
@thomhurst thomhurst deleted the fix/6177-combined-datasources-fresh-instance-per-combination branch June 7, 2026 18:19
github-actions Bot pushed a commit to IntelliTect/CodingGuidelines that referenced this pull request Jun 8, 2026
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>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit.Core&package-manager=nuget&previous-version=1.48.6&new-version=1.51.0)](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>
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.

Flaky in AOT: CombinedDataSource_WithNestedPropertyInjectionAndMultipleIAsyncInitializers fails intermittently

1 participant