Ignore stale MSBuild server console writes - #14850
Conversation
After a server build completes, switch its redirect writer to a null sink so tasks retaining Console.Out cannot fail a later build. Add regression coverage for write-after-dispose behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa3f99a2-bb35-4d8c-9381-91d8d05e752a
Limit the compatibility fix to post-disposal writes and leave the existing timer callback locking behavior unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa3f99a2-bb35-4d8c-9381-91d8d05e752a
|
Note this is a workaround at a lower level for hard to update internal consumer. The contract is Console APIs are banned in tasks and in code transitively reachable by tasks. |
There was a problem hiding this comment.
Pull request overview
Fixes MSBuild Server reuse scenarios where third-party code (Spectre.Console via Microsoft.Sbom.Targets) retains a stale Console.Out/Console.Error reference and later writes through a disposed RedirectConsoleWriter, causing ObjectDisposedException and lost SBOM dependency data.
Changes:
- Update
RedirectConsoleWriterdisposal to swap its destination toTextWriter.Nullso late writes are discarded rather than throwing. - Add a unit test verifying that writes after
Dispose()neither throw nor invoke the forwarding callback.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| src/Build/BackEnd/Node/OutOfProcServerNode.cs | Makes RedirectConsoleWriter tolerate post-dispose writes by redirecting to TextWriter.Null and adding a disposal state. |
| src/Build.UnitTests/BackEnd/RedirectConsoleWriter_Tests.cs | Adds a regression test for writes after dispose. |
Suppressed comments (1)
src/Build/BackEnd/Node/OutOfProcServerNode.cs:756
- TimerCallback reads
_disposedand calls_bufferWriter.GetStringBuilder()without taking_lock, whileDisposemutates_disposed, swaps_internalWriter, and disposes_bufferWriterunder that lock. This unsynchronized access is a data race and can (in rare timing) observe stale_disposedand touch/discard a disposed_bufferWriter, reintroducingObjectDisposedExceptionor invoking the callback after disposal.
private void TimerCallback(object? state)
{
if (!_disposed && _bufferWriter.GetStringBuilder().Length > 0)
{
Flush();
}
}
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Approve with commentsThe core fix is correct and well-targeted. I read all 35 One point worth adding to the PR description: No ChangeWave needed — the change removes a warning rather than adding one ( 🔴 H-1 —
|
before Dispose() |
after Dispose() |
|
|---|---|---|
| .NET 10 | utf-16 |
utf-16 |
| .NET Framework 4.8 | utf-16 |
Windows-1252 |
So on net472 a retained Console.Out reports a different, ANSI encoding after the request ends — and probing Console.Out.Encoding is exactly what a console library like Spectre does. StringWriter.Encoding has no dispose check, so reading the buffer instead is stable and removes the unlocked mutable read:
// Deliberately reads the buffer writer, not the swappable destination, so the reported
// encoding is stable across dispose and identical on all target frameworks.
public override Encoding Encoding => _bufferWriter.Encoding;🟠 M-3 — TimerCallback's new guard is decorative, and races the StringBuilder
src/Build/BackEnd/Node/OutOfProcServerNode.cs:750-756
if (!_disposed && _bufferWriter.GetStringBuilder().Length > 0)_timer.Dispose() (line 762) does not wait for an in-flight callback, so this can run concurrently with Dispose. It's safe in practice — Flush() re-checks _disposed under _lock, and Dispose holds _lock across the whole transition — and an 800-iteration write-storm + dispose + stale-tick stress across both runtimes produced zero failures. But:
_disposedis non-volatile and read outside the lock, so the guard added here doesn't actually guarantee anything.StringBuilder.Lengthis two unsynchronizedintreads racingsb.Clear()andsb.Append. Benign (worst case a spurious no-op flush or a skipped tick the next 40 ms tick recovers) but formally UB, and this line is already being edited.- Each tick takes
_locktwice.
All three go away by doing the check inside the lock you already need, with identical tick cadence and flush semantics:
private void TimerCallback(object? state)
{
lock (_lock)
{
if (!_disposed && _bufferWriter.GetStringBuilder().Length > 0)
{
FlushInternal();
}
}
}🟠 M-4 / M-5 — Test coverage
src/Build.UnitTests/BackEnd/RedirectConsoleWriter_Tests.cs:30-41
Two gaps:
The reset discards the more interesting assertion. callbackInvoked = false; after Dispose() throws away the fact that the dispose-time flush does fire the callback and deliver the buffered text. Assert it instead.
Nothing tests the property the fix exists to provide. The bug in #14691 is cross-request: a third party retains request N's writer and writes during request N+1. The current test would pass against several wrong implementations — including one that forwards stale writes to the live request's sink, which is the specific mistake the design is avoiding. A test that pins it:
[Fact]
public void WriteThroughStaleWriter_DoesNotLeakIntoSubsequentRequestOutput()
{
List<string> firstRequestOutput = [];
List<string> secondRequestOutput = [];
// Simulates the reference Spectre.Console caches process-wide.
OutOfProcServerNode.RedirectConsoleWriter staleWriter = new(text => firstRequestOutput.Add(text));
staleWriter.Dispose(); // request 1 completes
using OutOfProcServerNode.RedirectConsoleWriter liveWriter = new(text => secondRequestOutput.Add(text));
Should.NotThrow(() => staleWriter.WriteLine("component detection output from request 2"));
liveWriter.Flush();
secondRequestOutput.ShouldNotContain(s => s.Contains("component detection"));
firstRequestOutput.ShouldNotContain(s => s.Contains("component detection"));
}And since the post-dispose safety of Write(char), Write(char[],int,int), Write(ReadOnlySpan<char>), Flush, the async trio, Close() and double-Dispose() all depend on TextWriter base-class fallback that differs between net472 and net10.0, they're worth a [Theory] rather than inspection:
public static TheoryData<string, Action<OutOfProcServerNode.RedirectConsoleWriter>> PostDisposeOperations => new()
{
{ "Write(char)", w => w.Write('c') },
{ "Write(string)", w => w.Write("x") },
{ "Write(char[],int,int)", w => w.Write(new[] { 'a', 'b' }, 0, 2) },
{ "Write(ReadOnlySpan)", w => w.Write("x".AsSpan()) },
{ "Write(format,args)", w => w.Write("{0}", 1) },
{ "WriteLine()", w => w.WriteLine() },
{ "WriteLine(string)", w => w.WriteLine("x") },
{ "Flush()", w => w.Flush() },
{ "WriteAsync(string)", w => w.WriteAsync("x").GetAwaiter().GetResult() },
{ "WriteLineAsync(string)", w => w.WriteLineAsync("x").GetAwaiter().GetResult() },
{ "FlushAsync()", w => w.FlushAsync().GetAwaiter().GetResult() },
{ "Close()", w => w.Close() },
{ "Dispose() again", w => w.Dispose() },
};
[Theory]
[MemberData(nameof(PostDisposeOperations))]
public void WriteAfterDispose_IsDiscarded_WithoutThrowingOrInvokingCallback(
string description,
Action<OutOfProcServerNode.RedirectConsoleWriter> operation)
{
List<string> callbackPayloads = [];
OutOfProcServerNode.RedirectConsoleWriter writer = new(text => callbackPayloads.Add(text));
writer.Write("buffered before dispose");
writer.Dispose();
// The final request flush must still deliver everything that was buffered.
callbackPayloads.ShouldHaveSingleItem().ShouldBe("buffered before dispose");
callbackPayloads.Clear();
Should.NotThrow(() => operation(writer));
callbackPayloads.ShouldBeEmpty();
}🟠 M-6 — Pre-existing: Write(string, params object?[]) calls WriteLine
src/Build/BackEnd/Node/OutOfProcServerNode.cs:598-604
public override void Write(string format, params object?[] arg)
{
lock (_lock)
{
_internalWriter.WriteLine(format, arg); // should be Write
}
}Present on main too, so not introduced here — but it means Console.Write("{0} {1}", a, b) emits a spurious newline only on MSBuild Server, i.e. the same "server output differs from non-server output" family this PR is fixing. One-word fix in the same class; happy either way if you'd rather keep scope tight and file an issue.
🟠 M-7 / 🟡 L-8 — FlushInternal sends empty payloads, and _bufferWriter.Flush() is dead
src/Build/BackEnd/Node/OutOfProcServerNode.cs:785-793
Every request's dispose currently sends two empty ServerNodeConsoleWrite IPC packets (one per writer) when nothing was buffered, plus one per idle timer tick that slips through. And StringWriter has no buffer — Flush() is the empty TextWriter.Flush() (it doesn't even throw when disposed). Both are pre-existing, but the method is newly extracted here, so it's the natural moment:
private void FlushInternal()
{
StringBuilder sb = _bufferWriter.GetStringBuilder();
if (sb.Length == 0)
{
return;
}
string captured = sb.ToString();
sb.Clear();
_writeCallback(captured);
}🟡 Low
- L-9 —
_internalWriter(line 446) is now a misleading name: it's the swappable destination, while_bufferWriteris the actual internal writer. Someone scanning 35 overrides will assume the opposite. Renaming to_destinationmakes the whole fix self-documenting. - L-10 — The ordering inside the
finally(lines 772-777) is load-bearing:_internalWriter = TextWriter.Nulland_disposed = truemust both precede_bufferWriter.Dispose(). Correct as written, but nothing says so, and it's exactly what a future cleanup reorders. - L-11 — Please add a
<remarks>on the class explaining why the writer is kept alive-but-inert (third-party code cachesConsole.Outacross server requests — .NET 11 Preview 7 Parallelism Breaks SBOM Generation #14691), and that forwarding stale writes to the current request's writer would be an output-leak bug. Without it this gets "simplified" back to a plainDispose. - L-12 — Silently dropping is right for the user but undiagnosable for us. This file already uses
CommunicationsUtilities.Trace(lines 103, 251, 262, 328, 352), which goes to theMSBUILDDEBUGCOMMlog and is not user-visible, so it doesn't collide with the no-new-warnings policy. Trace once (guard with anint+Interlocked.Exchange), not per write, or Spectre's table renderer will flood it. - L-13 —
_writeCallbackcapturesthisviaSendPacket(lines 415-416), so a retained stale writer roots the node, its endpoint and packet queue for the process lifetime. Bounded, so near-zero practical impact — but given the premise is "someone holds our writer forever", clearing it in thefinally(_writeCallback = static _ => { };) would make the no-stale-callbacks guarantee structural rather than incidental.
Nits
NewLineandFormatProvideraren't overridden, so they silently flip fromStringWriter/CurrentCulturetoNullTextWriter/InvariantCultureat dispose. Harmless since the output is discarded — noting only for completeness.- Test name: repo convention is
MethodUnderTest_Scenario_ExpectedResult.
Restore process console streams when server build execution throws, keep the reported encoding stable after disposal, clarify the inert destination, and strengthen the regression test around final flush behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa3f99a2-bb35-4d8c-9381-91d8d05e752a
|
Addressed the relevant review points in
I kept the pre-existing format-overload and empty-packet cleanups out of scope, and did not add tracing or callback clearing. I rebuilt and reran the actual |
|
good feedback, I am unifying the effort with investigation of #14859 |
Resolve the OutOfProcServerNode refactor by retaining the new server flow and applying the stale-writer null-sink behavior on top. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa3f99a2-bb35-4d8c-9381-91d8d05e752a
Problem
MSBuild Server installs request-scoped
RedirectConsoleWriterinstances asConsole.OutandConsole.Errorwhile a build runs, then flushes and disposes them when the request completes.Microsoft.Sbom.Targets4.1.5 reaches Spectre.Console through Component Detection. Spectre caches the first request'sConsole.Outin a process-wide singleton. When a secondPackrequest reuses the server, SBOM generation writes through that disposed writer and raisesObjectDisposedException. The SBOM task catches the exception, soPackstill exits successfully, but the generated manifest loses its detected dependencies.This is the failure reported in #14691.
Change
After its final request flush,
RedirectConsoleWriterreplaces its internal destination withTextWriter.Nullbefore disposing the request buffer. Later writes through a retained reference are discarded rather than throwing or being forwarded to another build client.The writer continues reporting the buffer encoding after disposal. The existing timer behavior and synchronization are unchanged.
No ChangeWave is required: this is limited to MSBuild Server, removes a failure/warning path, and does not change build outputs.
Unit regression test
WriteAfterDispose_IsDiscardedWithoutThrowingOrInvokingCallbackverifies that:Before the implementation change, the test failed on both
net10.0andnet472with:After merging current
main, allRedirectConsoleWriter_Testspass on bothnet11.0and .NET Framework.End-to-end
Microsoft.Sbom.TargetsreproductionThe test project used the actual affected task and a package dependency for Component Detection to find:
A small inline MSBuild task logged the executing process ID. For each comparison, bootstrap MSBuild was rebuilt from a clean worktree, the project was compiled before measurement, the server was shut down to guarantee a cold start, and two separate
Packrequests were issued.NoBuild=truekeeps the measurement focused on server-hosted SBOM generation:Results
326f6a7ab225164for both requestsTotalNumberOfPackages=4ObjectDisposedExceptionwarnings plusThere were no packages detectedSbomTaskRepro;Newtonsoft.Jsonwas missing0,0bf0825c5dc9980for both requestsTotalNumberOfPackages=4ObjectDisposedException;TotalNumberOfPackages=4Newtonsoft.Json0,0The base failure was the same stack reported in #14691:
The identical PID within each pair proves that the second pack reused the same MSBuild Server and the same Spectre singleton. On the PR build, the second request still completed component detection and preserved the dependency data; only output sent through Spectre's stale first-request writer was discarded.
Addresses #14691.