Skip to content

Commit 4050f4d

Browse files
authored
Merge pull request #1198 from Scriptwonder/fix/issue-1173-stdio-port-stability
fix(stdio): retry same port on bind race instead of silent fallback (#1173)
2 parents c0908b8 + b0fefe4 commit 4050f4d

3 files changed

Lines changed: 88 additions & 7 deletions

File tree

MCPForUnity/Editor/Helpers/PortManager.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,25 @@ public static int DiscoverNewPort()
6464
return newPort;
6565
}
6666

67+
/// <summary>
68+
/// How long the configured port may keep returning AddressAlreadyInUse before the
69+
/// bridge gives up on it and discovers a new one. The common cause of a transient
70+
/// conflict is our own previous listener whose OS socket has not been released yet
71+
/// after a domain reload (slower on Windows/macOS); this window covers that race so
72+
/// the bridge keeps the configured port instead of stranding the client on the
73+
/// orphan (#1173).
74+
/// </summary>
75+
public const double BusyPortFallbackWindowSeconds = 3.0;
76+
77+
/// <summary>
78+
/// Decide whether a configured port that keeps reporting AddressAlreadyInUse should be
79+
/// abandoned for a freshly discovered port. Returns false (keep retrying the same port)
80+
/// until it has been continuously busy for <see cref="BusyPortFallbackWindowSeconds"/>,
81+
/// which distinguishes a domain-reload socket-release race from a foreign occupant (#1173).
82+
/// </summary>
83+
public static bool ShouldAbandonBusyPort(double busyForSeconds)
84+
=> busyForSeconds >= BusyPortFallbackWindowSeconds;
85+
6786
/// <summary>
6887
/// Persist a user-selected port and return the value actually stored.
6988
/// If <paramref name="port"/> is unavailable, the next available port is chosen instead.

MCPForUnity/Editor/Services/Transport/Transports/StdioBridgeHost.cs

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ public static class StdioBridgeHost
4646
private static bool isStarting = false;
4747
private static double nextStartAt = 0.0f;
4848
private static double nextHeartbeatAt = 0.0f;
49+
// EditorApplication.timeSinceStartup of the first AddressAlreadyInUse on the configured
50+
// port; 0 when the port binds cleanly. Drives the same-port retry window (#1173).
51+
private static double _portBusySince = 0.0;
52+
// If the port has been continuously busy longer than this, _portBusySince is treated as a
53+
// stale leftover from an abandoned retry and a fresh window is started (#1173).
54+
private const double PortBusyStaleResetSeconds = 60.0;
4955
private static int heartbeatSeq = 0;
5056
private static Dictionary<string, QueuedCommand> commandQueue = new();
5157
private static int mainThreadId;
@@ -284,26 +290,59 @@ public static void Start()
284290
}
285291
catch (SocketException se) when (se.SocketErrorCode == SocketError.AddressAlreadyInUse)
286292
{
287-
// Port is busy. Try switching to a new port once; if that also fails,
288-
// let the reload handler retry with async backoff instead of blocking here.
293+
// The configured port is busy. The usual cause is our own previous listener
294+
// whose OS socket has not been released yet after a domain reload: Stop()/
295+
// Dispose runs on the main thread, but the kernel frees the bound port a few
296+
// hundred ms later (longer on Windows/macOS).
297+
//
298+
// Do NOT silently switch to a new port on the first conflict — the Python
299+
// client stays pinned to the configured port and ends up talking to the
300+
// orphan, returning busy/timeout forever (#1173). Keep the configured port
301+
// and fail this attempt WITHOUT blocking: the reload handler's async resume
302+
// schedule and the editor-idle retry re-invoke Start() on the same port within
303+
// ~1s, by which point the OS has released it. Only after the port stays busy
304+
// past the fallback window do we treat it as a foreign occupant and switch.
305+
double now = EditorApplication.timeSinceStartup;
306+
// Start a fresh window on the first conflict, or if a stale timestamp
307+
// survived a long idle gap (a real reload + retry resolves in seconds).
308+
if (_portBusySince <= 0.0 || (now - _portBusySince) > PortBusyStaleResetSeconds) _portBusySince = now;
309+
310+
if (!PortManager.ShouldAbandonBusyPort(now - _portBusySince))
311+
{
312+
try { listener?.Stop(); } catch { }
313+
try { listener?.Server?.Dispose(); } catch { }
314+
listener = null;
315+
McpLog.Warn($"Port {currentUnityPort} not released yet after reload; retrying same port.");
316+
WriteHeartbeat(true, "port_busy");
317+
nextStartAt = now + 0.3; // throttle the editor-idle retry loop
318+
// Arm the editor-idle retry even when Start() was called directly
319+
// (e.g. StartAutoConnect), not only during reload resume — so a transient
320+
// AddressAlreadyInUse can never leave the bridge permanently stopped.
321+
if (!ensureUpdateHooked)
322+
{
323+
ensureUpdateHooked = true;
324+
EditorApplication.update += EnsureStartedOnEditorIdle;
325+
}
326+
return;
327+
}
328+
289329
int oldPort = currentUnityPort;
290330
currentUnityPort = PortManager.DiscoverNewPort();
331+
_portBusySince = 0.0;
291332

292333
try
293334
{
294335
EditorPrefs.SetInt(EditorPrefKeys.UnitySocketPort, currentUnityPort);
295336
}
296337
catch { }
297338

298-
if (IsDebugEnabled())
299-
{
300-
McpLog.Info($"Port {oldPort} occupied, switching to port {currentUnityPort}");
301-
}
339+
McpLog.Warn($"Port {oldPort} still occupied after {PortManager.BusyPortFallbackWindowSeconds:0.#}s; falling back to port {currentUnityPort} (clients follow via the status file).");
302340

303341
listener = CreateConfiguredListener(currentUnityPort);
304342
listener.Start();
305343
}
306344

345+
_portBusySince = 0.0;
307346
isRunning = true;
308347
isAutoConnectMode = false;
309348
string platform = Application.platform.ToString();
@@ -631,7 +670,8 @@ private static async Task HandleClientAsync(TcpClient client, CancellationToken
631670
bool isBenign =
632671
msg.IndexOf("Connection closed before reading expected bytes", StringComparison.OrdinalIgnoreCase) >= 0
633672
|| msg.IndexOf("Read timed out", StringComparison.OrdinalIgnoreCase) >= 0
634-
|| ex is IOException;
673+
|| ex is IOException
674+
|| ex is ObjectDisposedException;
635675
if (isBenign)
636676
{
637677
if (IsDebugEnabled()) McpLog.Info($"Client handler: {msg}", always: false);

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/PortManagerTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,28 @@ public void IsPortAvailable_ReturnsFalse_WhenPortHeldWithReuseAddr()
109109
}
110110
#endif
111111

112+
[Test]
113+
public void ShouldAbandonBusyPort_KeepsSamePort_WithinReleaseWindow()
114+
{
115+
// A port busy for less than the fallback window is treated as our own
116+
// not-yet-released listener after a domain reload — keep retrying the same
117+
// port instead of silently switching and stranding the client (#1173).
118+
Assert.IsFalse(PortManager.ShouldAbandonBusyPort(0.0));
119+
Assert.IsFalse(PortManager.ShouldAbandonBusyPort(
120+
PortManager.BusyPortFallbackWindowSeconds - 0.5));
121+
}
122+
123+
[Test]
124+
public void ShouldAbandonBusyPort_FallsBack_AfterReleaseWindow()
125+
{
126+
// A port that stays busy past the window is a foreign occupant — only then
127+
// does the bridge discover and switch to a new port.
128+
Assert.IsTrue(PortManager.ShouldAbandonBusyPort(
129+
PortManager.BusyPortFallbackWindowSeconds));
130+
Assert.IsTrue(PortManager.ShouldAbandonBusyPort(
131+
PortManager.BusyPortFallbackWindowSeconds + 5.0));
132+
}
133+
112134
[Test]
113135
public void DiscoverNewPort_ReturnsAvailablePort()
114136
{

0 commit comments

Comments
 (0)