From a80202c3031de846d69f36e7ffc7eb42bcea6840 Mon Sep 17 00:00:00 2001 From: King Date: Sun, 19 Apr 2026 23:31:22 +0530 Subject: [PATCH] Fix 100% CPU spin when MCP server child exits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Sources/AgentMCP/StdioConnection.swift | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/Sources/AgentMCP/StdioConnection.swift b/Sources/AgentMCP/StdioConnection.swift index 74133d2..1e29c2c 100644 --- a/Sources/AgentMCP/StdioConnection.swift +++ b/Sources/AgentMCP/StdioConnection.swift @@ -24,15 +24,28 @@ final class StdioConnection: @unchecked Sendable, MCPConnection { self.reader = reader self.errorReader = errorReader - // Drain stderr to prevent the server from blocking on a full pipe (64 KB OS limit) + // Drain stderr to prevent the server from blocking on a full pipe (64 KB OS limit). + // On EOF (child exited / pipe closed), availableData returns empty Data; if we + // don't clear the handler, the dispatch source fires in a tight loop forever, + // burning ~100% CPU per pipe. Same pattern on the reader below. errorReader.readabilityHandler = { handle in - _ = handle.availableData + let data = handle.availableData + if data.isEmpty { + handle.readabilityHandler = nil + } } // Set up non-blocking read via readabilityHandler reader.readabilityHandler = { [weak self] handle in let data = handle.availableData - guard !data.isEmpty, let self else { return } + if data.isEmpty { + handle.readabilityHandler = nil + return + } + guard let self else { + handle.readabilityHandler = nil + return + } self.lock.lock() self.buffer.append(data)