fix: cap test output at 1M chars to prevent OOM#5561
Conversation
Tests producing excessive output caused OutOfMemoryException in ValueStringBuilder.Grow() when collecting results. ConcurrentStringWriter now trims oldest output when exceeding 1M characters (~2MB), keeping the most recent 75% to avoid per-write trim thrashing. A truncation notice is prepended when output is read back.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 0 |
TIP This summary will be updated as you push new changes. Give us feedback
There was a problem hiding this comment.
Code Review
Good fix for the OOM issue (#5560). The approach is sound and the implementation is clean. A few observations:
Thread Safety: ??= on the Writer Fields (Minor)
// Lines 51-53
public TextWriter OutputWriter => _outputWriter ??= new ConcurrentStringWriter(GetOutputBuilder(), GetOutputLock());
public TextWriter ErrorOutputWriter => _errorOutputWriter ??= new ConcurrentStringWriter(GetErrorOutputBuilder(), GetErrorOutputLock());??= is not atomic — two threads racing on first access could each construct a ConcurrentStringWriter. The "losing" writer is discarded, but if it had already set its own _truncated = true before being dropped, that state is silently lost. In practice this is a very narrow race, and GetOutputBuilder() / GetOutputLock() are both safe via LazyInitializer.EnsureInitialized, so both writers would share the same StringBuilder + lock.
This was already the pre-existing behaviour under the [field: AllowNull, MaybeNull] semi-auto property (which generates identical ??= code), so it's not a regression. But if full correctness is desired, LazyInitializer.EnsureInitialized (already used everywhere else in Context) is the consistent pattern:
private ConcurrentStringWriter? _outputWriter;
private ConcurrentStringWriter GetOutputWriter() =>
LazyInitializer.EnsureInitialized(ref _outputWriter,
() => new ConcurrentStringWriter(GetOutputBuilder(), GetOutputLock()))!;
public TextWriter OutputWriter => GetOutputWriter();
public virtual string GetStandardOutput() => _outputWriter?.GetContent() ?? string.Empty;Design: TrimIfNeeded() Called on Every Write
Currently TrimIfNeeded() runs a _builder.Length > MaxOutputLength comparison after every single write, including Write(char value) which is called character-by-character. The comparison itself is O(1) and cheap, but consider that StringBuilder.Length is a property getter.
An optional micro-optimisation once truncation has never occurred is to skip the check entirely:
private void TrimIfNeeded()
{
// Fast path: once we've never exceeded the limit and we're well under,
// most writes can skip the branch entirely.
if (_builder.Length <= MaxOutputLength)
return;
_builder.Remove(0, _builder.Length - TrimTarget);
_truncated = true;
}This is effectively what the code already does (the if condition is the same), so this is just a style note — the logic is already correct.
Design: "Keep most recent" is the right call
The choice to trim the oldest output and keep the most recent is a good fit for test debugging — failures tend to be logged near the end. The 75% trim target (amortises the O(N) StringBuilder.Remove call across ~256K chars before re-triggering) is a sensible tradeoff.
Encapsulation Improvement is Well Done
Moving GetContent() into ConcurrentStringWriter — centralising the lock acquisition, length check, and truncation-notice prepend — is a clean encapsulation win. The simplification of GetStandardOutput() / GetErrorOutput() to one-liners is a direct payoff. The _truncated flag is correctly written under the write lock and read under the read lock; ReaderWriterLockSlim provides the necessary acquire/release barriers.
Async Writes
No WriteAsync overrides needed — TextWriter's default async implementations delegate to the sync overrides, so TrimIfNeeded() is covered on the async path too.
Overall: the approach correctly addresses the root cause, the locking discipline is sound, and the refactor is a net improvement. The thread-safety note on ??= is worth considering for full correctness parity with the rest of Context's lazy-init pattern, but is not blocking.
|
Azure Pipelines: 1 pipeline(s) were filtered out due to trigger conditions. |
|
Azure Pipelines: 1 pipeline(s) were filtered out due to trigger conditions. |
There was a problem hiding this comment.
Code Review (Follow-up)
The previous review comment about using LazyInitializer.EnsureInitialized for the writer fields has been addressed in the second commit — the pattern is now consistent with the rest of Context. That concern is resolved.
The overall approach remains sound. Two new findings on the current state of the code:
1. Surrogate Pair Splitting in TrimIfNeeded()
TrimIfNeeded() does a raw StringBuilder.Remove(0, _builder.Length - TrimTarget). The first retained character is at index _builder.Length - TrimTarget. If that character happens to be a low surrogate whose paired high surrogate was just removed, the builder is left with an unpaired low surrogate — invalid UTF-16.
HtmlReporter.TruncateOutput() already handles this correctly (lines 535–540):
var cutAt = MaxOutputLength;
if (char.IsHighSurrogate(value[cutAt - 1]))
cutAt--;The same guard is needed in TrimIfNeeded():
private void TrimIfNeeded()
{
if (_builder.Length <= MaxOutputLength)
return;
var removeCount = _builder.Length - TrimTarget;
// Avoid splitting a surrogate pair at the boundary
if (removeCount > 0 && char.IsHighSurrogate(_builder[removeCount - 1]))
removeCount--;
_builder.Remove(0, removeCount);
_truncated = true;
}In practice, output that triggers OOM is usually plain ASCII logs, so this is unlikely to cause real-world failures — but correctness is correctness, especially given that the HtmlReporter already treats this as worth guarding.
2. Double-Truncation Edge Case (Minor)
The ConcurrentStringWriter trims the builder to TrimTarget (786 432 chars) on overflow. GetContent() then prepends the TruncationNotice (~92 chars), giving ~786 524 chars total — well under both limits.
However, after a trim, subsequent writes can accumulate output again up to MaxOutputLength (1 048 576 chars) before the next trim fires. At read time GetContent() would then return 1 048 576 + 92 = 1 048 668 chars. This is just barely over HtmlReporter.MaxOutputLength (1 048 576), so HtmlReporter.TruncateOutput() fires again — truncating the first 92 characters of the string, which is exactly the TruncationNotice itself.
The effect is:
- The user sees the tail of the actual output (correct)
- The
[... output truncated ...]notice is cut (replaced by HtmlReporter's own notice at the end) — probably acceptable, but slightly confusing
The simplest fix, if desired, is to either:
- Reduce
ConcurrentStringWriter.MaxOutputLengthbylen(TruncationNotice)so the worst-case output fromGetContent()stays at or below 1 048 576, or - Accept the current behaviour since both notices refer to truncation and it is not harmful
This is a very low-priority cosmetic issue, not a correctness bug.
Summary
| Finding | Severity | Addressed by Second Commit? |
|---|---|---|
??= race on writer fields (previous review) |
Low | Yes — LazyInitializer now used consistently |
Surrogate split in TrimIfNeeded() |
Low-Medium | No — new finding |
| Double-truncation edge case | Very Low (cosmetic) | No — new finding |
The core fix (capping output at write time to prevent OOM) is correct and the refactoring is a net improvement. The surrogate splitting issue is worth a one-liner guard for correctness parity with HtmlReporter.
|
Azure Pipelines: 1 pipeline(s) were filtered out due to trigger conditions. |
There was a problem hiding this comment.
Code Review
This is a clean, well-scoped fix for a real OOM issue. The design is sound — capping at write time with amortized trimming is the right place to enforce the limit. A few observations:
Correctness — Surrogate Pair Boundary
The surrogate-pair guard is correct for the case it targets: if the last-removed char is a high surrogate, decrement removeCount to keep the full pair. Two edge cases worth noting:
-
What if
removeCount - 1 == 0after the decrement?removeCount > 0prevents underflow, but ifremoveCountbecomes 0 the builder won't be trimmed at all, which is safe — it will trim on the next write instead. -
What if
_builder[removeCount - 1]is a low surrogate (its high surrogate was already in the removed range)? In valid UTF-16 that can't happen independently, so the current check is sufficient. A more defensive approach — though arguably over-engineering for test output — would be:while (removeCount > 0 && char.IsLowSurrogate(_builder[removeCount])) removeCount--;
(Check the first kept char rather than the last removed one — semantically clearer.)
Performance of StringBuilder.Remove(0, N)
Remove(0, N) with N ≈ 256 K chars causes a full left-shift of the remaining 768 K chars every time the buffer saturates. This is O(remaining length) per trim. For a test dumping continuous output past 1 MB, that's a 768 K memcopy every ~256 K writes.
The 25% headroom amortizes this well for typical tests, but for truly pathological output generators the trim could dominate. If this ever becomes a hot path, a ring buffer (fixed char[] with head/tail indices) or just tracking an _startOffset int and slicing on GetContent() would reduce trim cost to O(1). For now the Remove approach is fine — just worth a // O(n) shift; amortized by 25% headroom comment to orient future readers.
GetContent() Holds Read Lock While Allocating
_lock.EnterReadLock();
// ...
var content = _builder.ToString(); // allocates up to 2 MB under the lock
return _truncated ? string.Concat(TruncationNotice, content) : content;For a 1 MB buffer, ToString() + optional Concat allocates ~2–4 MB while blocking all writers. This is called at the end of a test run (cold path), so it's not a concern in practice — but it's worth knowing if this pattern is ever reused in a hotter context.
Architecture — Consolidation of GetStandardOutput()/GetErrorOutput()
Moving the lock acquisition and truncation-notice logic into GetContent() is the right call. The old pattern (duplicated read-lock try/finally in Context) was error-prone; now there's a single, tested path. The _outputWriter?.GetContent() ?? string.Empty one-liners are a clear improvement.
LazyInitializer Refactor
Using LazyInitializer.EnsureInitialized is consistent with the rest of the class and correctly thread-safe (CAS semantics). The trade-off vs. the old field ??= is that the factory may run more than once under heavy contention, but since the losing instances are immediately discarded and share the same underlying StringBuilder/lock via the lazy-init chain, there's no correctness issue.
Minor
private const int TrimTarget = MaxOutputLength * 3 / 4;— worth a// 786,432 charscomment (or just leave the arithmetic visible as-is; it's self-documenting).- The
TruncationNoticemessage correctly says "showing most recent output" which accurately describes the keep-tail strategy. Good UX.
Overall: Solid fix. The design decisions (cap at write time, keep tail, amortized trim, truncation notice on read) are all well-reasoned and the implementation is correct. The main architectural alternative worth knowing about (ring buffer) is premature for this use case. Ready to merge.
There was a problem hiding this comment.
Code Review
This is a clean, well-scoped fix for a real OOM issue. The design is sound — capping at write time with amortized trimming is the right place to enforce the limit.
Correctness — Surrogate Pair Boundary
The surrogate-pair guard is correct for the targeted case: if the last-removed char is a high surrogate, decrement removeCount to keep the full pair. A more semantically clear alternative would check the first kept char instead:
// More readable: ensure the first kept char is not a lone low surrogate
while (removeCount > 0 && char.IsLowSurrogate(_builder[removeCount]))
removeCount--;The current check is logically equivalent for valid UTF-16, but checking the kept boundary is easier to reason about.
Performance of StringBuilder.Remove(0, N)
Remove(0, N) with N ≈ 256K chars causes a full left-shift of the remaining 768K chars — O(remaining length) per trim. The 25% headroom amortizes this well for typical tests. If this ever becomes a hot path (a test continuously flooding output past 1MB), the alternative is tracking a _startOffset int and slicing on GetContent() — O(1) trim. For now the Remove approach is completely justified; just worth a comment like // O(n) shift; amortized by 25% headroom for future readers.
GetContent() Holds Read Lock While Allocating
StringBuilder.ToString() for a 1MB buffer allocates ~2MB while blocking all writers. This is called at end-of-test (cold path), not a practical concern — but worth knowing if this pattern gets reused in a hotter context.
Architecture — Consolidation of GetStandardOutput()/GetErrorOutput()
Moving lock acquisition and truncation-notice logic into GetContent() is the right design. The old duplicated read-lock try/finally in Context was error-prone; the new one-liners are a clear improvement.
LazyInitializer Refactor
Consistent with the rest of the class and correctly thread-safe (CAS semantics). The factory may run more than once under contention but since losing instances share the same underlying StringBuilder/lock via the lazy-init chain, there is no correctness issue.
Overall: Solid, well-reasoned fix. The design decisions (cap at write time, keep tail, amortized trim, truncation notice on read) are all correct. The main architectural alternative (ring buffer) would be premature for this use case. Ready to merge.
Code ReviewThis is a clean, well-scoped fix for a real OOM issue. The design is sound — capping at write time with amortized trimming is the right place to enforce the limit. Correctness — Surrogate Pair BoundaryThe surrogate-pair guard is correct for the targeted case: if the last-removed char is a high surrogate, decrement // More readable: ensure the first kept char is not a lone low surrogate
while (removeCount > 0 && char.IsLowSurrogate(_builder[removeCount]))
removeCount--;The current check is logically equivalent for valid UTF-16 input, but checking the kept boundary is easier to reason about. Performance of
|
Summary
Closes #5560
ConcurrentStringWriterto preventOutOfMemoryExceptionwhen tests produce excessive output[... output truncated ...]notice when reading back truncated outputRoot cause
ConcurrentStringWriterbacked byStringBuilderhad no size limit. When tests produced large output,ValueStringBuilder.Grow()in the reporting path (ToTestNode→GetStandardOutput) tried to allocate arrays too large forArrayPool, causing OOM.Design
ConcurrentStringWriter.TrimIfNeeded()— all 14 Write/WriteLine overrides call it after each append, under the existing write lockStringBuilder.Remove(0, N)amortized by 25% headroomConcurrentStringWriter.GetContent()owns lock acquisition, content retrieval, and truncation notice —Context.GetStandardOutput()/GetErrorOutput()are now one-linersTest plan
ContextThreadSafetyTests— concurrent read/write safetyCaptureOutputTests— 80 output capture scenariosParallelConsoleOutputTests— 44 parallel console testsConsoleTests— console interceptionTestBuildContextOutputCaptureTests— build-time output