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
18 changes: 18 additions & 0 deletions TUnit.Core/Context.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,30 @@ TestContext.Current as Context
private readonly ReaderWriterLockSlim _errorOutputLock = new(LockRecursionPolicy.NoRecursion);
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
private StringBuilder? _consoleStdOutLineBuffer;
private StringBuilder? _consoleStdErrLineBuffer;
private readonly object _consoleStdOutBufferLock = new();
private readonly object _consoleStdErrBufferLock = 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 ??= new StringBuilder(), _consoleStdOutBufferLock);
}

internal (StringBuilder Buffer, object Lock) GetConsoleStdErrLineBuffer()
{
return (_consoleStdErrLineBuffer ??= new StringBuilder(), _consoleStdErrBufferLock);
}

internal Context(Context? parent)
{
Parent = parent;
Expand Down
84 changes: 63 additions & 21 deletions TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@ namespace TUnit.Engine.Logging;
/// </summary>
internal abstract class OptimizedConsoleInterceptor : TextWriter
{
private readonly StringBuilder _lineBuffer = new();

public override Encoding Encoding => Encoding.UTF8;

/// <summary>
/// Gets the log level to use when routing console output to sinks.
/// </summary>
protected abstract LogLevel SinkLogLevel { get; }

/// <summary>
/// Gets the line buffer and lock from the current context.
/// This ensures each test has its own buffer, preventing output mixing between parallel tests.
/// </summary>
protected abstract (StringBuilder Buffer, object Lock) GetLineBuffer();

private protected abstract TextWriter GetOriginalOut();

private protected abstract void ResetDefault();
Expand Down Expand Up @@ -61,19 +65,32 @@ public override ValueTask DisposeAsync()
public override void Flush()
{
// Flush any buffered partial line
if (_lineBuffer.Length > 0)
var (buffer, bufferLock) = GetLineBuffer();
lock (bufferLock)
{
RouteToSinks(_lineBuffer.ToString());
_lineBuffer.Clear();
if (buffer.Length > 0)
{
RouteToSinks(buffer.ToString());
buffer.Clear();
}
}
}

public override async Task FlushAsync()
{
if (_lineBuffer.Length > 0)
var (buffer, bufferLock) = GetLineBuffer();
string? content = null;
lock (bufferLock)
{
await RouteToSinksAsync(_lineBuffer.ToString()).ConfigureAwait(false);
_lineBuffer.Clear();
if (buffer.Length > 0)
{
content = buffer.ToString();
buffer.Clear();
}
}
if (content != null)
{
await RouteToSinksAsync(content).ConfigureAwait(false);
}
}

Expand All @@ -96,7 +113,11 @@ public override void Write(char[]? buffer)
public override void Write(string? value)
{
if (value == null) return;
_lineBuffer.Append(value);
var (buffer, bufferLock) = GetLineBuffer();
lock (bufferLock)
{
buffer.Append(value);
}
}
public override void Write(uint value) => Write(value.ToString());
public override void Write(ulong value) => Write(value.ToString());
Expand All @@ -108,19 +129,32 @@ public override void Write(string? value)

private void BufferChar(char value)
{
_lineBuffer.Append(value);
var (buffer, bufferLock) = GetLineBuffer();
lock (bufferLock)
{
buffer.Append(value);
}
}

private void BufferChars(char[] buffer, int index, int count)
{
_lineBuffer.Append(buffer, index, 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 line = _lineBuffer.ToString();
_lineBuffer.Clear();
var (buffer, bufferLock) = GetLineBuffer();
string line;
lock (bufferLock)
{
line = buffer.ToString();
buffer.Clear();
}
RouteToSinks(line);
}

Expand All @@ -138,11 +172,15 @@ public override void WriteLine()
public override void WriteLine(string? value)
{
// Prepend any buffered content
if (_lineBuffer.Length > 0)
var (buffer, bufferLock) = GetLineBuffer();
lock (bufferLock)
{
_lineBuffer.Append(value);
value = _lineBuffer.ToString();
_lineBuffer.Clear();
if (buffer.Length > 0)
{
buffer.Append(value);
value = buffer.ToString();
buffer.Clear();
}
}
RouteToSinks(value);
}
Expand Down Expand Up @@ -172,11 +210,15 @@ public override async Task WriteLineAsync(char[] buffer, int index, int count)

public override async Task WriteLineAsync(string? value)
{
if (_lineBuffer.Length > 0)
var (buffer, bufferLock) = GetLineBuffer();
lock (bufferLock)
{
_lineBuffer.Append(value);
value = _lineBuffer.ToString();
_lineBuffer.Clear();
if (buffer.Length > 0)
{
buffer.Append(value);
value = buffer.ToString();
buffer.Clear();
}
}
await RouteToSinksAsync(value).ConfigureAwait(false);
}
Expand Down
4 changes: 4 additions & 0 deletions TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Text;
using TUnit.Core;
using TUnit.Core.Logging;

namespace TUnit.Engine.Logging;
Expand All @@ -10,6 +12,8 @@ internal class StandardErrorConsoleInterceptor : OptimizedConsoleInterceptor

protected override LogLevel SinkLogLevel => LogLevel.Error;

protected override (StringBuilder Buffer, object Lock) GetLineBuffer() => Context.Current.GetConsoleStdErrLineBuffer();

static StandardErrorConsoleInterceptor()
{
DefaultError = new StreamWriter(Console.OpenStandardError())
Expand Down
4 changes: 4 additions & 0 deletions TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Text;
using TUnit.Core;
using TUnit.Core.Logging;

namespace TUnit.Engine.Logging;
Expand All @@ -10,6 +12,8 @@ internal class StandardOutConsoleInterceptor : OptimizedConsoleInterceptor

protected override LogLevel SinkLogLevel => LogLevel.Information;

protected override (StringBuilder Buffer, object Lock) GetLineBuffer() => Context.Current.GetConsoleStdOutLineBuffer();

static StandardOutConsoleInterceptor()
{
DefaultOut = new StreamWriter(Console.OpenStandardOutput())
Expand Down
46 changes: 46 additions & 0 deletions TUnit.TestProject/Bugs/Issue4545/ParallelConsoleOutputTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace TUnit.TestProject.Bugs.Issue4545;

public class ParallelConsoleOutputTests
{
[Test]
[Repeat(10)]
public async Task Test1_ShouldCaptureOnlyOwnOutput()
{
Console.Write("Test1-Start");
await Task.Delay(10);
Console.WriteLine("-Test1-End");

var output = TestContext.Current!.GetStandardOutput();
await Assert.That(output).Contains("Test1-Start-Test1-End");
await Assert.That(output).DoesNotContain("Test2");
await Assert.That(output).DoesNotContain("Test3");
}

[Test]
[Repeat(10)]
public async Task Test2_ShouldCaptureOnlyOwnOutput()
{
Console.Write("Test2-Start");
await Task.Delay(10);
Console.WriteLine("-Test2-End");

var output = TestContext.Current!.GetStandardOutput();
await Assert.That(output).Contains("Test2-Start-Test2-End");
await Assert.That(output).DoesNotContain("Test1");
await Assert.That(output).DoesNotContain("Test3");
}

[Test]
[Repeat(10)]
public async Task Test3_ShouldCaptureOnlyOwnOutput()
{
Console.Write("Test3-Start");
await Task.Delay(10);
Console.WriteLine("-Test3-End");

var output = TestContext.Current!.GetStandardOutput();
await Assert.That(output).Contains("Test3-Start-Test3-End");
await Assert.That(output).DoesNotContain("Test1");
await Assert.That(output).DoesNotContain("Test2");
}
}
Loading