fix: prevent StringBuilder race in console interceptor during parallel tests#5412
fix: prevent StringBuilder race in console interceptor during parallel tests#5412JSkimming wants to merge 1 commit intothomhurst:mainfrom
Conversation
…l tests OptimizedConsoleInterceptor.Write(StringBuilder) calls .ToString() on the caller's StringBuilder. ASP.NET Core's ConsoleLogger pools its StringBuilder and may return it to the pool immediately after Write returns. Under parallel test execution, another thread can pick up the pooled StringBuilder and mutate it while .ToString() is still traversing the internal chunk list, causing ArgumentOutOfRangeException (Parameter 'chunkLength'). Fix: copy the StringBuilder content via string.Create + CopyTo with a catch for the race condition. If the race hits, the output for that single log entry is lost rather than crashing the test. Also move the _outputBuilder.Length check inside the ReaderWriterLockSlim in Context.GetStandardOutput() and GetErrorOutput() — StringBuilder.Length is not thread-safe and should not be read outside the lock. Fixes thomhurst#5411
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 5 |
TIP This summary will be updated as you push new changes. Give us feedback
|
OK, so this is the real me, not Claude on my behalf. I'm somewhat sceptical about ASP.NET mutating the string builder before the call completes, but with the stack tracing being so sparse, there's not much to go on. The change seems at least neutral. The locking changes are certainly worthwhile, as Length shouldn't be read outside of a lock, though it isn't a mutating operation and shouldn't cause the issue. Happy to take feedback and make adjustments. |
There was a problem hiding this comment.
Code Review
Good defensive fix for a real crash. The two changes have different confidence levels — the fix is clearly correct, while the fix deserves a closer look.
Context.cs — Clean and correct
Moving the early-return inside the read lock is the right call. Reading outside the lock was a genuine data race (the backing field is not volatile). This is a straightforward, unambiguous improvement.
OptimizedConsoleInterceptor.cs — Concerns
1. CopyTo doesn't actually improve thread-safety over ToString()
Both and traverse the same internal chunk list. If the StringBuilder is mutated concurrently, both throw for the same reasons. The real safety net here is the — the use of + is a more complex spelling of the same vulnerability.
A simpler implementation that is functionally equivalent:
If there's a measurable allocation or perf reason to prefer , it should be documented. As written, it adds complexity without safety benefit.
2. Silent log loss is a debugging hazard
When the race fires, the log entry is silently discarded with no trace. In a CI environment debugging flaky tests, this makes the problem harder to diagnose — you lose the very output that would help. Consider at least incrementing a counter or writing a trace-level warning to stderr so there's some signal that truncation occurred:
3. Is the root cause actually what we think it is?
The PR description attributes the crash to ASP.NET Core returning the pooled to the pool before returns. But the overload is synchronous — and the author's own comment acknowledges scepticism about this explanation.
Two alternative causes worth considering:
- Code coverage instrumentation can introduce GC pressure and unexpected reordering that exposes pre-existing unsafe reads (like the length-outside-lock bug fixed in this PR).
- **A bug in ASP.NET Core's ** returning the StringBuilder before the Write completes — if so, it's worth filing an upstream issue in addition to this defensive fix.
The fix alone might resolve the original crash if the actual race was on the output builder rather than the ASP.NET Core pooled one.
Suggestion
If the approach is kept, add a comment explaining why it's preferred over (e.g. a measured perf difference). Otherwise, simplify to + catch — the intent is clearer and the behaviour is identical.
Summary
The locking fix in is solid and should definitely be merged. The approach in is a pragmatic safety net for an inherently racy situation with externally-owned memory, but the + implementation doesn't improve on +catch in either safety or clarity. The silent log-loss behaviour is the main concern for long-term maintainability.
There was a problem hiding this comment.
Code Review
Good defensive fix for a real crash. The two changes have different confidence levels — the Context.cs fix is clearly correct, while the OptimizedConsoleInterceptor.cs fix deserves a closer look.
Context.cs — Clean and correct
Moving the early-return length check inside the read lock is the right call. Reading StringBuilder.Length outside the lock was a genuine data race. This is a straightforward, unambiguous improvement.
OptimizedConsoleInterceptor.cs — Concerns
1. CopyTo does not actually improve thread-safety over ToString()
Both StringBuilder.ToString() and StringBuilder.CopyTo() traverse the same internal chunk list. If the StringBuilder is mutated concurrently, both throw ArgumentOutOfRangeException for the same reasons. The real safety net here is the try/catch — the string.Create + CopyTo approach is a more complex spelling of the same vulnerability.
A simpler implementation that is functionally equivalent:
private static string CopyStringBuilderSafely(StringBuilder? value)
{
if (value is null) return string.Empty;
try
{
return value.ToString();
}
catch (ArgumentOutOfRangeException)
{
return string.Empty;
}
}If there is a measurable allocation or perf reason to prefer CopyTo, it should be documented. As written, it adds complexity without safety benefit.
2. Silent log loss is a debugging hazard
When the race fires, the log entry is silently discarded with no trace. In a CI environment debugging flaky tests, this makes the problem harder to diagnose — you lose the very output that would have helped. Consider at minimum a Trace.WriteLine or a counter so there is some signal that truncation occurred.
3. Is the root cause actually what we think it is?
The PR description attributes the crash to ASP.NET Core returning the pooled StringBuilder to the pool before Write(StringBuilder) returns. But Write(StringBuilder) is synchronous — and the author's own comment acknowledges scepticism about this.
Two alternative causes worth considering:
- Code coverage instrumentation can introduce GC pressure and unexpected reordering that exposes pre-existing unsafe reads (like the Context.cs length-outside-lock bug fixed in this PR).
- If it is an ASP.NET Core ConsoleLogger bug (returning the StringBuilder before Write completes), it may be worth filing an upstream issue in addition to this defensive workaround.
The Context.cs fix alone might resolve the original crash if the actual race was on the Context output builder rather than the ASP.NET Core pooled one.
Suggestion
If the CopyTo approach is kept over ToString(), add a comment explaining the performance rationale. Otherwise, simplify to ToString() + catch — the intent is clearer and the behaviour is identical.
Summary
The locking fix in Context.cs is solid and should definitely be merged. The CopyStringBuilderSafely approach in OptimizedConsoleInterceptor.cs is a pragmatic safety net, but the string.Create + CopyTo implementation does not improve on ToString() + catch in either safety or clarity. The silent log-loss behaviour is the main concern for long-term maintainability.
|
If you can reproduce it again, run with |
|
Superseded by #5414 — the owner took a cleaner approach: removing the StringBuilder overrides entirely rather than catching the exception. The base TextWriter handles StringBuilder chunk-by-chunk via GetChunks(), avoiding the race without any ToString() call. |
Fixes #5411
Problem
OptimizedConsoleInterceptor.Write(StringBuilder)calls.ToString()on the caller'sStringBuilder. ASP.NET Core'sConsoleLoggerpools itsStringBuilderand may return it to the pool immediately afterWritereturns. Under parallel test execution with code coverage, another thread can pick up the pooledStringBuilderand mutate it while.ToString()is still traversing the internal chunk list, causing:Fix
OptimizedConsoleInterceptor.cs: Replacevalue?.ToString()withCopyStringBuilderSafely(value)for allStringBuilder-accepting overloads (Write,WriteAsync,WriteLine,WriteLineAsync). The helper copies viastring.Create+StringBuilder.CopyToand catchesArgumentOutOfRangeExceptionif the race hits — losing one log entry rather than crashing the test.Context.cs: Move the_outputBuilder.Lengthearly-return check inside theReaderWriterLockSliminGetStandardOutput()andGetErrorOutput().StringBuilder.Lengthis not thread-safe and was being read outside the lock.Test plan
ContextThreadSafetyTestscovers concurrent read/write on the output builder