Skip to content

[Server] Fix stdio server ending by itself on an idle Windows pipe - #671

Open
dg wants to merge 1 commit into
modelcontextprotocol:mainfrom
dg:fix-stdio-idle-windows-pipe
Open

dg wants to merge 1 commit into
modelcontextprotocol:mainfrom
dg:fix-stdio-idle-windows-pipe

Conversation

@dg

@dg dg commented Nov 5, 2026

Copy link
Copy Markdown

On Windows, fgets() on an idle pipe blocks even in non-blocking mode and after about 13.5 hours reports a false EOF, so a stdio server ends by itself (php/php-src#25204). StdioTransport now reads only once stream_select() reports input, which also keeps the run loop going while idle. This needs PHP 8.5, older versions behave as before.

On Windows, PHP waits for data on a pipe in a loop of 3,200,000 usleep(10) calls, also in non-blocking
mode. One call takes about 15 ms there, so fgets() on an idle pipe blocks for about 13.5 hours and then
reports a false EOF, which ends listen(). Reading only after stream_select() reports input stays out of
that loop. It needs PHP 8.5, where stream_select() stopped reporting idle pipes as readable
(php/php-src#16889).

stream_select() throws a ValueError on a stream it cannot select, like php://memory, instead of
returning false.
dg added a commit to dg/pohoda-mcp that referenced this pull request Nov 5, 2026
PHP on Windows waits for data on a pipe in a loop of 3,200,000 usleep(10) calls, also in non-blocking
mode. One call takes about 15 ms there, so fgets() on an idle pipe blocks for about 13.5 hours and then
reports a false EOF, which ends the server with exit code 0 and no error. mcp/sdk calls fgets() on an
idle pipe, so the transport now reads only after stream_select() reports input. It needs PHP 8.5, as
stream_select() on Windows reports an idle pipe as readable before that.

See php/php-src#25204 and modelcontextprotocol/php-sdk#671.
dg added a commit to dg/pohoda-mcp that referenced this pull request Nov 5, 2026
PHP on Windows waits for data on a pipe in a loop of 3,200,000 usleep(10) calls, also in non-blocking
mode. One call takes about 15 ms there, so fgets() on an idle pipe blocks for about 13.5 hours and then
reports a false EOF, which ends the server with exit code 0 and no error. mcp/sdk calls fgets() on an
idle pipe, so the transport now reads only after stream_select() reports input. It needs PHP 8.5, as
stream_select() on Windows reports an idle pipe as readable before that.

See php/php-src#25204 and modelcontextprotocol/php-sdk#671.
dg added a commit to dg/pohoda-mcp that referenced this pull request Nov 5, 2026
PHP on Windows waits for data on a pipe in a loop of 3,200,000 usleep(10) calls, also in non-blocking
mode. One call takes about 15 ms there, so fgets() on an idle pipe blocks for about 13.5 hours and then
reports a false EOF, which ends the server with exit code 0 and no error. mcp/sdk calls fgets() on an
idle pipe, so the transport now reads only after stream_select() reports input. It needs PHP 8.5, as
stream_select() on Windows reports an idle pipe as readable before that.

See php/php-src#25204 and modelcontextprotocol/php-sdk#671.
dg added a commit to dg/pohoda-mcp that referenced this pull request Nov 5, 2026
PHP on Windows waits for data on a pipe in a loop of 3,200,000 usleep(10) calls, also in non-blocking
mode. One call takes about 15 ms there, so fgets() on an idle pipe blocks for about 13.5 hours and then
reports a false EOF, which ends the server with exit code 0 and no error. mcp/sdk calls fgets() on an
idle pipe, so the transport now reads only after stream_select() reports input. It needs PHP 8.5, as
stream_select() on Windows reports an idle pipe as readable before that.

See php/php-src#25204 and modelcontextprotocol/php-sdk#671.
dg added a commit to dg/pohoda-mcp that referenced this pull request Nov 5, 2026
PHP on Windows waits for data on a pipe in a loop of 3,200,000 usleep(10) calls, also in non-blocking
mode. One call takes about 15 ms there, so fgets() on an idle pipe blocks for about 13.5 hours and then
reports a false EOF, which ends the server with exit code 0 and no error. mcp/sdk calls fgets() on an
idle pipe, so the transport now reads only after stream_select() reports input. It needs PHP 8.5, as
stream_select() on Windows reports an idle pipe as readable before that.

The run loop then also runs on an idle connection, and mcp/sdk 0.9 only reads the session there, so the
first read past its TTL of an hour deletes it and every later request gets "Session not found or has
expired" without an id. A stdio session lasts as long as the process, so the server gives it no TTL.

See php/php-src#25204, modelcontextprotocol/php-sdk#671 and modelcontextprotocol/php-sdk#672.
dg added a commit to dg/pohoda-mcp that referenced this pull request Nov 5, 2026
PHP on Windows waits for data on a pipe in a loop of 3,200,000 usleep(10) calls, also in non-blocking
mode. One call takes about 15 ms there, so fgets() on an idle pipe blocks for about 13.5 hours and then
reports a false EOF, which ends the server with exit code 0 and no error. mcp/sdk calls fgets() on an
idle pipe, so the transport now reads only after stream_select() reports input. It needs PHP 8.5, as
stream_select() on Windows reports an idle pipe as readable before that.

The run loop then also runs on an idle connection, and mcp/sdk 0.9 only reads the session there, so the
first read past its TTL of an hour deletes it and every later request gets "Session not found or has
expired" without an id. A stdio session lasts as long as the process, so the server gives it no TTL.

See php/php-src#25204, modelcontextprotocol/php-sdk#671 and modelcontextprotocol/php-sdk#672.
dg added a commit to nette/mcp-inspector that referenced this pull request Nov 6, 2026
PHP on Windows waits for data on a pipe in a loop of 3,200,000 usleep(10) calls, also in non-blocking
mode. One call takes about 15 ms there, so fgets() on an idle pipe blocks for about 13.5 hours and then
reports a false EOF, which ends the server with exit code 0 and no error. mcp/sdk calls fgets() on an
idle pipe, so the transport now reads only after stream_select() reports input. It needs PHP 8.5, as
stream_select() on Windows reports an idle pipe as readable before that.

The run loop then also runs on an idle connection, and mcp/sdk 0.9 only reads the session there, so the
first read past its TTL of an hour deletes it and every later request gets "Session not found or has
expired" without an id. A stdio session lasts as long as the process, so the server gives it no TTL.

See php/php-src#25204, modelcontextprotocol/php-sdk#671 and modelcontextprotocol/php-sdk#672.
dg added a commit to dg/google-services that referenced this pull request Nov 6, 2026
PHP on Windows waits for data on a pipe in a loop of 3,200,000 usleep(10) calls, also in non-blocking
mode. One call takes about 15 ms there, so fgets() on an idle pipe blocks for about 13.5 hours and then
reports a false EOF, which ends the server with exit code 0 and no error. mcp/sdk calls fgets() on an
idle pipe, so the transport now reads only after stream_select() reports input. It needs PHP 8.5, as
stream_select() on Windows reports an idle pipe as readable before that.

The run loop then also runs on an idle connection, and mcp/sdk 0.9 only reads the session there, so the
first read past its TTL of an hour deletes it and every later request gets "Session not found or has
expired" without an id. A stdio session lasts as long as the process, so the server gives it no TTL.

See php/php-src#25204, modelcontextprotocol/php-sdk#671 and modelcontextprotocol/php-sdk#672.
dg added a commit to dg/fio-mcp that referenced this pull request Nov 6, 2026
PHP on Windows waits for data on a pipe in a loop of 3,200,000 usleep(10) calls, also in non-blocking
mode. One call takes about 15 ms there, so fgets() on an idle pipe blocks for about 13.5 hours and then
reports a false EOF, which ends the server with exit code 0 and no error. mcp/sdk calls fgets() on an
idle pipe, so the transport now reads only after stream_select() reports input. It needs PHP 8.5, as
stream_select() on Windows reports an idle pipe as readable before that.

The run loop then also runs on an idle connection, and mcp/sdk 0.9 only reads the session there, so the
first read past its TTL of an hour deletes it and every later request gets "Session not found or has
expired" without an id. A stdio session lasts as long as the process, so the server gives it no TTL.

See php/php-src#25204, modelcontextprotocol/php-sdk#671 and modelcontextprotocol/php-sdk#672.
@chr-hertel
chr-hertel requested a balanced review from Copilot November 7, 2026 19:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

An unhandled stream_select() failure bypasses the 50 ms sleep, causing listen() to enter a CPU-consuming busy loop while the input remains open.

1 open issue
What changed in this PR

This PR addresses idle Windows pipes causing the SDK’s stdio server to block and eventually exit on a false EOF. The Windows fix requires PHP 8.5.

Changes:

  • Wait for input readiness before reading, with a fallback for unselectable streams.
  • Add regression tests for idle pipes and unselectable input.
  • Document both fixes in the changelog.
File Description
tests/​Unit/​Server/​Transport/​StdioTransportTest.php Tests idle-pipe responsiveness and unselectable input.
src/​Server/​Transport/​StdioTransport.php Checks input readiness before reading and catches selection errors.
CHANGELOG.md Records the fixes and PHP version requirement.

Review effort: Balanced

usleep(50000);
// A stream that cannot be selected, like php://memory, warns and throws, so it is just read.
try {
return 0 !== @stream_select($read, $write, $except, 0, 50000);

This branch has not been deployed

No deployments
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.

2 participants