Skip to content

Commit 20fcefa

Browse files
Damian HornaCopilot
andcommitted
Stop sending after a partial frame write
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
1 parent d8e681b commit 20fcefa

2 files changed

Lines changed: 239 additions & 19 deletions

File tree

src/Microsoft.TestPlatform.CommunicationUtilities/LengthPrefixCommunicationChannel.cs

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,14 @@ namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities;
1818
/// </summary>
1919
public class LengthPrefixCommunicationChannel : ICommunicationChannel
2020
{
21+
private readonly Stream _stream;
22+
2123
private readonly BinaryReader _reader;
2224

2325
private readonly BinaryWriter _writer;
2426

27+
private volatile Exception? _sendFailure;
28+
2529
/// <summary>
2630
/// Sync object for sending messages
2731
/// Write for binarywriter is NOT thread-safe
@@ -30,6 +34,7 @@ public class LengthPrefixCommunicationChannel : ICommunicationChannel
3034

3135
public LengthPrefixCommunicationChannel(Stream stream)
3236
{
37+
_stream = stream;
3338
_reader = new BinaryReader(stream, Encoding.UTF8, true);
3439

3540
// Using the Buffered stream while writing, improves the write performance. By reducing the number of writes.
@@ -42,29 +47,43 @@ public LengthPrefixCommunicationChannel(Stream stream)
4247
/// <inheritdoc />
4348
public Task Send(string data)
4449
{
45-
try
50+
// Writing Message on binarywriter is not Thread-Safe
51+
// Need to sync one by one to avoid buffer corruption
52+
lock (_writeSyncObject)
4653
{
47-
// Writing Message on binarywriter is not Thread-Safe
48-
// Need to sync one by one to avoid buffer corruption
49-
lock (_writeSyncObject)
54+
if (_sendFailure is not null)
55+
{
56+
throw new CommunicationException("Unable to send data over channel because a previous send failed.", _sendFailure);
57+
}
58+
59+
try
5060
{
5161
_writer.Write(data);
5262
_writer.Flush();
5363
}
54-
}
55-
catch (NotSupportedException ex) when (!_writer.BaseStream.CanWrite)
56-
{
57-
// As we are simply creating streams around some stream passed as ctor argument, we
58-
// end up in some unsynchronized behavior where it's possible that the outside stream
59-
// was disposed and we are still trying to write something. In such case we would fail
60-
// with "System.NotSupportedException: Stream does not support writing.".
61-
// To avoid being too generic in that catch, I am checking if the stream is not writable.
62-
EqtTrace.Verbose("LengthPrefixCommunicationChannel.Send: BaseStream is not writable (most likely it was dispose). {0}", ex);
63-
}
64-
catch (Exception ex)
65-
{
66-
EqtTrace.Error("LengthPrefixCommunicationChannel.Send: Error sending data: {0}.", ex);
67-
throw new CommunicationException("Unable to send data over channel.", ex);
64+
catch (NotSupportedException ex) when (!_stream.CanWrite)
65+
{
66+
// The owner can dispose the stream while the channel is shutting down.
67+
EqtTrace.Verbose("LengthPrefixCommunicationChannel.Send: BaseStream is not writable (most likely it was disposed). {0}", ex);
68+
}
69+
catch (Exception ex)
70+
{
71+
_sendFailure = ex;
72+
73+
try
74+
{
75+
// A failed write may have left a length prefix or a partial payload on the wire.
76+
// Close the stream before another send can reuse the broken frame boundary.
77+
_stream.Dispose();
78+
}
79+
catch (Exception disposeException)
80+
{
81+
EqtTrace.Error("LengthPrefixCommunicationChannel.Send: Error closing stream after send failure: {0}.", disposeException);
82+
}
83+
84+
EqtTrace.Error("LengthPrefixCommunicationChannel.Send: Error sending data: {0}.", ex);
85+
throw new CommunicationException("Unable to send data over channel.", ex);
86+
}
6887
}
6988

7089
return Task.CompletedTask;
@@ -107,16 +126,36 @@ public Task NotifyDataAvailable(CancellationToken cancellationToken)
107126
/// <inheritdoc />
108127
public void Dispose()
109128
{
129+
// Dispose can run while another thread is inside Send. Take the write lock when it is
130+
// free, so disposal never flushes the writer mid-frame, but never wait for it: blocking
131+
// here would turn a stuck socket write into a shutdown hang.
132+
var lockTaken = false;
110133
try
111134
{
135+
Monitor.TryEnter(_writeSyncObject, ref lockTaken);
136+
112137
EqtTrace.Verbose("LengthPrefixCommunicationChannel.Dispose: Dispose reader and writer.");
113138
_reader.Dispose();
114-
_writer.Dispose();
139+
140+
// BinaryWriter.Dispose flushes its BufferedStream. That would re-emit bytes of an
141+
// incomplete frame after a failed send, and race the writer while a send is in
142+
// flight. Send flushes every message, so nothing is pending in either case.
143+
if (lockTaken && _sendFailure is null)
144+
{
145+
_writer.Dispose();
146+
}
115147
}
116148
catch (ObjectDisposedException)
117149
{
118150
// We don't own the underlying stream lifecycle so it's possible that it's already disposed.
119151
}
152+
finally
153+
{
154+
if (lockTaken)
155+
{
156+
Monitor.Exit(_writeSyncObject);
157+
}
158+
}
120159

121160
GC.SuppressFinalize(this);
122161
}

test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/LengthPrefixCommunicationChannelTests.cs

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
33

44
using System;
5+
using System.Diagnostics;
56
using System.IO;
67
using System.Threading;
78
using System.Threading.Tasks;
@@ -25,6 +26,8 @@ public class LengthPrefixCommunicationChannelTests : IDisposable
2526

2627
private readonly BinaryWriter _writer;
2728

29+
public TestContext TestContext { get; set; } = null!;
30+
2831
public LengthPrefixCommunicationChannelTests()
2932
{
3033
_stream = new MemoryStream();
@@ -120,6 +123,27 @@ public void DisposeShouldNotCloseTheStream()
120123
Assert.IsTrue(_stream.CanWrite);
121124
}
122125

126+
/// <summary>
127+
/// Characterization test. Send after Dispose writes to the stream today, because the writer
128+
/// is created with leaveOpen and its disposal only flushes. Shutdown races rely on that:
129+
/// Dispose runs while timer driven sends are still in flight. This test passes both before
130+
/// and after the send failure latch, so it fails if that behavior is ever changed by accident.
131+
/// </summary>
132+
[TestMethod]
133+
public async Task SendAfterDisposeShouldStillWriteToTheStream()
134+
{
135+
using var stream = new MemoryStream();
136+
var channel = new LengthPrefixCommunicationChannel(stream);
137+
channel.Dispose();
138+
139+
await channel.Send(Dummydata);
140+
141+
Assert.IsTrue(stream.CanWrite);
142+
SeekToBeginning(stream);
143+
using var reader = new BinaryReader(stream);
144+
Assert.AreEqual(Dummydata, reader.ReadString());
145+
}
146+
123147
[TestMethod]
124148
public async Task DoNotFailWhenWritingOnADisposedBaseStream()
125149
{
@@ -138,6 +162,90 @@ public async Task DoNotFailWhenReadingFromADisposedBaseStream()
138162
await _channel.NotifyDataAvailable(new CancellationToken());
139163
}
140164

165+
[TestMethod]
166+
public async Task SendShouldCloseStreamAndRejectLaterMessagesAfterPartialFrame()
167+
{
168+
using var stream = new FailingWriteStream(bytesBeforeFailure: 3);
169+
var channel = new LengthPrefixCommunicationChannel(stream);
170+
var message = new string('x', SocketConstants.BufferSize + 1);
171+
172+
await Assert.ThrowsExactlyAsync<CommunicationException>(() => channel.Send(message));
173+
174+
Assert.HasCount(3, stream.WrittenBytes);
175+
using (var prefixStream = new MemoryStream(stream.WrittenBytes))
176+
using (var prefixReader = new BinaryReader(prefixStream))
177+
{
178+
Assert.AreEqual(message.Length, Read7BitEncodedInt(prefixReader));
179+
}
180+
181+
var writeCallCount = stream.WriteCallCount;
182+
await Assert.ThrowsExactlyAsync<CommunicationException>(() => channel.Send(Dummydata));
183+
184+
Assert.IsTrue(stream.IsDisposed);
185+
Assert.AreEqual(writeCallCount, stream.WriteCallCount);
186+
Assert.HasCount(3, stream.WrittenBytes);
187+
}
188+
189+
[TestMethod]
190+
public async Task DisposeShouldNotFlushBufferedDataAfterSendFailure()
191+
{
192+
using var stream = new FailingWriteStream(bytesBeforeFailure: 0);
193+
var channel = new LengthPrefixCommunicationChannel(stream);
194+
195+
await Assert.ThrowsExactlyAsync<CommunicationException>(() => channel.Send(Dummydata));
196+
var writeCallCount = stream.WriteCallCount;
197+
198+
channel.Dispose();
199+
200+
Assert.AreEqual(writeCallCount, stream.WriteCallCount);
201+
}
202+
203+
[TestMethod]
204+
public async Task ConcurrentSendShouldNotWriteAfterFirstSendFails()
205+
{
206+
using var stream = new FailingWriteStream(bytesBeforeFailure: 3, blockFirstWrite: true);
207+
var channel = new LengthPrefixCommunicationChannel(stream);
208+
var message = new string('x', SocketConstants.BufferSize + 1);
209+
210+
var firstSend = Task.Run(() => channel.Send(message), TestContext.CancellationToken);
211+
Assert.IsTrue(stream.WriteStarted.Wait(TimeSpan.FromSeconds(5), TestContext.CancellationToken));
212+
213+
var secondSend = Task.Run(() => channel.Send(Dummydata), TestContext.CancellationToken);
214+
await Task.Delay(50, TestContext.CancellationToken);
215+
Assert.IsFalse(secondSend.IsCompleted);
216+
217+
stream.ReleaseWrite.Set();
218+
219+
await Assert.ThrowsExactlyAsync<CommunicationException>(() => firstSend);
220+
await Assert.ThrowsExactlyAsync<CommunicationException>(() => secondSend);
221+
Assert.IsTrue(stream.IsDisposed);
222+
Assert.AreEqual(1, stream.WriteCallCount);
223+
}
224+
225+
[TestMethod]
226+
public async Task DisposeShouldNeitherWriteNorBlockWhileASendIsInFlight()
227+
{
228+
using var stream = new FailingWriteStream(bytesBeforeFailure: 3, blockFirstWrite: true);
229+
var channel = new LengthPrefixCommunicationChannel(stream);
230+
var message = new string('x', SocketConstants.BufferSize + 1);
231+
232+
var send = Task.Run(() => channel.Send(message), TestContext.CancellationToken);
233+
Assert.IsTrue(stream.WriteStarted.Wait(TimeSpan.FromSeconds(5), TestContext.CancellationToken));
234+
235+
var writeCallCount = stream.WriteCallCount;
236+
var stopwatch = Stopwatch.StartNew();
237+
channel.Dispose();
238+
stopwatch.Stop();
239+
240+
// The in-flight write is held for up to five seconds. Disposal must not wait for it,
241+
// and must not flush the writer underneath it.
242+
Assert.IsLessThan(TimeSpan.FromSeconds(2), stopwatch.Elapsed, $"Dispose blocked for {stopwatch.Elapsed}.");
243+
Assert.AreEqual(writeCallCount, stream.WriteCallCount);
244+
245+
stream.ReleaseWrite.Set();
246+
await Assert.ThrowsExactlyAsync<CommunicationException>(() => send);
247+
}
248+
141249
// TODO
142250
// WriteFromMultilpleThreadShouldBeInSequence
143251
private static void SeekToBeginning(Stream stream)
@@ -173,4 +281,77 @@ private static int Read7BitEncodedInt(BinaryReader reader)
173281

174282
return count;
175283
}
284+
285+
private sealed class FailingWriteStream : MemoryStream
286+
{
287+
private readonly int _bytesBeforeFailure;
288+
private readonly bool _blockFirstWrite;
289+
290+
private int _acceptedBytes;
291+
private bool _failureInjected;
292+
293+
public FailingWriteStream(int bytesBeforeFailure, bool blockFirstWrite = false)
294+
{
295+
_bytesBeforeFailure = bytesBeforeFailure;
296+
_blockFirstWrite = blockFirstWrite;
297+
}
298+
299+
public bool IsDisposed { get; private set; }
300+
301+
public int WriteCallCount { get; private set; }
302+
303+
public byte[] WrittenBytes => ToArray();
304+
305+
public ManualResetEventSlim WriteStarted { get; } = new(false);
306+
307+
public ManualResetEventSlim ReleaseWrite { get; } = new(false);
308+
309+
public override void Write(byte[] buffer, int offset, int count)
310+
{
311+
if (IsDisposed)
312+
{
313+
throw new ObjectDisposedException(nameof(FailingWriteStream));
314+
}
315+
316+
WriteCallCount++;
317+
WriteStarted.Set();
318+
if (!_failureInjected)
319+
{
320+
if (_blockFirstWrite && !ReleaseWrite.Wait(TimeSpan.FromSeconds(5)))
321+
{
322+
throw new TimeoutException("Timed out waiting to release the injected write failure.");
323+
}
324+
325+
_failureInjected = true;
326+
var remainingBytes = _bytesBeforeFailure - _acceptedBytes;
327+
if (remainingBytes > 0)
328+
{
329+
var acceptedCount = Math.Min(remainingBytes, count);
330+
base.Write(buffer, offset, acceptedCount);
331+
_acceptedBytes += acceptedCount;
332+
}
333+
334+
throw new IOException("Injected write failure.");
335+
}
336+
337+
base.Write(buffer, offset, count);
338+
}
339+
340+
protected override void Dispose(bool disposing)
341+
{
342+
if (IsDisposed)
343+
{
344+
return;
345+
}
346+
347+
IsDisposed = true;
348+
if (disposing)
349+
{
350+
WriteStarted.Dispose();
351+
ReleaseWrite.Dispose();
352+
}
353+
354+
base.Dispose(disposing);
355+
}
356+
}
176357
}

0 commit comments

Comments
 (0)