From 6e371441fae8950963ab6b7e0f514ab8d83c2217 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:49:05 +0100 Subject: [PATCH 1/2] fix: create fresh non-shared instances per CombinedDataSources combination [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 --- .../TestData/CombinedDataSourcesAttribute.cs | 67 ++++++++++++------- TUnit.TestProject/CombinedDataSourceTests.cs | 16 +++++ 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs b/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs index 28171aa5e4..c12d77ea18 100644 --- a/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs +++ b/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs @@ -101,23 +101,36 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat throw new InvalidOperationException("CombinedDataSources requires test information but none is available. This may occur during static property initialization."); } - // For each parameter, collect all possible values (individual values, not arrays) - var parameterValueSets = new List>(); + // For each parameter, collect a factory per possible value (individual values, not arrays). + // Values are materialized per combination (not once up front) so that non-shared + // reference values - e.g. [ClassDataSource] with SharedType.None - produce a fresh + // instance for every test case instead of one instance shared across the cartesian + // product, which races during parallel property injection/initialization (#6177). + var parameterValueFactorySets = new List>>>(); foreach (var param in parameterInformation) { - var parameterValues = await GetParameterValues(param, dataGeneratorMetadata); - parameterValueSets.Add(parameterValues); + var parameterValueFactories = await GetParameterValueFactories(param, dataGeneratorMetadata); + parameterValueFactorySets.Add(parameterValueFactories); } - // Compute Cartesian product of all parameter value sets - foreach (var combination in GetCartesianProduct(parameterValueSets)) + // Compute Cartesian product of all parameter value factory sets + foreach (var combination in GetCartesianProduct(parameterValueFactorySets)) { - yield return () => Task.FromResult(combination)!; + yield return async () => + { + var row = new object?[combination.Length]; + for (var i = 0; i < combination.Length; i++) + { + row[i] = await combination[i](); + } + + return row; + }; } } - private async Task> GetParameterValues(ParameterMetadata parameterMetadata, DataGeneratorMetadata dataGeneratorMetadata) + private async Task>>> GetParameterValueFactories(ParameterMetadata parameterMetadata, DataGeneratorMetadata dataGeneratorMetadata) { // Get all IDataSourceAttribute attributes on this parameter // Prefer cached attributes from source generator for AOT compatibility @@ -149,7 +162,7 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat throw new InvalidOperationException($"Parameter '{parameterMetadata.Name}' has no data source attributes. All parameters must have at least one IDataSourceAttribute when using [CombinedDataSources]."); } - var allValues = new List(); + var allValueFactories = new List>>(); // Process each data source attribute foreach (var dataSourceAttr in dataSourceAttributes) @@ -179,23 +192,23 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat InstanceFactory = dataGeneratorMetadata.InstanceFactory }; - // Get data rows from this data source (need to await async enumerable) - var dataRows = await ProcessDataSourceAsync(dataSourceAttr, singleParamMetadata); + // Get data row factories from this data source (need to await async enumerable) + var dataRowFactories = await ProcessDataSourceAsync(dataSourceAttr, singleParamMetadata); - allValues.AddRange(dataRows); + allValueFactories.AddRange(dataRowFactories); } - if (allValues.Count == 0) + if (allValueFactories.Count == 0) { throw new InvalidOperationException($"Parameter '{parameterMetadata.Name}' data sources produced no values."); } - return allValues; + return allValueFactories; } - private static async Task> ProcessDataSourceAsync(IDataSourceAttribute dataSourceAttr, DataGeneratorMetadata metadata) + private static async Task>>> ProcessDataSourceAsync(IDataSourceAttribute dataSourceAttr, DataGeneratorMetadata metadata) { - var values = new List(); + var valueFactories = new List>>(); // Special handling for ArgumentsAttribute when used on parameters with CombinedDataSources // ArgumentsAttribute yields ONE row containing ALL values, but for CombinedDataSources we need @@ -203,25 +216,31 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat if (dataSourceAttr is ArgumentsAttribute argsAttr) { // Each value in Arguments should be a separate option for this parameter - values.AddRange(argsAttr.Values); + foreach (var value in argsAttr.Values) + { + valueFactories.Add(() => Task.FromResult(value)); + } } else { await foreach (var dataRowFunc in dataSourceAttr.GetDataRowsAsync(metadata)) { - var dataRow = await dataRowFunc(); - if (dataRow != null && dataRow.Length > 0) + // Defer invocation: the row factory runs once per combination that uses it, + // so each test case gets its own value (fresh instance for non-shared sources). + valueFactories.Add(async () => { + var dataRow = await dataRowFunc(); + // Each data row should have exactly one element for this parameter - values.Add(dataRow[0]); - } + return dataRow is { Length: > 0 } ? dataRow[0] : null; + }); } } - return values; + return valueFactories; } - private static IEnumerable GetCartesianProduct(IReadOnlyList> parameterValueSets) + private static IEnumerable GetCartesianProduct(IReadOnlyList> parameterValueSets) { var dimensionCount = parameterValueSets.Count; @@ -241,7 +260,7 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat while (true) { - var row = new object?[dimensionCount]; + var row = new T[dimensionCount]; for (var dimension = 0; dimension < dimensionCount; dimension++) { row[dimension] = parameterValueSets[dimension][indices[dimension]]; diff --git a/TUnit.TestProject/CombinedDataSourceTests.cs b/TUnit.TestProject/CombinedDataSourceTests.cs index 04f373d570..82193619b8 100644 --- a/TUnit.TestProject/CombinedDataSourceTests.cs +++ b/TUnit.TestProject/CombinedDataSourceTests.cs @@ -622,6 +622,22 @@ public async Task CombinedDataSource_WithNestedPropertyInjectionAndMultipleIAsyn await Assert.That(address.IsValidated).IsTrue(); } + public class NonSharedInstance; + + private static readonly System.Collections.Concurrent.ConcurrentDictionary SeenNonSharedInstances = new(); + + [Test] + [CombinedDataSources] + public async Task CombinedDataSource_NonSharedClassDataSource_CreatesDistinctInstancePerTestCase( + [Arguments(1, 2)] int x, + [ClassDataSource] NonSharedInstance instance) + { + // ClassDataSource defaults to SharedType.None - each test case must get its OWN instance. + // Sharing one instance across the cartesian combinations causes a property-injection / + // initialization race during parallel test registration (#6177). + await Assert.That(SeenNonSharedInstances.TryAdd(instance, 0)).IsTrue(); + } + [Test] [CombinedDataSources] public async Task CombinedDataSource_ComplexScenario_MultipleParametersWithMixedFeatures( From a7ec9f28cf5414941856f1630a2f0388c8ce319d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:58:27 +0100 Subject: [PATCH 2/2] refactor: extract shared CartesianProductHelper 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. --- .../TestData/CombinedDataSourcesAttribute.cs | 51 +--------------- .../TestData/MatrixDataSourceAttribute.cs | 52 +--------------- TUnit.Core/Helpers/CartesianProductHelper.cs | 61 +++++++++++++++++++ TUnit.TestProject/CombinedDataSourceTests.cs | 3 +- 4 files changed, 67 insertions(+), 100 deletions(-) create mode 100644 TUnit.Core/Helpers/CartesianProductHelper.cs diff --git a/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs b/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs index c12d77ea18..73a02e109b 100644 --- a/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs +++ b/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using TUnit.Core.Enums; using TUnit.Core.Extensions; +using TUnit.Core.Helpers; namespace TUnit.Core; @@ -115,7 +116,7 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat } // Compute Cartesian product of all parameter value factory sets - foreach (var combination in GetCartesianProduct(parameterValueFactorySets)) + foreach (var combination in CartesianProductHelper.GetCartesianProduct(parameterValueFactorySets)) { yield return async () => { @@ -240,52 +241,4 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat return valueFactories; } - private static IEnumerable GetCartesianProduct(IReadOnlyList> parameterValueSets) - { - var dimensionCount = parameterValueSets.Count; - - // Any empty dimension makes the product empty (matches the previous - // Aggregate/SelectMany behaviour where SelectMany over [] yields nothing). - for (var dimension = 0; dimension < dimensionCount; dimension++) - { - if (parameterValueSets[dimension].Count == 0) - { - yield break; - } - } - - // Odometer-style Cartesian product: the last dimension varies fastest, - // matching the previous Aggregate/SelectMany ordering exactly. - var indices = new int[dimensionCount]; - - while (true) - { - var row = new T[dimensionCount]; - for (var dimension = 0; dimension < dimensionCount; dimension++) - { - row[dimension] = parameterValueSets[dimension][indices[dimension]]; - } - - yield return row; - - // Advance the odometer from the rightmost dimension. - var position = dimensionCount - 1; - while (position >= 0) - { - if (++indices[position] < parameterValueSets[position].Count) - { - break; - } - - indices[position] = 0; - position--; - } - - // All dimensions wrapped back to zero: enumeration is complete. - if (position < 0) - { - yield break; - } - } - } } diff --git a/TUnit.Core/Attributes/TestData/MatrixDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MatrixDataSourceAttribute.cs index 57e6c2df4a..25e1684d84 100644 --- a/TUnit.Core/Attributes/TestData/MatrixDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/MatrixDataSourceAttribute.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using TUnit.Core.Enums; using TUnit.Core.Extensions; +using TUnit.Core.Helpers; namespace TUnit.Core; @@ -70,7 +71,7 @@ public sealed class MatrixDataSourceAttribute : UntypedDataSourceGeneratorAttrib valueSets[i] = GetAllArguments(dataGeneratorMetadata, parameterInformation[i]); } - foreach (var rowArray in GetMatrixValues(valueSets)) + foreach (var rowArray in CartesianProductHelper.GetCartesianProduct(valueSets)) { var excluded = false; foreach (var exclusion in exclusions) @@ -246,53 +247,4 @@ private bool IsExcluded(object?[] exclusion, object?[] rowArray) throw new ArgumentNullException($"No MatrixAttribute found for parameter '{sourceGeneratedParameterInformation.Name}' and the parameter type '{resolvedType.Name}' cannot be auto-generated. Only bool and enum types support auto-generation."); } - private static IEnumerable GetMatrixValues(IReadOnlyList> elements) - { - var dimensionCount = elements.Count; - - // Any empty dimension makes the product empty (matches the previous - // Aggregate/SelectMany behaviour where SelectMany over [] yields nothing). - for (var dimension = 0; dimension < dimensionCount; dimension++) - { - if (elements[dimension].Count == 0) - { - yield break; - } - } - - // Odometer-style Cartesian product: the last dimension varies fastest, - // matching the previous Aggregate/SelectMany ordering exactly. - var indices = new int[dimensionCount]; - - while (true) - { - var row = new object?[dimensionCount]; - for (var dimension = 0; dimension < dimensionCount; dimension++) - { - row[dimension] = elements[dimension][indices[dimension]]; - } - - yield return row; - - // Advance the odometer from the rightmost dimension. - var position = dimensionCount - 1; - while (position >= 0) - { - if (++indices[position] < elements[position].Count) - { - break; - } - - indices[position] = 0; - position--; - } - - // All dimensions wrapped back to zero: enumeration is complete. - if (position < 0) - { - yield break; - } - } - } - } diff --git a/TUnit.Core/Helpers/CartesianProductHelper.cs b/TUnit.Core/Helpers/CartesianProductHelper.cs new file mode 100644 index 0000000000..be71398480 --- /dev/null +++ b/TUnit.Core/Helpers/CartesianProductHelper.cs @@ -0,0 +1,61 @@ +namespace TUnit.Core.Helpers; + +/// +/// Computes Cartesian products for data source expansion +/// (e.g. and ). +/// +internal static class CartesianProductHelper +{ + /// + /// Computes the Cartesian product of the given sets. + /// The last dimension varies fastest, matching Aggregate/SelectMany ordering. + /// + public static IEnumerable GetCartesianProduct(IReadOnlyList> sets) + { + var dimensionCount = sets.Count; + + // Any empty dimension makes the product empty (matches the previous + // Aggregate/SelectMany behaviour where SelectMany over [] yields nothing). + for (var dimension = 0; dimension < dimensionCount; dimension++) + { + if (sets[dimension].Count == 0) + { + yield break; + } + } + + // Odometer-style Cartesian product: the last dimension varies fastest, + // matching the previous Aggregate/SelectMany ordering exactly. + var indices = new int[dimensionCount]; + + while (true) + { + var row = new T[dimensionCount]; + for (var dimension = 0; dimension < dimensionCount; dimension++) + { + row[dimension] = sets[dimension][indices[dimension]]; + } + + yield return row; + + // Advance the odometer from the rightmost dimension. + var position = dimensionCount - 1; + while (position >= 0) + { + if (++indices[position] < sets[position].Count) + { + break; + } + + indices[position] = 0; + position--; + } + + // All dimensions wrapped back to zero: enumeration is complete. + if (position < 0) + { + yield break; + } + } + } +} diff --git a/TUnit.TestProject/CombinedDataSourceTests.cs b/TUnit.TestProject/CombinedDataSourceTests.cs index 82193619b8..9d43a74d64 100644 --- a/TUnit.TestProject/CombinedDataSourceTests.cs +++ b/TUnit.TestProject/CombinedDataSourceTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using TUnit.Core.Interfaces; using TUnit.TestProject.Attributes; @@ -624,7 +625,7 @@ public async Task CombinedDataSource_WithNestedPropertyInjectionAndMultipleIAsyn public class NonSharedInstance; - private static readonly System.Collections.Concurrent.ConcurrentDictionary SeenNonSharedInstances = new(); + private static readonly ConcurrentDictionary SeenNonSharedInstances = new(); [Test] [CombinedDataSources]