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

Open Beginner friendly
#282 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
88/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
swift
Domain
networking

Research direction

Open Sources/MCP/Base/Transports/NetworkTransport.swift and inspect receiveLoop's two terminal, non-reconnecting branches around lines 686-691 and 728-732. Compare them with the reconnecting branches, then reproduce a disabled-reconnection peer disconnect and confirm the underlying socket is cancelled instead of remaining in CLOSE_WAIT.

Written by the indexing model from the issue text.

Description

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.

Dominant language
Swift
Stars
1.5k
Forks
243
PR merge metrics
No merged PRs in 30d

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from modelcontextprotocol/swift-sdk

All issues in modelcontextprotocol/swift-sdk

Similar issues

More Swift issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.