Skip to content

[Server] Let an event loop drive the fibers request handlers run in - #616

Merged
chr-hertel merged 6 commits into
modelcontextprotocol:mainfrom
webagil-kevin:handler-fiber-scheduler
Nov 8, 2026
Merged

chr-hertel merged 6 commits into
modelcontextprotocol:mainfrom
webagil-kevin:handler-fiber-scheduler

Conversation

@webagil-kevin

Copy link
Copy Markdown
Contributor

Fixes #615

Reproduction: #615. Lines are on main at 57b45eb.

Why it fails

The server runs each request handler in a fiber it creates (StatelessProtocol.php:675, Protocol.php:415) and resumes that fiber whenever it is suspended: with null on any suspension that is not an elicitation (StatelessProtocol.php:707), and from the transports when no client request is pending (StreamableHttpTransport.php:316, StdioTransport.php:401). Under an event loop, a handler awaiting I/O suspends that same fiber for the loop, with nothing. The server resumes it with null: the await returns early or throws Must call resume() or throw() before calling suspend() again, and the loop later resumes a fiber that has moved on. Under Revolt, the event loop terminates.

Change

  • Mcp\Server\Fiber\FiberSchedulerInterface::wait(FiberSettlement) and FiberSettlement: how the runtime lets itself run until a handler fiber is the server's again.
  • HandlerFibers (@internal) creates, starts, resumes and suspends handler fibers. start() and resume() return once the fiber suspended for the server again, or returned; in between, the scheduler waits.
  • ClientGateway suspends through HandlerFibers::suspend(), so a suspension made after the loop resumed the fiber still reaches the server. A NotificationSuspension or RequestSuspension that a handler passes to \Fiber::suspend() itself is read as the server's, as the existing tests do.
  • Protocol, StatelessProtocol, BaseTransport, StreamableHttpTransport and StdioTransport go through HandlerFibers. A fiber it did not create is driven as before.
  • Builder::setFiberScheduler(). Protocol::__construct() and StatelessProtocol::__construct() take the scheduler as an optional last argument.
  • Without a scheduler, a foreign suspension throws a LogicException naming Builder::setFiberScheduler() (-32603), instead of being resumed with null. The loop keeps running.
  • What a handler throws while the runtime drives its fiber is rethrown on the server's side, never inside the loop.
  • CHANGELOG.md (0.10.0) and a "Fiber Scheduler" section in docs/run/server-builder.md, with a Revolt scheduler.

Verification

Each job of pipeline.yaml and docs.yml, replayed in Docker on main (57b45eb) and with the change, with the same dependency resolution as CI:

  • New tests, in tests/Unit/Server/Fiber/:
    • HandlerFibersTest runs a dependency-free FakeEventLoop, which suspends the fiber with nothing and resumes it from its own turn, as Revolt does.
    • EventLoopToolCallTest calls a tool awaiting I/O over Streamable HTTP, in both eras, with and without a scheduler.
  • QA on PHP 8.1: composer validate --strict, php-cs-fixer fix --dry-run, phpstan analyse and make docs-api are clean, as on main.
  • Unit:
    • PHP 8.1 to 8.5, highest: 2668 tests on main, 2679 with the change (11 new, 5 skipped on both), all green.
    • Lowest (--prefer-lowest --prefer-stable, flex, SYMFONY_REQUIRE='>=6.4'): green on 8.1 to 8.5, with the deprecation count of main (2, 4, 4, 16, 17).
    • Symfony 5.4.* and 6.4.* on PHP 8.2, 7.4.* and 8.0.* on PHP 8.4: green.
  • Integration, PHP 8.1 to 8.5: 108 tests green, as on main (18 skipped on 8.1 on both).
  • Interop, Node 22: the server suite has 103 tests (7 skipped) and the client suite 72 (1 skipped), green as on main.
  • Conformance: the server suite (served by the fixture, run against http://localhost/) and the client suite, on 2025-11-25 and 2026-07-28, pass their baselines, as on main.
  • Guides (zensical build --strict): no issues, as on main.
  • End to end, PHP 8.5.11 with revolt/event-loop 1.0.9, the reproduction of [Server] Handler fibers suspended by an event loop are resumed by the server with null #615:
    • on main, it answers -32603 and the event loop terminates;
    • with the change and no scheduler, it answers -32603 with the LogicException's message, and the loop ends cleanly;
    • with the scheduler of the guide, it answers slept 204 ms, then 100 ms.

Not verified

  • ReactPHP (react/async): its scheduler is the same few lines with a Deferred and await(), not run.
  • An end-to-end fixture server on an event loop. The new tests drive Server and StreamableHttpTransport in-process on the fake loop, so no dev dependency is added. Happy to add an integration fixture on revolt/event-loop, as a dev dependency, if you'd rather have one.
  • Windows.

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

Critical fiber lifecycle defects allow inline wake-ups to trigger FiberError and transport-resume failures to bypass request error handling, leaving requests without terminal JSON-RPC errors.

2 open issues
What changed in this PR

This PR addresses #615 by letting an event-loop scheduler drive request-handler fibers while they await I/O, instead of the server resuming them prematurely.

Changes:

  • Adds a scheduler interface, settlement signal, and shared handler-fiber lifecycle management.
  • Integrates scheduling into the builder, both protocol eras, client gateway, and transports.
  • Adds regression tests and documents scheduler configuration with a Revolt example.
File Description
tests/​Unit/​Server/​Fiber/​HandlerFibersTest.php Tests fiber ownership, suspension, and exception handling.
tests/​Unit/​Server/​Fiber/​FakeEventLoop.php Provides a dependency-free scheduler for tests.
tests/​Unit/​Server/​Fiber/​EventLoopToolCallTest.php Tests HTTP tool calls with and without scheduling in both eras.
src/​Server/​Transport/​StreamableHttpTransport.php Routes streamed handler resumes through shared fiber management.
src/​Server/​Transport/​StdioTransport.php Routes stdio handler resumes through shared fiber management.
src/​Server/​Transport/​BaseTransport.php Uses shared fiber management when resuming after yield failures.
src/​Server/​Stateless/​StatelessProtocol.php Adds scheduler-aware handler execution.
src/​Server/​Protocol.php Adds scheduler-aware handshake-era handler execution.
src/​Server/​Fiber/​HandlerFiberState.php Tracks suspension ownership, exceptions, and runtime waits.
src/​Server/​Fiber/​HandlerFibers.php Centralizes handler-fiber lifecycle operations.
src/​Server/​Fiber/​FiberSettlement.php Adds a one-shot settlement signal.
src/​Server/​Fiber/​FiberSchedulerInterface.php Defines the runtime waiting contract.
src/​Server/​ClientGateway.php Records gateway suspensions as server-owned.
src/​Server/​Builder.php Exposes scheduler configuration for both protocols.
docs/​run/​server-builder.md Documents scheduler setup with a Revolt example.
CHANGELOG.md Records the scheduler API and event-loop handling changes.

Review effort: Balanced

Comment thread src/Server/Fiber/FiberSettlement.php Resolved
Comment thread src/Server/Fiber/HandlerFiberState.php Resolved
@chr-hertel
chr-hertel force-pushed the handler-fiber-scheduler branch from 5478856 to c8c5b45 Compare November 8, 2026 10:45
@chr-hertel

Copy link
Copy Markdown
Member

Thanks @webagil-kevin - the approach is good, so I took over to get this landed. Rebased on main and added on top of your commit:

  • Fiber schedulers must wake the server on a later turn of the loop - ReactPHP's Deferred + await() resumes inline and failed on every await. That now fails with a LogicException naming the fix, and the guide has a working ReactPHP scheduler
  • A failed resume on the transport answers its request with -32603 and drops the fiber, on HTTP & stdio - before, the stream ended without an answer and stdio let it escape listen()
  • The guide lists the limits under an event loop: streamed responses bypass the PSR-7 body, waiting on the client blocks the loop before 2026-07-28, gateway calls only work from the handler's fiber and a session lock rejects concurrent requests of a session
  • Changelog got a [BC Break] line for handlers calling \Fiber::suspend() themselves

Loop-aware transports are a follow up, and #636 brings the coverage on a real loop.

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.

Comment thread src/Server/Fiber/HandlerFiberState.php Resolved

@chr-hertel chr-hertel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks @webagil-kevin - will follow up with some examples in #636

@chr-hertel
chr-hertel merged commit 691ea56 into modelcontextprotocol:main Nov 8, 2026
90 of 108 checks passed
@webagil-kevin

Copy link
Copy Markdown
Contributor Author

Thanks a lot @chr-hertel for taking this over and landing it, and for the follow-ups in #636 and #670. The wake-up check and the correlated -32603 close exactly the gaps I had left.

Building on it, I'll open two follow-ups shortly: a small fix so that a handler failing while its dropped fiber unwinds gets logged instead of lost, and a way for event-loop servers to stream the SSE frames of 2026-07-28 responses, the first limit listed under "Limits Under an Event Loop".

@chr-hertel

Copy link
Copy Markdown
Member

Awesome, thanks for following up @webagil-kevin!

@chr-hertel chr-hertel added the breaking change Breaking the Backwards Compatibility Promise label Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking change Breaking the Backwards Compatibility Promise bug Something isn't working Server Issues & PRs related to the Server component

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Server] Handler fibers suspended by an event loop are resumed by the server with null

3 participants