Skip to content

Harden TerminalTestReporter teardown and remove dead _buildErrorsCount#8426

Open
Evangelink wants to merge 1 commit into
mainfrom
dev/amauryleve/terminal-resilience
Open

Harden TerminalTestReporter teardown and remove dead _buildErrorsCount#8426
Evangelink wants to merge 1 commit into
mainfrom
dev/amauryleve/terminal-resilience

Conversation

@Evangelink
Copy link
Copy Markdown
Member

What

Three small, independently-justified hardening changes to the MTP terminal output device, drawn from a recent terminal review.

1. Restore the Windows console mode in TerminalTestReporter.Dispose

When the terminal is set up, NativeMethods.QueryIsScreenAndTryEnableAnsiColorCodes may enable ENABLE_VIRTUAL_TERMINAL_PROCESSING and save the original mode in _originalConsoleMode. Today only the normal TestExecutionCompleted path restores it — so on an abnormal teardown (unhandled exception, Dispose called without a completed run, hot-reload session that never reached TestExecutionCompleted), the user's shell is left with whatever mode we enabled.

Dispose now also calls RestoreConsoleMode, and TestExecutionCompleted nulls out _originalConsoleMode after restoring so the second call is a no-op when the normal lifecycle runs.

2. Make TestProgressStateAwareTerminal resilient on shutdown

  • ThreadProc used to catch only ObjectDisposedException. A broken pipe / IOException from the underlying console would silently kill the refresher thread, leaving subsequent writes flowing through WriteToTerminal (still under the lock) but with no spinner and no diagnostic. We now catch Exception and still attempt one EraseProgress on the way out.
  • StopShowingProgress is now idempotent and resilient to EraseProgress / StopBusyIndicator throwing (it's a best-effort cleanup at that point).
  • Dispose is now idempotent and always tears the refresher down, even when StopShowingProgress was never reached, so the cursor (HideCursor is emitted at start) and busy indicator are always restored.

3. Remove dead _buildErrorsCount field

The field was declared, reset to 0 in TestExecutionCompleted, and read by the summary colorization (colorizePassed / colorizeSkipped) — but nothing ever assigned it a non-zero value. The _buildErrorsCount == 0 conjuncts were always true; dropping them does not change the rendered summary in any reachable state.

Why now

These came out of a structured review of the terminal code. We started from a larger list of suspected concurrency issues, validated each one against the actual threading model (the platform's per-consumer SingleReader=true channel serializes ConsumeAsync, and the renderer ticks at 500ms), and confirmed that the only items that translated to user-observable defects were the abnormal-teardown cursor/console-mode leak and the over-narrow render-thread catch. The dead _buildErrorsCount is pure cleanup that came up while validating the summary path.

Validation

  • dotnet build src\Platform\Microsoft.Testing.Platform\Microsoft.Testing.Platform.csproj — succeeded on all TFMs (netstandard2.0, net8.0, net9.0) with 0 warnings.
  • Microsoft.Testing.Platform.UnitTests — all 820 tests pass on both net8.0 and net9.0 (2 unrelated cancellation tests are skipped, matching baseline).

Not in scope

The review also surfaced UX/feature ideas (--reporter=llm mode, --max-stdout-lines, source-context windows around failure frames, hint extension points, etc.) and refactoring opportunities (duplication between HumanReadableDurationFormatter.Render and Append, the object-typed list in AnsiTerminalTestProgressFrame). Those are intentionally left out of this PR so it stays narrowly scoped to the validated correctness issues.

Three small, independently-justified changes to the MTP terminal:

1. `TerminalTestReporter.Dispose` now also restores the Windows console mode
   (`ENABLE_VIRTUAL_TERMINAL_PROCESSING`). Previously only the normal
   `TestExecutionCompleted` path restored it, so an abnormal teardown
   (unhandled exception, `Dispose` without a completed run) left the
   user's shell with the mode we silently enabled. The saved value is now
   consumed in `TestExecutionCompleted` so the additional `Dispose`
   restore is a no-op when the normal lifecycle runs.

2. `TestProgressStateAwareTerminal` is now idempotently disposable and its
   render-thread proc no longer swallows only `ObjectDisposedException`.
   A broken pipe / `IOException` from the underlying console used to kill
   the refresher silently and leave the spinner permanently gone with no
   diagnostic. We now catch `Exception`, still attempt `EraseProgress`
   on the way out, and make `StopShowingProgress` + `Dispose` safe to
   call repeatedly (`Dispose` always tears the refresher down, even when
   `StopShowingProgress` was never reached).

3. Drop the dead `_buildErrorsCount` field. It was declared, reset to 0
   in `TestExecutionCompleted` and read by the summary colorization, but
   nothing ever assigned it a non-zero value, so the `_buildErrorsCount == 0`
   conjuncts in `colorizePassed` / `colorizeSkipped` were always true.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 20, 2026 20:14
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the terminal output device teardown paths in Microsoft.Testing.Platform so abnormal shutdowns (exceptions, incomplete lifecycles, hot reload interruptions) leave the user’s terminal in a sane state, and removes a dead summary field.

Changes:

  • Make TestProgressStateAwareTerminal teardown more resilient (broader catch in refresher thread, best-effort cleanup, idempotent Dispose/stop).
  • Restore Windows console mode from TerminalTestReporter.Dispose, and “consume” the saved original mode after the normal completion path.
  • Remove the unused _buildErrorsCount and simplify summary colorization logic.
Show a summary per file
File Description
src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TestProgressStateAwareTerminal.cs Adds idempotent shutdown and best-effort cleanup for the progress refresher and busy indicator.
src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.Summary.cs Removes _buildErrorsCount from summary colorization conditions.
src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.Lifecycle.cs Restores console mode at completion and clears saved mode afterward.
src/Platform/Microsoft.Testing.Platform/OutputDevice/Terminal/TerminalTestReporter.cs Restores console mode in Dispose to cover abnormal teardown paths.

Copilot's findings

  • Files reviewed: 4/4 changed files
  • Comments generated: 2

Comment on lines 122 to +143
public void StartShowingProgress(int workerCount)
{
if (GetShowProgress())
{
_progressItems = new TestProgressState[workerCount];
_terminal.StartBusyIndicator();
// If we crash unexpectedly without completing this thread we don't want it to keep the process running.
_refresher = new Thread(ThreadProc) { IsBackground = true };
_refresher.Start();
}
}

internal void StopShowingProgress()
{
if (GetShowProgress())
if (_stopped || !GetShowProgress())
{
_cts.Cancel();
_refresher?.Join();
return;
}

_stopped = true;
_cts.Cancel();
_refresher?.Join();
Comment on lines 55 to 62
_terminalWithProgress.StopShowingProgress();

_terminalWithProgress.WriteToTerminal(_isDiscovery ? AppendTestDiscoverySummary : AppendTestRunSummary);

NativeMethods.RestoreConsoleMode(_originalConsoleMode);
// Consume the saved console mode so a later Dispose does not try to restore again.
_originalConsoleMode = null;

Comment on lines +91 to +94
catch
{
// Best-effort cleanup; we are already in teardown.
}
Comment on lines +150 to +153
catch
{
// Best-effort cleanup; we are already in teardown.
}
Copy link
Copy Markdown
Member Author

@Evangelink Evangelink left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — PR #8426: Harden TerminalTestReporter teardown

All three stated goals (console-mode restore in Dispose, idempotent StopShowingProgress/Dispose, removal of dead _buildErrorsCount) are sound and the overall direction is correct. Three findings below, one of which is a genuine correctness concern under concurrent teardown.

# Dimension Finding Severity
1 Threading & Concurrency _stopped/_disposed are plain bool with no memory-visibility guarantee — if torn down from a thread other than the calling thread (e.g. finalizer), the idempotency guard can be bypassed. MINOR
2 Algorithmic Correctness EraseProgress() is called twice on the normal shutdown path: once by ThreadProc (always, after the loop) and again by StopShowingProgress after Join(). Harmless today but logically inconsistent. MINOR
3 Code Clarity The catch (Exception) comment in ThreadProc describes intent inaccurately — the cleanup it refers to happens after the catch block, not inside it. TRIVIAL
4–21 All other dimensions N/A or clean for this change.

Summary: The hardening changes are correct and desirable. The two minor issues are worth a quick follow-up pass but should not block merge.

Generated by Expert Code Review (on open) for issue #8426 · ● 5.5M

}

_terminal.EraseProgress();
try
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misleading comment (minor clarity)

The catch (Exception) block comment says the refresher must not "silently tear down ... without making a best-effort attempt to clean up the screen", but this catch block itself does nothing. The actual cleanup attempt is the unconditional try { _terminal.EraseProgress(); } that follows. The comment should describe what the block does ("swallow so the unconditional cleanup below can still run") rather than describing what it avoids:

catch (Exception)
{
    // Swallow so that the unconditional EraseProgress() below still runs.
    // There is no other thread to surface this to; the test run itself should
    // still be allowed to complete.
}

// Best-effort cleanup; we are already in teardown.
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double EraseProgress() on normal shutdown path

ThreadProc now always calls _terminal.EraseProgress() before it exits (lines 87–91). StopShowingProgress then calls _refresher?.Join() — which blocks until ThreadProc has already run EraseProgress() — and immediately calls _terminal.EraseProgress() a second time. The intent seems to be handling the case where the thread exits abnormally before reaching the cleanup, but because the catch (Exception) block does not re-throw and the cleanup is unconditional, EraseProgress is always called twice on this path.

Calling EraseProgress twice is harmless in practice (erasing an already-erased progress line is a no-op), but it signals a logical gap. Consider skipping the redundant call in StopShowingProgress if the refresher thread was joined cleanly, or removing the EraseProgress from ThreadProc and keeping it only in StopShowingProgress/Dispose.

_cts.Cancel();
_refresher?.Join();    // ThreadProc has already erased progress at this point

try
{
    // This EraseProgress is now redundant on the normal path.
    _terminal.EraseProgress();
    _terminal.StopBusyIndicator();
}

@@ -33,6 +33,8 @@
/// </summary>
private Thread? _refresher;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_stopped / _disposed not volatile (threading)

Both fields are plain bool. _stopped is written in StopShowingProgress and read in both StopShowingProgress and (indirectly) Dispose. _disposed is written and read in Dispose. While lifecycle callers are expected to be sequential in practice, the refresher thread is a background thread, and there is no memory barrier or volatile annotation to guarantee that a write on the calling thread is visible across threads.

For the idempotency guards to be reliable under any calling pattern (e.g. Dispose called from a finalizer or a different thread during process teardown), consider marking these volatile or using Interlocked.CompareExchange:

private volatile bool _stopped;
private volatile bool _disposed;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants