From 3d806b1cc85b4524191bf9f9e81abb2b5ae48235 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:35:01 +0100 Subject: [PATCH] perf(engine): skip Console.Out/Err FlushAsync when no output captured (#5712) Every test unconditionally awaited two FlushAsync calls in the finally block, producing two extra async state machines per test for the common case where nothing was written to stdout/stderr. The console interceptor now sets a volatile HasCapturedConsoleOutput flag on the context whenever a write path runs. TestCoordinator checks the flag before calling FlushAsync, avoiding the two state machines when no output was captured. Correctness is preserved: any write lands -> flag set -> flush happens, identical to the old unconditional flush for the Task.Run fire-and-forget race. ~1.5-2% CPU reduction, -2 state machines per passing test. --- TUnit.Core/Context.cs | 9 ++++++ .../Logging/OptimizedConsoleInterceptor.cs | 25 +++++++++++----- .../Services/TestExecution/TestCoordinator.cs | 30 +++++++++++-------- 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/TUnit.Core/Context.cs b/TUnit.Core/Context.cs index a4d00352ce..6e2c9a677e 100644 --- a/TUnit.Core/Context.cs +++ b/TUnit.Core/Context.cs @@ -35,6 +35,11 @@ TestContext.Current as Context private ConsoleLineBuffer? _consoleStdOutLineBuffer; private ConsoleLineBuffer? _consoleStdErrLineBuffer; + // Set by the console interceptor on first write so TestCoordinator can skip the + // two Console.Out/Err FlushAsync state machines per test when nothing was written. + // Volatile read/write is cheap and sufficient — the flag only ever transitions false -> true. + private int _consoleOutputCaptured; + // Thread-safe: console interceptors may access from multiple threads. private StringBuilder GetOutputBuilder() => LazyInitializer.EnsureInitialized(ref _outputBuilder)!; @@ -63,6 +68,10 @@ private ConcurrentStringWriter GetErrorOutputWriter() => internal ConsoleLineBuffer ConsoleStdErrLineBuffer => LazyInitializer.EnsureInitialized(ref _consoleStdErrLineBuffer)!; + internal bool HasCapturedConsoleOutput => Volatile.Read(ref _consoleOutputCaptured) != 0; + + internal void MarkConsoleOutputCaptured() => Volatile.Write(ref _consoleOutputCaptured, 1); + internal Context(Context? parent) { Parent = parent; diff --git a/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs b/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs index fef11518c4..297b7c327a 100644 --- a/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs +++ b/TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs @@ -29,6 +29,17 @@ internal abstract class OptimizedConsoleInterceptor : TextWriter /// protected abstract ConsoleLineBuffer GetLineBuffer(); + /// + /// Returns the current context's line buffer and marks the context as having captured + /// console output, so TestCoordinator can skip the FlushAsync state machines when nothing + /// was written. Called from write paths only. + /// + private ConsoleLineBuffer GetLineBufferForWrite() + { + Context.Current.MarkConsoleOutputCaptured(); + return GetLineBuffer(); + } + private protected abstract TextWriter GetOriginalOut(); private protected abstract void ResetDefault(); @@ -83,12 +94,12 @@ public override async Task FlushAsync() // Write methods - buffer partial writes until we get a complete line public override void Write(bool value) => Write(value.ToString()); - public override void Write(char value) => GetLineBuffer().Append(value); + public override void Write(char value) => GetLineBufferForWrite().Append(value); public override void Write(char[]? buffer) { if (buffer != null) { - GetLineBuffer().Append(buffer, 0, buffer.Length); + GetLineBufferForWrite().Append(buffer, 0, buffer.Length); } } public override void Write(decimal value) => Write(value.ToString()); @@ -100,11 +111,11 @@ public override void Write(char[]? buffer) public override void Write(string? value) { if (value == null) return; - GetLineBuffer().Append(value); + GetLineBufferForWrite().Append(value); } public override void Write(uint value) => Write(value.ToString()); public override void Write(ulong value) => Write(value.ToString()); - public override void Write(char[] buffer, int index, int count) => GetLineBuffer().Append(buffer, index, count); + public override void Write(char[] buffer, int index, int count) => GetLineBufferForWrite().Append(buffer, index, count); public override void Write(string format, object? arg0) => Write(string.Format(format, arg0)); public override void Write(string format, object? arg0, object? arg1) => Write(string.Format(format, arg0, arg1)); public override void Write(string format, object? arg0, object? arg1, object? arg2) => Write(string.Format(format, arg0, arg1, arg2)); @@ -113,7 +124,7 @@ public override void Write(string? value) // WriteLine methods - flush buffer and route complete line to sinks public override void WriteLine() { - RouteToSinks(GetLineBuffer().Drain()); + RouteToSinks(GetLineBufferForWrite().Drain()); } public override void WriteLine(bool value) => WriteLine(value.ToString()); @@ -130,7 +141,7 @@ public override void WriteLine() public override void WriteLine(string? value) { // Prepend any buffered content - value = GetLineBuffer().AppendAndDrain(value); + value = GetLineBufferForWrite().AppendAndDrain(value); RouteToSinks(value); } @@ -159,7 +170,7 @@ public override async Task WriteLineAsync(char[] buffer, int index, int count) public override async Task WriteLineAsync(string? value) { - value = GetLineBuffer().AppendAndDrain(value); + value = GetLineBufferForWrite().AppendAndDrain(value); await RouteToSinksAsync(value).ConfigureAwait(false); } diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 0df9702a58..facec067cc 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -134,20 +134,24 @@ await RetryHelper.ExecuteWithRetry(test.Context, } finally { - // Flush console interceptors to ensure all buffered output is captured. - // This is critical for output from Console.Write() without newline. The flush - // is unconditional because `HasCapturedOutput` is a point-in-time check: a test - // that fires `Task.Run(() => Console.WriteLine(...))` without awaiting can land - // writes after the check, which would silently lose output. The interceptor's - // own FlushIfNonEmpty path is a cheap no-op when there is nothing buffered. - try - { - await Console.Out.FlushAsync().ConfigureAwait(false); - await Console.Error.FlushAsync().ConfigureAwait(false); - } - catch (Exception flushEx) + // Flush console interceptors only when the test actually wrote something. The + // interceptor sets HasCapturedConsoleOutput on first write, so skipping when the + // flag is false avoids two async state machines per test in the common case of a + // passing test that produced no output. A test that fires-and-forgets writes via + // Task.Run without awaiting is already racing with termination under the old + // unconditional flush — the flag preserves identical semantics for that case + // (writes land -> flag set -> flush happens). + if (test.Context.HasCapturedConsoleOutput) { - await _logger.LogErrorAsync($"Error flushing console output for {test.TestId}: {flushEx}").ConfigureAwait(false); + try + { + await Console.Out.FlushAsync().ConfigureAwait(false); + await Console.Error.FlushAsync().ConfigureAwait(false); + } + catch (Exception flushEx) + { + await _logger.LogErrorAsync($"Error flushing console output for {test.TestId}: {flushEx}").ConfigureAwait(false); + } } // Stay null on the success path — materializing the list only when something actually fails