Skip to content

Fix 100% CPU spin when MCP server child exits#1

Open
Dtensor wants to merge 1 commit intomacOS26:mainfrom
Dtensor:fix/stdio-eof-spin
Open

Fix 100% CPU spin when MCP server child exits#1
Dtensor wants to merge 1 commit intomacOS26:mainfrom
Dtensor:fix/stdio-eof-spin

Conversation

@Dtensor
Copy link
Copy Markdown

@Dtensor Dtensor commented Apr 19, 2026

Summary

  • NSFileHandle.readabilityHandler keeps firing after the child process exits — availableData returns empty Data on every fire, the dispatch source re-arms immediately, and each leaked handler burns one CPU core.
  • In StdioConnection.init, both the stdout reader and the stderr drainer treat empty data as a no-op. When the MCP server crashes or closes its pipes, the two handlers spin forever at ~100% CPU each (200% total) until the host process is killed.
  • Fix: in both closures, treat empty availableData as EOF and set handle.readabilityHandler = nil to tear down the dispatch source. Also clear the reader's handler on self == nil (symmetric dead-pipe case when the client is gone).

Repro

  • Spawn any MCP server via StdioConnection.
  • Kill the server process (SIGKILL on its pid, or any crash).
  • Observe host app at ~200% CPU until force-quit. sample <pid> shows two com.apple.NSFileHandle.fd_monitoring serial queues spinning on -[NSConcreteFileHandle availableData] → fstat → read(0 bytes), with the two closures in StdioConnection.init on top of the stack.

After fix

  • Same repro: host stays idle. Verified by running a multi-step task that previously triggered the spin deterministically; host CPU stayed at <3% across the full run, with no fd_monitoring hot threads in sample output.

Behavior change

  • Only the empty-data branch is new; live-stream handling is unchanged.
  • Empty availableData is the documented signal for EOF on an anonymous Pipe(), so no false positives on real data.

Test plan

  • Reproduce the spin on main (2x NSFileHandle fd_monitoring at ~100% each).
  • Apply the patch and rebuild; re-run the same workload — host stays idle.
  • Verify long-running streams still get data (happy path through the reader body).

When a child MCP server process exits (or the write end of its pipe
closes for any reason), NSFileHandle.readabilityHandler does not stop
firing. availableData returns empty Data on every invocation, and the
underlying dispatch source is re-armed immediately, so each leaked
handler pegs one CPU core until the process is killed. Two leaked
pipes (stdout + stderr) = 200% CPU forever.

Diagnosed via `sample` — two `com.apple.NSFileHandle.fd_monitoring`
serial queues burning ~100% CPU each in
  -[NSConcreteFileHandle availableData] -> fstat -> read(0 bytes)
with the closure frames pointing at the two readabilityHandlers in
StdioConnection.init.

Fix: in both handlers, treat an empty availableData as EOF and set
`handle.readabilityHandler = nil` to tear down the dispatch source.
Also clear the reader's handler if `self` has deallocated — same
dead-pipe scenario, just triggered by the client side going away.

Behavior on live streams is unchanged; the empty-data branch is only
taken on EOF.
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