Skip to content

stdio_server busy-loops at 100% CPU on Windows after the host suspends and resumes #3411

Description

@pangi

Summary

On Windows, a stdio_server()-based MCP server survives a system suspend/resume
cycle as a live process but its event loop starts spinning: one thread burns a
full CPU core indefinitely while the server no longer serves any requests. The
process never exits and never recovers, so it keeps a core pinned until it is
killed manually.

I hit this with two server instances at once, each pinning a core (~2 cores lost
on an 8-core machine) for nearly two hours before I noticed.

Environment

mcp 1.29.0
anyio 4.14.2
Python 3.13.14 (uv-managed CPython, cpython-3.13-windows-x86_64-none)
OS Windows 10 Pro N, build 19045
Host Claude Desktop 1.40609.0.0 (stdio transport)
Server a FastMCP server using the default mcp.run() stdio path
Event loop policy WindowsProactorEventLoopPolicy (Python default on Windows)

What happens

  1. The host launches the server over stdio. It initializes normally —
    initialize, tools/list, prompts/list, resources/list all complete.
  2. The machine goes to sleep.
  3. The machine wakes.
  4. From the moment of resume, the server's main thread spins at 100% of one
    core, forever. No further MCP traffic is logged. The process does not exit.

Evidence that resume is the trigger

The host log recorded the resume precisely:

19:02:32 [warn] [event-loop-stall] main process blocked for 8065382ms [likely sleep: power_event]
19:02:32 [info] Timeout detector: tick arrived 8008s late — treating as system wake
19:02:39 [info] [event-loop-stall] OS resume

Accumulated CPU time of the two server processes, sampled at 20:55:11:

PID CPU time wall time since resume (19:02:39) ratio
A 6719 s 6752 s 99.5 %
B 6625 s 6752 s 98.1 %

So both processes have been burning ~100% of a core continuously since the exact
second of resume, and were healthy before it. A 5-second live sample confirmed
100.3% of one core each at the time of measurement.

Thread-level detail

Only the main thread is hot; the worker threads are idle:

PID A
   Id   ThreadState  WaitReason   TotalCPU_s
 5284   Running                      6637.00
13812   Wait         UserRequest        0.00
 6312   Wait         Executive          0.00

This is what makes me think the spin is in the event loop itself rather than in
the anyio.wrap_file(...) stdin reader: if the reader thread were hitting EOF in
a tight loop, the CPU would show up on a worker thread, not the main one.

Hypothesis (not confirmed with a debugger)

WindowsProactorEventLoopPolicy drives the loop off an IOCP. My guess is that
the completion port or one of the registered handles is left in a state where
GetQueuedCompletionStatus returns immediately after resume, so
ProactorEventLoop._poll() returns with no events and a zero timeout forever.

I want to be clear that I have not attached a debugger to confirm this — the
correlation with resume and the main-thread-only CPU profile are what I actually
observed. If a maintainer can suggest what to capture (a py-spy dump on the
spinning process, for instance) I am happy to reproduce and collect it.

Notes

  • Both instances were affected, including the one the host was actively
    connected to — so this is not only an orphaned-process problem.
  • The server had no network connection open to its backend at the time (no
    established TCP socket), i.e. it was genuinely idle work-wise.
  • The server itself has no background tasks or polling loops of its own; all its
    work is on-demand inside tool calls. Nothing in application code is looping.

Possible mitigations

Two things that might be worth considering, in increasing order of ambition:

  1. Document it. Even a note that Windows stdio servers can spin after
    suspend/resume would have saved me a couple of hours of process forensics.
  2. Detect the dead pipe and exit. If stdin is unreadable/broken after resume,
    the server is useless anyway — exiting cleanly would let the host respawn it,
    and would turn a pinned core into a transparent reconnect.

As a local workaround I am considering forcing WindowsSelectorEventLoopPolicy
for this process, since this particular server only needs sockets and threads
(no subprocess transports). I have not yet verified whether that actually avoids
the spin.

Metadata

Metadata

Assignees

No one assigned

    Labels

    v1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions