fix(editor): stop stdio connection UI showing false "Bridge not running"

Two related stdio connection-UI reliability bugs surfaced while testing
with a live domain-reload cycle:

1. Start Session race (severe). StdioTransportClient.StartAsync returned
   true unconditionally, then callers immediately verified — but
   VerifyAsync only reads StdioBridgeHost.IsRunning, which is still false
   while the previous port releases after a reload (Start() defers the
   bind to an editor-idle retry, or falls back to a new port after
   BusyPortFallbackWindowSeconds). Result: "Connection verification
   failed: Bridge not running", and Start Session only connected after
   several clicks. StartAsync now waits (bounded, ReadyWaitTimeoutSeconds)
   for the bridge to actually bind before reporting success.

2. Health-indicator flash. VerifyBridgeConnectionInternalAsync flipped the
   indicator to Unhealthy on a single transient verify miss during a
   reload/port-hop, then recovered — misleading. It now debounces via
   UnhealthyVerificationThreshold (mirrors the #1207 orphan-session
   debounce), resetting on any reachable result.

Both decisions are pure, unit-tested helpers (ShouldKeepWaitingForReady,
ShouldReportUnhealthy). 16 EditMode tests green (4 readiness + 5 debounce
+ 7 existing #1207 orphan, no regression).

Claude-Session: https://claude.ai/code/session_015JRaRFZy4piZzZtW5NabJS
This commit is contained in:
Shutong Wu
2026-07-06 10:11:34 -07:00
parent e6ecad84c7
commit c344625e4c
7 changed files with 188 additions and 4 deletions
@@ -1,6 +1,7 @@
using System;
using System.Threading.Tasks;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
namespace MCPForUnity.Editor.Services.Transport.Transports
{
@@ -15,21 +16,52 @@ namespace MCPForUnity.Editor.Services.Transport.Transports
public string TransportName => "stdio";
public TransportState State => _state;
public Task<bool> StartAsync()
// Bounded window to wait for the bridge to actually bind after StartAutoConnect. Covers the
// OS port-release delay after a domain reload (the same port can stay held for a few hundred
// ms, longer on Windows/macOS), during which Start() defers binding to an editor-idle retry
// or falls back to a new port once BusyPortFallbackWindowSeconds elapses.
internal const double ReadyWaitTimeoutSeconds = 5.0;
private const int ReadyPollIntervalMs = 100;
// Pure predicate (unit-testable): keep polling while the bridge is not yet ready and the
// bounded window has not elapsed.
internal static bool ShouldKeepWaitingForReady(bool bridgeReady, double secondsWaited)
=> !bridgeReady && secondsWaited < ReadyWaitTimeoutSeconds;
public async Task<bool> StartAsync()
{
try
{
StdioBridgeHost.StartAutoConnect();
_state = TransportState.Connected("stdio", port: StdioBridgeHost.GetCurrentPort());
return Task.FromResult(true);
// StartAutoConnect triggers the bind, but when the previous port is still held after a
// domain reload it defers binding to an editor-idle retry — so IsRunning can still be
// false right here. Wait (bounded) for the bridge to actually become ready before
// reporting success; otherwise callers immediately verify a bool that was never
// awaited and get a spurious "Bridge not running" (the Start Session race).
bool ready = await WaitForBridgeReadyAsync();
_state = ready
? TransportState.Connected("stdio", port: StdioBridgeHost.GetCurrentPort())
: TransportState.Disconnected("stdio", "Bridge not ready yet (port still releasing after reload).");
return ready;
}
catch (Exception ex)
{
_state = TransportState.Disconnected("stdio", ex.Message);
return Task.FromResult(false);
return false;
}
}
private static async Task<bool> WaitForBridgeReadyAsync()
{
double start = EditorApplication.timeSinceStartup;
while (ShouldKeepWaitingForReady(StdioBridgeHost.IsRunning, EditorApplication.timeSinceStartup - start))
{
await Task.Delay(ReadyPollIntervalMs);
}
return StdioBridgeHost.IsRunning;
}
public Task StopAsync()
{
StdioBridgeHost.Stop();
@@ -59,6 +59,7 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
private bool httpServerToggleInProgress;
private Task verificationTask;
private string lastHealthStatus;
private int consecutiveVerifyFailures;
private double lastLocalServerRunningPollTime;
private bool lastLocalServerRunning;
@@ -318,6 +319,17 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
RefreshHttpUi();
}
// Consecutive failed bridge verifications required before the health indicator is
// shown as Unhealthy ("broken"). A stdio domain reload briefly rebinds the listener
// — the port can even hop (e.g. 6402 -> 6403) — during which a single VerifyAsync()
// ping transiently fails with "Bridge not running" even though the bridge recovers on
// its own. Flashing broken on that lone miss is misleading, so debounce: require
// repeated failures before surfacing it.
internal const int UnhealthyVerificationThreshold = 2;
internal static bool ShouldReportUnhealthy(int consecutiveVerifyFailures)
=> consecutiveVerifyFailures >= UnhealthyVerificationThreshold;
public void UpdateConnectionStatus()
{
var bridgeService = MCPServiceLocator.Bridge;
@@ -1044,6 +1056,7 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
{
newStatus = HealthStatus.Healthy;
isHealthy = true;
consecutiveVerifyFailures = 0;
// Only log if state changed
if (lastHealthStatus != newStatus)
@@ -1054,8 +1067,11 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
}
else if (result.HandshakeValid)
{
// Handshake succeeded, so the socket is reachable — not the transient
// rebind case. Ping failing is a genuine warning; reset the miss counter.
newStatus = HealthStatus.PingFailed;
isHealthy = false;
consecutiveVerifyFailures = 0;
// Log once per distinct warning state
if (lastHealthStatus != newStatus)
@@ -1066,6 +1082,19 @@ namespace MCPForUnity.Editor.Windows.Components.Connection
}
else
{
// Could not reach the bridge at all. A stdio reload rebinds the listener
// (the port can hop), so one miss does not mean it is down. Debounce: only
// surface "broken" after repeated failures (mirrors #1207). Until then leave
// the indicator in its current state; the next verification resolves it.
consecutiveVerifyFailures++;
if (!ShouldReportUnhealthy(consecutiveVerifyFailures))
{
McpLog.Debug(
$"Connection verification miss {consecutiveVerifyFailures}/{UnhealthyVerificationThreshold} " +
$"(transient, not surfacing): {result.Message}");
return;
}
newStatus = HealthStatus.Unhealthy;
isHealthy = false;
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4963343217cdb4937835086fabddc98a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,44 @@
using NUnit.Framework;
using MCPForUnity.Editor.Services.Transport.Transports;
namespace MCPForUnityTests.Editor.Transport
{
/// <summary>
/// Regression tests for the Start Session readiness race: stdio StartAsync used to return true
/// unconditionally and callers immediately verified StdioBridgeHost.IsRunning — which is still
/// false while the previous port releases after a domain reload, producing a spurious
/// "Bridge not running" until the user clicked Start Session several times. StartAsync now waits
/// (bounded) for the bridge to actually bind; this covers the wait predicate.
/// </summary>
public class StdioTransportClientReadinessTests
{
private static readonly double Timeout = StdioTransportClient.ReadyWaitTimeoutSeconds;
[Test]
public void KeepsWaiting_WhenNotReady_AndWithinWindow()
{
Assert.IsTrue(StdioTransportClient.ShouldKeepWaitingForReady(bridgeReady: false, secondsWaited: 0.0));
Assert.IsTrue(StdioTransportClient.ShouldKeepWaitingForReady(bridgeReady: false, secondsWaited: Timeout - 0.1));
}
[Test]
public void StopsWaiting_AsSoonAsReady()
{
Assert.IsFalse(StdioTransportClient.ShouldKeepWaitingForReady(bridgeReady: true, secondsWaited: 0.0));
}
[Test]
public void StopsWaiting_WhenWindowElapses_EvenIfNotReady()
{
Assert.IsFalse(StdioTransportClient.ShouldKeepWaitingForReady(bridgeReady: false, secondsWaited: Timeout));
Assert.IsFalse(StdioTransportClient.ShouldKeepWaitingForReady(bridgeReady: false, secondsWaited: Timeout + 1.0));
}
[Test]
public void ReadyWindow_IsGenerousEnoughForPortRelease()
{
// Must cover the OS port-release delay + the BusyPortFallbackWindowSeconds (3s) fallback.
Assert.GreaterOrEqual(Timeout, 3.0);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 322603f43226040548e602c8bb6cc204
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,49 @@
using NUnit.Framework;
using MCPForUnity.Editor.Windows.Components.Connection;
namespace MCPForUnityTests.Editor.Windows
{
/// <summary>
/// Regression tests for the stdio health-verification debounce: a domain reload briefly
/// rebinds the bridge listener (the port can hop, e.g. 6402 -> 6403), during which a single
/// VerifyAsync() ping transiently fails with "Bridge not running" even though the bridge
/// recovers on its own. The health indicator must not flash "broken" on that lone miss —
/// mirroring the #1207 orphaned-session debounce.
/// </summary>
public class McpConnectionSectionHealthDebounceTests
{
private static readonly int Threshold = McpConnectionSection.UnhealthyVerificationThreshold;
[Test]
public void SingleVerifyMiss_DoesNotReportUnhealthy()
{
// The lone transient miss during a reload rebind must be tolerated.
Assert.IsFalse(McpConnectionSection.ShouldReportUnhealthy(1));
}
[Test]
public void BelowThreshold_DoesNotReportUnhealthy()
{
Assert.IsFalse(McpConnectionSection.ShouldReportUnhealthy(Threshold - 1));
}
[Test]
public void AtThreshold_ReportsUnhealthy()
{
Assert.IsTrue(McpConnectionSection.ShouldReportUnhealthy(Threshold));
}
[Test]
public void PastThreshold_ReportsUnhealthy()
{
Assert.IsTrue(McpConnectionSection.ShouldReportUnhealthy(Threshold + 3));
}
[Test]
public void Threshold_RequiresMoreThanOneFailure()
{
// The whole point of the debounce: a single miss must never be enough.
Assert.GreaterOrEqual(Threshold, 2);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b1a4438b3752b44c49e980187025c2bf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: