Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 40 additions & 14 deletions TUnit.Core/Context.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,28 +21,42 @@ TestContext.Current as Context
?? BeforeTestDiscoveryContext.Current as Context
?? GlobalContext.Current;

private readonly StringBuilder _outputBuilder = new();
private readonly StringBuilder _errorOutputBuilder = new();
private readonly ReaderWriterLockSlim _outputLock = new(LockRecursionPolicy.NoRecursion);
private readonly ReaderWriterLockSlim _errorOutputLock = new(LockRecursionPolicy.NoRecursion);
// Lazy output state: avoid allocating StringBuilder + RWLS + ConsoleLineBuffer
// for contexts that never receive output (the common case for most tests)
private StringBuilder? _outputBuilder;
private StringBuilder? _errorOutputBuilder;
private ReaderWriterLockSlim? _outputLock;
private ReaderWriterLockSlim? _errorOutputLock;
private DefaultLogger? _defaultLogger;

// Console interceptor line buffers for partial writes (Console.Write without newline)
// These are stored per-context to prevent output mixing between parallel tests
// ConsoleLineBuffer uses Lock internally for efficient synchronization
private readonly ConsoleLineBuffer _consoleStdOutLineBuffer = new();
private readonly ConsoleLineBuffer _consoleStdErrLineBuffer = new();
private ConsoleLineBuffer? _consoleStdOutLineBuffer;
private ConsoleLineBuffer? _consoleStdErrLineBuffer;

// Thread-safe: console interceptors may access from multiple threads.
private StringBuilder GetOutputBuilder() =>
LazyInitializer.EnsureInitialized(ref _outputBuilder)!;
private StringBuilder GetErrorOutputBuilder() =>
LazyInitializer.EnsureInitialized(ref _errorOutputBuilder)!;
private ReaderWriterLockSlim GetOutputLock() =>
LazyInitializer.EnsureInitialized(ref _outputLock, static () => new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion))!;
private ReaderWriterLockSlim GetErrorOutputLock() =>
LazyInitializer.EnsureInitialized(ref _errorOutputLock, static () => new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion))!;

[field: AllowNull, MaybeNull]
public TextWriter OutputWriter => field ??= new ConcurrentStringWriter(_outputBuilder, _outputLock);
public TextWriter OutputWriter => field ??= new ConcurrentStringWriter(GetOutputBuilder(), GetOutputLock());

[field: AllowNull, MaybeNull]
public TextWriter ErrorOutputWriter => field ??= new ConcurrentStringWriter(_errorOutputBuilder, _errorOutputLock);
public TextWriter ErrorOutputWriter => field ??= new ConcurrentStringWriter(GetErrorOutputBuilder(), GetErrorOutputLock());

// Internal accessors for console interceptor line buffers
internal ConsoleLineBuffer ConsoleStdOutLineBuffer => _consoleStdOutLineBuffer;
internal ConsoleLineBuffer ConsoleStdOutLineBuffer =>
LazyInitializer.EnsureInitialized(ref _consoleStdOutLineBuffer)!;

internal ConsoleLineBuffer ConsoleStdErrLineBuffer => _consoleStdErrLineBuffer;
internal ConsoleLineBuffer ConsoleStdErrLineBuffer =>
LazyInitializer.EnsureInitialized(ref _consoleStdErrLineBuffer)!;

internal Context(Context? parent)
{
Expand Down Expand Up @@ -82,7 +96,13 @@ public void AddAsyncLocalValues()

public virtual string GetStandardOutput()
{
_outputLock.EnterReadLock();
if (_outputBuilder == null)
{
return string.Empty;
}

var outputLock = GetOutputLock();
outputLock.EnterReadLock();

try
{
Expand All @@ -92,13 +112,19 @@ public virtual string GetStandardOutput()
}
finally
{
_outputLock.ExitReadLock();
outputLock.ExitReadLock();
}
}

public virtual string GetErrorOutput()
{
_errorOutputLock.EnterReadLock();
if (_errorOutputBuilder == null)
{
return string.Empty;
}

var errorOutputLock = GetErrorOutputLock();
errorOutputLock.EnterReadLock();

try
{
Expand All @@ -108,7 +134,7 @@ public virtual string GetErrorOutput()
}
finally
{
_errorOutputLock.ExitReadLock();
errorOutputLock.ExitReadLock();
}
}

Expand Down
37 changes: 22 additions & 15 deletions TUnit.Core/DataGeneratorMetadataCreator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,6 @@ public static DataGeneratorMetadata CreateForReflectionDiscovery(
TestBuilderContext = new TestBuilderContextAccessor(new TestBuilderContext
{
TestMetadata = null!, // Not available during discovery
Events = new TestContextEvents(),
StateBag = new ConcurrentDictionary<string, object?>()
}),
MembersToGenerate = membersToGenerate,
TestInformation = methodMetadata,
Expand Down Expand Up @@ -158,8 +156,6 @@ public static DataGeneratorMetadata CreateForGenericTypeDiscovery(
{
TestMetadata = discoveryMethodMetadata,
DataSourceAttribute = dataSource,
Events = new TestContextEvents(),
StateBag = new ConcurrentDictionary<string, object?>()
}),
MembersToGenerate = [dummyParameter],
TestInformation = discoveryMethodMetadata,
Expand All @@ -184,17 +180,28 @@ public static DataGeneratorMetadata CreateForPropertyInjection(
TestContextEvents? events = null,
ConcurrentDictionary<string, object?>? objectBag = null)
{
var testBuilderContext = testContext != null
? TestBuilderContext.FromTestContext(testContext, dataSource)
: methodMetadata != null
? new TestBuilderContext
{
Events = events ?? new TestContextEvents(),
TestMetadata = methodMetadata,
DataSourceAttribute = dataSource,
StateBag = objectBag ?? new ConcurrentDictionary<string, object?>()
}
: TestSessionContext.GlobalStaticPropertyContext;
TestBuilderContext testBuilderContext;
if (testContext != null)
{
testBuilderContext = TestBuilderContext.FromTestContext(testContext, dataSource);
}
else if (methodMetadata != null)
{
testBuilderContext = new TestBuilderContext
{
Events = events ?? new TestContextEvents(),
TestMetadata = methodMetadata,
DataSourceAttribute = dataSource,
};
if (objectBag != null)
{
testBuilderContext.StateBag = objectBag;
}
}
else
{
testBuilderContext = TestSessionContext.GlobalStaticPropertyContext;
}

return new DataGeneratorMetadata
{
Expand Down
14 changes: 14 additions & 0 deletions TUnit.Core/Helpers/ReferenceEqualityComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,17 @@ public int GetHashCode(object obj)
return RuntimeHelpers.GetHashCode(obj);
}
}

/// <summary>
/// Generic version of <see cref="ReferenceEqualityComparer"/> for strongly-typed collections.
/// </summary>
internal sealed class ReferenceEqualityComparer<T> : IEqualityComparer<T> where T : class
{
public static readonly ReferenceEqualityComparer<T> Instance = new();

private ReferenceEqualityComparer() { }

public bool Equals(T? x, T? y) => ReferenceEquals(x, y);

public int GetHashCode(T obj) => RuntimeHelpers.GetHashCode(obj);
}
4 changes: 4 additions & 0 deletions TUnit.Core/Interfaces/ITestRegisteredEventReceiver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ namespace TUnit.Core.Interfaces;
/// identified but before it goes through the discovery pipeline.
/// </para>
/// <para>
/// <b>Thread safety:</b> When many tests are registered, <see cref="OnTestRegistered"/>
/// may be called concurrently from multiple threads. Implementations must be thread-safe.
/// </para>
/// <para>
/// The <see cref="IEventReceiver.Order"/> property can be used to control the execution order
/// when multiple implementations of this interface exist.
/// </para>
Expand Down
4 changes: 2 additions & 2 deletions TUnit.Core/Logging/LogSinkRouter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ internal static class LogSinkRouter
public static void RouteToSinks(LogLevel level, string message, Exception? exception, Context? context)
{
var sinks = TUnitLoggerFactory.GetSinks();
if (sinks.Count == 0)
if (sinks.Length == 0)
{
return;
}
Expand Down Expand Up @@ -41,7 +41,7 @@ public static void RouteToSinks(LogLevel level, string message, Exception? excep
public static async ValueTask RouteToSinksAsync(LogLevel level, string message, Exception? exception, Context? context)
{
var sinks = TUnitLoggerFactory.GetSinks();
if (sinks.Count == 0)
if (sinks.Length == 0)
{
return;
}
Expand Down
14 changes: 6 additions & 8 deletions TUnit.Core/Logging/TUnitLoggerFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public static class TUnitLoggerFactory
{
private static readonly List<ILogSink> _sinks = [];
private static readonly Lock _lock = new();
private static volatile ILogSink[] _sinksCache = [];

/// <summary>
/// Registers a log sink instance to receive log messages from all tests.
Expand Down Expand Up @@ -90,6 +91,7 @@ public static void AddSink(ILogSink sink)
lock (_lock)
{
_sinks.Add(sink);
_sinksCache = [.. _sinks];
}
}

Expand Down Expand Up @@ -120,13 +122,7 @@ public static void AddSink(ILogSink sink)
/// <summary>
/// Gets all registered sinks. For internal use.
/// </summary>
internal static IReadOnlyList<ILogSink> GetSinks()
{
lock (_lock)
{
return _sinks.ToArray();
}
}
internal static ILogSink[] GetSinks() => _sinksCache;

/// <summary>
/// Disposes all sinks that implement IAsyncDisposable or IDisposable.
Expand All @@ -137,8 +133,9 @@ internal static async ValueTask DisposeAllAsync()
ILogSink[] sinksToDispose;
lock (_lock)
{
sinksToDispose = _sinks.ToArray();
sinksToDispose = _sinksCache;
_sinks.Clear();
_sinksCache = [];
}

foreach (var sink in sinksToDispose)
Expand Down Expand Up @@ -169,6 +166,7 @@ internal static void Clear()
lock (_lock)
{
_sinks.Clear();
_sinksCache = [];
}
}
}
5 changes: 4 additions & 1 deletion TUnit.Core/Models/ClassHookContext.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using TUnit.Core.Helpers;

namespace TUnit.Core;

Expand Down Expand Up @@ -27,11 +28,12 @@ internal ClassHookContext(AssemblyHookContext assemblyHookContext) : base(assemb
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
public required Type ClassType { get; init; }

private readonly HashSet<TestContext> _testSet = new(ReferenceEqualityComparer<TestContext>.Instance);
private readonly List<TestContext> _tests = [];

public void AddTest(TestContext testContext)
{
if (_tests.Contains(testContext))
if (!_testSet.Add(testContext))
{
return; // Prevent duplicates
}
Expand Down Expand Up @@ -75,6 +77,7 @@ public override int GetHashCode()

internal void RemoveTest(TestContext test)
{
_testSet.Remove(test);
_tests.Remove(test);

if (_tests.Count is 0)
Expand Down
18 changes: 11 additions & 7 deletions TUnit.Core/TestContext.Output.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Collections.Concurrent;
using TUnit.Core.Helpers;
using TUnit.Core.Interfaces;

Expand All @@ -16,10 +15,14 @@ internal record TimingEntry(string StepName, DateTimeOffset Start, DateTimeOffse
public partial class TestContext
{
// Internal backing fields and properties
internal ConcurrentBag<TimingEntry> Timings { get; } = [];
private readonly ConcurrentBag<Artifact> _artifactsBag = new();
// Timings are written sequentially by the framework during test execution, never by user code.
internal List<TimingEntry> Timings { get; } = [];
// Artifacts use a lock because AttachArtifact is user-facing and can be called
// from parallel Task.WhenAll branches within a single test.
private readonly Lock _artifactsLock = new();
private readonly List<Artifact> _artifacts = [];

internal IReadOnlyList<Artifact> Artifacts => _artifactsBag.ToArray();
internal IReadOnlyList<Artifact> Artifacts { get { lock (_artifactsLock) return [.. _artifacts]; } }

// Explicit interface implementations for ITestOutput
TextWriter ITestOutput.StandardOutput => OutputWriter;
Expand All @@ -28,18 +31,19 @@ public partial class TestContext

void ITestOutput.AttachArtifact(Artifact artifact)
{
_artifactsBag.Add(artifact);
lock (_artifactsLock) _artifacts.Add(artifact);
}

void ITestOutput.AttachArtifact(string filePath, string? displayName, string? description)
{
var fileInfo = new FileInfo(filePath);
_artifactsBag.Add(new Artifact
var artifact = new Artifact
{
File = fileInfo,
DisplayName = displayName ?? fileInfo.Name,
Description = description
});
};
lock (_artifactsLock) _artifacts.Add(artifact);
}

string ITestOutput.GetStandardOutput() => GetOutput();
Expand Down
Loading
Loading