@@ -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 ) ;
0 commit comments