From 7a7bef8a02f507108889687f2f58609a03e98a64 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:10:32 +0000 Subject: [PATCH 1/6] fix: don't block waiting callers while IAsyncInitializer runs its synchronous part ObjectInitializer deduplicated InitializeAsync with Lazy in ExecutionAndPublication mode, which runs the factory - InitializeAsync itself - under a lock. Every other caller for the same shared object blocked a thread-pool thread in Monitor.Enter until the synchronous part of InitializeAsync finished, starving the pool when that part did sync-over-async (e.g. Testcontainers' Docker probe) (#6904). Publish a TaskCompletionSource (RunContinuationsAsynchronously) before any user code runs instead. The caller that publishes it runs InitializeAsync inline, as before, with no lock held and awaits it directly, so it takes no extra thread-pool hop; other callers await the published task and resume on their own pool threads. InitializeAsync still runs once per object, failures stay cached with the original exception object (#4715), and a caller's cancellation only stops that caller waiting. Same pattern as ObjectLifecycleService.EnsureInitializedAsync. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SZ1LwaBqCw5AJXeAU1vVo4 --- src/TUnit.Core/ObjectInitializer.cs | 71 +++--- .../Services/ObjectLifecycleService.cs | 2 +- .../TUnit.UnitTests/ObjectInitializerTests.cs | 239 ++++++++++++++++++ 3 files changed, 281 insertions(+), 31 deletions(-) create mode 100644 tests/TUnit.UnitTests/ObjectInitializerTests.cs diff --git a/src/TUnit.Core/ObjectInitializer.cs b/src/TUnit.Core/ObjectInitializer.cs index 0acc3a6afe6..d3b35aa3d98 100644 --- a/src/TUnit.Core/ObjectInitializer.cs +++ b/src/TUnit.Core/ObjectInitializer.cs @@ -20,10 +20,12 @@ namespace TUnit.Core; /// internal static class ObjectInitializer { - // Use Lazy pattern to ensure InitializeAsync is called exactly once per object, - // even under contention. GetOrAdd's factory can be called multiple times, but with - // Lazy + ExecutionAndPublication mode, only one initialization actually runs. - private static readonly ConcurrentDictionary> InitializationTasks = + // One task per object, published before any user code runs, so InitializeAsync is called + // exactly once per object even under contention. No lock is held while InitializeAsync runs: + // Lazy + ExecutionAndPublication ran its synchronous part under a lock, so every other + // caller blocked a thread-pool thread until it finished, starving the pool when that part + // blocked on async work itself (#6904). + private static readonly ConcurrentDictionary InitializationTasks = new(Helpers.ReferenceEqualityComparer.Instance); /// @@ -88,12 +90,10 @@ internal static bool IsInitialized(object? obj) return false; } - // Use Status == RanToCompletion to ensure we don't return true for faulted/canceled tasks + // Use Status == RanToCompletion to ensure we don't return true for pending/faulted/canceled tasks // (IsCompletedSuccessfully is not available in netstandard2.0) - // With Lazy, we need to check if the Lazy has a value AND that value completed successfully - return InitializationTasks.TryGetValue(obj, out var lazyTask) && - lazyTask.IsValueCreated && - lazyTask.Value.Status == TaskStatus.RanToCompletion; + return InitializationTasks.TryGetValue(obj, out var initializationTask) && + initializationTask.Status == TaskStatus.RanToCompletion; } /// @@ -112,32 +112,43 @@ private static async ValueTask InitializeCoreAsync( IAsyncInitializer asyncInitializer, CancellationToken cancellationToken) { - // Use Lazy with ExecutionAndPublication mode to ensure InitializeAsync - // is called exactly once, even under contention. GetOrAdd's factory may be - // called multiple times, but Lazy ensures only one initialization runs. - var lazyTask = InitializationTasks.GetOrAdd(obj, - static (_, asyncInitializer) => new Lazy( - asyncInitializer.InitializeAsync, - LazyThreadSafetyMode.ExecutionAndPublication) - , asyncInitializer); - - try + if (!InitializationTasks.TryGetValue(obj, out var initializationTask)) { - // Wait for initialization with cancellation support - await lazyTask.Value.WaitAsync(cancellationToken); + // RunContinuationsAsynchronously: when the initialization completes, callers waiting on + // a shared object resume on their own thread-pool threads instead of one after another + // inline on the thread that completed it. + var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + initializationTask = InitializationTasks.GetOrAdd(obj, completionSource.Task); + + if (ReferenceEquals(initializationTask, completionSource.Task)) + { + // Only the caller that published the task runs InitializeAsync - inline, as before - and + // it resumes straight from it, without the thread-pool hop the completion source gives waiters. + await RunInitializerAsync(asyncInitializer, completionSource).WaitAsync(cancellationToken); + } } - catch (OperationCanceledException) + + // Do NOT remove faulted tasks from the cache - subsequent callers get the same error + // immediately. Removing and retrying can cause hangs when InitializeAsync partially + // initialized resources (e.g. started ports/processes) that block re-initialization (#4715). + // The cancellation token only stops this caller waiting; the initialization keeps running. + await initializationTask.WaitAsync(cancellationToken); + } + + private static async Task RunInitializerAsync(IAsyncInitializer asyncInitializer, TaskCompletionSource completionSource) + { + try { - // Propagate cancellation without modification - throw; + // The synchronous part of InitializeAsync still runs on the caller's thread and context; + // only publishing the result doesn't need to return there. + await asyncInitializer.InitializeAsync().ConfigureAwait(false); + completionSource.SetResult(true); } - catch + catch (Exception ex) { - // Do NOT remove from cache - the faulted Lazy stays so subsequent - // callers get the same error immediately via .WaitAsync() on the faulted task. - // Removing and retrying can cause hangs when InitializeAsync partially initialized - // resources (e.g. started ports/processes) that block re-initialization (#4715). - throw; + // SetException rather than SetCanceled, so callers get the original exception object - + // including an OperationCanceledException thrown by InitializeAsync. + completionSource.SetException(ex); } } } diff --git a/src/TUnit.Engine/Services/ObjectLifecycleService.cs b/src/TUnit.Engine/Services/ObjectLifecycleService.cs index 98e5ecd8b74..2775b95da84 100644 --- a/src/TUnit.Engine/Services/ObjectLifecycleService.cs +++ b/src/TUnit.Engine/Services/ObjectLifecycleService.cs @@ -41,7 +41,7 @@ internal sealed class ObjectLifecycleService : IObjectRegistry, IInitializationC #if NET // Gates span creation so only the first caller for a given object creates a trace span. // Subsequent callers (concurrent tests sharing the same object) skip span creation - // and just await ObjectInitializer's deduplicated Lazy. + // and just await ObjectInitializer's deduplicated initialization task. // Uses ConditionalWeakTable so per-test objects can be GC'd after their test completes. private readonly ConditionalWeakTable> _spannedObjects = new(); #endif diff --git a/tests/TUnit.UnitTests/ObjectInitializerTests.cs b/tests/TUnit.UnitTests/ObjectInitializerTests.cs new file mode 100644 index 00000000000..0be2b648a28 --- /dev/null +++ b/tests/TUnit.UnitTests/ObjectInitializerTests.cs @@ -0,0 +1,239 @@ +using TUnit.Core; +using TUnit.Core.Interfaces; + +namespace TUnit.UnitTests; + +public class ObjectInitializerTests +{ + // Only bound how long a regression can hang the suite - passing runs never wait for them. + // The prefix gate must outlast HangTimeout, or a blocked caller would be released before it is detected. + private static readonly TimeSpan HangTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan PrefixGateTimeout = TimeSpan.FromSeconds(60); + + // https://github.com/thomhurst/TUnit/issues/6904 + [Test] + public async Task Waiting_Caller_Does_Not_Block_While_InitializeAsync_Runs_Synchronously() + { + var fixture = new BlockingPrefixInitializer(); + var initialization = Task.Run(() => ObjectInitializer.InitializeAsync(fixture).AsTask()); + await fixture.PrefixEntered.Task.WaitAsync(HangTimeout); + + var callReturned = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var waiter = Task.Run(() => + { + var pending = ObjectInitializer.InitializeAsync(fixture); + callReturned.SetResult(true); + return pending.AsTask(); + }); + + var callReturnedWhilePrefixBlocked = await Task.WhenAny(callReturned.Task, Task.Delay(HangTimeout)) == callReturned.Task; + var completedWhilePrefixBlocked = waiter.IsCompleted; + + fixture.ReleasePrefix(); + await Task.WhenAll(initialization, waiter); + + await Assert.That(callReturnedWhilePrefixBlocked).IsTrue(); + await Assert.That(completedWhilePrefixBlocked).IsFalse(); + await Assert.That(fixture.InitializeCount).IsEqualTo(1); + await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsTrue(); + } + + [Test] + public async Task Waiting_Caller_Observes_Its_Cancellation_While_InitializeAsync_Runs_Synchronously() + { + var fixture = new BlockingPrefixInitializer(); + var initialization = Task.Run(() => ObjectInitializer.InitializeAsync(fixture).AsTask()); + await fixture.PrefixEntered.Task.WaitAsync(HangTimeout); + + using var cancellationTokenSource = new CancellationTokenSource(); + var waiter = Task.Run(() => ObjectInitializer.InitializeAsync(fixture, cancellationTokenSource.Token).AsTask()); + cancellationTokenSource.Cancel(); + + var waiterFinishedWhilePrefixBlocked = await Task.WhenAny(waiter, Task.Delay(HangTimeout)) == waiter; + + fixture.ReleasePrefix(); + await initialization; + + await Assert.That(waiterFinishedWhilePrefixBlocked).IsTrue(); + await Assert.That(async () => await waiter).Throws(); + await Assert.That(fixture.InitializeCount).IsEqualTo(1); + await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsTrue(); + } + + [Test] + public async Task Cancelling_One_Waiter_Does_Not_Cancel_The_Shared_Initialization() + { + var fixture = new GatedInitializer(); + var initialization = ObjectInitializer.InitializeAsync(fixture).AsTask(); + + using var cancellationTokenSource = new CancellationTokenSource(); + var cancelledWaiter = ObjectInitializer.InitializeAsync(fixture, cancellationTokenSource.Token).AsTask(); + cancellationTokenSource.Cancel(); + + await Assert.That(async () => await cancelledWaiter).Throws(); + await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsFalse(); + + fixture.Complete(); + await initialization.WaitAsync(HangTimeout); + await ObjectInitializer.InitializeAsync(fixture); + + await Assert.That(fixture.InitializeCount).IsEqualTo(1); + await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsTrue(); + } + + [Test] + public async Task Waiters_Do_Not_Resume_Inline_On_The_Thread_That_Completes_Initialization() + { + var fixture = new GatedInitializer(); + using var cancellationTokenSource = new CancellationTokenSource(); + var initialization = ObjectInitializer.InitializeAsync(fixture, cancellationTokenSource.Token).AsTask(); + + var completingThreadId = 0; + var completeReturned = false; + + // Registers its continuation before the initialization is completed below. + var resumedInline = ResumedInlineAsync(ObjectInitializer.InitializeAsync(fixture, cancellationTokenSource.Token)); + + await Task.Run(() => + { + Volatile.Write(ref completingThreadId, Environment.CurrentManagedThreadId); + fixture.Complete(); + Volatile.Write(ref completeReturned, true); + }); + + await initialization.WaitAsync(HangTimeout); + await Assert.That(await resumedInline.WaitAsync(HangTimeout)).IsFalse(); + + async Task ResumedInlineAsync(ValueTask pending) + { + await pending.ConfigureAwait(false); + return !Volatile.Read(ref completeReturned) + && Environment.CurrentManagedThreadId == Volatile.Read(ref completingThreadId); + } + } + + [Test] + public async Task InitializeAsync_Runs_Once_Under_Contention() + { + var fixtures = Enumerable.Range(0, 100).Select(_ => new YieldingInitializer()).ToArray(); + + var callers = fixtures + .SelectMany(fixture => Enumerable.Range(0, 8).Select(_ => Task.Run(() => ObjectInitializer.InitializeAsync(fixture).AsTask()))) + .ToArray(); + await Task.WhenAll(callers).WaitAsync(HangTimeout); + + foreach (var fixture in fixtures) + { + await Assert.That(fixture.InitializeCount).IsEqualTo(1); + await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsTrue(); + } + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task Failure_Is_Cached_And_Rethrown_To_Every_Caller(bool throwSynchronously) + { + var fixture = new ThrowingInitializer(new InvalidOperationException("initialization failed"), throwSynchronously); + + var first = await Assert.That(async () => await ObjectInitializer.InitializeAsync(fixture)).Throws(); + var second = await Assert.That(async () => await ObjectInitializer.InitializeAsync(fixture)).Throws(); + + await Assert.That(first).IsSameReferenceAs(fixture.Exception); + await Assert.That(second).IsSameReferenceAs(fixture.Exception); + await Assert.That(fixture.InitializeCount).IsEqualTo(1); + await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsFalse(); + } + + [Test] + public async Task OperationCanceledException_From_InitializeAsync_Reaches_Callers_Unchanged() + { + var fixture = new ThrowingInitializer(new OperationCanceledException("initializer gave up"), throwSynchronously: false); + + var first = await Assert.That(async () => await ObjectInitializer.InitializeAsync(fixture)).Throws(); + var second = await Assert.That(async () => await ObjectInitializer.InitializeAsync(fixture)).Throws(); + + await Assert.That(first).IsSameReferenceAs(fixture.Exception); + await Assert.That(second).IsSameReferenceAs(fixture.Exception); + await Assert.That(fixture.InitializeCount).IsEqualTo(1); + await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsFalse(); + } + + /// + /// Blocks inside the synchronous part of InitializeAsync (before its first await), like + /// sync-over-async code in a third-party constructor would. + /// + private sealed class BlockingPrefixInitializer : IAsyncInitializer + { + private readonly ManualResetEventSlim _prefixGate = new(); + private int _initializeCount; + + public TaskCompletionSource PrefixEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public int InitializeCount => Volatile.Read(ref _initializeCount); + + public void ReleasePrefix() => _prefixGate.Set(); + + public async Task InitializeAsync() + { + Interlocked.Increment(ref _initializeCount); + PrefixEntered.SetResult(true); + _prefixGate.Wait(PrefixGateTimeout); + await Task.Yield(); + } + } + + /// + /// Suspends until is called; the rest of InitializeAsync then runs + /// inline on the calling thread. + /// + private sealed class GatedInitializer : IAsyncInitializer + { + private readonly TaskCompletionSource _gate = new(); + private int _initializeCount; + + public int InitializeCount => Volatile.Read(ref _initializeCount); + + public void Complete() => _gate.SetResult(true); + + public async Task InitializeAsync() + { + Interlocked.Increment(ref _initializeCount); + await _gate.Task; + } + } + + private sealed class YieldingInitializer : IAsyncInitializer + { + private int _initializeCount; + + public int InitializeCount => Volatile.Read(ref _initializeCount); + + public async Task InitializeAsync() + { + Interlocked.Increment(ref _initializeCount); + await Task.Yield(); + } + } + + private sealed class ThrowingInitializer(Exception exception, bool throwSynchronously) : IAsyncInitializer + { + private int _initializeCount; + + public Exception Exception { get; } = exception; + + public int InitializeCount => Volatile.Read(ref _initializeCount); + + public Task InitializeAsync() + { + Interlocked.Increment(ref _initializeCount); + return throwSynchronously ? throw Exception : ThrowAfterYieldAsync(); + } + + private async Task ThrowAfterYieldAsync() + { + await Task.Yield(); + throw Exception; + } + } +} From 80d26daf41b9408b8955243f1e3e6a0824c413f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:21:43 +0000 Subject: [PATCH 2/6] test: cover initializing-caller cancellation; tighten ObjectInitializer tests - Cancelling the caller that runs InitializeAsync only stops that caller: waiters still get the result, or the original exception object. - Assert the precondition of the inline-continuation test (InitializeAsync itself completed on the completing thread) and cover both a cancellable and a non-cancellable wait. - Bound every await with a timeout, release the blocking prefix in a finally, and cover a synchronously thrown OperationCanceledException. - Publish the result after the try/catch and make the comments precise. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SZ1LwaBqCw5AJXeAU1vVo4 --- src/TUnit.Core/ObjectInitializer.cs | 12 +- .../TUnit.UnitTests/ObjectInitializerTests.cs | 147 +++++++++++++----- 2 files changed, 118 insertions(+), 41 deletions(-) diff --git a/src/TUnit.Core/ObjectInitializer.cs b/src/TUnit.Core/ObjectInitializer.cs index d3b35aa3d98..ba99cd98de7 100644 --- a/src/TUnit.Core/ObjectInitializer.cs +++ b/src/TUnit.Core/ObjectInitializer.cs @@ -90,7 +90,7 @@ internal static bool IsInitialized(object? obj) return false; } - // Use Status == RanToCompletion to ensure we don't return true for pending/faulted/canceled tasks + // Use Status == RanToCompletion to ensure we don't return true for pending or failed initializations // (IsCompletedSuccessfully is not available in netstandard2.0) return InitializationTasks.TryGetValue(obj, out var initializationTask) && initializationTask.Status == TaskStatus.RanToCompletion; @@ -114,9 +114,9 @@ private static async ValueTask InitializeCoreAsync( { if (!InitializationTasks.TryGetValue(obj, out var initializationTask)) { - // RunContinuationsAsynchronously: when the initialization completes, callers waiting on - // a shared object resume on their own thread-pool threads instead of one after another - // inline on the thread that completed it. + // RunContinuationsAsynchronously: when the initialization completes, the continuations of + // callers waiting on a shared object are queued, rather than run one after another inline + // on the thread that completed it. var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); initializationTask = InitializationTasks.GetOrAdd(obj, completionSource.Task); @@ -142,13 +142,15 @@ private static async Task RunInitializerAsync(IAsyncInitializer asyncInitializer // The synchronous part of InitializeAsync still runs on the caller's thread and context; // only publishing the result doesn't need to return there. await asyncInitializer.InitializeAsync().ConfigureAwait(false); - completionSource.SetResult(true); } catch (Exception ex) { // SetException rather than SetCanceled, so callers get the original exception object - // including an OperationCanceledException thrown by InitializeAsync. completionSource.SetException(ex); + return; } + + completionSource.SetResult(true); } } diff --git a/tests/TUnit.UnitTests/ObjectInitializerTests.cs b/tests/TUnit.UnitTests/ObjectInitializerTests.cs index 0be2b648a28..4d080046506 100644 --- a/tests/TUnit.UnitTests/ObjectInitializerTests.cs +++ b/tests/TUnit.UnitTests/ObjectInitializerTests.cs @@ -14,23 +14,33 @@ public class ObjectInitializerTests [Test] public async Task Waiting_Caller_Does_Not_Block_While_InitializeAsync_Runs_Synchronously() { - var fixture = new BlockingPrefixInitializer(); + using var fixture = new BlockingPrefixInitializer(); var initialization = Task.Run(() => ObjectInitializer.InitializeAsync(fixture).AsTask()); - await fixture.PrefixEntered.Task.WaitAsync(HangTimeout); - var callReturned = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var waiter = Task.Run(() => + Task waiter = Task.CompletedTask; + bool callReturnedWhilePrefixBlocked; + bool completedWhilePrefixBlocked; + + try { - var pending = ObjectInitializer.InitializeAsync(fixture); - callReturned.SetResult(true); - return pending.AsTask(); - }); + await fixture.PrefixEntered.Task.WaitAsync(HangTimeout); - var callReturnedWhilePrefixBlocked = await Task.WhenAny(callReturned.Task, Task.Delay(HangTimeout)) == callReturned.Task; - var completedWhilePrefixBlocked = waiter.IsCompleted; + waiter = Task.Run(() => + { + var pending = ObjectInitializer.InitializeAsync(fixture); + callReturned.SetResult(true); + return pending.AsTask(); + }); + + callReturnedWhilePrefixBlocked = await Task.WhenAny(callReturned.Task, Task.Delay(HangTimeout)) == callReturned.Task; + completedWhilePrefixBlocked = waiter.IsCompleted; + } + finally + { + fixture.ReleasePrefix(); + } - fixture.ReleasePrefix(); - await Task.WhenAll(initialization, waiter); + await Task.WhenAll(initialization, waiter).WaitAsync(HangTimeout); await Assert.That(callReturnedWhilePrefixBlocked).IsTrue(); await Assert.That(completedWhilePrefixBlocked).IsFalse(); @@ -41,18 +51,27 @@ public async Task Waiting_Caller_Does_Not_Block_While_InitializeAsync_Runs_Synch [Test] public async Task Waiting_Caller_Observes_Its_Cancellation_While_InitializeAsync_Runs_Synchronously() { - var fixture = new BlockingPrefixInitializer(); + using var fixture = new BlockingPrefixInitializer(); + using var cancellationTokenSource = new CancellationTokenSource(); var initialization = Task.Run(() => ObjectInitializer.InitializeAsync(fixture).AsTask()); - await fixture.PrefixEntered.Task.WaitAsync(HangTimeout); + Task waiter = Task.CompletedTask; + bool waiterFinishedWhilePrefixBlocked; - using var cancellationTokenSource = new CancellationTokenSource(); - var waiter = Task.Run(() => ObjectInitializer.InitializeAsync(fixture, cancellationTokenSource.Token).AsTask()); - cancellationTokenSource.Cancel(); + try + { + await fixture.PrefixEntered.Task.WaitAsync(HangTimeout); + + waiter = Task.Run(() => ObjectInitializer.InitializeAsync(fixture, cancellationTokenSource.Token).AsTask()); + cancellationTokenSource.Cancel(); - var waiterFinishedWhilePrefixBlocked = await Task.WhenAny(waiter, Task.Delay(HangTimeout)) == waiter; + waiterFinishedWhilePrefixBlocked = await Task.WhenAny(waiter, Task.Delay(HangTimeout)) == waiter; + } + finally + { + fixture.ReleasePrefix(); + } - fixture.ReleasePrefix(); - await initialization; + await initialization.WaitAsync(HangTimeout); await Assert.That(waiterFinishedWhilePrefixBlocked).IsTrue(); await Assert.That(async () => await waiter).Throws(); @@ -61,20 +80,20 @@ public async Task Waiting_Caller_Observes_Its_Cancellation_While_InitializeAsync } [Test] - public async Task Cancelling_One_Waiter_Does_Not_Cancel_The_Shared_Initialization() + public async Task Cancelling_A_Waiting_Caller_Does_Not_Cancel_The_Shared_Initialization() { var fixture = new GatedInitializer(); - var initialization = ObjectInitializer.InitializeAsync(fixture).AsTask(); - using var cancellationTokenSource = new CancellationTokenSource(); + var initializingCaller = ObjectInitializer.InitializeAsync(fixture).AsTask(); var cancelledWaiter = ObjectInitializer.InitializeAsync(fixture, cancellationTokenSource.Token).AsTask(); + cancellationTokenSource.Cancel(); await Assert.That(async () => await cancelledWaiter).Throws(); await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsFalse(); fixture.Complete(); - await initialization.WaitAsync(HangTimeout); + await initializingCaller.WaitAsync(HangTimeout); await ObjectInitializer.InitializeAsync(fixture); await Assert.That(fixture.InitializeCount).IsEqualTo(1); @@ -82,17 +101,52 @@ public async Task Cancelling_One_Waiter_Does_Not_Cancel_The_Shared_Initializatio } [Test] - public async Task Waiters_Do_Not_Resume_Inline_On_The_Thread_That_Completes_Initialization() + [Arguments(false)] + [Arguments(true)] + public async Task Cancelling_The_Initializing_Caller_Does_Not_Cancel_The_Shared_Initialization(bool initializationFails) + { + var fixture = new GatedInitializer(); + var failure = new InvalidOperationException("initialization failed"); + using var cancellationTokenSource = new CancellationTokenSource(); + var initializingCaller = ObjectInitializer.InitializeAsync(fixture, cancellationTokenSource.Token).AsTask(); + var waiter = ObjectInitializer.InitializeAsync(fixture).AsTask(); + + cancellationTokenSource.Cancel(); + + await Assert.That(async () => await initializingCaller).Throws(); + await Assert.That(waiter.IsCompleted).IsFalse(); + + if (initializationFails) + { + fixture.Fail(failure); + var observed = await Assert.That(async () => await waiter.WaitAsync(HangTimeout)).Throws(); + await Assert.That(observed).IsSameReferenceAs(failure); + } + else + { + fixture.Complete(); + await waiter.WaitAsync(HangTimeout); + } + + await Assert.That(fixture.InitializeCount).IsEqualTo(1); + await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsEqualTo(!initializationFails); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task Waiters_Do_Not_Resume_Inline_On_The_Thread_That_Completes_Initialization(bool cancellableWait) { var fixture = new GatedInitializer(); using var cancellationTokenSource = new CancellationTokenSource(); - var initialization = ObjectInitializer.InitializeAsync(fixture, cancellationTokenSource.Token).AsTask(); + var cancellationToken = cancellableWait ? cancellationTokenSource.Token : CancellationToken.None; + var initializingCaller = ObjectInitializer.InitializeAsync(fixture, cancellationToken).AsTask(); var completingThreadId = 0; var completeReturned = false; // Registers its continuation before the initialization is completed below. - var resumedInline = ResumedInlineAsync(ObjectInitializer.InitializeAsync(fixture, cancellationTokenSource.Token)); + var waiterResumedInline = ResumedInlineAsync(ObjectInitializer.InitializeAsync(fixture, cancellationToken)); await Task.Run(() => { @@ -101,8 +155,12 @@ await Task.Run(() => Volatile.Write(ref completeReturned, true); }); - await initialization.WaitAsync(HangTimeout); - await Assert.That(await resumedInline.WaitAsync(HangTimeout)).IsFalse(); + await initializingCaller.WaitAsync(HangTimeout); + + // Precondition: InitializeAsync itself finished inline on the completing thread, so its + // completion is what would drag waiters along with it. + await Assert.That(fixture.ResumedOnThreadId).IsEqualTo(completingThreadId); + await Assert.That(await waiterResumedInline.WaitAsync(HangTimeout)).IsFalse(); async Task ResumedInlineAsync(ValueTask pending) { @@ -146,9 +204,11 @@ public async Task Failure_Is_Cached_And_Rethrown_To_Every_Caller(bool throwSynch } [Test] - public async Task OperationCanceledException_From_InitializeAsync_Reaches_Callers_Unchanged() + [Arguments(true)] + [Arguments(false)] + public async Task OperationCanceledException_From_InitializeAsync_Reaches_Callers_Unchanged(bool throwSynchronously) { - var fixture = new ThrowingInitializer(new OperationCanceledException("initializer gave up"), throwSynchronously: false); + var fixture = new ThrowingInitializer(new OperationCanceledException("initializer gave up"), throwSynchronously); var first = await Assert.That(async () => await ObjectInitializer.InitializeAsync(fixture)).Throws(); var second = await Assert.That(async () => await ObjectInitializer.InitializeAsync(fixture)).Throws(); @@ -163,7 +223,7 @@ public async Task OperationCanceledException_From_InitializeAsync_Reaches_Caller /// Blocks inside the synchronous part of InitializeAsync (before its first await), like /// sync-over-async code in a third-party constructor would. /// - private sealed class BlockingPrefixInitializer : IAsyncInitializer + private sealed class BlockingPrefixInitializer : IAsyncInitializer, IDisposable { private readonly ManualResetEventSlim _prefixGate = new(); private int _initializeCount; @@ -177,29 +237,44 @@ private sealed class BlockingPrefixInitializer : IAsyncInitializer public async Task InitializeAsync() { Interlocked.Increment(ref _initializeCount); - PrefixEntered.SetResult(true); + PrefixEntered.TrySetResult(true); _prefixGate.Wait(PrefixGateTimeout); await Task.Yield(); } + + public void Dispose() => _prefixGate.Dispose(); } /// - /// Suspends until is called; the rest of InitializeAsync then runs - /// inline on the calling thread. + /// Suspends until or is called; the rest of + /// InitializeAsync then runs inline on the calling thread. /// private sealed class GatedInitializer : IAsyncInitializer { private readonly TaskCompletionSource _gate = new(); private int _initializeCount; + private int _resumedOnThreadId; public int InitializeCount => Volatile.Read(ref _initializeCount); + public int ResumedOnThreadId => Volatile.Read(ref _resumedOnThreadId); + public void Complete() => _gate.SetResult(true); + public void Fail(Exception exception) => _gate.SetException(exception); + public async Task InitializeAsync() { Interlocked.Increment(ref _initializeCount); - await _gate.Task; + + try + { + await _gate.Task; + } + finally + { + Volatile.Write(ref _resumedOnThreadId, Environment.CurrentManagedThreadId); + } } } From 648db2daf6a5f49328e5f07c53e5c149fd9ba227 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:35:12 +0000 Subject: [PATCH 3/6] perf: let the initializing caller await InitializeAsync directly The previous version awaited a helper async method that ran InitializeAsync and published its outcome. BenchmarkDotNet showed that extra async frame cost ~0.15-0.7 us and ~86 B per first initialization of a per-test fixture whose InitializeAsync really awaits. The caller that publishes the task now awaits InitializeAsync itself and completes the TaskCompletionSource afterwards; only if it stops waiting (cancellation) or the initialization fails does a helper publish the initialization's own outcome, so one caller's cancellation is never cached as the result. First initialization now costs the same as the Lazy code and allocates less; repeat calls are unchanged. Also: a null task from InitializeAsync fails every caller with one InvalidOperationException naming the type; the published failure copy is marked observed; publishing doesn't wait for the initializing caller's context. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SZ1LwaBqCw5AJXeAU1vVo4 --- src/TUnit.Core/ObjectInitializer.cs | 48 ++++++++++++++++--- .../TUnit.UnitTests/ObjectInitializerTests.cs | 31 +++++++++++- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/TUnit.Core/ObjectInitializer.cs b/src/TUnit.Core/ObjectInitializer.cs index ba99cd98de7..8805385fd44 100644 --- a/src/TUnit.Core/ObjectInitializer.cs +++ b/src/TUnit.Core/ObjectInitializer.cs @@ -122,9 +122,26 @@ private static async ValueTask InitializeCoreAsync( if (ReferenceEquals(initializationTask, completionSource.Task)) { - // Only the caller that published the task runs InitializeAsync - inline, as before - and - // it resumes straight from it, without the thread-pool hop the completion source gives waiters. - await RunInitializerAsync(asyncInitializer, completionSource).WaitAsync(cancellationToken); + // Only the caller that published the task runs InitializeAsync - inline, as before - and it + // awaits it directly, so it pays no extra thread-pool hop or async frame for publishing it. + var initializerTask = StartInitializer(asyncInitializer); + + try + { + // ConfigureAwait(false): publishing the result for other callers mustn't wait for this + // caller's context; this caller's own await still resumes on it. + await initializerTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + // Publish the initialization's own outcome - not this caller's cancellation - now, + // or once it completes if only this caller stopped waiting. + _ = PublishOutcomeAsync(initializerTask, completionSource); + throw; + } + + completionSource.SetResult(true); + return; } } @@ -135,19 +152,36 @@ private static async ValueTask InitializeCoreAsync( await initializationTask.WaitAsync(cancellationToken); } - private static async Task RunInitializerAsync(IAsyncInitializer asyncInitializer, TaskCompletionSource completionSource) + private static Task StartInitializer(IAsyncInitializer asyncInitializer) { try { - // The synchronous part of InitializeAsync still runs on the caller's thread and context; - // only publishing the result doesn't need to return there. - await asyncInitializer.InitializeAsync().ConfigureAwait(false); + return asyncInitializer.InitializeAsync() + ?? throw new InvalidOperationException($"{asyncInitializer.GetType().FullName}.InitializeAsync() returned null."); + } + catch (Exception ex) + { + // Non-async implementations can throw synchronously (and a null task is a bug in the + // initializer) - treat either like a faulted initialization. + return Task.FromException(ex); + } + } + + private static async Task PublishOutcomeAsync(Task initializerTask, TaskCompletionSource completionSource) + { + try + { + await initializerTask.ConfigureAwait(false); } catch (Exception ex) { // SetException rather than SetCanceled, so callers get the original exception object - // including an OperationCanceledException thrown by InitializeAsync. completionSource.SetException(ex); + + // The failure itself was observed above; this copy exists for other callers, so don't report + // it as unobserved if none of them ever awaits it. + _ = completionSource.Task.Exception; return; } diff --git a/tests/TUnit.UnitTests/ObjectInitializerTests.cs b/tests/TUnit.UnitTests/ObjectInitializerTests.cs index 4d080046506..e49048b84c9 100644 --- a/tests/TUnit.UnitTests/ObjectInitializerTests.cs +++ b/tests/TUnit.UnitTests/ObjectInitializerTests.cs @@ -157,8 +157,8 @@ await Task.Run(() => await initializingCaller.WaitAsync(HangTimeout); - // Precondition: InitializeAsync itself finished inline on the completing thread, so its - // completion is what would drag waiters along with it. + // Precondition: InitializeAsync itself finished inline on the completing thread, so the result + // is published there too - which is where waiters would run if their continuations were inlined. await Assert.That(fixture.ResumedOnThreadId).IsEqualTo(completingThreadId); await Assert.That(await waiterResumedInline.WaitAsync(HangTimeout)).IsFalse(); @@ -219,6 +219,20 @@ public async Task OperationCanceledException_From_InitializeAsync_Reaches_Caller await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsFalse(); } + [Test] + public async Task InitializeAsync_Returning_Null_Fails_Every_Caller_With_The_Same_Exception() + { + var fixture = new NullReturningInitializer(); + + var first = await Assert.That(async () => await ObjectInitializer.InitializeAsync(fixture)).Throws(); + var second = await Assert.That(async () => await ObjectInitializer.InitializeAsync(fixture)).Throws(); + + await Assert.That(second).IsSameReferenceAs(first); + await Assert.That(first!.Message).Contains(nameof(NullReturningInitializer)); + await Assert.That(fixture.InitializeCount).IsEqualTo(1); + await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsFalse(); + } + /// /// Blocks inside the synchronous part of InitializeAsync (before its first await), like /// sync-over-async code in a third-party constructor would. @@ -291,6 +305,19 @@ public async Task InitializeAsync() } } + private sealed class NullReturningInitializer : IAsyncInitializer + { + private int _initializeCount; + + public int InitializeCount => Volatile.Read(ref _initializeCount); + + public Task InitializeAsync() + { + Interlocked.Increment(ref _initializeCount); + return null!; + } + } + private sealed class ThrowingInitializer(Exception exception, bool throwSynchronously) : IAsyncInitializer { private int _initializeCount; From ee1e45a42411507410fbb1c173e6b3813b8f64a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:12:40 +0000 Subject: [PATCH 4/6] test: bound the cancellation assertions' awaits with the hang timeout If cancellation regressed, these awaits would hang the suite instead of failing, because the fixture is only released after them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SZ1LwaBqCw5AJXeAU1vVo4 --- tests/TUnit.UnitTests/ObjectInitializerTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/TUnit.UnitTests/ObjectInitializerTests.cs b/tests/TUnit.UnitTests/ObjectInitializerTests.cs index e49048b84c9..54e48174e1e 100644 --- a/tests/TUnit.UnitTests/ObjectInitializerTests.cs +++ b/tests/TUnit.UnitTests/ObjectInitializerTests.cs @@ -74,7 +74,7 @@ public async Task Waiting_Caller_Observes_Its_Cancellation_While_InitializeAsync await initialization.WaitAsync(HangTimeout); await Assert.That(waiterFinishedWhilePrefixBlocked).IsTrue(); - await Assert.That(async () => await waiter).Throws(); + await Assert.That(async () => await waiter.WaitAsync(HangTimeout)).Throws(); await Assert.That(fixture.InitializeCount).IsEqualTo(1); await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsTrue(); } @@ -89,7 +89,7 @@ public async Task Cancelling_A_Waiting_Caller_Does_Not_Cancel_The_Shared_Initial cancellationTokenSource.Cancel(); - await Assert.That(async () => await cancelledWaiter).Throws(); + await Assert.That(async () => await cancelledWaiter.WaitAsync(HangTimeout)).Throws(); await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsFalse(); fixture.Complete(); @@ -113,7 +113,7 @@ public async Task Cancelling_The_Initializing_Caller_Does_Not_Cancel_The_Shared_ cancellationTokenSource.Cancel(); - await Assert.That(async () => await initializingCaller).Throws(); + await Assert.That(async () => await initializingCaller.WaitAsync(HangTimeout)).Throws(); await Assert.That(waiter.IsCompleted).IsFalse(); if (initializationFails) From 92c54e2c4e5e13b2c532dbcfd98e127de7ddcf18 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:30:37 +0000 Subject: [PATCH 5/6] refactor: one completion path for every caller, as suggested in review Following the review on thomhurst/TUnit#6906: a helper runs InitializeAsync and completes a plain TaskCompletionSource; every caller, including the one that started it, waits the same way on the published task. - Drop RunContinuationsAsynchronously (a separate change); without it the initializing caller pays no extra thread-pool hop, so it no longer needs its own completion path, StartInitializer or PublishOutcomeAsync. - Drop the custom InvalidOperationException for a null task and the observed-exception read. - Keep InitializeCoreAsync async, so an OperationCanceledException thrown by InitializeAsync still completes callers' tasks as Canceled, as before. - Trim the tests to the five cases from the review; the failure case covers synchronous/asynchronous and ordinary/OperationCanceledException failures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SZ1LwaBqCw5AJXeAU1vVo4 --- src/TUnit.Core/ObjectInitializer.cs | 54 +---- .../TUnit.UnitTests/ObjectInitializerTests.cs | 201 ++++-------------- 2 files changed, 56 insertions(+), 199 deletions(-) diff --git a/src/TUnit.Core/ObjectInitializer.cs b/src/TUnit.Core/ObjectInitializer.cs index 8805385fd44..b5d627a42ff 100644 --- a/src/TUnit.Core/ObjectInitializer.cs +++ b/src/TUnit.Core/ObjectInitializer.cs @@ -107,6 +107,9 @@ internal static void ClearCache() InitializationTasks.Clear(); } + // Kept async (rather than returning the WaitAsync task as a ValueTask) so that an + // OperationCanceledException thrown by InitializeAsync still completes callers' tasks as + // Canceled, as it did before, instead of Faulted. private static async ValueTask InitializeCoreAsync( object obj, IAsyncInitializer asyncInitializer, @@ -114,34 +117,14 @@ private static async ValueTask InitializeCoreAsync( { if (!InitializationTasks.TryGetValue(obj, out var initializationTask)) { - // RunContinuationsAsynchronously: when the initialization completes, the continuations of - // callers waiting on a shared object are queued, rather than run one after another inline - // on the thread that completed it. - var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var completionSource = new TaskCompletionSource(); initializationTask = InitializationTasks.GetOrAdd(obj, completionSource.Task); if (ReferenceEquals(initializationTask, completionSource.Task)) { - // Only the caller that published the task runs InitializeAsync - inline, as before - and it - // awaits it directly, so it pays no extra thread-pool hop or async frame for publishing it. - var initializerTask = StartInitializer(asyncInitializer); - - try - { - // ConfigureAwait(false): publishing the result for other callers mustn't wait for this - // caller's context; this caller's own await still resumes on it. - await initializerTask.WaitAsync(cancellationToken).ConfigureAwait(false); - } - catch - { - // Publish the initialization's own outcome - not this caller's cancellation - now, - // or once it completes if only this caller stopped waiting. - _ = PublishOutcomeAsync(initializerTask, completionSource); - throw; - } - - completionSource.SetResult(true); - return; + // Only the caller that published the task runs InitializeAsync - inline up to its + // first await, as before, but with no lock held (#6904). + _ = RunInitializerAsync(asyncInitializer, completionSource); } } @@ -152,36 +135,17 @@ private static async ValueTask InitializeCoreAsync( await initializationTask.WaitAsync(cancellationToken); } - private static Task StartInitializer(IAsyncInitializer asyncInitializer) + private static async Task RunInitializerAsync(IAsyncInitializer asyncInitializer, TaskCompletionSource completionSource) { try { - return asyncInitializer.InitializeAsync() - ?? throw new InvalidOperationException($"{asyncInitializer.GetType().FullName}.InitializeAsync() returned null."); - } - catch (Exception ex) - { - // Non-async implementations can throw synchronously (and a null task is a bug in the - // initializer) - treat either like a faulted initialization. - return Task.FromException(ex); - } - } - - private static async Task PublishOutcomeAsync(Task initializerTask, TaskCompletionSource completionSource) - { - try - { - await initializerTask.ConfigureAwait(false); + await asyncInitializer.InitializeAsync().ConfigureAwait(false); } catch (Exception ex) { // SetException rather than SetCanceled, so callers get the original exception object - // including an OperationCanceledException thrown by InitializeAsync. completionSource.SetException(ex); - - // The failure itself was observed above; this copy exists for other callers, so don't report - // it as unobserved if none of them ever awaits it. - _ = completionSource.Task.Exception; return; } diff --git a/tests/TUnit.UnitTests/ObjectInitializerTests.cs b/tests/TUnit.UnitTests/ObjectInitializerTests.cs index 54e48174e1e..0c09beb5b55 100644 --- a/tests/TUnit.UnitTests/ObjectInitializerTests.cs +++ b/tests/TUnit.UnitTests/ObjectInitializerTests.cs @@ -49,61 +49,48 @@ public async Task Waiting_Caller_Does_Not_Block_While_InitializeAsync_Runs_Synch } [Test] - public async Task Waiting_Caller_Observes_Its_Cancellation_While_InitializeAsync_Runs_Synchronously() + public async Task InitializeAsync_Runs_Once_Under_Contention() { - using var fixture = new BlockingPrefixInitializer(); - using var cancellationTokenSource = new CancellationTokenSource(); - var initialization = Task.Run(() => ObjectInitializer.InitializeAsync(fixture).AsTask()); - Task waiter = Task.CompletedTask; - bool waiterFinishedWhilePrefixBlocked; - - try - { - await fixture.PrefixEntered.Task.WaitAsync(HangTimeout); + var fixtures = Enumerable.Range(0, 100).Select(_ => new YieldingInitializer()).ToArray(); - waiter = Task.Run(() => ObjectInitializer.InitializeAsync(fixture, cancellationTokenSource.Token).AsTask()); - cancellationTokenSource.Cancel(); + var callers = fixtures + .SelectMany(fixture => Enumerable.Range(0, 8).Select(_ => Task.Run(() => ObjectInitializer.InitializeAsync(fixture).AsTask()))) + .ToArray(); + await Task.WhenAll(callers).WaitAsync(HangTimeout); - waiterFinishedWhilePrefixBlocked = await Task.WhenAny(waiter, Task.Delay(HangTimeout)) == waiter; - } - finally + foreach (var fixture in fixtures) { - fixture.ReleasePrefix(); + await Assert.That(fixture.InitializeCount).IsEqualTo(1); + await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsTrue(); } - - await initialization.WaitAsync(HangTimeout); - - await Assert.That(waiterFinishedWhilePrefixBlocked).IsTrue(); - await Assert.That(async () => await waiter.WaitAsync(HangTimeout)).Throws(); - await Assert.That(fixture.InitializeCount).IsEqualTo(1); - await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsTrue(); } [Test] - public async Task Cancelling_A_Waiting_Caller_Does_Not_Cancel_The_Shared_Initialization() + [Arguments(false, true)] + [Arguments(false, false)] + [Arguments(true, true)] + [Arguments(true, false)] + public async Task Failure_Is_Cached_And_Rethrown_As_The_Same_Exception(bool cancellation, bool throwSynchronously) { - var fixture = new GatedInitializer(); - using var cancellationTokenSource = new CancellationTokenSource(); - var initializingCaller = ObjectInitializer.InitializeAsync(fixture).AsTask(); - var cancelledWaiter = ObjectInitializer.InitializeAsync(fixture, cancellationTokenSource.Token).AsTask(); + Exception failure = cancellation ? new OperationCanceledException("initializer gave up") : new InvalidOperationException("initialization failed"); + var fixture = new ThrowingInitializer(failure, throwSynchronously); - cancellationTokenSource.Cancel(); - - await Assert.That(async () => await cancelledWaiter.WaitAsync(HangTimeout)).Throws(); - await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsFalse(); - - fixture.Complete(); - await initializingCaller.WaitAsync(HangTimeout); - await ObjectInitializer.InitializeAsync(fixture); + var first = ObjectInitializer.InitializeAsync(fixture).AsTask(); + var second = ObjectInitializer.InitializeAsync(fixture).AsTask(); + await Assert.That(await CaptureAsync(first)).IsSameReferenceAs(failure); + await Assert.That(await CaptureAsync(second)).IsSameReferenceAs(failure); + // An OperationCanceledException from the initializer still cancels callers' tasks, as before. + await Assert.That(first.IsCanceled).IsEqualTo(cancellation); + await Assert.That(second.IsCanceled).IsEqualTo(cancellation); await Assert.That(fixture.InitializeCount).IsEqualTo(1); - await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsTrue(); + await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsFalse(); } [Test] [Arguments(false)] [Arguments(true)] - public async Task Cancelling_The_Initializing_Caller_Does_Not_Cancel_The_Shared_Initialization(bool initializationFails) + public async Task Cancelling_The_Initializing_Caller_Does_Not_Poison_The_Result(bool initializationFails) { var fixture = new GatedInitializer(); var failure = new InvalidOperationException("initialization failed"); @@ -133,104 +120,37 @@ public async Task Cancelling_The_Initializing_Caller_Does_Not_Cancel_The_Shared_ } [Test] - [Arguments(true)] - [Arguments(false)] - public async Task Waiters_Do_Not_Resume_Inline_On_The_Thread_That_Completes_Initialization(bool cancellableWait) + public async Task Cancelling_A_Waiting_Caller_Does_Not_Cancel_The_Initialization() { var fixture = new GatedInitializer(); using var cancellationTokenSource = new CancellationTokenSource(); - var cancellationToken = cancellableWait ? cancellationTokenSource.Token : CancellationToken.None; - var initializingCaller = ObjectInitializer.InitializeAsync(fixture, cancellationToken).AsTask(); - - var completingThreadId = 0; - var completeReturned = false; + var initializingCaller = ObjectInitializer.InitializeAsync(fixture).AsTask(); + var cancelledWaiter = ObjectInitializer.InitializeAsync(fixture, cancellationTokenSource.Token).AsTask(); - // Registers its continuation before the initialization is completed below. - var waiterResumedInline = ResumedInlineAsync(ObjectInitializer.InitializeAsync(fixture, cancellationToken)); + cancellationTokenSource.Cancel(); - await Task.Run(() => - { - Volatile.Write(ref completingThreadId, Environment.CurrentManagedThreadId); - fixture.Complete(); - Volatile.Write(ref completeReturned, true); - }); + await Assert.That(async () => await cancelledWaiter.WaitAsync(HangTimeout)).Throws(); + await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsFalse(); + fixture.Complete(); await initializingCaller.WaitAsync(HangTimeout); + await ObjectInitializer.InitializeAsync(fixture); - // Precondition: InitializeAsync itself finished inline on the completing thread, so the result - // is published there too - which is where waiters would run if their continuations were inlined. - await Assert.That(fixture.ResumedOnThreadId).IsEqualTo(completingThreadId); - await Assert.That(await waiterResumedInline.WaitAsync(HangTimeout)).IsFalse(); - - async Task ResumedInlineAsync(ValueTask pending) - { - await pending.ConfigureAwait(false); - return !Volatile.Read(ref completeReturned) - && Environment.CurrentManagedThreadId == Volatile.Read(ref completingThreadId); - } + await Assert.That(fixture.InitializeCount).IsEqualTo(1); + await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsTrue(); } - [Test] - public async Task InitializeAsync_Runs_Once_Under_Contention() + private static async Task CaptureAsync(Task task) { - var fixtures = Enumerable.Range(0, 100).Select(_ => new YieldingInitializer()).ToArray(); - - var callers = fixtures - .SelectMany(fixture => Enumerable.Range(0, 8).Select(_ => Task.Run(() => ObjectInitializer.InitializeAsync(fixture).AsTask()))) - .ToArray(); - await Task.WhenAll(callers).WaitAsync(HangTimeout); - - foreach (var fixture in fixtures) + try { - await Assert.That(fixture.InitializeCount).IsEqualTo(1); - await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsTrue(); + await task.WaitAsync(HangTimeout); + return null; + } + catch (Exception ex) + { + return ex; } - } - - [Test] - [Arguments(true)] - [Arguments(false)] - public async Task Failure_Is_Cached_And_Rethrown_To_Every_Caller(bool throwSynchronously) - { - var fixture = new ThrowingInitializer(new InvalidOperationException("initialization failed"), throwSynchronously); - - var first = await Assert.That(async () => await ObjectInitializer.InitializeAsync(fixture)).Throws(); - var second = await Assert.That(async () => await ObjectInitializer.InitializeAsync(fixture)).Throws(); - - await Assert.That(first).IsSameReferenceAs(fixture.Exception); - await Assert.That(second).IsSameReferenceAs(fixture.Exception); - await Assert.That(fixture.InitializeCount).IsEqualTo(1); - await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsFalse(); - } - - [Test] - [Arguments(true)] - [Arguments(false)] - public async Task OperationCanceledException_From_InitializeAsync_Reaches_Callers_Unchanged(bool throwSynchronously) - { - var fixture = new ThrowingInitializer(new OperationCanceledException("initializer gave up"), throwSynchronously); - - var first = await Assert.That(async () => await ObjectInitializer.InitializeAsync(fixture)).Throws(); - var second = await Assert.That(async () => await ObjectInitializer.InitializeAsync(fixture)).Throws(); - - await Assert.That(first).IsSameReferenceAs(fixture.Exception); - await Assert.That(second).IsSameReferenceAs(fixture.Exception); - await Assert.That(fixture.InitializeCount).IsEqualTo(1); - await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsFalse(); - } - - [Test] - public async Task InitializeAsync_Returning_Null_Fails_Every_Caller_With_The_Same_Exception() - { - var fixture = new NullReturningInitializer(); - - var first = await Assert.That(async () => await ObjectInitializer.InitializeAsync(fixture)).Throws(); - var second = await Assert.That(async () => await ObjectInitializer.InitializeAsync(fixture)).Throws(); - - await Assert.That(second).IsSameReferenceAs(first); - await Assert.That(first!.Message).Contains(nameof(NullReturningInitializer)); - await Assert.That(fixture.InitializeCount).IsEqualTo(1); - await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsFalse(); } /// @@ -260,19 +180,15 @@ public async Task InitializeAsync() } /// - /// Suspends until or is called; the rest of - /// InitializeAsync then runs inline on the calling thread. + /// Suspends until or is called. /// private sealed class GatedInitializer : IAsyncInitializer { private readonly TaskCompletionSource _gate = new(); private int _initializeCount; - private int _resumedOnThreadId; public int InitializeCount => Volatile.Read(ref _initializeCount); - public int ResumedOnThreadId => Volatile.Read(ref _resumedOnThreadId); - public void Complete() => _gate.SetResult(true); public void Fail(Exception exception) => _gate.SetException(exception); @@ -280,15 +196,7 @@ private sealed class GatedInitializer : IAsyncInitializer public async Task InitializeAsync() { Interlocked.Increment(ref _initializeCount); - - try - { - await _gate.Task; - } - finally - { - Volatile.Write(ref _resumedOnThreadId, Environment.CurrentManagedThreadId); - } + await _gate.Task; } } @@ -305,37 +213,22 @@ public async Task InitializeAsync() } } - private sealed class NullReturningInitializer : IAsyncInitializer - { - private int _initializeCount; - - public int InitializeCount => Volatile.Read(ref _initializeCount); - - public Task InitializeAsync() - { - Interlocked.Increment(ref _initializeCount); - return null!; - } - } - private sealed class ThrowingInitializer(Exception exception, bool throwSynchronously) : IAsyncInitializer { private int _initializeCount; - public Exception Exception { get; } = exception; - public int InitializeCount => Volatile.Read(ref _initializeCount); public Task InitializeAsync() { Interlocked.Increment(ref _initializeCount); - return throwSynchronously ? throw Exception : ThrowAfterYieldAsync(); + return throwSynchronously ? throw exception : ThrowAfterYieldAsync(); } private async Task ThrowAfterYieldAsync() { await Task.Yield(); - throw Exception; + throw exception; } } } From f35b875f01f58001bbcc8e8b437dd8a5aa8fe1a4 Mon Sep 17 00:00:00 2001 From: "m.pishchulin" Date: Mon, 28 Sep 2026 19:19:31 +0100 Subject: [PATCH 6/6] fix: queue shared initializer continuations asynchronously --- src/TUnit.Core/ObjectInitializer.cs | 3 +- .../TUnit.UnitTests/ObjectInitializerTests.cs | 55 ++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/TUnit.Core/ObjectInitializer.cs b/src/TUnit.Core/ObjectInitializer.cs index b5d627a42ff..7b2ffe777fd 100644 --- a/src/TUnit.Core/ObjectInitializer.cs +++ b/src/TUnit.Core/ObjectInitializer.cs @@ -117,7 +117,8 @@ private static async ValueTask InitializeCoreAsync( { if (!InitializationTasks.TryGetValue(obj, out var initializationTask)) { - var completionSource = new TaskCompletionSource(); + // Waiting tests must not run inline on the thread that completes initialization. + var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); initializationTask = InitializationTasks.GetOrAdd(obj, completionSource.Task); if (ReferenceEquals(initializationTask, completionSource.Task)) diff --git a/tests/TUnit.UnitTests/ObjectInitializerTests.cs b/tests/TUnit.UnitTests/ObjectInitializerTests.cs index 0c09beb5b55..f80cf3c0e78 100644 --- a/tests/TUnit.UnitTests/ObjectInitializerTests.cs +++ b/tests/TUnit.UnitTests/ObjectInitializerTests.cs @@ -140,6 +140,54 @@ public async Task Cancelling_A_Waiting_Caller_Does_Not_Cancel_The_Initialization await Assert.That(ObjectInitializer.IsInitialized(fixture)).IsTrue(); } + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task Waiting_Continuations_Do_Not_Run_On_The_Initializing_Thread(bool cancellableWait) + { + // Arrange + const int waiterCount = 4; + using var fixture = new BlockingPrefixInitializer(completeSynchronously: true); + using var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellableWait ? cancellationTokenSource.Token : CancellationToken.None; + var initializingThreadId = 0; + Task[] waiters = []; + + // A dedicated thread cannot later pick up correctly queued waiter continuations. + var initialization = Task.Factory.StartNew(() => + { + initializingThreadId = Environment.CurrentManagedThreadId; + return ObjectInitializer.InitializeAsync(fixture).AsTask(); + }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + + async Task ObserveContinuationAsync() + { + await ObjectInitializer.InitializeAsync(fixture, cancellationToken).ConfigureAwait(false); + return Environment.CurrentManagedThreadId; + } + + // Act + try + { + await fixture.PrefixEntered.Task.WaitAsync(HangTimeout); + waiters = Enumerable.Range(0, waiterCount).Select(_ => ObserveContinuationAsync()).ToArray(); + } + finally + { + fixture.ReleasePrefix(); + await initialization.WaitAsync(HangTimeout); + } + + var continuationThreads = await Task.WhenAll(waiters).WaitAsync(HangTimeout); + + // Assert + await Assert.That(continuationThreads.Length).IsEqualTo(waiterCount); + foreach (var threadId in continuationThreads) + { + await Assert.That(threadId).IsNotEqualTo(initializingThreadId); + } + } + private static async Task CaptureAsync(Task task) { try @@ -157,7 +205,7 @@ public async Task Cancelling_A_Waiting_Caller_Does_Not_Cancel_The_Initialization /// Blocks inside the synchronous part of InitializeAsync (before its first await), like /// sync-over-async code in a third-party constructor would. /// - private sealed class BlockingPrefixInitializer : IAsyncInitializer, IDisposable + private sealed class BlockingPrefixInitializer(bool completeSynchronously = false) : IAsyncInitializer, IDisposable { private readonly ManualResetEventSlim _prefixGate = new(); private int _initializeCount; @@ -173,7 +221,10 @@ public async Task InitializeAsync() Interlocked.Increment(ref _initializeCount); PrefixEntered.TrySetResult(true); _prefixGate.Wait(PrefixGateTimeout); - await Task.Yield(); + if (!completeSynchronously) + { + await Task.Yield(); + } } public void Dispose() => _prefixGate.Dispose();