NetworkTransport leaks the underlying socket (CLOSE_WAIT) when reconnection is disabled and the receive loop terminates
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
- Server accepts a connection via
NetworkTransportwith
reconnectionConfig: .disabled. - A client connects, then disconnects gracefully (or the connection
errors in some other way that doesn't trigger reconnection). - Inspect the server process's open files (
lsof -p <pid> -i): the
socket for that connection is stuck inCLOSE_WAITindefinitely. - 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from modelcontextprotocol/swift-sdk
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
-
enhancement
Difficulty 1/5 Under an hour Newbie friendliness 78/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
-
Difficulty 3/5 1-2 days Newbie friendliness 72/100
modelcontextprotocol/swift-sdk#287 · 1 comment · 1 reaction ·
All issues in modelcontextprotocol/swift-sdk
Similar issues
-
enhancement
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
-
type: docs
Difficulty 1/5 Under an hour Newbie friendliness 95/100
googleapis/google-cloud-swift#971 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
bitcoindevkit/bdk-ffi#1125 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
mozilla-mobile/firefox-ios#35743 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
manaflow-ai/cmux#13417 ·