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
-
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.
-
HandleToolNotifications reaches case 4 ("tool exited") and calls
WaitForProcessExit (ToolTask.cs:967-979).
-
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.
-
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.
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.WaitForProcessExitwaits only 2 seconds for theasync stdout/stderr pipes to reach EOF after the tool process has exited. When the
AsyncStreamReaderthread 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 whoseassertions depend on the last line of tool output) in PR builds.
Where
src/Utilities/ToolTask.cs, methodWaitForProcessExit, ~lines 1083–1116.const int eofTimeoutSec = 2;at line 1099.WaitHandle.WaitAll(eofEvents, TimeSpan.FromSeconds(eofTimeoutSec));at line 1102 — return value is discarded; no further drain is attempted on
timeout.
Mechanism
Process.Startis called withRedirectStandardOutput/Error= true andBeginOutputReadLine/BeginErrorReadLineis called(
ToolTask.cs:795-798). This wires the framework'sAsyncStreamReaderto callReceiveStandardErrorOrOutputData(
ToolTask.cs:1273-1321) for each line, ending withData == nullat EOF.That terminating callback is what signals
_standardOutputEOF/_standardErrorEOF.HandleToolNotificationsreaches case 4 ("tool exited") and callsWaitForProcessExit(ToolTask.cs:967-979).Wave 18.6 path:
Process.WaitForExit(timeout)is documented to not wait on the asyncpipes when any timeout is supplied — only the parameterless overload does.
So everything depends on the 2-second
WaitAll.After returning,
HandleToolNotificationscallsLogMessagesFromStandardError/Outputonce (ToolTask.cs:977-978) and thenexits the consumer loop. Whatever is still buffered inside the
AsyncStreamReader(i.e. has not yet been delivered via the callback thatenqueues into
_standardOutputData/_standardErrorData) is lost.Why CI hits this and dev boxes don't
cmd /C type <file>andsh -c "cat <file>"— used by the failing test —exit very quickly. The reader thread races the post-exit timer.
others, so the async-pipe reader thread can easily be context-switched out
for >2 s after the process handle is already signaled.
Test symptoms
Flaky test:
ToolTaskCanChangeCanonicalErrorFormat(
src/Utilities.UnitTests/ToolTask_Tests.cs:572).The test writes two lines to a temp file:
then
cmd /C type/sh -c cats the file and asserts the canonical-formatwarning becomes a logged warning and the
BADTHINGHAPPENEDline becomes alogged error. The
BADTHINGHAPPENEDline is the last non-blank line ofoutput, 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 beforeMyTool.LogEventsFromTextOutputsaw it).engine.AssertLogContains("BADTHINGHAPPENED")— fails for the same reason.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, andthe 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 (
MockEngine3not thread-safe, temp-file leak onfailure, return value of
t.Execute()discarded). The temp-file generatoruses
Guid.NewGuid()and a collision check, ruling out cross-test nameclashes; 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 withthe 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:protection that motivated Wave 18.6 is preserved).
with the reader and so that data already delivered is logged even if the
outer wait ultimately times out.
within {0}s; output may be truncated") so the rare grandchild case is
recognizable.
The fix stays under the existing
Wave18_6gate; no new wave needed. Binlogimpact is limited to the new diagnostic on the rare timeout path.
A draft implementation already exists in chat history.
Secondary improvements (separate, lower priority)
MockEngine3thread-safe (src/Utilities.UnitTests/MockEngine.cs),or migrate test usages to the shared
MockEngine. Not the proximate causebut eliminates a future flake source.
ToolTaskCanChangeCanonicalErrorFormat: wrap the temp-file delete intry/finally, assertt.Execute()returnedtrue, and on failure dumpengine.Logand the temp file contents toITestOutputHelperso the nextflake is diagnosable.
Acceptance criteria
ToolTaskCanChangeCanonicalErrorFormatpasses 100 / 100 runs locally andon CI under load.
Microsoft.Build.Utilities.UnitTestsand theMicrosoft.Build.UnitTestsToolTask suites.targeted one) still completes in bounded time and emits the new diagnostic.