Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions TUnit.Core/Context.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)!;
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 18 additions & 7 deletions TUnit.Engine/Logging/OptimizedConsoleInterceptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ internal abstract class OptimizedConsoleInterceptor : TextWriter
/// </summary>
protected abstract ConsoleLineBuffer GetLineBuffer();

/// <summary>
/// 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.
/// </summary>
private ConsoleLineBuffer GetLineBufferForWrite()
{
Context.Current.MarkConsoleOutputCaptured();
return GetLineBuffer();
}

private protected abstract TextWriter GetOriginalOut();

private protected abstract void ResetDefault();
Expand Down Expand Up @@ -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());
Expand All @@ -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));
Expand All @@ -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());
Expand All @@ -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);
}

Expand Down Expand Up @@ -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);
}

Expand Down
30 changes: 17 additions & 13 deletions TUnit.Engine/Services/TestExecution/TestCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading