Stop sending after a partial frame write - #16445
Stop sending after a partial frame write#16445Damian Horna (damianhorna) wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Send currently no-ops after Dispose, which can silently drop messages and hide teardown/disconnect issues in callers.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR hardens LengthPrefixCommunicationChannel so that a partial-frame write failure becomes terminal: it records the first send failure, closes the underlying stream to prevent subsequent frame-boundary corruption, and ensures later sends fail fast without touching the writer.
Changes:
- Track the first send failure and close the underlying stream on send exceptions to avoid corrupting subsequent frames.
- Serialize disposal with sends and avoid disposing the
BinaryWriterafter a send failure to prevent buffered re-flush of partial frame bytes. - Add regression tests that simulate partial-frame writes and concurrent sends to validate the new terminal-failure behavior.
File summaries
| File | Description |
|---|---|
| src/Microsoft.TestPlatform.CommunicationUtilities/LengthPrefixCommunicationChannel.cs | Makes send failures terminal by recording the first failure, closing the underlying stream, and coordinating disposal with concurrent sends. |
| test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/LengthPrefixCommunicationChannelTests.cs | Adds regression coverage for partial-frame failures, disposal after send failure, and concurrent send behavior. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
2a215fd to
cbe837e
Compare
cbe837e to
43b3e39
Compare
There was a problem hiding this comment.
🟡 Changes recommended
There are concurrency/behavior issues around Dispose vs Send synchronization and a newly added test that asserts invalid Send-after-Dispose behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Lite
| [TestMethod] | ||
| public async Task SendAfterDisposeShouldStillWriteToTheStream() | ||
| { | ||
| using var stream = new MemoryStream(); | ||
| var channel = new LengthPrefixCommunicationChannel(stream); | ||
| channel.Dispose(); | ||
|
|
||
| await channel.Send(Dummydata); | ||
|
|
||
| Assert.IsTrue(stream.CanWrite); | ||
| SeekToBeginning(stream); | ||
| using var reader = new BinaryReader(stream); | ||
| Assert.AreEqual(Dummydata, reader.ReadString()); | ||
| } |
There was a problem hiding this comment.
This one I would push back on.
The test passes as written, and it also passes on unmodified main, where it is one of the ten green tests in this file. Two details combine to make a post-Dispose send reach the stream: BinaryWriter tracks no disposed state, so Write has nothing to reject, and the channel builds both reader and writer with leaveOpen set (new BinaryWriter(..., Encoding.UTF8, true)), so disposing them does not close the stream underneath. This PR does not touch that path.
It is a characterization test. It asserts no new guarantee and documents no contract. It records behaviour that shutdown currently leans on, since Dispose runs while timer-driven sends are still in flight. If someone later adds the disposed-state guard, this surfaces as a failing test rather than as a silent change to teardown.
Happy to rename it if Should reads too much like a promise.
There was a problem hiding this comment.
🟡 Changes recommended
Dispose is not serialized with Send (risking flush/retry during concurrent teardown) and one new unit test encodes a contradictory post-Dispose send contract.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/LengthPrefixCommunicationChannelTests.cs:138
- This test asserts that Send continues to work after LengthPrefixCommunicationChannel.Dispose(), but Dispose disposes the BinaryWriter (when no prior send failure). That makes subsequent Send calls invalid and, with the current Send implementation, would be treated as a send failure that can also dispose the underlying stream. Either the production code needs an explicit post-dispose behavior, or this test should be removed to avoid encoding an incorrect contract.
[TestMethod]
public async Task SendAfterDisposeShouldStillWriteToTheStream()
{
using var stream = new MemoryStream();
var channel = new LengthPrefixCommunicationChannel(stream);
channel.Dispose();
await channel.Send(Dummydata);
Assert.IsTrue(stream.CanWrite);
SeekToBeginning(stream);
using var reader = new BinaryReader(stream);
Assert.AreEqual(Dummydata, reader.ReadString());
}
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Lite
20fcefa to
e7c7c86
Compare
There was a problem hiding this comment.
🟢 Approval recommended
The behavioral change is well-scoped to IPC framing correctness and is backed by targeted regression/concurrency tests, with only minor wording nits noted.
Review details
Suppressed comments (1)
src/Microsoft.TestPlatform.CommunicationUtilities/LengthPrefixCommunicationChannel.cs:143
- This comment says "Send flushes every message, so nothing is pending in either case", but the failure path is specifically about avoiding flushing buffered bytes after a failed send. Tweaking the wording would make the rationale clearer and avoid implying there can never be pending buffered data after a failure.
// BinaryWriter.Dispose flushes its BufferedStream. That would re-emit bytes of an
// incomplete frame after a failed send, and race the writer while a send is in
// flight. Send flushes every message, so nothing is pending in either case.
if (lockTaken && _sendFailure is null)
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
e7c7c86 to
57cba1c
Compare
There was a problem hiding this comment.
🟢 Approval recommended
The behavioral change is well-scoped to the IPC framing layer and is backed by focused unit tests that cover the new failure-closed and concurrency/disposal scenarios.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
57cba1c to
9ae0c52
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
This changes core IPC error-handling and stream lifecycle semantics (including closing externally-owned streams) in a high-impact component, warranting final human review despite the added tests.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
LengthPrefixCommunicationChannel writes each message as a 7-bit encoded byte count followed by the payload, through a BufferedStream. A payload larger than the free buffer space forces the buffered prefix out first, so a write that fails part way through can leave the prefix on the wire without its body. Send logged that error and left the channel usable. Callers that swallow CommunicationException, such as the timer-driven cache flush, keep sending, and the receiver reads the next message inside the frame the previous one never finished. A captured trace shows the whole sequence. A 33,185 byte StatsChange flushes its 3 byte prefix a1 83 02, then the payload write fails with SocketException 10038. 126 ms later another thread sends a 38,382 byte message with prefix ee ab 02. The receiver consumes 33,185 bytes, which is the second message's prefix plus the first 33,182 bytes of its payload, decodes ee ab as U+FFFD and 02 as U+0002, and aborts with "Unexpected character encountered while parsing value" at line 0, position 0. Latch the first send failure, dispose the stream so the peer sees a disconnect rather than a shifted frame, and reject later sends. Skip BinaryWriter.Dispose after a failure because it flushes the BufferedStream and can re-emit bytes of the incomplete frame. Run the whole Send body under the write lock so a concurrent send cannot pass the latch check and then write. Dispose takes the write lock with Monitor.TryEnter rather than waiting for it. Waiting would block shutdown behind a stuck socket write, which is what happens today: with an in-flight write held open, the existing Dispose blocks until that write completes because BinaryWriter.Dispose flushes the BufferedStream underneath it. Skipping the flush when the lock is busy is safe, since Send flushes every message. Shutdown behavior is otherwise unchanged. Send after Dispose still writes, and the NotSupportedException tolerance for an owner-disposed stream stays. Add regression tests for a prefix-only write, disposal after a buffered failure, a concurrent send queued behind a failing one, and disposal racing an in-flight send. All four fail on unmodified main. A fifth test pins the existing post-Dispose behavior so this change cannot alter it silently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e03d8a7-a869-4ca9-ab36-45529635e316
9ae0c52 to
9d08023
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
It changes core IPC transport send/dispose failure semantics in a high-impact area and should be validated by a human reviewer across expected runtime/host scenarios.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
|
@microsoft-github-policy-service agree company="Microsoft" |
|
Adding a runnable repro, since #2879 was closed asking for exactly this. https://gist.github.com/damianhorna/7677d1050b2fcc93464f73f302594fe3, two files, no clone needed: It builds the same write path Output: Three details worth pointing at: The prefix goes out on its own. The next message is consumed as the previous one's body. The receiver reads length 33,185 and takes 33,185 bytes, which are message 2's prefix plus the first 33,182 bytes of its body. Message 2 has to be at least as large as message 1's declared length, or the receiver just blocks on bytes that never arrive. That is probably part of why this has been hard to pin down: the same fault shows up as a hang, a JSON error, or nothing at all, depending on what the next message happens to be. The sizes above are from a captured failure, so the prefix bytes and the leading The same thing is covered by unit tests against the real class, so treat the repro above as illustration
The last failure in the list is not about this bug and is worth a separate look: on |
Every test here so far checks what the sender does after a failed frame: that it stops writing, that Dispose does not flush, that a queued send is rejected. None of them check the thing that actually broke, which is what the peer decodes off the wire. PeerShouldNotDecodeAMessageThatWasNeverSent puts a real channel on both ends. The first send fails once its length prefix has been flushed, the second send lands behind that orphaned prefix, and the peer is then asked to read one message. On unmodified main it decodes 16,385 characters beginning U+FFFD U+FFFD U+0001, a frame that neither send produced, which is the JSON abort as the receiver experiences it. With this change the second send never reaches the wire and the peer has no complete frame to read. Pure addition; no existing test is modified.
There was a problem hiding this comment.
🟢 Approval recommended
The change addresses a concrete IPC framing failure mode with a clear fail-closed strategy and comprehensive unit tests covering the new behavior and key concurrency/dispose edge cases.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
… the stream The catch block carried the whole policy with no explanation of the part that needs one: why a channel that deliberately does not own its stream closes it here. Moving it to a named method puts that policy in one place and gives the reasoning somewhere to live. No behavior change. The trace messages are unchanged so existing diagnostics still match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e03d8a7-a869-4ca9-ab36-45529635e316
There was a problem hiding this comment.
🔵 Needs a closer look
It changes core IPC transport failure semantics (faulting/closing on send failure and altering dispose behavior) in a wire-critical component, so it warrants final human review despite strong unit test coverage.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
A test run can lose results and still report success.
When a socket write fails partway through a message, the runner and the testhost can lose track of where
one message ends and the next begins. From there the receiver is reading at the wrong offset. It either
deserializes garbage and aborts the run, or waits for a message that will never arrive. Neither side
reports that anything went wrong.
This is the
Unexpected character encountered while parsing valueabort atPath '', line 0, position 0.#2879 was closed for want of a repro. There is one below, as a test.
It is not rare where the conditions line up. On one CI pipeline over three weeks, 141 of 379 pull request
builds aborted mid run, and 106 of those still reported success.
The green ones are the problem. An aborted run does not turn failing tests into passing tests, it stops
running them, and a test that never ran reports nothing. For 39 pull requests in that window there is both
a clean successful build and an aborted successful build of the same PR. In 38 of the 39, the aborted build
published coverage for about half as many lines. Same commit, same suite, both green.
What this PR does: once a
Sendhas failed, stop using that channel. Latch the failure, close thetransport, and reject later sends with
CommunicationException.It cannot un-send bytes that already left. What it prevents is the next message being read as the body of a
message that was never finished.
How the two sides get out of sync
Two messages, sizes and prefix bytes taken from a real capture:
The first message's payload never left, but its 3 byte prefix did, and the channel stayed open. So the
receiver takes
a1 83 02at face value and consumes 33,185 bytes as that message's body: the secondmessage's prefix, plus the first 33,182 bytes of its payload.
ee abis not valid UTF-8, so it decodes toU+FFFD, and02toU+0002. The result is not JSON. Theposition is 0 because the corruption starts at the first byte of the frame, which is why the error names no
useful location.
The receiver cannot recover on its own. A length prefixed stream has no marker to resync on, so once the
boundary is lost, every later message is garbage.
Why the prefix goes out alone
This is the part that makes it look impossible.
BinaryWriter.Write(string)writes the prefix and thepayload in one call, so how does one arrive without the other?
From a verbose
/Diagcapture:TestExecution.StatsChange.BinaryWriterputs the 3 byte prefixinto the
BufferedStream, then writes the payload.BufferedStreamflushes the 3 bufferedprefix bytes as their own write and passes the payload straight through. The prefix flush succeeds.
SocketException 10038.Sendwraps it inCommunicationException,TestRunCache.OnCacheTimeHitswallows that, and the run continues.So it needs a buffer boundary, a mid message write failure, a caller that swallows it, and a second send.
That combination is why it presents as intermittent and why it resisted reproduction. The arithmetic
matches the log at every step.
The change
In
LengthPrefixCommunicationChannel, a failed send records the failure, closes the transport, and everylater send on that channel throws.
The failure path is a named method,
FaultChannel, rather than the body of acatch, because the part thatneeds justifying deserves somewhere to put it.
Sendalso moved fully inside the existing_writeSyncObjectlock, andDisposenow takes that lock withMonitor.TryEnterinstead of not taking it at all.This does not stop the underlying write from failing. That is not vstest's to stop. It stops a failed write
from silently corrupting the next message.
What you will want to push back on
Three things, in the order I would raise them.
It closes a stream the channel does not own.
Disposedeliberately leaves the stream open, andDisposeShouldNotCloseTheStreampins that. This change does not touch either. What closes the stream is afailed send, and that is a different situation, because only that one leaves the peer holding a prefix for a
body that will never arrive.
There is no way to tell the peer in band. The framing it is reading is the thing that broke, so a
"disregard that" message would just be consumed as the missing body. End of stream is the only signal left,
and the peer already handles it. Latching without closing stops the corruption but leaves the peer blocked
until some outer timeout fires.
On the ownership itself: both callers build this channel over a
TcpClientstream, inSocketServer.csandSocketClient.cs, and both close that client in their own error path.SocketServerspells out what thatmeans in a comment: "Close the client and dispose the underlying stream". So the stream this closes was
already on its way down. What changes is that it closes when the framing breaks rather than whenever the
read side eventually notices.
It is more than one change. Three things move: the latch, closing the transport, and the locking. That
is worth being precise about, because they are not three independent ideas.
The lock is not a separate improvement. On
mainthetry/catchsits outside the lock, so withoutwidening it a concurrent send can slip in between another send's failure and the latch being set. That is
the exact race this fix exists to close.
The
Disposechange fixes something already broken onmain.Disposethere takes no lock at all, so itcan race an in flight write, and since
BinaryWriter.Disposeflushes theBufferedStream, it blocks behindthat write. The test below measures 5 seconds on unmodified
main.TryEnteris not compensating for thewider lock, it fixes what was already there.
The latch alone is what fixes the reported corruption. If you would rather take that first and consider the
rest separately, say so and I will split it. Same if you want the latch without closing the transport, or
want it limited to
IOExceptionandSocketException.It will fail runs that pass today. Yes. After a send throws there is no way to know whether the frame
was actually damaged, so this will also close channels that were fine, and where the failed send was the
last one,
mainwould have finished the run and this will not.What it will not do is turn working builds red. The 106 green builds above had already stopped testing
partway through, and the paired comparison puts the median one at roughly half the code of a clean build of
the same pull request. Those builds are not passing, they are declining to report. Whether you want that
made visible by default in a released product is yours to decide, but I would rather you decided it with
that number in front of you than without it.
How to verify it
PeerShouldNotDecodeAMessageThatWasNeverSentruns a realLengthPrefixCommunicationChannelon both ends.It fails the first send after its prefix has been flushed, sends a second message, then asks the peer to
read one message. On unmodified
main:Same shape as production. The byte values differ only because the test uses
SocketConstants.BufferSize + 1rather than the sizes that happened to occur in the capture.
Five tests fail on unmodified
mainand pass with this change:maindoesPeerShouldNotDecodeAMessageThatWasNeverSentSendShouldCloseStreamAndRejectLaterMessagesAfterPartialFrameSendsucceeds instead of throwingDisposeShouldNotFlushBufferedDataAfterSendFailureDisposeperforms a second writeConcurrentSendShouldNotWriteAfterFirstSendFailsSendwrites after the first one failedDisposeShouldNeitherWriteNorBlockWhileASendIsInFlightDisposeblocks for 5 seconds behind the in flight writeSendAfterDisposeShouldStillWriteToTheStreampasses before and after. It is there to catch any change topost
Disposebehavior.I ran these against a worktree at unmodified
origin/mainwith only the test file applied, to confirm theyfail for the stated reason rather than through test setup: 15 total, 5 failed. With the change: 15 total,
0 failed. The nine existing tests in the file pass unchanged.
If you would rather watch it over a real socket, there is a standalone program at
https://gist.github.com/damianhorna/7677d1050b2fcc93464f73f302594fe3 that runs with
dotnet runand printsthe production byte values exactly. It reimplements the write path rather than referencing the assembly, so
treat it as an illustration. The tests are the load bearing evidence.
Other questions
Why fail the channel on any exception, rather than only on the ones that leave a partial frame?
Because
BinaryWriter.Write(string)does not report how much it wrote. After it throws, the number of bytesthat reached the peer is unknown, so whether the frame is intact is also unknown.
Why
Monitor.TryEnterinDisposerather thanlock? Because waiting is the harmful part. A plainlockwould keep the 5 second stall described above and add a shutdown hang whenever a socket write isstuck. Taking the lock only when it is free gives the same protection against flushing mid frame, and
skipping the flush loses nothing, because
Sendflushes after every message it completes. The only thingthe buffer can still hold is part of a frame that failed, which is exactly what must not go out.
Why no
_isDisposedguard onSend? I tried one and reverted it. Shutdown legitimately sends afterDispose, becauseDisposeruns while timer driven sends are still in flight, so the guard silentlydropped real messages.
SendAfterDisposeShouldStillWriteToTheStreampins the current behavior instead.Why no new exception type?
Sendalready threwCommunicationExceptionon write failure. No callerretries a failed
Sendor catches that type, so rejecting later sends the same way changes no handling.Why is the
NotSupportedExceptionfilter now on_streaminstead of_writer.BaseStream? ReadingBaseStreamflushes the writer, and an exception filter runs while the exception is still in flight. On achannel that has just failed mid frame, that flush is the last thing you want. The channel already holds
the stream, so it can check that instead. The log message was updated to match.