diff --git a/TUnit.Core/AotCompatibility/GenericTestRegistry.cs b/TUnit.Core/AotCompatibility/GenericTestRegistry.cs index 051a5958e4..c7a20c3d99 100644 --- a/TUnit.Core/AotCompatibility/GenericTestRegistry.cs +++ b/TUnit.Core/AotCompatibility/GenericTestRegistry.cs @@ -12,7 +12,7 @@ public static class GenericTestRegistry private static readonly ConcurrentDictionary _compiledMethods = new(); private static readonly ConcurrentDictionary> _registeredCombinations = new(); private static readonly ConcurrentDictionary _directInvocationDelegates = new(); - private static readonly object _combinationsLock = new(); + private static readonly Lock _combinationsLock = new(); /// /// Registers a pre-compiled generic method instance. diff --git a/TUnit.Core/Context.cs b/TUnit.Core/Context.cs index ed7a2a53f3..6721a283a4 100644 --- a/TUnit.Core/Context.cs +++ b/TUnit.Core/Context.cs @@ -29,11 +29,9 @@ TestContext.Current as Context // Console interceptor line buffers for partial writes (Console.Write without newline) // These are stored per-context to prevent output mixing between parallel tests - // Using Lazy for thread-safe initialization - private readonly Lazy _consoleStdOutLineBuffer = new(() => new StringBuilder()); - private readonly Lazy _consoleStdErrLineBuffer = new(() => new StringBuilder()); - private readonly object _consoleStdOutBufferLock = new(); - private readonly object _consoleStdErrBufferLock = new(); + // ConsoleLineBuffer uses Lock internally for efficient synchronization + private readonly ConsoleLineBuffer _consoleStdOutLineBuffer = new(); + private readonly ConsoleLineBuffer _consoleStdErrLineBuffer = new(); [field: AllowNull, MaybeNull] public TextWriter OutputWriter => field ??= new ConcurrentStringWriter(_outputBuilder, _outputLock); @@ -41,16 +39,10 @@ TestContext.Current as Context [field: AllowNull, MaybeNull] public TextWriter ErrorOutputWriter => field ??= new ConcurrentStringWriter(_errorOutputBuilder, _errorOutputLock); - // Internal accessors for console interceptor line buffers with thread safety - internal (StringBuilder Buffer, object Lock) GetConsoleStdOutLineBuffer() - { - return (_consoleStdOutLineBuffer.Value, _consoleStdOutBufferLock); - } + // Internal accessors for console interceptor line buffers + internal ConsoleLineBuffer ConsoleStdOutLineBuffer => _consoleStdOutLineBuffer; - internal (StringBuilder Buffer, object Lock) GetConsoleStdErrLineBuffer() - { - return (_consoleStdErrLineBuffer.Value, _consoleStdErrBufferLock); - } + internal ConsoleLineBuffer ConsoleStdErrLineBuffer => _consoleStdErrLineBuffer; internal Context(Context? parent) { diff --git a/TUnit.Core/Logging/ConsoleLineBuffer.cs b/TUnit.Core/Logging/ConsoleLineBuffer.cs new file mode 100644 index 0000000000..df1c4bf702 --- /dev/null +++ b/TUnit.Core/Logging/ConsoleLineBuffer.cs @@ -0,0 +1,85 @@ +using System.Text; + +namespace TUnit.Core.Logging; + +/// +/// Thread-safe line buffer for console interceptor partial writes. +/// Uses internally for efficient synchronization. +/// Each context owns its own buffer, preventing output mixing between parallel tests. +/// +internal sealed class ConsoleLineBuffer +{ + private readonly StringBuilder _buffer = new(); + private readonly Lock _lock = new(); + + internal void Append(string value) + { + lock (_lock) + { + _buffer.Append(value); + } + } + + internal void Append(char value) + { + lock (_lock) + { + _buffer.Append(value); + } + } + + internal void Append(char[] buffer, int index, int count) + { + lock (_lock) + { + _buffer.Append(buffer, index, count); + } + } + + internal string Drain() + { + lock (_lock) + { + var result = _buffer.ToString(); + _buffer.Clear(); + return result; + } + } + + /// + /// If the buffer has content, appends to it, drains, and returns the combined result. + /// If the buffer is empty, returns unchanged. + /// + internal string? AppendAndDrain(string? value) + { + lock (_lock) + { + if (_buffer.Length > 0) + { + _buffer.Append(value); + value = _buffer.ToString(); + _buffer.Clear(); + } + } + + return value; + } + + /// + /// If the buffer has content, drains and returns it. Otherwise returns null. + /// + internal string? FlushIfNonEmpty() + { + lock (_lock) + { + if (_buffer.Length > 0) + { + var result = _buffer.ToString(); + _buffer.Clear(); + return result; + } + + return null; + } + } +} diff --git a/TUnit.Engine.Tests/ThreadSafeOutput.cs b/TUnit.Engine.Tests/ThreadSafeOutput.cs index 98cb8b3f91..57a8457398 100644 --- a/TUnit.Engine.Tests/ThreadSafeOutput.cs +++ b/TUnit.Engine.Tests/ThreadSafeOutput.cs @@ -2,7 +2,7 @@ public static class ThreadSafeOutput { - private static readonly object OutputLock = new(); + private static readonly Lock OutputLock = new(); public static void WriteLine(string value) { diff --git a/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs b/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs index 212665d7c2..e8c4cbb428 100644 --- a/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs +++ b/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs @@ -23,10 +23,11 @@ internal abstract class OptimizedConsoleInterceptor : TextWriter protected abstract LogLevel SinkLogLevel { get; } /// - /// Gets the line buffer and lock from the current context. + /// Gets the line buffer from the current context. /// This ensures each test has its own buffer, preventing output mixing between parallel tests. + /// Locking is handled internally by using the efficient Lock type. /// - protected abstract (StringBuilder Buffer, object Lock) GetLineBuffer(); + protected abstract ConsoleLineBuffer GetLineBuffer(); private protected abstract TextWriter GetOriginalOut(); @@ -64,30 +65,16 @@ public override ValueTask DisposeAsync() public override void Flush() { - // Flush any buffered partial line - var (buffer, bufferLock) = GetLineBuffer(); - lock (bufferLock) + var content = GetLineBuffer().FlushIfNonEmpty(); + if (content != null) { - if (buffer.Length > 0) - { - RouteToSinks(buffer.ToString()); - buffer.Clear(); - } + RouteToSinks(content); } } public override async Task FlushAsync() { - var (buffer, bufferLock) = GetLineBuffer(); - string? content = null; - lock (bufferLock) - { - if (buffer.Length > 0) - { - content = buffer.ToString(); - buffer.Clear(); - } - } + var content = GetLineBuffer().FlushIfNonEmpty(); if (content != null) { await RouteToSinksAsync(content).ConfigureAwait(false); @@ -96,12 +83,12 @@ public override async Task FlushAsync() // Write methods - buffer partial writes until we get a complete line public override void Write(bool value) => Write(value.ToString()); - public override void Write(char value) => BufferChar(value); + public override void Write(char value) => GetLineBuffer().Append(value); public override void Write(char[]? buffer) { if (buffer != null) { - BufferChars(buffer, 0, buffer.Length); + GetLineBuffer().Append(buffer, 0, buffer.Length); } } public override void Write(decimal value) => Write(value.ToString()); @@ -113,49 +100,20 @@ public override void Write(char[]? buffer) public override void Write(string? value) { if (value == null) return; - var (buffer, bufferLock) = GetLineBuffer(); - lock (bufferLock) - { - buffer.Append(value); - } + GetLineBuffer().Append(value); } public override void Write(uint value) => Write(value.ToString()); public override void Write(ulong value) => Write(value.ToString()); - public override void Write(char[] buffer, int index, int count) => BufferChars(buffer, index, count); + public override void Write(char[] buffer, int index, int count) => GetLineBuffer().Append(buffer, index, count); public override void Write(string format, object? arg0) => Write(string.Format(format, arg0)); public override void Write(string format, object? arg0, object? arg1) => Write(string.Format(format, arg0, arg1)); public override void Write(string format, object? arg0, object? arg1, object? arg2) => Write(string.Format(format, arg0, arg1, arg2)); public override void Write(string format, params object?[] arg) => Write(string.Format(format, arg)); - private void BufferChar(char value) - { - var (buffer, bufferLock) = GetLineBuffer(); - lock (bufferLock) - { - buffer.Append(value); - } - } - - private void BufferChars(char[] buffer, int index, int count) - { - var (lineBuffer, bufferLock) = GetLineBuffer(); - lock (bufferLock) - { - lineBuffer.Append(buffer, index, count); - } - } - // WriteLine methods - flush buffer and route complete line to sinks public override void WriteLine() { - var (buffer, bufferLock) = GetLineBuffer(); - string line; - lock (bufferLock) - { - line = buffer.ToString(); - buffer.Clear(); - } - RouteToSinks(line); + RouteToSinks(GetLineBuffer().Drain()); } public override void WriteLine(bool value) => WriteLine(value.ToString()); @@ -172,16 +130,7 @@ public override void WriteLine() public override void WriteLine(string? value) { // Prepend any buffered content - var (buffer, bufferLock) = GetLineBuffer(); - lock (bufferLock) - { - if (buffer.Length > 0) - { - buffer.Append(value); - value = buffer.ToString(); - buffer.Clear(); - } - } + value = GetLineBuffer().AppendAndDrain(value); RouteToSinks(value); } @@ -210,16 +159,7 @@ public override async Task WriteLineAsync(char[] buffer, int index, int count) public override async Task WriteLineAsync(string? value) { - var (buffer, bufferLock) = GetLineBuffer(); - lock (bufferLock) - { - if (buffer.Length > 0) - { - buffer.Append(value); - value = buffer.ToString(); - buffer.Clear(); - } - } + value = GetLineBuffer().AppendAndDrain(value); await RouteToSinksAsync(value).ConfigureAwait(false); } diff --git a/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs b/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs index f0e7d9e3c5..4fb980d204 100644 --- a/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs +++ b/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs @@ -1,4 +1,3 @@ -using System.Text; using TUnit.Core; using TUnit.Core.Logging; @@ -12,7 +11,7 @@ internal class StandardErrorConsoleInterceptor : OptimizedConsoleInterceptor protected override LogLevel SinkLogLevel => LogLevel.Error; - protected override (StringBuilder Buffer, object Lock) GetLineBuffer() => Context.Current.GetConsoleStdErrLineBuffer(); + protected override ConsoleLineBuffer GetLineBuffer() => Context.Current.ConsoleStdErrLineBuffer; static StandardErrorConsoleInterceptor() { diff --git a/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs b/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs index ce0cb703b1..1c778084b6 100644 --- a/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs +++ b/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs @@ -1,4 +1,3 @@ -using System.Text; using TUnit.Core; using TUnit.Core.Logging; @@ -12,7 +11,7 @@ internal class StandardOutConsoleInterceptor : OptimizedConsoleInterceptor protected override LogLevel SinkLogLevel => LogLevel.Information; - protected override (StringBuilder Buffer, object Lock) GetLineBuffer() => Context.Current.GetConsoleStdOutLineBuffer(); + protected override ConsoleLineBuffer GetLineBuffer() => Context.Current.ConsoleStdOutLineBuffer; static StandardOutConsoleInterceptor() { diff --git a/TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs b/TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs index c30c74597b..355c401fea 100644 --- a/TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs +++ b/TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs @@ -38,7 +38,7 @@ public async ValueTask ExecuteTestsWithConstraintsAsync( // Track which constraint keys are currently in use var lockedKeys = new HashSet(); - var lockObject = new object(); + var lockObject = new Lock(); // Indexed structure for tests waiting for their constraint keys to become available var waitingTestIndex = new WaitingTestIndex(); @@ -122,7 +122,7 @@ private async Task WaitAndExecuteTestAsync( IReadOnlyList constraintKeys, TaskCompletionSource startSignal, HashSet lockedKeys, - object lockObject, + Lock lockObject, WaitingTestIndex waitingTestIndex, CancellationToken cancellationToken) { @@ -142,7 +142,7 @@ private async Task ExecuteTestAndReleaseKeysAsync( AbstractExecutableTest test, IReadOnlyList constraintKeys, HashSet lockedKeys, - object lockObject, + Lock lockObject, WaitingTestIndex waitingTestIndex, CancellationToken cancellationToken) { diff --git a/TUnit.Mocks.Http/MockHttpHandler.cs b/TUnit.Mocks.Http/MockHttpHandler.cs index 820f6f2c21..aa272e54e3 100644 --- a/TUnit.Mocks.Http/MockHttpHandler.cs +++ b/TUnit.Mocks.Http/MockHttpHandler.cs @@ -11,7 +11,7 @@ namespace TUnit.Mocks.Http; public sealed class MockHttpHandler : HttpMessageHandler { private readonly List _setups = new(); - private readonly object _setupsLock = new(); + private readonly Lock _setupsLock = new(); private readonly ConcurrentQueue _requests = new(); private HttpStatusCode _defaultStatusCode = HttpStatusCode.NotFound; private bool _throwOnUnmatched; diff --git a/TUnit.Playwright/BrowserTest.cs b/TUnit.Playwright/BrowserTest.cs index 1bd00c363b..2a1c1b29ce 100644 --- a/TUnit.Playwright/BrowserTest.cs +++ b/TUnit.Playwright/BrowserTest.cs @@ -17,7 +17,7 @@ public BrowserTest(BrowserTypeLaunchOptions options) public IBrowser Browser { get; internal set; } = null!; private readonly List _contexts = []; - private readonly object _contextsLock = new(); + private readonly Lock _contextsLock = new(); private readonly BrowserTypeLaunchOptions _options; public async Task NewContext(BrowserNewContextOptions options)