Repository navigation
[Server] Let an event loop drive the fibers request handlers run in - #616
Conversation
There was a problem hiding this comment.
🟡 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
5478856 to
c8c5b45
Compare
|
Thanks @webagil-kevin - the approach is good, so I took over to get this landed. Rebased on
Loop-aware transports are a follow up, and #636 brings the coverage on a real loop. |
There was a problem hiding this comment.
Note
Copilot was unable to run its full agentic suite in this review.
Copilot review overview
3 open issues
On the early-return error path, the transport writes a final result but does not clear… · New This new exception message points users exclusively toBuilder::setFiberScheduler(), but this PR… · New This test opens$inputand$outputresources but never closes them. That can cause handle leaks… · New
2 resolved since last review
Review effort: Lite
chr-hertel
left a comment
There was a problem hiding this comment.
Thanks @webagil-kevin - will follow up with some examples in #636
|
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 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 |
|
Awesome, thanks for following up @webagil-kevin! |


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: withnullon 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 withnull: the await returns early or throwsMust 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)andFiberSettlement: 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()andresume()return once the fiber suspended for the server again, or returned; in between, the scheduler waits.ClientGatewaysuspends throughHandlerFibers::suspend(), so a suspension made after the loop resumed the fiber still reaches the server. ANotificationSuspensionorRequestSuspensionthat a handler passes to\Fiber::suspend()itself is read as the server's, as the existing tests do.Protocol,StatelessProtocol,BaseTransport,StreamableHttpTransportandStdioTransportgo throughHandlerFibers. A fiber it did not create is driven as before.Builder::setFiberScheduler().Protocol::__construct()andStatelessProtocol::__construct()take the scheduler as an optional last argument.LogicExceptionnamingBuilder::setFiberScheduler()(-32603), instead of being resumed withnull. The loop keeps running.CHANGELOG.md(0.10.0) and a "Fiber Scheduler" section indocs/run/server-builder.md, with a Revolt scheduler.Verification
Each job of
pipeline.yamlanddocs.yml, replayed in Docker on main (57b45eb) and with the change, with the same dependency resolution as CI:tests/Unit/Server/Fiber/:HandlerFibersTestruns a dependency-freeFakeEventLoop, which suspends the fiber with nothing and resumes it from its own turn, as Revolt does.EventLoopToolCallTestcalls a tool awaiting I/O over Streamable HTTP, in both eras, with and without a scheduler.composer validate --strict,php-cs-fixer fix --dry-run,phpstan analyseandmake docs-apiare clean, as on main.--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).http://localhost/) and the client suite, on2025-11-25and2026-07-28, pass their baselines, as on main.zensical build --strict): no issues, as on main.-32603and the event loop terminates;-32603with theLogicException's message, and the loop ends cleanly;slept 204 ms, then 100 ms.Not verified
react/async): its scheduler is the same few lines with aDeferredandawait(), not run.ServerandStreamableHttpTransportin-process on the fake loop, so no dev dependency is added. Happy to add an integration fixture onrevolt/event-loop, as a dev dependency, if you'd rather have one.