Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
100 changes: 100 additions & 0 deletions TUnit.Core/Logging/ConsoleLineBuffer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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 Lazy<StringBuilder> _buffer = new(() => new StringBuilder());
private readonly Lock _lock = new();

/// <summary>
/// Appends a string to the buffer.
/// </summary>
internal void Append(string value)
{
lock (_lock)
{
_buffer.Value.Append(value);
}
}

/// <summary>
/// Appends a single character to the buffer.
/// </summary>
internal void Append(char value)
{
lock (_lock)
{
_buffer.Value.Append(value);
}
}

/// <summary>
/// Appends a range of characters to the buffer.
/// </summary>
internal void Append(char[] buffer, int index, int count)
{
lock (_lock)
{
_buffer.Value.Append(buffer, index, count);
}
}

/// <summary>
/// Drains all buffered content and clears the buffer.
/// </summary>
internal string Drain()
{
lock (_lock)
{
var buf = _buffer.Value;
var result = buf.ToString();
buf.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)
{
var buf = _buffer.Value;
if (buf.Length > 0)
{
buf.Append(value);
value = buf.ToString();
buf.Clear();
}
}

return value;
}

/// <summary>
/// If the buffer has content, drains and returns it. Otherwise returns <c>null</c>.
/// </summary>
internal string? FlushIfNonEmpty()
{
lock (_lock)
{
var buf = _buffer.Value;
if (buf.Length > 0)
{
var result = buf.ToString();
buf.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
89 changes: 13 additions & 76 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,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);
Expand All @@ -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());
Expand All @@ -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());
Expand All @@ -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);
}

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

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