Conversation
Fixes CoderGamester#141. StopServer() closed clients and the listener in one try block and then cleared the field in finally, so a throwing CloseAllClients() skipped WebSocketServer.Stop() and dropped the only reference to the server. After the domain unloaded, finalizers no longer run, so the bound TcpListener could never be closed again: the port stayed "listening" but unserviced, and every later bind failed with AddressAlreadyInUse. - close the listener unconditionally, each step in its own try/catch - ForceCloseListener(): reflect out the TcpListener and Stop()/Socket.Close() - keep a static _socketOwner so the reload hook does not depend on the singleton - track every server created in the domain and reclaim listeners on bind failure - retry budget 10 attempts (~32s) -> 150 attempts (~15 min) - treat SocketError.AccessDenied like "port busy" (Windows reports WSAEACCES) - lifecycle trace to Temp/mcp_bridge_diag.log for future diagnosis
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix: release the WebSocket listener even when
CloseAllClientsthrowsFixes the root cause behind #141 (
Failed to start WebSocket server: Port <PORT> is already in use,cleared only by restarting Unity).
Target:
CoderGamester/mcp-unity@382a43a(v1.5.0)Patch:
mcp-unity-port-leak-fix.patch(single file:Editor/UnityBridge/McpUnityServer.cs)Root cause
McpUnityServer.StopServer()closes clients and the listener inside onetryblock, and thenclears the field in
finally:CloseAllClientswalks live sessions and callsClose(...)on each one. When a client is in ahalf-closed state (
CLOSE_WAIT— exactly what happens when the MCP client times out and drops thesocket during a reload) it throws. The exception is swallowed, so:
WebSocketServer.Stop()is never called → the underlyingTcpListenerstays bound.finallydrops the only reference to the server object.AppDomain, so thesocket handle is only released when the editor process exits.
From that point the port is permanently "alive but unserviced": TCP connects succeed (kernel backlog)
while every WebSocket handshake hangs, and every later bind fails with
AddressAlreadyInUse(
WSAEACCESin some Windows states). Only restarting Unity clears it — the behaviour reported in #141.The liveness probe added for #141 correctly detects this state, but as its own remarks state, it
deliberately does not repair it; the repair has to happen before the socket is orphaned.
Evidence
Instrumented with file-based lifecycle logging (
Debug.Logoutput is dropped during domain reload):and, on the next start attempt,
ForceCloseAllKnownListeners: known=1, closed=0with the port stillheld by the process — i.e. the bound socket was no longer reachable from any managed object.
Changes
StopServer()— close the listener unconditionally. Client shutdown,server.Stop()and theexplicit listener close each get their own
try/catch; the listener is always released beforethe fields are cleared. This is the actual fix.
ForceCloseListener()— closes the listener even ifStop()was a no-op or threw, by takingWebSocketServer._listener(TcpListener) via reflection and callingStop()+Socket.Close().Also called from
CleanupFailedStart()._socketOwner+OnBeforeAssemblyReload— the reload hook no longer depends on the singletonbeing alive; if the singleton is gone it closes the recorded socket directly.
_knownServers+ForceCloseAllKnownListeners()— every server created in the domain istracked, so a start failure can reclaim a listener that an earlier failed start left behind.
DelayedStartMaxAttempts10 → 150 with longer tail delays (≈15 min instead of~32 s). A listener held by a just-killed editor process can take minutes to disappear on Windows;
the server now waits for it instead of giving up.
SocketError.AccessDeniedis retried too — Windows reportsWSAEACCESrather thanAddressAlreadyInUsewhen the port is held by a socket created withSO_EXCLUSIVEADDRUSE.Temp/mcp_bridge_diag.log. Reviewers may drop this;it is independent of the fix.
WebSocketServer.ReuseAddresswas evaluated and deliberately notenabled: it produces
WSAEACCES, and new connections can be delivered to the stale listener.Verification (Unity 6.3 LTS / 6000.3.23f1, Windows 11)
beforeAssemblyReload→StopServer→ rebind 1.2 s later ✅How to test
AutoStartServer: true).recompile_scriptsor just edit a script) and immediately retry an MCPcall — it must succeed within a few seconds.
netstat -ano | findstr 8090should show exactly oneLISTENINGsocket owned by the editor, andTemp/mcp_bridge_diag.log(if diagnostics kept) should showSTART OK: ... listenerBound=Trueafter each reload.