From 536ff25163a2bbbe59aec7e26f27f3ea62bb3db7 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 24 Jan 2026 12:35:47 +0000 Subject: [PATCH] fix: prevent console output mixing between parallel tests (#4545) ## Problem When running multiple tests in parallel, console output (Console.Write/WriteLine) was being mixed between tests or missing entirely. This occurred because the console interceptor's line buffer was a singleton instance field shared across all parallel tests. ## Root Cause OptimizedConsoleInterceptor used `private readonly StringBuilder _lineBuffer` which was shared by all tests. When Test A called Console.Write() without a newline, it buffered text in the shared buffer. If Test B then called Console.WriteLine(), it would append to the same buffer and route the combined output to Test B's context via Context.Current. ## Solution Move console line buffers into Context itself, leveraging the existing robust Context.Current AsyncLocal mechanism: - Added per-context console buffers in Context.cs with thread safety locks - Made GetLineBuffer() abstract, returning (StringBuilder Buffer, object Lock) - Added locking around all buffer operations for thread safety within tests - Updated StandardOutConsoleInterceptor and StandardErrorConsoleInterceptor ## Benefits - Per-test isolation: each test context has its own console line buffers - Thread safety: locks protect concurrent access within a single test - Consistency: follows same pattern as OutputWriter/ErrorOutputWriter - No new AsyncLocal: reuses existing Context.Current mechanism ## Testing - All 80 existing CaptureOutputTests pass - Added 33 new ParallelConsoleOutputTests for regression testing Fixes #4545 Co-Authored-By: Claude Sonnet 4.5 --- TUnit.Core/Context.cs | 18 ++++ .../Logging/OptimizedConsoleInterceptor.cs | 84 ++++++++++++++----- .../StandardErrorConsoleInterceptor.cs | 4 + .../Logging/StandardOutConsoleInterceptor.cs | 4 + .../Issue4545/ParallelConsoleOutputTests.cs | 46 ++++++++++ 5 files changed, 135 insertions(+), 21 deletions(-) create mode 100644 TUnit.TestProject/Bugs/Issue4545/ParallelConsoleOutputTests.cs 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"); + } +}