Skip to content

Fix Client message loop spinning at 100% CPU on stream EOF - #221

Open
piersdd wants to merge 1 commit into
modelcontextprotocol:mainfrom
piersdd:fix/client-spin-on-eof
Open

piersdd wants to merge 1 commit into
modelcontextprotocol:mainfrom
piersdd:fix/client-spin-on-eof

Conversation

@piersdd

@piersdd piersdd commented Apr 23, 2026

Copy link
Copy Markdown

Summary

When a transport stream finishes (e.g. subprocess death closes the pipe), the Client's message handling loop spins at 100% CPU indefinitely.

The root cause is the repeat { ... } while true outer loop in Client.connect(transport:) (line 217). When the for try await data in stream loop exits normally on stream EOF, the repeat re-enters connection.receive(), gets an instantly-finishing stream, and loops again — consuming a full CPU core.

The Task.isCancelled check at the top of the repeat body only helps if disconnect() was called externally. On a normal subprocess/transport death, no cancellation is signalled — the stream simply finishes.

Server.swift in this same package does not have this bug — it uses a flat do { for try await ... } without an outer repeat loop.

Fix

Remove the repeat { ... } while true wrapper, matching the Server.swift pattern. The message handling task now:

  1. Calls connection.receive() once
  2. Iterates the stream with for try await
  3. Exits cleanly when the stream finishes (EOF) or an error is thrown

The catch for isResourceTemporarilyUnavailable (which used continue to re-enter the repeat) is also removed. That EAGAIN-style error is a transient I/O condition handled internally by the transport's read loop — it does not surface through the AsyncThrowingStream that receive() returns.

Reproduction

  1. Start a Client connected via StdioTransport to a subprocess
  2. Kill the subprocess (or let it exit)
  3. Observe the Client's task consuming 100% CPU via sample or Activity Monitor

The tight loop shows up as:

Client.connect(transport:) → AsyncThrowingStream.Iterator.next() → repeat

Test plan

  • Existing test suite passes (545 tests in downstream consumer, SDK tests unmodified)
  • Verified CPU drops from 88–99% to 0% after subprocess death
  • disconnect() still terminates the loop correctly (cancels the task)
  • Normal message handling unaffected (stream stays open while transport is alive)

@deseven

deseven commented Jul 7, 2026

Copy link
Copy Markdown

Thank you so much, this solved my problem with randomly hanging tests where I run hundreds of tools quickly.

The Client's message handling loop uses `repeat { ... } while true` around
the `for try await data in stream` loop. When the transport stream finishes
(subprocess death, pipe EOF), the for-await exits normally, but the repeat
loop immediately re-enters `connection.receive()`, gets an instantly-finishing
stream, and spins at 100% CPU indefinitely.

Server.swift does not have this bug — it uses a flat `do { for try await }`
without an outer repeat. Match that pattern in Client.

The `catch` for `isResourceTemporarilyUnavailable` (with `continue` back into
the repeat) was the original motivation for the outer loop, but that error
cannot occur on a finished stream — it only matters during active I/O.
Removing the retry-on-EAGAIN is safe because the stream itself handles
transient read errors internally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@piersdd
piersdd force-pushed the fix/client-spin-on-eof branch from 1ea8365 to 42c30a1 Compare August 23, 2026 00:58
@piersdd

piersdd commented Aug 23, 2026

Copy link
Copy Markdown
Author

Rebased onto current main (a0ae212, including #223) so this is mergeable again.

This was already approved, and the July comment from @deseven said it unblocked hanging tests under rapid tool use. Happy to rebase again if needed.

ianegordon added a commit to ianegordon/swift-sdk that referenced this pull request Sep 10, 2026
… entries 5 and 5a

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DMgYCJ9tYavK3NhBCDTsn
ianegordon added a commit to ianegordon/swift-sdk that referenced this pull request Sep 12, 2026
…odelcontextprotocol#275 commit 8e36cfa, robertoscipionecom)

Manifest entry 10. Upstream commit 8e36cfa extracted from refs/pull/275/head,
unmodified: same author, same patch-id (e588896c…), cherry-picked with -x so
the commit records its own provenance.

Client.send suspended on a checked continuation only a matching response could
resume, and the request task was unstructured, so cancelling the caller never
reached it: a caller that gave up stayed suspended until the server answered,
which for a dead server is never. Three linked fixes — forward cancellation
from RequestContext.value to the request task, call cancelRequest from the
task cancellation handler, and remember ids cancelled before their
continuation registered.

Two of modelcontextprotocol#275's three commits are deliberately skipped, not adapted: 40c5951
(loop termination) and fded08a (its test) duplicate manifest entries 5 and 5a
from modelcontextprotocol#221. Taking them would conflict in Client.swift and produce a duplicate
testMessageLoopStopsWhenStreamFinishes declaration that does not compile.
The full PR head stays fetchable as branch pr/275.

Two defects in this commit are carried as accepted, not fixed; see FORK.md
entry 10. A cancellation landing before the send completes is notified before
the request is sent, so the peer can ignore an unknown-id cancellation and
then execute the request. And cancelledBeforeRegistration cannot distinguish
'not registered yet' from 'already completed', so cancelling a finished
request leaves an entry until disconnect. Both need the request-state
redesign that belongs upstream, not a fork patch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Eb9yGSXH1TVkg5Afsu9phk
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.

4 participants