fix: use non-blocking stdout writes in stdio_server to prevent event loop deadlock#2070
fix: use non-blocking stdout writes in stdio_server to prevent event loop deadlock#2070retospect wants to merge 1 commit intomodelcontextprotocol:mainfrom
Conversation
…loop deadlock When a tool returns a response larger than the OS pipe buffer (64 KB on macOS), stdout_writer blocks the entire event loop on write() because anyio.wrap_file delegates to a synchronous write on a blocking fd. Fix: set stdout fd to non-blocking mode and write in 4 KB chunks via os.write(), catching BlockingIOError (EAGAIN) and yielding to the event loop before retrying. Custom stdout overrides use the original path. Closes modelcontextprotocol#547
|
Thanks for the investigation and the detailed writeup — but after a thorough review, I don't think we can merge this. Closing for the following reasons: This doesn't fix #547Issue #547 describes a server "hanging" after The root cause analysis is incorrectThe PR states that If you genuinely observed hangs with 74KB responses, the cause is elsewhere — most likely a client-side issue or a true bidirectional deadlock (both sides blocked on write), which this change also wouldn't fix since the data still has to go somewhere. Regressions this would introduce
If you have a minimal reproducer for the large-response hang (including the client side), please open a new issue — happy to dig into what's actually going on there. |
fix: use non-blocking stdout writes in stdio_server to prevent event loop deadlock
Problem
When an MCP server tool returns a response larger than the OS pipe buffer (64 KB on macOS),
stdout_writerblocks the entire event loop on theawait stdout.write()call. This happens becauseanyio.wrap_filedelegates to a synchronouswrite()on a blocking fd — if the pipe buffer is full (client hasn't read yet), the write syscall blocks, and no other async tasks can run.In practice this manifests as:
list_paperswith 500+ entries returning ~74 KB of JSON)Reported in #547.
Root cause
anyio.wrap_file(TextIOWrapper(sys.stdout.buffer))wraps the synchronous file in a thread worker, but the underlyingwrite()still blocks when the kernel pipe buffer is full. Since MCP stdio transport is a single pipe between server and client, the client must read before the server can write more — but the server can't process the client's next read request because the event loop is blocked on the write.Fix
For the default stdout path (no custom override):
os.set_blocking(fd, False))os.write(), catchingBlockingIOError(EAGAIN) and yielding to the event loop withawait anyio.sleep(0.005)before retryingThis ensures the event loop never blocks on a pipe-full condition. The 4 KB chunk size is well below the 64 KB macOS pipe buffer, so most writes complete in a single syscall. When the buffer fills, the coroutine yields and retries after the client drains some data.
Custom stdout overrides (the
stdoutparameter) use the originalanyio.wrap_filepath unchanged.Testing
Tested in production with an MCP server managing 500+ research papers, where
list_papersregularly returns 60-80 KB responses. Before this fix, the server would hang ~1 in 3 calls. After the fix, zero hangs over weeks of use.Closes #547