using System; using System.Collections; using System.IO; using System.Net.Sockets; using System.Text; using System.Threading; using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; using MCPForUnity.Editor.Services.Transport.Transports; namespace MCPForUnityTests.Editor.Services { /// /// Tests that StdioBridgeHost correctly handles client reconnection scenarios. /// After an abrupt client disconnect, a new client must be able to connect and /// have its commands processed — this was broken by the zombie state bug (#785). /// [TestFixture] public class StdioBridgeReconnectTests { private const int ConnectTimeoutMs = 5000; private const int ReadTimeoutMs = 10000; [UnityTest] public IEnumerator NewClient_AfterAbruptDisconnect_CanSendAndReceiveCommands() { if (!StdioBridgeHost.IsRunning) { Assert.Ignore("StdioBridgeHost is not running; skipping reconnect test."); yield break; } int port = StdioBridgeHost.GetCurrentPort(); // --- First client: connect, verify ping/pong, then abruptly close --- using (var client1 = new TcpClient()) { Assert.IsTrue(client1.ConnectAsync("127.0.0.1", port).Wait(ConnectTimeoutMs), "First client connect timed out"); client1.ReceiveTimeout = ReadTimeoutMs; var stream1 = client1.GetStream(); string handshake1 = ReadLine(stream1, ReadTimeoutMs); Assert.That(handshake1, Does.Contain("FRAMING=1"), "First client should receive handshake"); // Send a framed ping SendFrame(stream1, Encoding.UTF8.GetBytes("ping")); byte[] pongBytes = ReadFrame(stream1, ReadTimeoutMs); string pong1 = Encoding.UTF8.GetString(pongBytes); Assert.That(pong1, Does.Contain("pong"), "First client should get pong response"); // Abrupt close — simulates server crash / domain reload disconnect client1.Client.LingerState = new LingerOption(true, 0); client1.Close(); } // Wait a few frames for cleanup for (int i = 0; i < 10; i++) yield return null; // --- Second client: connect and verify commands still work --- using (var client2 = new TcpClient()) { Assert.IsTrue(client2.ConnectAsync("127.0.0.1", port).Wait(ConnectTimeoutMs), "Second client connect timed out"); client2.ReceiveTimeout = ReadTimeoutMs; var stream2 = client2.GetStream(); string handshake2 = ReadLine(stream2, ReadTimeoutMs); Assert.That(handshake2, Does.Contain("FRAMING=1"), "Second client should receive handshake"); // Send a framed ping — this is the critical check that would fail // if the bridge is in zombie state. SendFrame(stream2, Encoding.UTF8.GetBytes("ping")); byte[] pongBytes2 = ReadFrame(stream2, ReadTimeoutMs); string pong2 = Encoding.UTF8.GetString(pongBytes2); Assert.That(pong2, Does.Contain("pong"), "Second client should get pong response after reconnect"); client2.Close(); } } [UnityTest] public IEnumerator NewClient_WhileOldClientStillConnected_ClosesStaleClient() { if (!StdioBridgeHost.IsRunning) { Assert.Ignore("StdioBridgeHost is not running; skipping reconnect test."); yield break; } int port = StdioBridgeHost.GetCurrentPort(); // --- First client: connect and verify handshake (but don't close) --- var client1 = new TcpClient(); try { Assert.IsTrue(client1.ConnectAsync("127.0.0.1", port).Wait(ConnectTimeoutMs), "First client connect timed out"); client1.ReceiveTimeout = ReadTimeoutMs; var stream1 = client1.GetStream(); string handshake1 = ReadLine(stream1, ReadTimeoutMs); Assert.That(handshake1, Does.Contain("FRAMING=1"), "First client should receive handshake"); // Verify ping works on first client SendFrame(stream1, Encoding.UTF8.GetBytes("ping")); byte[] pong1Bytes = ReadFrame(stream1, ReadTimeoutMs); Assert.That(Encoding.UTF8.GetString(pong1Bytes), Does.Contain("pong")); // --- Second client: connect while first is still open --- using (var client2 = new TcpClient()) { Assert.IsTrue(client2.ConnectAsync("127.0.0.1", port).Wait(ConnectTimeoutMs), "Second client connect timed out"); client2.ReceiveTimeout = ReadTimeoutMs; var stream2 = client2.GetStream(); string handshake2 = ReadLine(stream2, ReadTimeoutMs); Assert.That(handshake2, Does.Contain("FRAMING=1"), "Second client should receive handshake"); // Stale-client cleanup runs synchronously in HandleClientAsync before // the read loop, so by the time we read the handshake it's already done. // No yield needed — yielding here creates a window for the MCP Python // server to reconnect and close our test client as stale. SendFrame(stream2, Encoding.UTF8.GetBytes("ping")); byte[] pong2Bytes = ReadFrame(stream2, ReadTimeoutMs); Assert.That(Encoding.UTF8.GetString(pong2Bytes), Does.Contain("pong"), "Second client should get pong after stale client cleanup"); client2.Close(); } // First client should now be disconnected by the bridge. // A read attempt should throw or return 0 bytes. yield return null; bool firstClientDisconnected = false; try { SendFrame(stream1, Encoding.UTF8.GetBytes("ping")); ReadFrame(stream1, 2000); } catch { firstClientDisconnected = true; } Assert.IsTrue(firstClientDisconnected, "First client should be disconnected after second client connects"); } finally { try { client1.Close(); } catch { } } } #region Frame protocol helpers private static string ReadLine(NetworkStream stream, int timeoutMs) { var sb = new StringBuilder(); var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); stream.ReadTimeout = timeoutMs; while (DateTime.UtcNow < deadline) { int b = stream.ReadByte(); if (b < 0) throw new IOException("Connection closed while reading line"); if (b == '\n') return sb.ToString(); sb.Append((char)b); } throw new TimeoutException("Timed out reading line from stream"); } private static void SendFrame(NetworkStream stream, byte[] payload) { byte[] header = new byte[8]; ulong len = (ulong)payload.LongLength; header[0] = (byte)(len >> 56); header[1] = (byte)(len >> 48); header[2] = (byte)(len >> 40); header[3] = (byte)(len >> 32); header[4] = (byte)(len >> 24); header[5] = (byte)(len >> 16); header[6] = (byte)(len >> 8); header[7] = (byte)(len); stream.Write(header, 0, 8); stream.Write(payload, 0, payload.Length); stream.Flush(); } private static byte[] ReadFrame(NetworkStream stream, int timeoutMs) { stream.ReadTimeout = timeoutMs; byte[] header = ReadExact(stream, 8, timeoutMs); ulong payloadLen = ((ulong)header[0] << 56) | ((ulong)header[1] << 48) | ((ulong)header[2] << 40) | ((ulong)header[3] << 32) | ((ulong)header[4] << 24) | ((ulong)header[5] << 16) | ((ulong)header[6] << 8) | header[7]; if (payloadLen == 0 || payloadLen > 16 * 1024 * 1024) throw new IOException($"Invalid frame length: {payloadLen}"); return ReadExact(stream, (int)payloadLen, timeoutMs); } private static byte[] ReadExact(NetworkStream stream, int count, int timeoutMs) { byte[] buffer = new byte[count]; int offset = 0; var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); while (offset < count) { if (DateTime.UtcNow > deadline) throw new TimeoutException($"Timed out reading {count} bytes (got {offset})"); int remaining = count - offset; int read = stream.Read(buffer, offset, remaining); if (read == 0) throw new IOException("Connection closed before reading expected bytes"); offset += read; } return buffer; } #endregion } }