diff --git a/TUnit.Core/Context.cs b/TUnit.Core/Context.cs
index d7299ef88c8..feea90df110 100644
--- a/TUnit.Core/Context.cs
+++ b/TUnit.Core/Context.cs
@@ -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;
diff --git a/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs b/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs
index 3ba2bc37d28..212665d7c2f 100644
--- a/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs
+++ b/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs
@@ -15,8 +15,6 @@ namespace TUnit.Engine.Logging;
///
internal abstract class OptimizedConsoleInterceptor : TextWriter
{
- private readonly StringBuilder _lineBuffer = new();
-
public override Encoding Encoding => Encoding.UTF8;
///
@@ -24,6 +22,12 @@ internal abstract class OptimizedConsoleInterceptor : TextWriter
///
protected abstract LogLevel SinkLogLevel { get; }
+ ///
+ /// Gets the line buffer and lock from the current context.
+ /// This ensures each test has its own buffer, preventing output mixing between parallel tests.
+ ///
+ protected abstract (StringBuilder Buffer, object Lock) GetLineBuffer();
+
private protected abstract TextWriter GetOriginalOut();
private protected abstract void ResetDefault();
@@ -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);
}
}
@@ -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());
@@ -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);
}
@@ -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);
}
@@ -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);
}
diff --git a/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs b/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs
index ba1e4fd0a69..f0e7d9e3c56 100644
--- a/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs
+++ b/TUnit.Engine/Logging/StandardErrorConsoleInterceptor.cs
@@ -1,3 +1,5 @@
+using System.Text;
+using TUnit.Core;
using TUnit.Core.Logging;
namespace TUnit.Engine.Logging;
@@ -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())
diff --git a/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs b/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs
index 075054faa2d..ce0cb703b19 100644
--- a/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs
+++ b/TUnit.Engine/Logging/StandardOutConsoleInterceptor.cs
@@ -1,3 +1,5 @@
+using System.Text;
+using TUnit.Core;
using TUnit.Core.Logging;
namespace TUnit.Engine.Logging;
@@ -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())
diff --git a/TUnit.TestProject/Bugs/Issue4545/ParallelConsoleOutputTests.cs b/TUnit.TestProject/Bugs/Issue4545/ParallelConsoleOutputTests.cs
new file mode 100644
index 00000000000..c5f57302f72
--- /dev/null
+++ b/TUnit.TestProject/Bugs/Issue4545/ParallelConsoleOutputTests.cs
@@ -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");
+ }
+}