diff --git a/TUnit.Core/Context.cs b/TUnit.Core/Context.cs index 842fe69722..b266d4c055 100644 --- a/TUnit.Core/Context.cs +++ b/TUnit.Core/Context.cs @@ -45,11 +45,16 @@ private ReaderWriterLockSlim GetOutputLock() => private ReaderWriterLockSlim GetErrorOutputLock() => LazyInitializer.EnsureInitialized(ref _errorOutputLock, static () => new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion))!; - [field: AllowNull, MaybeNull] - public TextWriter OutputWriter => field ??= new ConcurrentStringWriter(GetOutputBuilder(), GetOutputLock()); + private ConcurrentStringWriter? _outputWriter; + private ConcurrentStringWriter? _errorOutputWriter; + private ConcurrentStringWriter GetOutputWriter() => + LazyInitializer.EnsureInitialized(ref _outputWriter, () => new ConcurrentStringWriter(GetOutputBuilder(), GetOutputLock()))!; + private ConcurrentStringWriter GetErrorOutputWriter() => + LazyInitializer.EnsureInitialized(ref _errorOutputWriter, () => new ConcurrentStringWriter(GetErrorOutputBuilder(), GetErrorOutputLock()))!; - [field: AllowNull, MaybeNull] - public TextWriter ErrorOutputWriter => field ??= new ConcurrentStringWriter(GetErrorOutputBuilder(), GetErrorOutputLock()); + public TextWriter OutputWriter => GetOutputWriter(); + + public TextWriter ErrorOutputWriter => GetErrorOutputWriter(); // Internal accessors for console interceptor line buffers internal ConsoleLineBuffer ConsoleStdOutLineBuffer => @@ -101,49 +106,9 @@ public void AddAsyncLocalValues() #endif } - public virtual string GetStandardOutput() - { - if (_outputBuilder == null) - { - return string.Empty; - } - - var outputLock = GetOutputLock(); - outputLock.EnterReadLock(); - - try - { - return _outputBuilder.Length == 0 - ? string.Empty - : _outputBuilder.ToString(); - } - finally - { - outputLock.ExitReadLock(); - } - } + public virtual string GetStandardOutput() => _outputWriter?.GetContent() ?? string.Empty; - public virtual string GetErrorOutput() - { - if (_errorOutputBuilder == null) - { - return string.Empty; - } - - var errorOutputLock = GetErrorOutputLock(); - errorOutputLock.EnterReadLock(); - - try - { - return _errorOutputBuilder.Length == 0 - ? string.Empty - : _errorOutputBuilder.ToString(); - } - finally - { - errorOutputLock.ExitReadLock(); - } - } + public virtual string GetErrorOutput() => _errorOutputWriter?.GetContent() ?? string.Empty; public DefaultLogger GetDefaultLogger() { @@ -167,8 +132,17 @@ public void Dispose() /// internal sealed class ConcurrentStringWriter : TextWriter { + private const int MaxOutputLength = 1_048_576; // 1M chars (~2MB) + + // Trim to 75% of max to avoid re-trimming on every subsequent write + private const int TrimTarget = MaxOutputLength * 3 / 4; + + private static readonly string TruncationNotice = + $"[... output truncated — exceeded {MaxOutputLength:N0} character limit, showing most recent output ...]{Environment.NewLine}"; + private readonly StringBuilder _builder; private readonly ReaderWriterLockSlim _lock; + private bool _truncated; public ConcurrentStringWriter(StringBuilder builder, ReaderWriterLockSlim lockSlim) { @@ -184,6 +158,7 @@ public override void Write(char value) try { _builder.Append(value); + TrimIfNeeded(); } finally { @@ -199,6 +174,7 @@ public override void Write(string? value) try { _builder.Append(value); + TrimIfNeeded(); } finally { @@ -215,6 +191,7 @@ public override void Write(char[] buffer, int index, int count) try { _builder.Append(buffer, index, count); + TrimIfNeeded(); } finally { @@ -229,6 +206,7 @@ public override void WriteLine() try { _builder.AppendLine(); + TrimIfNeeded(); } finally { @@ -242,6 +220,7 @@ public override void WriteLine(string? value) try { _builder.AppendLine(value); + TrimIfNeeded(); } finally { @@ -257,6 +236,7 @@ public override void Write(char[]? buffer) try { _builder.Append(buffer); + TrimIfNeeded(); } finally { @@ -271,6 +251,7 @@ public override void Write(bool value) try { _builder.Append(value); + TrimIfNeeded(); } finally { @@ -284,6 +265,7 @@ public override void Write(int value) try { _builder.Append(value); + TrimIfNeeded(); } finally { @@ -297,6 +279,7 @@ public override void Write(uint value) try { _builder.Append(value); + TrimIfNeeded(); } finally { @@ -310,6 +293,7 @@ public override void Write(long value) try { _builder.Append(value); + TrimIfNeeded(); } finally { @@ -323,6 +307,7 @@ public override void Write(ulong value) try { _builder.Append(value); + TrimIfNeeded(); } finally { @@ -336,6 +321,7 @@ public override void Write(float value) try { _builder.Append(value); + TrimIfNeeded(); } finally { @@ -349,6 +335,7 @@ public override void Write(double value) try { _builder.Append(value); + TrimIfNeeded(); } finally { @@ -362,6 +349,7 @@ public override void Write(decimal value) try { _builder.Append(value); + TrimIfNeeded(); } finally { @@ -377,6 +365,7 @@ public override void Write(object? value) try { _builder.Append(value); + TrimIfNeeded(); } finally { @@ -385,6 +374,42 @@ public override void Write(object? value) } } + private void TrimIfNeeded() + { + if (_builder.Length > MaxOutputLength) + { + var removeCount = _builder.Length - TrimTarget; + + // Avoid splitting a surrogate pair at the trim boundary + if (removeCount > 0 && char.IsHighSurrogate(_builder[removeCount - 1])) + { + removeCount--; + } + + _builder.Remove(0, removeCount); + _truncated = true; + } + } + + internal string GetContent() + { + _lock.EnterReadLock(); + try + { + if (_builder.Length == 0) + { + return string.Empty; + } + + var content = _builder.ToString(); + return _truncated ? string.Concat(TruncationNotice, content) : content; + } + finally + { + _lock.ExitReadLock(); + } + } + public override void Flush() { // StringBuilder doesn't need flushing