235 lines
9.5 KiB
C#
235 lines
9.5 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
[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
|
|
}
|
|
}
|