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
2 changes: 1 addition & 1 deletion TUnit.Core/AotCompatibility/GenericTestRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public static class GenericTestRegistry
private static readonly ConcurrentDictionary<GenericMethodKey, MethodInfo> _compiledMethods = new();
private static readonly ConcurrentDictionary<Type, HashSet<Type[]>> _registeredCombinations = new();
private static readonly ConcurrentDictionary<GenericMethodKey, Delegate> _directInvocationDelegates = new();
private static readonly object _combinationsLock = new();
private static readonly Lock _combinationsLock = new();

/// <summary>
/// Registers a pre-compiled generic method instance.
Expand Down
20 changes: 6 additions & 14 deletions TUnit.Core/Context.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,28 +29,20 @@ 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<T> for thread-safe initialization
private readonly Lazy<StringBuilder> _consoleStdOutLineBuffer = new(() => new StringBuilder());
private readonly Lazy<StringBuilder> _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);

[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)
{
Expand Down
85 changes: 85 additions & 0 deletions TUnit.Core/Logging/ConsoleLineBuffer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System.Text;

namespace TUnit.Core.Logging;

/// <summary>
/// Thread-safe line buffer for console interceptor partial writes.
/// Uses <see cref="Lock"/> internally for efficient synchronization.
/// Each context owns its own buffer, preventing output mixing between parallel tests.
/// </summary>
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;
}
}

/// <summary>
/// If the buffer has content, appends <paramref name="value"/> to it, drains, and returns the combined result.
/// If the buffer is empty, returns <paramref name="value"/> unchanged.
/// </summary>
internal string? AppendAndDrain(string? value)
{
lock (_lock)
{
if (_buffer.Length > 0)
{
_buffer.Append(value);
value = _buffer.ToString();
_buffer.Clear();
}
}

return value;
}

/// <summary>
/// If the buffer has content, drains and returns it. Otherwise returns <c>null</c>.
/// </summary>
internal string? FlushIfNonEmpty()
{
lock (_lock)
{
if (_buffer.Length > 0)
{
var result = _buffer.ToString();
_buffer.Clear();
return result;
}

return null;
}
}
}
2 changes: 1 addition & 1 deletion TUnit.Engine.Tests/ThreadSafeOutput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
88 changes: 14 additions & 74 deletions TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ internal abstract class OptimizedConsoleInterceptor : TextWriter
protected abstract LogLevel SinkLogLevel { get; }

/// <summary>
/// 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 <see cref="ConsoleLineBuffer"/> using the efficient Lock type.
/// </summary>
protected abstract (StringBuilder Buffer, object Lock) GetLineBuffer();
protected abstract ConsoleLineBuffer GetLineBuffer();

private protected abstract TextWriter GetOriginalOut();

Expand Down Expand Up @@ -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);
Expand All @@ -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());
Expand All @@ -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());
Expand All @@ -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);
}

Expand Down Expand Up @@ -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);
}

Expand Down
3 changes: 1 addition & 2 deletions TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Text;
using TUnit.Core;
using TUnit.Core.Logging;

Expand All @@ -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()
{
Expand Down
3 changes: 1 addition & 2 deletions TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System.Text;
using TUnit.Core;
using TUnit.Core.Logging;

Expand All @@ -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()
{
Expand Down
6 changes: 3 additions & 3 deletions TUnit.Engine/Scheduling/ConstraintKeyScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public async ValueTask ExecuteTestsWithConstraintsAsync(

// Track which constraint keys are currently in use
var lockedKeys = new HashSet<string>();
var lockObject = new object();
var lockObject = new Lock();

// Indexed structure for tests waiting for their constraint keys to become available
var waitingTestIndex = new WaitingTestIndex();
Expand Down Expand Up @@ -122,7 +122,7 @@ private async Task WaitAndExecuteTestAsync(
IReadOnlyList<string> constraintKeys,
TaskCompletionSource<bool> startSignal,
HashSet<string> lockedKeys,
object lockObject,
Lock lockObject,
WaitingTestIndex waitingTestIndex,
CancellationToken cancellationToken)
{
Expand All @@ -142,7 +142,7 @@ private async Task ExecuteTestAndReleaseKeysAsync(
AbstractExecutableTest test,
IReadOnlyList<string> constraintKeys,
HashSet<string> lockedKeys,
object lockObject,
Lock lockObject,
WaitingTestIndex waitingTestIndex,
CancellationToken cancellationToken)
{
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Mocks.Http/MockHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace TUnit.Mocks.Http;
public sealed class MockHttpHandler : HttpMessageHandler
{
private readonly List<RequestSetup> _setups = new();
private readonly object _setupsLock = new();
private readonly Lock _setupsLock = new();
private readonly ConcurrentQueue<CapturedRequest> _requests = new();
private HttpStatusCode _defaultStatusCode = HttpStatusCode.NotFound;
private bool _throwOnUnmatched;
Expand Down
2 changes: 1 addition & 1 deletion TUnit.Playwright/BrowserTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public BrowserTest(BrowserTypeLaunchOptions options)
public IBrowser Browser { get; internal set; } = null!;

private readonly List<IBrowserContext> _contexts = [];
private readonly object _contextsLock = new();
private readonly Lock _contextsLock = new();
private readonly BrowserTypeLaunchOptions _options;

public async Task<IBrowserContext> NewContext(BrowserNewContextOptions options)
Expand Down
Loading