From 6d2f62f6b13577c60ef7cf3d9953bfa780fe844b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:13:13 +0000 Subject: [PATCH 1/2] perf: replace object locks with Lock type for more efficient synchronization Replace `object` lock fields with `System.Threading.Lock` across the codebase. The Lock type (available via Polyfill on pre-.NET 9 targets) uses optimized `Lock.EnterScope()` instead of `Monitor.Enter/Exit`, reducing synchronization overhead in hot paths. For the console interceptor, introduce `ConsoleLineBuffer` in TUnit.Core to encapsulate the StringBuilder + Lock together, avoiding cross-assembly polyfill type mismatches while keeping all locking internal to the buffer class. --- .../AotCompatibility/GenericTestRegistry.cs | 2 +- TUnit.Core/Context.cs | 20 ++-- TUnit.Core/Logging/ConsoleLineBuffer.cs | 100 ++++++++++++++++++ TUnit.Engine.Tests/ThreadSafeOutput.cs | 2 +- .../Logging/OptimizedConsoleInterceptor.cs | 89 +++------------- .../StandardErrorConsoleInterceptor.cs | 3 +- .../Logging/StandardOutConsoleInterceptor.cs | 3 +- .../Scheduling/ConstraintKeyScheduler.cs | 6 +- TUnit.Mocks.Http/MockHttpHandler.cs | 2 +- TUnit.Playwright/BrowserTest.cs | 2 +- 10 files changed, 128 insertions(+), 101 deletions(-) create mode 100644 TUnit.Core/Logging/ConsoleLineBuffer.cs 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..490aa2aafd --- /dev/null +++ b/TUnit.Core/Logging/ConsoleLineBuffer.cs @@ -0,0 +1,100 @@ +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 Lazy _buffer = new(() => new StringBuilder()); + private readonly Lock _lock = new(); + + /// + /// Appends a string to the buffer. + /// + internal void Append(string value) + { + lock (_lock) + { + _buffer.Value.Append(value); + } + } + + /// + /// Appends a single character to the buffer. + /// + internal void Append(char value) + { + lock (_lock) + { + _buffer.Value.Append(value); + } + } + + /// + /// Appends a range of characters to the buffer. + /// + internal void Append(char[] buffer, int index, int count) + { + lock (_lock) + { + _buffer.Value.Append(buffer, index, count); + } + } + + /// + /// Drains all buffered content and clears the buffer. + /// + internal string Drain() + { + lock (_lock) + { + var buf = _buffer.Value; + var result = buf.ToString(); + buf.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) + { + var buf = _buffer.Value; + if (buf.Length > 0) + { + buf.Append(value); + value = buf.ToString(); + buf.Clear(); + } + } + + return value; + } + + /// + /// If the buffer has content, drains and returns it. Otherwise returns null. + /// + internal string? FlushIfNonEmpty() + { + lock (_lock) + { + var buf = _buffer.Value; + if (buf.Length > 0) + { + var result = buf.ToString(); + buf.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..f28e6e6628 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,13 @@ public override ValueTask DisposeAsync() public override void Flush() { - // Flush any buffered partial line - var (buffer, bufferLock) = GetLineBuffer(); - lock (bufferLock) - { - if (buffer.Length > 0) - { - RouteToSinks(buffer.ToString()); - buffer.Clear(); - } - } + var content = GetLineBuffer().FlushIfNonEmpty(); + 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 +80,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 +97,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 +127,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 +156,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) From da99c4709300e0f7565dda825c20f14beccb5db6 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:17:14 +0000 Subject: [PATCH 2/2] chore: address review findings from simplify pass - Replace Lazy with direct StringBuilder in ConsoleLineBuffer (removes per-access Lazy overhead on hot path) - Add null check in Flush() to match FlushAsync() consistency - Remove unnecessary WHAT-comments on self-evident Append/Drain methods --- TUnit.Core/Logging/ConsoleLineBuffer.cs | 41 ++++++------------- .../Logging/OptimizedConsoleInterceptor.cs | 5 ++- 2 files changed, 17 insertions(+), 29 deletions(-) diff --git a/TUnit.Core/Logging/ConsoleLineBuffer.cs b/TUnit.Core/Logging/ConsoleLineBuffer.cs index 490aa2aafd..df1c4bf702 100644 --- a/TUnit.Core/Logging/ConsoleLineBuffer.cs +++ b/TUnit.Core/Logging/ConsoleLineBuffer.cs @@ -9,52 +9,39 @@ namespace TUnit.Core.Logging; /// internal sealed class ConsoleLineBuffer { - private readonly Lazy _buffer = new(() => new StringBuilder()); + private readonly StringBuilder _buffer = new(); private readonly Lock _lock = new(); - /// - /// Appends a string to the buffer. - /// internal void Append(string value) { lock (_lock) { - _buffer.Value.Append(value); + _buffer.Append(value); } } - /// - /// Appends a single character to the buffer. - /// internal void Append(char value) { lock (_lock) { - _buffer.Value.Append(value); + _buffer.Append(value); } } - /// - /// Appends a range of characters to the buffer. - /// internal void Append(char[] buffer, int index, int count) { lock (_lock) { - _buffer.Value.Append(buffer, index, count); + _buffer.Append(buffer, index, count); } } - /// - /// Drains all buffered content and clears the buffer. - /// internal string Drain() { lock (_lock) { - var buf = _buffer.Value; - var result = buf.ToString(); - buf.Clear(); + var result = _buffer.ToString(); + _buffer.Clear(); return result; } } @@ -67,12 +54,11 @@ internal string Drain() { lock (_lock) { - var buf = _buffer.Value; - if (buf.Length > 0) + if (_buffer.Length > 0) { - buf.Append(value); - value = buf.ToString(); - buf.Clear(); + _buffer.Append(value); + value = _buffer.ToString(); + _buffer.Clear(); } } @@ -86,11 +72,10 @@ internal string Drain() { lock (_lock) { - var buf = _buffer.Value; - if (buf.Length > 0) + if (_buffer.Length > 0) { - var result = buf.ToString(); - buf.Clear(); + var result = _buffer.ToString(); + _buffer.Clear(); return result; } diff --git a/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs b/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs index f28e6e6628..e8c4cbb428 100644 --- a/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs +++ b/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs @@ -66,7 +66,10 @@ public override ValueTask DisposeAsync() public override void Flush() { var content = GetLineBuffer().FlushIfNonEmpty(); - RouteToSinks(content); + if (content != null) + { + RouteToSinks(content); + } } public override async Task FlushAsync()