Skip to content

ToolTask drops final stdout/stderr lines after Wave18_6 EOF wait — flaky ToolTaskCanChangeCanonicalErrorFormat #13734

Closed

Description

@rainersigwald

I have seen flaky failures in ToolTaskCanChangeCanonicalErrorFormat. Below is some Copilot analysis about the possible reason, which seems plausible to me but I haven't vetted it.

Summary

Under ChangeWave 18.6, ToolTask.WaitForProcessExit waits only 2 seconds for the
async stdout/stderr pipes to reach EOF after the tool process has exited. When the
AsyncStreamReader thread takes longer than 2 s to flush its internal buffer
(common on busy CI VMs), the EOF wait expires and the final lines of tool output
are silently lost — the post-exit drain only sees what already made it into
the queues, and the loop never revisits them.

This is causing intermittent failures of
ToolTaskCanChangeCanonicalErrorFormat (and likely other ToolTask tests whose
assertions depend on the last line of tool output) in PR builds.

Where

  • src/Utilities/ToolTask.cs, method WaitForProcessExit, ~lines 1083–1116.
  • The 2 s constant: const int eofTimeoutSec = 2; at line 1099.
  • The drop point: WaitHandle.WaitAll(eofEvents, TimeSpan.FromSeconds(eofTimeoutSec));
    at line 1102 — return value is discarded; no further drain is attempted on
    timeout.

Mechanism

  1. Process.Start is called with RedirectStandardOutput/Error = true and
    BeginOutputReadLine/BeginErrorReadLine is called
    (ToolTask.cs:795-798). This wires the framework's
    AsyncStreamReader to call ReceiveStandardErrorOrOutputData
    (ToolTask.cs:1273-1321) for each line, ending with Data == null at EOF.
    That terminating callback is what signals _standardOutputEOF /
    _standardErrorEOF.

  2. HandleToolNotifications reaches case 4 ("tool exited") and calls
    WaitForProcessExit (ToolTask.cs:967-979).

  3. Wave 18.6 path:

    proc.WaitForExit(int.MaxValue);   // does NOT drain pipes
    WaitHandle.WaitAll(eofEvents, TimeSpan.FromSeconds(2));

    Process.WaitForExit(timeout) is documented to not wait on the async
    pipes when any timeout is supplied — only the parameterless overload does.
    So everything depends on the 2-second WaitAll.

  4. After returning, HandleToolNotifications calls
    LogMessagesFromStandardError/Output once (ToolTask.cs:977-978) and then
    exits the consumer loop. Whatever is still buffered inside the
    AsyncStreamReader (i.e. has not yet been delivered via the callback that
    enqueues into _standardOutputData / _standardErrorData) is lost.

Why CI hits this and dev boxes don't

  • cmd /C type <file> and sh -c "cat <file>" — used by the failing test —
    exit very quickly. The reader thread races the post-exit timer.
  • CI runners typically have 2 vCPUs and run the Utilities test project alongside
    others, so the async-pipe reader thread can easily be context-switched out
    for >2 s after the process handle is already signaled.
  • On a developer box with idle cores, the reader almost always wins the race.

Test symptoms

Flaky test: ToolTaskCanChangeCanonicalErrorFormat
(src/Utilities.UnitTests/ToolTask_Tests.cs:572).

The test writes two lines to a temp file:

Main.cs(17,20): warning CS0168: The variable 'foo' is declared but never used.
BADTHINGHAPPENED: This is my custom error format that's not in canonical error format.

then cmd /C type / sh -c cats the file and asserts the canonical-format
warning becomes a logged warning and the BADTHINGHAPPENED line becomes a
logged error. The BADTHINGHAPPENED line is the last non-blank line of
output, so it's exactly the line most likely to be lost when the EOF wait
expires before the reader has flushed.

Expected failure modes:

  • engine.Errors.ShouldBe(1) — got 0 (BADTHINGHAPPENED line dropped before
    MyTool.LogEventsFromTextOutput saw it).
  • engine.AssertLogContains("BADTHINGHAPPENED") — fails for the same reason.
  • Less commonly, the warning line drops if both lines are still in the
    reader's buffer at process-exit.

The same race plausibly affects any other ToolTask test whose assertions
inspect the final lines of a short tool's output.

Reasoning trail

I asked two independent reviewers (Opus 4.7 and GPT-5.5 sub-agents) to read
src/Utilities/ToolTask.cs, src/Utilities.UnitTests/ToolTask_Tests.cs,
src/Utilities.UnitTests/MockEngine.cs, src/Shared/FileUtilities.cs, and
the project's xUnit configuration, and to rank plausible flake causes
independently. They converged on the same primary root cause (this one) and
on the same secondary issues (MockEngine3 not thread-safe, temp-file leak on
failure, return value of t.Execute() discarded). The temp-file generator
uses Guid.NewGuid() and a collision check, ruling out cross-test name
clashes; xUnit parallelism amplifies the timing window but is not itself the
cause.

The decisive evidence is the documented behavior of
Process.WaitForExit(timeout) (does not wait on async pipes), combined with
the fact that the 2 s constant predates any measurement on representative CI
hardware and the post-timeout code path discards data without retrying.

Proposed fix (sketch)

In WaitForProcessExit:

  • Raise the EOF budget from 2 s to ~30 s (still bounded — the grandchild-hang
    protection that motivated Wave 18.6 is preserved).
  • Drain the queues on every wait slice so that the consumer thread keeps pace
    with the reader and so that data already delivered is logged even if the
    outer wait ultimately times out.
  • On timeout, log a low-importance diagnostic ("tool did not close pipes
    within {0}s; output may be truncated") so the rare grandchild case is
    recognizable.

The fix stays under the existing Wave18_6 gate; no new wave needed. Binlog
impact is limited to the new diagnostic on the rare timeout path.

A draft implementation already exists in chat history.

Secondary improvements (separate, lower priority)

  • Make MockEngine3 thread-safe (src/Utilities.UnitTests/MockEngine.cs),
    or migrate test usages to the shared MockEngine. Not the proximate cause
    but eliminates a future flake source.
  • In ToolTaskCanChangeCanonicalErrorFormat: wrap the temp-file delete in
    try/finally, assert t.Execute() returned true, and on failure dump
    engine.Log and the temp file contents to ITestOutputHelper so the next
    flake is diagnosable.

Acceptance criteria

  • ToolTaskCanChangeCanonicalErrorFormat passes 100 / 100 runs locally and
    on CI under load.
  • No regressions in existing Microsoft.Build.Utilities.UnitTests and the
    Microsoft.Build.UnitTests ToolTask suites.
  • A grandchild-hang scenario (existing test, if any; otherwise a new
    targeted one) still completes in bounded time and emits the new diagnostic.

Activity

  1. jankratochvilcz commented on May 22, 2026

    @jankratochvilcz
    Contributor

    Adding another test that's hitting the same root cause described here.

    Build 1430301 (main, Windows-on-full Full Framework, 2026-05-21)

    Microsoft.Build.UnitTests.ToolTask_Tests.OverrideStdOutImportanceToHigh failed with:

    Assert.Contains() Failure: Sub-string not found
    String:    "C:\\Windows\\system32\\findstr.exe "hello" "C:\\Users\\…
    Not found: "hello world"
    

    The assertion looks for "hello world" (the content that findstr echoes back, i.e. the last line of tool stdout) but only sees the command-line echo. This is the exact symptom predicted by the analysis in the issue body: when the post-process EOF wait expires before AsyncStreamReader flushes, the final line of tool output is silently lost.

    Adding this to the list of tests likely to benefit from the proposed fix (raise EOF budget + drain on every slice). If the fix lands, recommend running both ToolTaskCanChangeCanonicalErrorFormat and OverrideStdOutImportanceToHigh 100× in CI as the acceptance gate.

  2. jankratochvilcz commented on May 27, 2026

    @jankratochvilcz
    Contributor

    Status from 2026-05-27 survey

    This test regressed between the pre-merge (May 14-21) and post-merge (May 21-27) windows on dnceng-public pipeline 75:

    Window Failing builds Distinct branches Fail ratio
    Pre (May 14-21, 128 builds) 19 13 14.8%
    Post (May 21-27, 111 builds) 24 17 21.6%

    The diagnostics added in #13830 did not address the actual race. Opening #13874 (draft) with the structural fix proposed in the original sketch above:

    • Replace the single 2 s WaitAll in WaitForProcessExit with a 50 ms-slice poll loop.
    • Drain LogMessagesFromStandardError/Output after every slice so data already delivered by AsyncStreamReader is logged even if EOF never arrives.
    • Raise the outer budget 2 s -> 30 s (still bounded for the grandchild-inherited-pipe case).
    • Emit a low-importance ToolTask.PipeEofTimeoutOnExit diagnostic on the rare timeout path.

    Verification so far: 60/60 stress runs on the 4 ToolTask tests that have been flaking in the same family. Keeping the issue open until next week's survey confirms the regression is gone.

    Also adding ToolTask_Tests.ErrorWhenTextSentToStandardError to this tracker -- it went 1 -> 4 post-merge with the same symptom (the asserted line is the last line of tool output), and the same fix should cover it.

  3. jankratochvilcz commented on May 27, 2026

    @jankratochvilcz
    Contributor

    Replaced #13874 (which patched ToolTask.WaitForProcessExit directly) with a test-only stabilization in #13875.

    Strategy: pad the three flaking tests' tool output with throwaway trailing lines so the asserted text is no longer the last line emitted. The underlying race in ToolTask.WaitForProcessExit (it can return before AsyncStreamReader flushes the final buffer) is preserved as a separate item for future work, but the tests no longer depend on the tail being captured.

    • ErrorWhenTextSentToStandardError – 16 stderr padding lines.
    • OverrideStdOutImportanceToHigh – 32-line temp file instead of 1.
    • ToolTaskCanChangeCanonicalErrorFormat – 32 padding lines after the asserted ANY_PREFIX: line; existing Stabilize 3 ToolTask_Tests flakes with diagnostics + timing fix #13830 diagnostics retained, plus engine-log/file-content dump on assertion failure.

    Validation: 124/124 ToolTask_Tests green on both TFMs; 5× stress run of the top regressor clean.

  4. added a commit that references this issue on Jun 1, 2026
    fd084bd
  5. github-actions commented on Jun 2, 2026

    @github-actions
    Contributor

    Flake recurrence detected — automated scan (2026-06-02) found Microsoft.Build.UnitTests.ToolTask_Tests.OverrideStdOutImportanceToHigh failing across 3 independent rolling-main build sources (IDs: 1430301, 1431561, 1440318) over 8 days (2026-05-21 through 2026-05-29), with single error signature cdaa4ce4:

    Assert.Contains() Failure: Sub-string not found
    String:    "C:\\Windows\\system32\\findstr.exe \"hello\" \"C:\\Users\\\"···"
    Not found: "hello world"
    

    This appears to be a recurrence of the same ToolTask stdout-capture race addressed in this issue. Human triage is recommended — please consider reopening or filing a new issue if the underlying fix is insufficient for this test.

    Generated by Flaky Test Triage and Fix · sonnet46 2.6M · ◷

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

Metadata

Metadata

Labels

flaky-testTests that have failed but passed on rerun.vendors

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions