Skip to content

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

Open
webagil-kevin wants to merge 1 commit into
modelcontextprotocol:mainfrom
webagil-kevin:handler-fiber-scheduler
Open

webagil-kevin wants to merge 1 commit into
modelcontextprotocol:mainfrom
webagil-kevin:handler-fiber-scheduler

Conversation

@webagil-kevin

Copy link
Copy Markdown

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.

@chr-hertel chr-hertel added Server Issues & PRs related to the Server component bug Something isn't working labels Oct 27, 2026
@chr-hertel
chr-hertel requested a balanced review from Copilot October 27, 2026 23:42

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 on lines +60 to +61
foreach ($callbacks as $callback) {
$callback();
Comment on lines +90 to +91
if (null === $this->scheduler) {
throw new LogicException('A request handler suspended its fiber for something other than the server, an event loop awaiting I/O for instance: set the runtime\'s fiber scheduler with Builder::setFiberScheduler().');

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

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