Skip to content

fix: release the WebSocket listener even when CloseAllClients throws (fixes #141) - #163

Open
1shuqing wants to merge 1 commit into
CoderGamester:mainfrom
1shuqing:fix/port-leak-on-domain-reload
Open

1shuqing wants to merge 1 commit into
CoderGamester:mainfrom
1shuqing:fix/port-leak-on-domain-reload

Conversation

@1shuqing

@1shuqing 1shuqing commented Sep 9, 2026

Copy link
Copy Markdown

fix: release the WebSocket listener even when CloseAllClients throws

Fixes 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 one try block, and then
clears the field in finally:

try
{
    CloseAllClients(closeCode ?? 1000, closeReason ?? "Server stopping");   // ← can throw
    _webSocketServer?.Stop();                                              // ← then never runs
}
catch (Exception ex) { McpLogger.LogError(...); }
finally
{
    _webSocketServer = null;                                               // ← reference dropped
    _socketOwner = null;
    Clients.Clear();
}

CloseAllClients walks live sessions and calls Close(...) on each one. When a client is in a
half-closed state (CLOSE_WAIT — exactly what happens when the MCP client times out and drops the
socket during a reload) it throws. The exception is swallowed, so:

  1. WebSocketServer.Stop() is never called → the underlying TcpListener stays bound.
  2. finally drops the only reference to the server object.
  3. The domain then unloads; finalizers do not run for objects in an unloaded AppDomain, so the
    socket 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
(WSAEACCES in 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.Log output is dropped during domain reload):

beforeAssemblyReload: instance=True, owner=True, ownerListening=True
StopServer enter: server=True, listening=True
ForceCloseListener: type=TcpListener, wasBound=False      <-- Stop() had been skipped

and, on the next start attempt, ForceCloseAllKnownListeners: known=1, closed=0 with the port still
held by the process — i.e. the bound socket was no longer reachable from any managed object.

Changes

  1. StopServer() — close the listener unconditionally. Client shutdown, server.Stop() and the
    explicit listener close each get their own try/catch; the listener is always released before
    the fields are cleared. This is the actual fix.
  2. ForceCloseListener() — closes the listener even if Stop() was a no-op or threw, by taking
    WebSocketServer._listener (TcpListener) via reflection and calling Stop() + Socket.Close().
    Also called from CleanupFailedStart().
  3. _socketOwner + OnBeforeAssemblyReload — the reload hook no longer depends on the singleton
    being alive; if the singleton is gone it closes the recorded socket directly.
  4. _knownServers + ForceCloseAllKnownListeners() — every server created in the domain is
    tracked, so a start failure can reclaim a listener that an earlier failed start left behind.
  5. Retry budgetDelayedStartMaxAttempts 10 → 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.
  6. SocketError.AccessDenied is retried too — Windows reports WSAEACCES rather than
    AddressAlreadyInUse when the port is held by a socket created with SO_EXCLUSIVEADDRUSE.
  7. Optional diagnostics — lifecycle trace to Temp/mcp_bridge_diag.log. Reviewers may drop this;
    it is independent of the fix. WebSocketServer.ReuseAddress was evaluated and deliberately not
    enabled: it produces WSAEACCES, and new connections can be delivered to the stale listener.

Verification (Unity 6.3 LTS / 6000.3.23f1, Windows 11)

Scenario Before After
Script recompile (domain reload) port leaked, MCP dead until editor restart beforeAssemblyReloadStopServer → rebind 1.2 s later ✅
Enter Play Mode port leaked rebind 4 s later ✅
Exit Play Mode port leaked bridge stays up ✅
Editor process restarted port unusable for minutes, no recovery old socket released ≈3 min later, retry binds automatically ✅

How to test

  1. Apply the patch, open Unity with the package installed (AutoStartServer: true).
  2. Confirm the MCP client connects.
  3. Trigger a script recompile (recompile_scripts or just edit a script) and immediately retry an MCP
    call — it must succeed within a few seconds.
  4. Enter and exit Play Mode, retry — it must still succeed.
  5. netstat -ano | findstr 8090 should show exactly one LISTENING socket owned by the editor, and
    Temp/mcp_bridge_diag.log (if diagnostics kept) should show START OK: ... listenerBound=True
    after each reload.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant