diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/TcpMessageHandler.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/TcpMessageHandler.cs index 2298c710b6..2ea0854cbc 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/TcpMessageHandler.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/TcpMessageHandler.cs @@ -19,8 +19,22 @@ internal sealed class TcpMessageHandler( Stream serverToClientStream, IMessageFormatter formatter) : IMessageHandler, IDisposable { + private const int ReadBufferSize = 4096; + private const int HeaderLineInitialCapacity = 256; + private readonly TcpClient _client = client; - private readonly StreamReader _reader = new(clientToServerStream); + + // The read side deliberately does NOT use a StreamReader. Content-Length is declared in UTF-8 *bytes* + // (see WriteRequestAsync), so the body must be consumed as bytes and decoded afterwards. A StreamReader + // hands out decoded characters, which for multi-byte UTF-8 content are fewer units than the declared + // length: the reader under-reads the frame, leaves its tail in the stream, and the framing permanently + // desynchronizes from the next frame onwards. Reading the headers through a StreamReader and the body + // from BaseStream would be worse still, because the reader's internal buffer would have already + // swallowed part of the body. Headers and body are therefore both read through this one byte-level + // buffer, so nothing can be buffered on the other side of the boundary. + private readonly Stream _readStream = clientToServerStream; + private readonly byte[] _readBuffer = new byte[ReadBufferSize]; + private readonly StreamWriter _writer = new(serverToClientStream) { // We need to force the NewLine because in Windows and nix different char sequence are used @@ -31,6 +45,17 @@ internal sealed class TcpMessageHandler( private readonly IMessageFormatter _formatter = formatter; private readonly ILogger _logger = new NopLogger(); + // Reused across header lines so the hot read path (server mode emits a notification per test) does not + // allocate a fresh buffer per line. Safe to keep as state for the same reason _readBufferOffset/ + // _readBufferCount are: reads are single-threaded by construction, driven by exactly one read loop. + // It grows to the longest header line seen on the connection and stays there, which is bounded by the + // same trust boundary as Content-Length below (a loopback channel to a test host this process launched). + private byte[] _headerLineBuffer = new byte[HeaderLineInitialCapacity]; + + private int _readBufferOffset; + private int _readBufferCount; + private bool _preambleHandled; + /// /// Initializes a new instance of the class with a logger for low-noise /// transport diagnostics (e.g. connection resets). @@ -66,22 +91,52 @@ public TcpMessageHandler( return null; } + // Content-Length counts UTF-8 bytes, so consume exactly that many bytes and decode + // afterwards. Reading characters here would under-read every multi-byte frame. + // + // commandSize is taken from the wire without an upper bound. That is deliberate and + // pre-existing: server mode is a loopback channel to a test host this process launched, and + // legitimate bodies are unbounded in principle (a test node update can carry an arbitrarily + // large stack trace or captured stdout), so any cap would be a guess that risks rejecting + // valid traffic. Note the byte-based read below more than halves the previous worst-case + // allocation, which rented commandSize *chars*. #if NETCOREAPP - char[] commandCharsBuffer = ArrayPool.Shared.Rent(commandSize); + byte[] bodyBuffer = ArrayPool.Shared.Rent(commandSize); try { - Memory memoryBuffer = new(commandCharsBuffer, 0, commandSize); - await _reader.ReadBlockAsync(memoryBuffer, cancellationToken).ConfigureAwait(false); - return _formatter.Deserialize(memoryBuffer); + if (!await ReadExactlyAsync(bodyBuffer, commandSize, cancellationToken).ConfigureAwait(false)) + { + // The peer went away mid-frame; treat it like any other disconnect. + return null; + } + + int charCount = Encoding.UTF8.GetCharCount(bodyBuffer, 0, commandSize); + char[] charsBuffer = ArrayPool.Shared.Rent(charCount); + try + { + Encoding.UTF8.GetChars(bodyBuffer, 0, commandSize, charsBuffer, 0); + return _formatter.Deserialize(new ReadOnlyMemory(charsBuffer, 0, charCount)); + } + finally + { + ArrayPool.Shared.Return(charsBuffer); + } } finally { - ArrayPool.Shared.Return(commandCharsBuffer); + ArrayPool.Shared.Return(bodyBuffer); } #else - char[] commandChars = new char[commandSize]; - await _reader.ReadBlockAsync(commandChars, 0, commandSize).WithCancellationAsync(cancellationToken).ConfigureAwait(false); - return _formatter.Deserialize(new string(commandChars, 0, commandSize)); + // System.Buffers is out of framework on netstandard2.0/net462 and this file also ships as + // source in the dependency-free MTP client package, so allocate rather than pool here. + byte[] bodyBuffer = new byte[commandSize]; + if (!await ReadExactlyAsync(bodyBuffer, commandSize, cancellationToken).ConfigureAwait(false)) + { + // The peer went away mid-frame; treat it like any other disconnect. + return null; + } + + return _formatter.Deserialize(Encoding.UTF8.GetString(bodyBuffer, 0, commandSize)); #endif } } @@ -125,13 +180,7 @@ private async Task ReadHeadersAsync(CancellationToken cancellationToken) while (true) { -#if NET7_0_OR_GREATER - string? line = await _reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); -#elif NET6_0_OR_GREATER - string? line = await _reader.ReadLineAsync().WaitAsync(cancellationToken).ConfigureAwait(false); -#else - string? line = await _reader.ReadLineAsync().WithCancellationAsync(cancellationToken).ConfigureAwait(false); -#endif + string? line = await ReadHeaderLineAsync(cancellationToken).ConfigureAwait(false); if (line is null || (line.Length == 0 && contentSize != -1)) { break; @@ -156,6 +205,106 @@ private async Task ReadHeadersAsync(CancellationToken cancellationToken) return contentSize; } + /// + /// Reads a single CRLF-terminated header line from the shared byte buffer. Headers are ASCII by protocol, + /// but they are decoded as UTF-8 (a superset) so a non-conformant peer cannot corrupt the framing. + /// Returns at end of stream. + /// + private async Task ReadHeaderLineAsync(CancellationToken cancellationToken) + { + int lineLength = 0; + while (true) + { + if (_readBufferOffset == _readBufferCount && !await FillReadBufferAsync(cancellationToken).ConfigureAwait(false)) + { + // End of stream. A partial line is returned as-is; the caller fails the frame either way. + return lineLength == 0 ? null : TrimPreamble(Encoding.UTF8.GetString(_headerLineBuffer, 0, lineLength)); + } + + byte current = _readBuffer[_readBufferOffset++]; + if (current == (byte)'\n') + { + // Tolerate both CRLF and a bare LF. + if (lineLength > 0 && _headerLineBuffer[lineLength - 1] == (byte)'\r') + { + lineLength--; + } + + return TrimPreamble(Encoding.UTF8.GetString(_headerLineBuffer, 0, lineLength)); + } + + if (lineLength == _headerLineBuffer.Length) + { + // Plain arrays rather than ArrayPool so this compiles on netstandard2.0/net462, where + // System.Buffers is out of framework and this file also ships as source in the + // dependency-free MTP client package. + byte[] grown = new byte[_headerLineBuffer.Length * 2]; + Array.Copy(_headerLineBuffer, grown, lineLength); + _headerLineBuffer = grown; + } + + _headerLineBuffer[lineLength++] = current; + } + } + + /// + /// Drops a UTF-8 byte-order mark from the very first line of the stream. The previous + /// -based reader had byte-order-mark detection enabled by default and silently + /// swallowed the preamble, so peers that write their headers through a preamble-emitting encoder (for + /// example new StreamWriter(stream, Encoding.UTF8)) kept working. Preserve that tolerance. + /// + private string TrimPreamble(string line) + { + if (_preambleHandled) + { + return line; + } + + _preambleHandled = true; + return line.Length > 0 && line[0] == '\uFEFF' + ? line.Substring(1) + : line; + } + + /// + /// Fills with exactly bytes, draining the shared + /// read buffer first. Returns if the stream ended before the frame was complete. + /// + private async Task ReadExactlyAsync(byte[] destination, int count, CancellationToken cancellationToken) + { + int written = 0; + while (written < count) + { + if (_readBufferOffset == _readBufferCount && !await FillReadBufferAsync(cancellationToken).ConfigureAwait(false)) + { + return false; + } + + int available = Math.Min(_readBufferCount - _readBufferOffset, count - written); + Array.Copy(_readBuffer, _readBufferOffset, destination, written, available); + _readBufferOffset += available; + written += available; + } + + return true; + } + + /// + /// Refills the shared read buffer. Returns at end of stream. + /// + private async Task FillReadBufferAsync(CancellationToken cancellationToken) + { +#if NETCOREAPP + int read = await _readStream.ReadAsync(_readBuffer.AsMemory(0, _readBuffer.Length), cancellationToken).ConfigureAwait(false); +#else + int read = await _readStream.ReadAsync(_readBuffer, 0, _readBuffer.Length, cancellationToken) + .WithCancellationAsync(cancellationToken).ConfigureAwait(false); +#endif + _readBufferOffset = 0; + _readBufferCount = read; + return read > 0; + } + public async Task WriteRequestAsync(RpcMessage message, CancellationToken cancellationToken) { string messageStr = await _formatter.SerializeAsync(message).ConfigureAwait(false); @@ -198,7 +347,7 @@ public async Task WriteRequestAsync(RpcMessage message, CancellationToken cancel public void Dispose() { - _reader.Dispose(); + _readStream.Dispose(); try { diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/TcpMessageHandlerTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/TcpMessageHandlerTests.cs index 9ec63bc5c1..a87d0b085b 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/TcpMessageHandlerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/TcpMessageHandlerTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Net; using System.Net.Sockets; using Microsoft.Testing.Platform.Logging; @@ -14,6 +15,17 @@ namespace Microsoft.Testing.Platform.UnitTests; [UnsupportedOSPlatform("browser")] public sealed class TcpMessageHandlerTests { + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + + // A string whose UTF-8 byte count is strictly greater than its char count: German umlauts (2 bytes), + // Japanese (3 bytes) and an emoji (4 bytes, 2 chars). Content-Length is declared in bytes, so a reader + // that consumes that number of *characters* under-reads the frame and leaves its tail in the stream. + private const string NonAsciiText = "Grüße 日本語 🎉 Čau"; + + // The same content carried in the JSON-RPC method, which both formatters always bind (params binding + // depends on the method being a registered one, so it is not usable in a transport-level test). + private const string NonAsciiMethod = "testing/Grüße 日本語 🎉 Čau"; + public TestContext TestContext { get; set; } = null!; [TestMethod] @@ -52,6 +64,222 @@ public async Task ReadAsync_ConnectionReset_LogsFullExceptionAndReturnsNullWhenL Times.Once); } + /// + /// A conformant peer (the vstest client, or the source-shipped client running on a different formatter) + /// emits the JSON body as raw UTF-8 and declares Content-Length in bytes, exactly as + /// does. The reader must consume that many bytes, so the + /// next frame starts on a header boundary. + /// + [TestMethod] + public async Task ReadAsync_RawUtf8FramesFromPeer_DoesNotDesynchronizeSubsequentFrames() + { + using ConnectedHandlers handlers = await ConnectedHandlers.CreateAsync().ConfigureAwait(false); + + // Two frames: the desynchronization only surfaces on the frame *after* the multi-byte one, because the + // under-read leaves the first body's tail to be parsed as the second frame's headers. + WriteRawFrame(handlers.WriterStream, BuildNotificationJson(NonAsciiMethod)); + WriteRawFrame(handlers.WriterStream, BuildNotificationJson("testing/second")); + + var first = (NotificationMessage)(await ReadWithTimeoutAsync(handlers).ConfigureAwait(false))!; + var second = (NotificationMessage)(await ReadWithTimeoutAsync(handlers).ConfigureAwait(false))!; + + // Comparing the method verifies every multi-byte character survived, not merely that a frame arrived. + Assert.AreEqual(NonAsciiMethod, first.Method); + Assert.AreEqual("testing/second", second.Method); + } + + /// + /// The same round trip through on both ends, using real server-to-client + /// notifications. + /// + /// Note this test only exercises the byte/char mismatch on the #else (Jsonite) branch, which emits + /// non-ASCII BMP characters unescaped. On .NET the System.Text.Json formatter escapes every non-ASCII + /// character to \uXXXX, so the body reaches the wire as pure ASCII and this degrades to an ASCII + /// round trip — it is NOT cross-TFM coverage of the defect. That coverage comes from + /// and + /// , which + /// frame raw UTF-8 bytes themselves and so bypass formatter escaping on every TFM. + /// + /// The second frame uses a different method so a desynchronized read cannot accidentally satisfy the + /// assertion. + /// + [TestMethod] + public async Task ReadAsync_HandlerWrittenFramesWithNonAscii_DoesNotDesynchronizeSubsequentFrames() + { + using ConnectedHandlers handlers = await ConnectedHandlers.CreateAsync().ConfigureAwait(false); + + // A localized log message is exactly how non-ASCII reaches this transport in production. + await handlers.Writer.WriteRequestAsync( + new NotificationMessage( + JsonRpcMethods.ClientLog, + new LogEventArgs(new ServerLogMessage(LogLevel.Information, NonAsciiText))), + TestContext.CancellationToken).ConfigureAwait(false); + await handlers.Writer.WriteRequestAsync( + new NotificationMessage( + JsonRpcMethods.TelemetryUpdate, + new TelemetryEventArgs("ascii-only", new Dictionary())), + TestContext.CancellationToken).ConfigureAwait(false); + + var first = (NotificationMessage)(await ReadWithTimeoutAsync(handlers).ConfigureAwait(false))!; + var second = (NotificationMessage)(await ReadWithTimeoutAsync(handlers).ConfigureAwait(false))!; + + Assert.AreEqual(JsonRpcMethods.ClientLog, first.Method); + Assert.AreEqual(JsonRpcMethods.TelemetryUpdate, second.Method); + } + + /// + /// Guards the ASCII fast path: a plain frame followed by another must still round-trip, so the byte-exact + /// read does not over-consume when bytes and characters happen to coincide. + /// + [TestMethod] + public async Task ReadAsync_AsciiFrames_RoundTripInOrder() + { + using ConnectedHandlers handlers = await ConnectedHandlers.CreateAsync().ConfigureAwait(false); + + WriteRawFrame(handlers.WriterStream, BuildNotificationJson("testing/first")); + WriteRawFrame(handlers.WriterStream, BuildNotificationJson("testing/second")); + + var first = (NotificationMessage)(await ReadWithTimeoutAsync(handlers).ConfigureAwait(false))!; + var second = (NotificationMessage)(await ReadWithTimeoutAsync(handlers).ConfigureAwait(false))!; + + Assert.AreEqual("testing/first", first.Method); + Assert.AreEqual("testing/second", second.Method); + } + + /// + /// A peer that writes its headers through a preamble-emitting encoder (for example + /// new StreamWriter(stream, Encoding.UTF8)) prefixes the very first header line with a UTF-8 + /// byte-order mark. The reader must skip it, as the previous StreamReader-based implementation did. + /// + [TestMethod] + public async Task ReadAsync_LeadingByteOrderMark_IsSkipped() + { + using ConnectedHandlers handlers = await ConnectedHandlers.CreateAsync().ConfigureAwait(false); + + byte[] preamble = Encoding.UTF8.GetPreamble(); + await handlers.WriterStream.WriteAsync(preamble, 0, preamble.Length, TestContext.CancellationToken).ConfigureAwait(false); + WriteRawFrame(handlers.WriterStream, BuildNotificationJson(NonAsciiMethod)); + WriteRawFrame(handlers.WriterStream, BuildNotificationJson("testing/second")); + + var first = (NotificationMessage)(await ReadWithTimeoutAsync(handlers).ConfigureAwait(false))!; + var second = (NotificationMessage)(await ReadWithTimeoutAsync(handlers).ConfigureAwait(false))!; + + Assert.AreEqual(NonAsciiMethod, first.Method); + Assert.AreEqual("testing/second", second.Method); + } + + /// + /// A body larger than the reader's internal buffer forces the refill loop in ReadExactlyAsync to + /// run several times, so the body spans buffer boundaries. Production traffic hits this routinely: a test + /// node update carrying a stack trace or captured standard output easily exceeds the buffer size. + /// A second frame follows to prove the reader stopped on the exact byte boundary. + /// + [TestMethod] + public async Task ReadAsync_BodyLargerThanReadBuffer_SpansMultipleRefillsWithoutDesynchronizing() + { + using ConnectedHandlers handlers = await ConnectedHandlers.CreateAsync().ConfigureAwait(false); + + // Comfortably larger than the 4 KB read buffer, and non-ASCII so the byte/char distinction still + // applies across every refill rather than only on the first. Carried in the method so the assertion + // below compares every single byte of the oversized frame. + string largeMethod = "testing/" + string.Concat(Enumerable.Repeat(NonAsciiText, 1000)); + + WriteRawFrame(handlers.WriterStream, BuildNotificationJson(largeMethod)); + WriteRawFrame(handlers.WriterStream, BuildNotificationJson("testing/after")); + + var large = (NotificationMessage)(await ReadWithTimeoutAsync(handlers).ConfigureAwait(false))!; + var after = (NotificationMessage)(await ReadWithTimeoutAsync(handlers).ConfigureAwait(false))!; + + Assert.AreEqual(largeMethod, large.Method); + Assert.AreEqual("testing/after", after.Method); + } + + /// + /// A header line longer than the initial header buffer forces the growth path in + /// ReadHeaderLineAsync to run through several doublings. Headers are unknown-length input from the + /// peer, so an over-long one must grow the buffer rather than truncate or corrupt the frame. + /// + [TestMethod] + public async Task ReadAsync_HeaderLineLongerThanInitialBuffer_GrowsAndParsesFrame() + { + using ConnectedHandlers handlers = await ConnectedHandlers.CreateAsync().ConfigureAwait(false); + + // An unknown header is ignored by the parser but must still be consumed in full. Well past the + // 256-byte initial header buffer, so the doubling path runs repeatedly. + string longHeaderValue = new('x', 5000); + string body = BuildNotificationJson("testing/first"); + byte[] bodyBytes = Encoding.UTF8.GetBytes(body); + byte[] header = Encoding.ASCII.GetBytes( + $"X-Padding: {longHeaderValue}\r\nContent-Length: {bodyBytes.Length}\r\nContent-Type: application/testingplatform\r\n\r\n"); + + await handlers.WriterStream.WriteAsync(header, 0, header.Length, TestContext.CancellationToken).ConfigureAwait(false); + await handlers.WriterStream.WriteAsync(bodyBytes, 0, bodyBytes.Length, TestContext.CancellationToken).ConfigureAwait(false); + WriteRawFrame(handlers.WriterStream, BuildNotificationJson("testing/second")); + + var first = (NotificationMessage)(await ReadWithTimeoutAsync(handlers).ConfigureAwait(false))!; + var second = (NotificationMessage)(await ReadWithTimeoutAsync(handlers).ConfigureAwait(false))!; + + Assert.AreEqual("testing/first", first.Method); + Assert.AreEqual("testing/second", second.Method); + } + + /// + /// A peer that announces a body and then disconnects before sending all of it must surface as a graceful + /// disconnect (), not as a hang waiting for bytes that will never arrive, and not as + /// an exception escaping to the read loop. + /// + [TestMethod] + public async Task ReadAsync_PeerDisconnectsMidBody_ReturnsNull() + { + using ConnectedHandlers handlers = await ConnectedHandlers.CreateAsync().ConfigureAwait(false); + + string body = BuildNotificationJson("testing/first"); + byte[] bodyBytes = Encoding.UTF8.GetBytes(body); + byte[] header = Encoding.ASCII.GetBytes( + $"Content-Length: {bodyBytes.Length}\r\nContent-Type: application/testingplatform\r\n\r\n"); + + await handlers.WriterStream.WriteAsync(header, 0, header.Length, TestContext.CancellationToken).ConfigureAwait(false); + + // Deliberately short: announce the full length, then send only part of it and hang up. + await handlers.WriterStream.WriteAsync(bodyBytes, 0, bodyBytes.Length / 2, TestContext.CancellationToken).ConfigureAwait(false); + await handlers.WriterStream.FlushAsync(TestContext.CancellationToken).ConfigureAwait(false); + handlers.CloseWriterSide(); + + RpcMessage? message = await ReadWithTimeoutAsync(handlers).ConfigureAwait(false); + + Assert.IsNull(message); + } + + /// + /// Builds a JSON-RPC notification body by hand, so the test controls the exact bytes that hit the wire + /// rather than going through a formatter that may escape non-ASCII characters. + /// + private static string BuildNotificationJson(string method) + => "{\"jsonrpc\":\"2.0\",\"method\":\"" + method + "\",\"params\":{}}"; + + /// + /// Writes a frame the way any conformant peer does: an ASCII header block whose Content-Length is + /// the UTF-8 byte count, followed by the raw UTF-8 body. + /// + private static void WriteRawFrame(NetworkStream stream, string jsonBody) + { + byte[] body = Encoding.UTF8.GetBytes(jsonBody); + byte[] header = Encoding.ASCII.GetBytes( + $"Content-Length: {body.Length}\r\nContent-Type: application/testingplatform\r\n\r\n"); + + stream.Write(header, 0, header.Length); + stream.Write(body, 0, body.Length); + stream.Flush(); + } + + private async Task ReadWithTimeoutAsync(ConnectedHandlers handlers) + { + Task readTask = handlers.Reader.ReadAsync(TestContext.CancellationToken); + Task completed = await Task.WhenAny(readTask, Task.Delay(DefaultTimeout, TestContext.CancellationToken)).ConfigureAwait(false); + Assert.AreSame(readTask, completed, "Timed out reading a frame; the transport is most likely desynchronized."); + return await readTask.ConfigureAwait(false); + } + private sealed class ConnectionResetStream(SocketException exception) : MemoryStream { public override int Read(byte[] buffer, int offset, int count) => throw exception; @@ -64,4 +292,64 @@ public override ValueTask ReadAsync(Memory buffer, CancellationToken => ValueTask.FromException(exception); #endif } + + /// + /// A pair of instances joined by a loopback TCP connection, so the bytes + /// exchanged are exactly what a real MTP server and client put on the wire. + /// + private sealed class ConnectedHandlers : IDisposable + { + private readonly TcpListener _listener; + private readonly TcpClient _clientSocket; + private readonly TcpClient _serverSocket; + + private ConnectedHandlers(TcpListener listener, TcpClient clientSocket, TcpClient serverSocket) + { + _listener = listener; + _clientSocket = clientSocket; + _serverSocket = serverSocket; + + WriterStream = serverSocket.GetStream(); + NetworkStream clientStream = clientSocket.GetStream(); + Writer = new TcpMessageHandler(serverSocket, WriterStream, WriterStream, FormatterUtilities.CreateFormatter()); + Reader = new TcpMessageHandler(clientSocket, clientStream, clientStream, FormatterUtilities.CreateFormatter()); + } + + /// Gets the raw stream behind , for tests that frame bytes themselves. + public NetworkStream WriterStream { get; } + + public TcpMessageHandler Writer { get; } + + public TcpMessageHandler Reader { get; } + + public static async Task CreateAsync() + { + TcpListener listener = new(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + + TcpClient clientSocket = new(); + await clientSocket.ConnectAsync(IPAddress.Loopback, port).ConfigureAwait(false); + clientSocket.NoDelay = true; + + TcpClient serverSocket = await listener.AcceptTcpClientAsync().ConfigureAwait(false); + serverSocket.NoDelay = true; + + return new ConnectedHandlers(listener, clientSocket, serverSocket); + } + + /// + /// Half-closes the writer's send direction, which the reader observes as an end of stream. + /// + public void CloseWriterSide() => _serverSocket.Client.Shutdown(SocketShutdown.Send); + + public void Dispose() + { + Writer.Dispose(); + Reader.Dispose(); + _clientSocket.Dispose(); + _serverSocket.Dispose(); + _listener.Stop(); + } + } }