Skip to content

move to TUnit, fix thread safety edge cases#34

Merged
lofcz merged 10 commits into
nextfrom
feat-tunit
Mar 10, 2026
Merged

move to TUnit, fix thread safety edge cases#34
lofcz merged 10 commits into
nextfrom
feat-tunit

Conversation

@lofcz

@lofcz lofcz commented Mar 10, 2026

Copy link
Copy Markdown
Owner

No description provided.

Copilot AI review requested due to automatic review settings March 10, 2026 00:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR migrates the test suite to TUnit / Microsoft.Testing.Platform and refactors FastCloner’s runtime configuration + caches to address thread-safety/configuration edge cases by using immutable config snapshots and config-keyed caches.

Changes:

  • Introduce FastClonerRuntimeConfig snapshots, FastClonerPublishedEngine, and a thread-static FastClonerRuntimeConfigScope.
  • Version cache entries (and some “safe type” decisions) by a configuration CacheKey to avoid cross-config contamination.
  • Migrate tests from NUnit-style assertions/fixtures to TUnit-style async assertions and lifecycle attributes; update test runner configuration.

Reviewed changes

Copilot reviewed 47 out of 48 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/global.json Configures the test runner (Microsoft.Testing.Platform).
src/FastCloner/FastCloner.cs Publishes immutable runtime config snapshots; updates public config/type-behavior APIs.
src/FastCloner/Code/FastClonerRuntimeConfig.cs Adds runtime config snapshot + published engine + thread-static scope types.
src/FastCloner/Code/FastClonerCache.cs Introduces config-keyed (“versioned”) caches and config-aware behavior queries.
src/FastCloner/Code/FastClonerSafeTypes.cs Splits known-types cache into default vs config-keyed results.
src/FastCloner/Code/FastClonerExprGenerator.cs Keys adaptive dictionary processor factory cache by config cache key.
src/FastCloner/Code/FastCloneState.cs Threads config through cloning state (thread-static pools/simple state).
src/FastCloner.Tests/FastCloner.Tests.csproj Replaces NUnit packages with TUnit; targets net10.0.
src/FastCloner.Tests/GlobalUsings.cs Removes NUnit global using (tests rely on new framework setup).
src/FastCloner.Tests/BaseTestFixture.cs Replaces NUnit fixtures with TUnit [Arguments] and setup/teardown attributes.
src/FastCloner.Tests/TypeTests.cs Migrates assertions/metadata to TUnit async asserts/properties.
src/FastCloner.Tests/SourceGeneratorTests.cs Migrates to async TUnit asserts; minor test body adjustments.
src/FastCloner.Tests/SourceGeneratorSafetyTests.cs Migrates assertions and Task member usage to TUnit style.
src/FastCloner.Tests/SourceGeneratorGenericTests.cs Migrates to async TUnit asserts; updates collection literals.
src/FastCloner.Tests/SourceGeneratorCrossNamespaceTests.cs Migrates to async TUnit asserts; simplifies regions/collections.
src/FastCloner.Tests/SourceGeneratorCompatibleAttribute.cs Adjusts attribute usage metadata for new test framework conventions.
src/FastCloner.Tests/ShallowCloneTests.cs Migrates to async TUnit asserts; updates numeric literal typing.
src/FastCloner.Tests/RecordTests.cs Migrates to async TUnit asserts.
src/FastCloner.Tests/ObjectTests.cs Migrates to async TUnit asserts; retains existing test coverage.
src/FastCloner.Tests/InheritanceTests.cs Migrates to async TUnit asserts.
src/FastCloner.Tests/IncludeAttributeTests.cs Migrates to async TUnit asserts.
src/FastCloner.Tests/ImplicitCollectionTests.cs Migrates to async TUnit asserts; updates collection literals.
src/FastCloner.Tests/IdentityPreservationTests.cs Migrates to async TUnit asserts; updates collection initializers.
src/FastCloner.Tests/GenericTests.cs Migrates to async TUnit asserts; updates reference-equality assertions.
src/FastCloner.Tests/FailureHypothesisTests.cs Migrates to async TUnit asserts; updates collection literals/usings.
src/FastCloner.Tests/DynamicMaxRecursionDepthTests.cs Updates lifecycle hooks and assertions for TUnit; resets global state after each test.
src/FastCloner.Tests/DiagnosticTests.cs Migrates diagnostic assertions to async TUnit style; modernizes collection literals.
src/FastCloner.Tests/DbTests.cs Converts to TUnit lifecycle; makes session factory static; adds non-parallel marker.
src/FastCloner.Tests/CtorTests.cs Migrates concurrency tests/assertions to async TUnit style.
src/FastCloner.Tests/ContextTests.cs Migrates to async TUnit asserts for context cloning behaviors.
src/FastCloner.Tests/ConcurrentTests.cs Migrates to async TUnit asserts; adjusts concurrency coordination.
src/FastCloner.Tests/CollectionTests.cs Migrates to async TUnit asserts for collection cloning tests.
src/FastCloner.Tests/CircularTests.cs Migrates to async TUnit asserts for cycle handling tests.
src/FastCloner.Tests/BenchmarkModelTests.cs Migrates to async TUnit asserts for benchmark model validation.
src/FastCloner.Tests/BehaviorAttributeTests.cs Migrates to async TUnit asserts for attribute-driven behavior tests.
src/FastCloner.Tests/ArrayTests.cs Migrates to async TUnit asserts; updates collection assertions and literals.
src/FastCloner.Tests/AdvancedCollectionTests.cs Migrates to async TUnit asserts; updates collection literals.
src/FastCloner.Tests/AbstractClassTests.cs Migrates to async TUnit asserts across polymorphic/abstract scenarios.
src/FastCloner.Benchmark/Program.cs Minor cleanup + pattern matching tweak; makes Program internal.
.bench-harness-current/Program.cs Adds a simple benchmark harness for quick local perf checks.
.bench-harness-current/BenchHarness.csproj Adds bench harness project referencing FastCloner.
Comments suppressed due to low confidence (1)

src/FastCloner.Tests/DbTests.cs:116

  • sessionFactory is nullable (ISessionFactory?), but it's dereferenced without a null-forgiving operator or guard (e.g., sessionFactory.OpenSession()). With nullable enabled this will generate warnings (and potentially fail builds if warnings are treated as errors). If SetUp guarantees initialization, consider making it non-nullable and assigning in SetUp (or use sessionFactory! at dereference sites).
    {
        using ISession? session = sessionFactory.OpenSession();
        using ITransaction? transaction = session.BeginTransaction();
        try

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/FastCloner/Code/FastClonerCache.cs Outdated
Comment on lines +361 to +382
private readonly ConcurrentDictionary<IntPtr, TValue> defaultCache = new ConcurrentDictionary<IntPtr, TValue>();
private readonly ConcurrentDictionary<ConfigTypeKey, TValue> versionedCache = new ConcurrentDictionary<ConfigTypeKey, TValue>();

public TValue GetOrAdd(Type type, Func<Type, TValue> valueFactory)
{
IntPtr handle = type.TypeHandle.Value;
return cache.TryGetValue(handle, out TValue? existing) ? existing : cache.GetOrAdd(handle, _ => valueFactory(type));
long cacheKey = GetAmbientCacheKey();
if (cacheKey == 0)
{
return defaultCache.TryGetValue(handle, out TValue? existing)
? existing
: defaultCache.GetOrAdd(handle, _ => valueFactory(type));
}

return versionedCache.GetOrAdd(new ConfigTypeKey(cacheKey, handle), _ => valueFactory(type));
}

public void Clear() => cache.Clear();
public void Clear()
{
defaultCache.Clear();
versionedCache.Clear();
}

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

The new versioned caches (e.g., versionedCache) are keyed by CacheKey and are never pruned. If callers toggle config (MaxRecursionDepth / type behaviors) repeatedly, these dictionaries can grow without bound and retain all historical entries. Consider a cleanup strategy (e.g., clear per-config caches when publishing a new config, keep only the latest N cacheKeys, or use a ConditionalWeakTable keyed by config instances).

Copilot uses AI. Check for mistakes.
Comment thread src/FastCloner/Code/FastClonerCache.cs Outdated
Comment on lines +67 to +77
private static readonly ConcurrentDictionary<CacheIdentity, CacheEntry> versionedEntries = new();

public static CacheEntry GetCurrent()
public static CacheEntry GetCurrent(FastClonerRuntimeConfig config)
{
long currentVersion = FastClonerCache.GetCacheVersion();

if (config.CacheKey != 0)
{
CacheIdentity identity = new(currentVersion, config.CacheKey);
return versionedEntries.GetOrAdd(identity, _ => Refresh(config, currentVersion));
}

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

ClonerCache<T> now stores per-(clearVersion, configCacheKey) entries in versionedEntries, but this dictionary is never cleared when cacheVersion bumps or when configs change. Over time this can accumulate entries for every config version ever published. Consider clearing versionedEntries when the global cache version changes, or otherwise bounding/pruning old identities.

Copilot uses AI. Check for mistakes.
Comment on lines +95 to +103
private static readonly ConcurrentDictionary<Type, bool> defaultKnownTypes = [];
private static readonly ConcurrentDictionary<ConfigKnownTypeKey, bool> versionedKnownTypes = [];

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static long GetAmbientCacheKey()
{
FastClonerRuntimeConfig? current = FastClonerRuntimeConfigScope.TryGetCurrent();
return current?.CacheKey ?? FastCloner.GetPublishedCacheKey();
}

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

FastClonerSafeTypes caches known-type results in versionedKnownTypes keyed by config CacheKey, but this cache is never pruned. If config cache keys are frequently incremented, this can grow indefinitely. Consider clearing versioned known-types when publishing a new config (or bounding/pruning old cache keys).

Copilot uses AI. Check for mistakes.

[Test]
public void Nullable_Shoild_Be_Cloned()
public async Task Nullable_Shoild_Be_Cloned()

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

Typo in the test name: Nullable_Shoild_Be_ClonedNullable_Should_Be_Cloned (helps discoverability/searchability and keeps naming consistent with other tests).

Suggested change
public async Task Nullable_Shoild_Be_Cloned()
public async Task Nullable_Should_Be_Cloned()

Copilot uses AI. Check for mistakes.
Comment thread src/FastCloner/FastCloner.cs Outdated
Comment on lines +45 to +49
publishedEngine = CreatePublishedEngine(
current.MaxRecursionDepth,
value,
current.CloneTypeBehaviors());
}

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

publishedEngine is read via Volatile.Read(ref publishedEngine) but written with a plain assignment inside the lock. Readers don't take the lock, so this isn't a guaranteed safe publication on all architectures. Use Volatile.Write(ref publishedEngine, ...) or Interlocked.Exchange(ref publishedEngine, ...) for the assignment(s) to match the volatile read semantics.

Copilot uses AI. Check for mistakes.
Comment on lines 113 to 116
/// <remarks>
/// Setting <see cref="CloneBehavior.Clone"/> removes any custom behavior (equivalent to <see cref="ClearTypeBehavior"/>).
/// Note that changing behavior clears the cache, which may impact performance until the cache is repopulated.
/// </remarks>

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

The XML docs for SetTypeBehavior / ClearTypeBehavior / ClearAllTypeBehaviors state that changing behavior clears the cache, but the implementation now only republishes a new runtime config without calling FastClonerCache.ClearCache() / FastClonerSafeTypes.ClearKnownTypesCache(). Either update the docs to reflect the new versioned-cache approach, or explicitly clear caches here if that contract is still intended.

Copilot uses AI. Check for mistakes.
@github-actions

github-actions Bot commented Mar 10, 2026

Copy link
Copy Markdown

Deep Clone Benchmarks

  • OS: ubuntu-latest
  • Generated (UTC): 2026-03-10 04:04:46

Current FastCloner vs DeepCloner

Benchmark DeepCloner FastCloner Delta Time DC Alloc FC Alloc Delta Alloc
SmallObject 92.94 ns 74.75 ns -20% faster 184 B 48 B -74% less
FileSpec 545.57 ns 291.71 ns -47% faster 920 B 416 B -55% less
StringArray_1000 627.47 ns 605.73 ns -3% faster 8,160 B 8,024 B ~same
SmallObjectWithCollections 673.91 ns 388.10 ns -42% faster 1,096 B 576 B -47% less
DynamicWithDictionary 1,427.77 ns 1,037.92 ns -27% faster 2,712 B 1,600 B -41% less
MediumNestedObject 1,647.47 ns 1,089.07 ns -34% faster 3,416 B 1,616 B -53% less
DynamicWithNestedObject 2,027.14 ns 1,323.34 ns -35% faster 3,560 B 1,776 B -50% less
DynamicWithArray 5,724.69 ns 4,549.68 ns -21% faster 8,800 B 2,744 B -69% less
LargeEventDocument_10MB 68,538.92 ns 38,495.15 ns -44% faster 129,792 B 49,824 B -62% less
ObjectList_100 180,604.50 ns 112,224.99 ns -38% faster 318,888 B 149,816 B -53% less
ObjectDictionary_50 838,178.54 ns 186,847.96 ns -78% faster 549,657 B 218,768 B -60% less
LargeLogBatch_10MB 6,154,544.53 ns 3,651,896.45 ns -41% faster 3,564,703 B 2,649,106 B -26% less

FastCloner vs latest next baseline

  • Baseline generated (UTC): 2026-03-10 04:03:23
  • Regression thresholds: time > 5%, alloc > 5%
Status Benchmark Delta Time Delta Alloc
DynamicWithArray -2% faster ~same
DynamicWithDictionary ~same ~same
🟢 DynamicWithNestedObject -6% faster ~same
🟢 FileSpec -14% faster ~same
🔴 LargeEventDocument_10MB +9% slower ~same
LargeLogBatch_10MB ~same ~same
MediumNestedObject ~same ~same
ObjectDictionary_50 ~same ~same
ObjectList_100 ~same ~same
🟢 SmallObject -6% faster ~same
SmallObjectWithCollections +4% slower ~same
StringArray_1000 -5% faster ~same

Regressions

  • LargeEventDocument_10MB: time +9% slower, alloc ~same

Improvements

  • DynamicWithNestedObject: time -6% faster, alloc ~same
  • FileSpec: time -14% faster, alloc ~same
  • SmallObject: time -6% faster, alloc ~same

Mixed changes

  • none

Copilot AI review requested due to automatic review settings March 10, 2026 01:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 52 out of 53 changed files in this pull request and generated 6 comments.

Comments suppressed due to low confidence (2)

src/FastCloner/Code/FastCloneState.cs:140

  • FastCloneState instances returned to the pool keep a reference to whatever Configuration they last used. If a non-default FastClonerRuntimeConfig was used, this can retain type-behavior dictionaries and prevent them from being GC’d while the state sits in the pool. Consider resetting Configuration back to FastClonerRuntimeConfig.Default inside Reset() (or just before pooling in Return).
    .github/workflows/benchmark.yml:201
  • RESULT_DIR is written as src/benchmark-results/... (relative to repo root), but the later “Generate normalized report and diff” step runs with working-directory: src and uses $env:RESULT_DIR directly. This will create outputs under src/src/benchmark-results/... and break the expected artifact paths. Either set RESULT_DIR to benchmark-results/... when running under src, or drop working-directory: src for the report/diff step.
    - name: Generate normalized report and diff
      if: steps.run_gate.outputs.should_run == 'true'
      working-directory: src
      shell: pwsh
      run: |
        New-Item -ItemType Directory -Force -Path $env:RESULT_DIR | Out-Null


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/FastCloner.Tests/DynamicMaxRecursionDepthTests.cs
Comment thread global.json
Comment thread src/FastCloner/Code/FastClonerCache.cs Outdated
Comment thread src/FastCloner/Code/FastClonerExprGenerator.cs
Comment thread .github/workflows/dotnet.yml
Comment thread src/FastCloner.Tests/BaseTestFixture.cs
Copilot AI review requested due to automatic review settings March 10, 2026 02:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 54 out of 55 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 23 to 26
env:
DOTNET_VERSION: "10.0.103"
BASELINE_BRANCH: "next"

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

The workflow sets BASELINE_BRANCH to next, but the benchmark CI README describes baseline artifacts and slow-path cloning coming from master. Please align the workflow/env var with the documented baseline branch (or update the README to match the intended branch).

Copilot uses AI. Check for mistakes.
Comment thread .github/workflows/release.yml
Co-Authored-By: Ingbert Palm <13691398+ingbertpalm@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 10, 2026 03:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 54 out of 55 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

src/FastCloner/Code/FastClonerSafeTypes.cs:286

  • ClearKnownTypesCache swaps out the knownTypes dictionary without any memory barrier. Because this cache is read concurrently, the assignment should use Volatile.Write/Interlocked.Exchange (and reads should use Volatile.Read) to avoid publication/reordering issues under concurrency when replacing the dictionary instance.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 54 to +61
- Push/PR to `next`: runs on `ubuntu-latest` by default.
- Manual run (`workflow_dispatch`): OS checkboxes allow running on Windows, Ubuntu, and macOS.
- PR runs post an upserted comment with:
- current `FastCloner` vs `DeepCloner` table
- regression/improvement diff against latest successful baseline from `next`
- regression/improvement diff against the latest successful baseline from `master`
- Baseline resolution order for PR runs:
- use the latest successful uploaded baseline artifact for the target OS from `master`
- if no artifact is available, fall back to a slow path that clones `master`, runs the benchmark suite there, and generates a fresh normalized baseline before comparing

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

This README says PR benchmarks diff against a baseline from master, but .github/workflows/benchmark.yml is currently configured with BASELINE_BRANCH: "next". Update the README or the workflow so they agree on which branch provides the baseline artifacts/slow-path baseline generation.

Copilot uses AI. Check for mistakes.
Comment on lines 18 to +31
@@ -26,6 +26,9 @@ static FastClonerExprGenerator()
fieldSetMethod = typeof(FieldInfo).GetMethod(nameof(FieldInfo.SetValue), [typeof(object), typeof(object)])!;
}

internal static void ClearAdaptiveDictionaryFactoryCache() => adaptiveDictionaryFactoryCache = new ConcurrentDictionary<AdaptiveDictionaryFactoryKey, Func<ExpressionPosition, object>>();
internal static int GetAdaptiveDictionaryFactoryCacheCountForTesting() => adaptiveDictionaryFactoryCache.Count;

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

ClearAdaptiveDictionaryFactoryCache replaces adaptiveDictionaryFactoryCache while other threads may be reading/adding. Use Volatile.Write/Interlocked.Exchange for the swap (and Volatile.Read at call sites if needed) to ensure safe publication of the new dictionary under concurrency.

Copilot uses AI. Check for mistakes.
Comment on lines 15 to 27
<ItemGroup>
<PackageReference Include="FluentNHibernate" Version="3.4.1" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="NHibernate" Version="5.6.0" />
<PackageReference Include="NUnit" Version="4.5.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.2.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="ObjectDumper.NET" Version="4.4.10-pre" />
<PackageReference Include="System.Collections.Immutable" Version="10.0.1" />
<PackageReference Include="System.Data.SqlClient" Version="4.9.1" />
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" />
<PackageReference Include="System.Drawing.Common" Version="10.0.1" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.3" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.3" />
<PackageReference Include="System.Threading.Tasks.Dataflow" Version="10.0.1" />
<PackageReference Include="TUnit" Version="1.19.16" />
</ItemGroup>

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

System.Threading.Tasks.Dataflow is used by FailureHypothesisTests (e.g., BufferBlock<T>), but the test project no longer references the System.Threading.Tasks.Dataflow NuGet package. This will break compilation unless the API is provided by the target framework. Add the package reference back (or remove/conditionalize the Dataflow-based tests).

Copilot uses AI. Check for mistakes.
@lofcz lofcz merged commit b090daf into next Mar 10, 2026
6 checks passed
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.

2 participants