Skip to content

NetworkTransport leaks the underlying socket (CLOSE_WAIT) when reconnection is disabled and the receive loop terminates #282

Description

@skirrellyjones

Title: NetworkTransport leaks the underlying socket (CLOSE_WAIT) when reconnection is disabled and the receive loop terminates

Version: 0.12.0 (tag), file Sources/MCP/Base/Transports/NetworkTransport.swift

Summary

When reconnectionConfig.enabled == false and the receive loop's underlying
NWConnection closes for any reason — including a normal graceful close by
the peer — the loop finishes the message stream but never calls
connection.cancel(). The NWConnection is left dangling: its .state
never transitions to .cancelled or .failed (a peer-initiated FIN alone
doesn't do that in Network.framework), so any code that watches
connection.state to decide when to clean up (as iMCP's
MCPConnectionManager.startHealthMonitoring() does) never fires either.
The socket is left open on the accepting side, stuck in CLOSE_WAIT,
until the process itself exits.

Where

receiveLoop's two terminal, non-reconnecting branches:

// NWError branch, ~line 686-691
} else {
    // We're not reconnecting, finish the message stream with error
    messageContinuation.finish(
        throwing: MCPError.transportError(error))
    break
}
// generic-error branch, ~line 728-732
break
} else {
    messageContinuation.finish(throwing: error)
}

Both give up on the connection without calling connection.cancel().
By contrast, the reconnecting branches a few lines above each call
self.connection.cancel() before attempting to reconnect — so the
cleanup exists in the codebase, it's just missing from the "give up"
paths.

Why this matters for server-side transports

A server that disables reconnection for its per-client connections (the
correct choice — a server shouldn't try to reconnect to a client) hits
this on literally every client disconnect. Each accepted connection that
ends leaks one file descriptor permanently. On macOS the default
per-process fd soft limit is 256, so a modest number of client
connect/disconnect cycles is enough to exhaust it and crash the process
with EMFILE ("Too many open files").

How I found it

I ran into this via mattt/iMCP, which
configures its server-side NetworkTransport with
reconnectionConfig: .disabled (in App/Controllers/ServerController.swift).
Its own MCP client (Claude Desktop) does a burst of rapid Bonjour
reconnect attempts on startup; each one left a socket behind. Within ~10
seconds of Claude Desktop launching, iMCP.app had 247+ sockets stuck in
CLOSE_WAIT, and it had already crashed once from file-descriptor
exhaustion (SIGABRT, Too many open files in CoreUI while loading
theme resources) during testing.

Repro

  1. Server accepts a connection via NetworkTransport with
    reconnectionConfig: .disabled.
  2. A client connects, then disconnects gracefully (or the connection
    errors in some other way that doesn't trigger reconnection).
  3. Inspect the server process's open files (lsof -p <pid> -i): the
    socket for that connection is stuck in CLOSE_WAIT indefinitely.
  4. Repeat step 2 a few hundred times (or just let a flaky client retry
    aggressively) and the process hits its fd limit and crashes.

Fix

Call connection.cancel() in both terminal branches before finishing
the message stream, mirroring what the reconnecting branches already do:

                             } else {
                                 // We're not reconnecting, finish the message stream with error
+                                // and release the underlying socket so it doesn't leak in CLOSE_WAIT.
+                                connection.cancel()
                                 messageContinuation.finish(
                                     throwing: MCPError.transportError(error))
                                 break
                             }
                             break
                         } else {
+                            // Not reconnecting: release the underlying socket so it doesn't
+                            // leak in CLOSE_WAIT (e.g. when the peer closes gracefully).
+                            connection.cancel()
                             messageContinuation.finish(throwing: error)
                         }

I've verified this locally against a self-built iMCP.app (0.12.0
checkout): before the fix, ~250 CLOSE_WAIT sockets accumulated within
10 seconds of a client's reconnect burst; after the fix, 0 — connections
are cleaned up immediately as clients disconnect. Happy to open a PR
with this change if useful.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions