From 4cd6590b5f4a9deeed7959039018aa52ff391803 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 5 Apr 2026 18:47:05 +0100 Subject: [PATCH] fix: prevent StringBuilder race in console interceptor during parallel tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove StringBuilder overrides from OptimizedConsoleInterceptor. The overrides called ToString() which traverses the entire internal chunk linked list — if the caller's StringBuilder is mutated concurrently, ToString() throws ArgumentOutOfRangeException(chunkLength). The base TextWriter implementation iterates chunks via GetChunks() and copies each one independently via Write(ReadOnlySpan), which the interceptor already overrides. Also move StringBuilder.Length checks inside the ReaderWriterLockSlim in Context.GetStandardOutput/GetErrorOutput — Length is not thread-safe. Fixes #5411 --- TUnit.Core/Context.cs | 18 ++++++------------ .../Logging/OptimizedConsoleInterceptor.cs | 9 +++------ 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/TUnit.Core/Context.cs b/TUnit.Core/Context.cs index 6721a283a4..dd5b916d00 100644 --- a/TUnit.Core/Context.cs +++ b/TUnit.Core/Context.cs @@ -82,16 +82,13 @@ public void AddAsyncLocalValues() public virtual string GetStandardOutput() { - if (_outputBuilder.Length == 0) - { - return string.Empty; - } - _outputLock.EnterReadLock(); try { - return _outputBuilder.ToString(); + return _outputBuilder.Length == 0 + ? string.Empty + : _outputBuilder.ToString(); } finally { @@ -101,16 +98,13 @@ public virtual string GetStandardOutput() public virtual string GetErrorOutput() { - if (_errorOutputBuilder.Length == 0) - { - return string.Empty; - } - _errorOutputLock.EnterReadLock(); try { - return _errorOutputBuilder.ToString(); + return _errorOutputBuilder.Length == 0 + ? string.Empty + : _errorOutputBuilder.ToString(); } finally { diff --git a/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs b/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs index e8c4cbb428..fef11518c4 100644 --- a/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs +++ b/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs @@ -164,18 +164,15 @@ public override async Task WriteLineAsync(string? value) } #if NET + // StringBuilder overrides intentionally omitted — base TextWriter iterates chunks + // via GetChunks() and calls Write(ReadOnlySpan) per chunk, avoiding ToString() + // which races with concurrent StringBuilder mutation (see #5411). public override void Write(ReadOnlySpan buffer) => Write(new string(buffer)); - public override void Write(StringBuilder? value) => Write(value?.ToString() ?? string.Empty); public override Task WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = new()) => WriteAsync(new string(buffer.Span)); - public override Task WriteAsync(StringBuilder? value, CancellationToken cancellationToken = new()) - => WriteAsync(value?.ToString() ?? string.Empty); public override void WriteLine(ReadOnlySpan buffer) => WriteLine(new string(buffer)); - public override void WriteLine(StringBuilder? value) => WriteLine(value?.ToString() ?? string.Empty); public override Task WriteLineAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = new()) => WriteLineAsync(new string(buffer.Span)); - public override Task WriteLineAsync(StringBuilder? value, CancellationToken cancellationToken = new()) - => WriteLineAsync(value?.ToString() ?? string.Empty); #endif public override IFormatProvider FormatProvider => GetOriginalOut().FormatProvider;