Skip to content
103 changes: 75 additions & 28 deletions src/TUnit.Core/ObjectInitializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ namespace TUnit.Core;
/// </remarks>
internal static class ObjectInitializer
{
// Use Lazy<Task> pattern to ensure InitializeAsync is called exactly once per object,
// even under contention. GetOrAdd's factory can be called multiple times, but with
// Lazy<Task> + ExecutionAndPublication mode, only one initialization actually runs.
private static readonly ConcurrentDictionary<object, Lazy<Task>> 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<Task> + 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<object, Task> InitializationTasks =
new(Helpers.ReferenceEqualityComparer.Instance);

/// <summary>
Expand Down Expand Up @@ -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 or failed initializations
// (IsCompletedSuccessfully is not available in netstandard2.0)
// With Lazy<Task>, 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;
}

/// <summary>
Expand All @@ -112,32 +112,79 @@ private static async ValueTask InitializeCoreAsync(
IAsyncInitializer asyncInitializer,
CancellationToken cancellationToken)
{
// Use Lazy<Task> 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<Task>(
asyncInitializer.InitializeAsync,
LazyThreadSafetyMode.ExecutionAndPublication)
, asyncInitializer);
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<bool>(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
// 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;
}
}

// 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 Task StartInitializer(IAsyncInitializer asyncInitializer)
{
try
{
// Wait for initialization with cancellation support
await lazyTask.Value.WaitAsync(cancellationToken);
return asyncInitializer.InitializeAsync()
?? throw new InvalidOperationException($"{asyncInitializer.GetType().FullName}.InitializeAsync() returned null.");
}
catch (OperationCanceledException)
catch (Exception ex)
{
// Propagate cancellation without modification
throw;
// 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);
}
catch
}

private static async Task PublishOutcomeAsync(Task initializerTask, TaskCompletionSource<bool> completionSource)
{
try
{
// Do NOT remove from cache - the faulted Lazy<Task> 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;
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;
}

completionSource.SetResult(true);
}
}
2 changes: 1 addition & 1 deletion src/TUnit.Engine/Services/ObjectLifecycleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task>.
// 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<object, StrongBox<int>> _spannedObjects = new();
#endif
Expand Down
Loading
Loading