Skip to content

Conversation

@thomhurst
Copy link
Owner

Problem

PR #4549 introduced per-context console line buffers to prevent output mixing between parallel tests. However, a post-merge review comment identified a thread-safety issue with the lazy initialization in Context.cs:

// NOT thread-safe
internal (StringBuilder Buffer, object Lock) GetConsoleStdOutLineBuffer()
{
    return (_consoleStdOutLineBuffer ??= new StringBuilder(), _consoleStdOutBufferLock);
}

Race condition: Multiple threads calling GetConsoleStdOutLineBuffer() simultaneously could each create their own StringBuilder before the null-coalescing assignment completes, undermining the per-context buffer isolation that #4549 was designed to provide.

Solution

Replace nullable StringBuilder? fields with Lazy<StringBuilder> for guaranteed thread-safe initialization:

TUnit.Core/Context.cs:

  • Changed StringBuilder? _consoleStdOutLineBufferLazy<StringBuilder> _consoleStdOutLineBuffer = new(() => new StringBuilder())
  • Changed StringBuilder? _consoleStdErrLineBufferLazy<StringBuilder> _consoleStdErrLineBuffer = new(() => new StringBuilder())
  • Updated accessors to use .Value instead of ??= operator

Benefits

Thread-safe by default - Lazy<T> guarantees single initialization even under concurrent access
No manual locking - No need for double-checked locking pattern
Idiomatic C# - Standard approach for lazy initialization
AOT-compatible - Works with Native AOT compilation
Zero behavior change - Transparent fix maintaining all existing functionality

Testing

Addresses feedback from: #4549 (comment)

🤖 Generated with Claude Code

Addresses post-merge feedback from PR #4549 regarding thread-safety
concerns in console buffer lazy initialization.

Replaced nullable StringBuilder fields with Lazy<StringBuilder> to
guarantee thread-safe initialization when multiple threads access
GetConsoleStdOutLineBuffer() or GetConsoleStdErrLineBuffer() concurrently.

The previous implementation using ??= was not thread-safe and could
result in multiple StringBuilder instances being created before
assignment completes, undermining per-context buffer isolation.

Benefits:
- Thread-safe by default with Lazy<T>
- No manual double-checked locking needed
- Cleaner, more idiomatic C# code
- AOT-compatible

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@thomhurst
Copy link
Owner Author

Summary

Replaces nullable StringBuilder fields with Lazy for thread-safe initialization of console line buffers.

Critical Issues

None found ✅

Analysis

The PR correctly addresses a legitimate thread-safety concern in the null-coalescing initialization pattern. However, I have one observation about the implementation:

Observation: Lock objects may be redundant

The change replaces ??= with Lazy<T> for thread-safe initialization, but keeps the separate lock objects (_consoleStdOutBufferLock, _consoleStdErrBufferLock). Looking at the usage in OptimizedConsoleInterceptor.cs:68-76, these locks protect access to the buffer contents during flush operations:

var (buffer, bufferLock) = GetLineBuffer();
lock (bufferLock)
{
    if (buffer.Length > 0)
    {
        RouteToSinks(buffer.ToString());
        buffer.Clear();
    }
}

The locks are clearly necessary for protecting buffer operations. The Lazy<T> wrapper only handles initialization thread-safety. So the current approach is correct - both mechanisms serve different purposes and are both needed.

Correctness:
✅ Thread-safe initialization via Lazy<T>
✅ Preserves existing locking for buffer content protection
✅ AOT-compatible (no reflection used)
✅ No behavioral changes
✅ Minimal allocation overhead (Lazy<T> wrapper is tiny, one-time cost)

Performance consideration:
The PR description in #4549 mentions this is a hot path (called on every Console.Write operation). Lazy<T>.Value access after initialization is extremely fast (just a field read + simple check), so there's no meaningful performance impact compared to the previous direct field access.

Verdict

APPROVE - Clean fix that properly addresses the thread-safety issue without introducing new problems.

🤖 Generated with Claude Code

@thomhurst thomhurst enabled auto-merge (squash) January 24, 2026 13:50
@thomhurst thomhurst merged commit 4b9d77c into main Jan 24, 2026
13 checks passed
@thomhurst thomhurst deleted the fix/thread-safe-console-buffer-init branch January 24, 2026 13:52
This was referenced Jan 25, 2026
This was referenced Jan 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants