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:
@@ -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:
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 322603f43226040548e602c8bb6cc204
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+49
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b1a4438b3752b44c49e980187025c2bf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user