Repository navigation
[Server] Handler fibers suspended by an event loop are resumed by the server with null #615
Description
The server runs every request handler in a \Fiber and resumes that fiber itself whenever it suspends. Under an event loop (Revolt, which AMPHP builds on; ReactPHP's async), the handler's own I/O suspends the same fiber for the loop. The server then resumes it with null as if it had suspended for the server:
- the handler's await returns early, or throws
Must call resume() or throw() before calling suspend() againon the next await; - the loop later resumes a fiber that has moved on, and under Revolt the event loop terminates.
So a tool that awaits anything (a database, an HTTP API, a sleep) cannot be served from an event-loop runtime such as amphp/http-server, and it takes the loop, the whole server process, down with it.
This is the server-side twin of #504 (fixed client-side by #599). It shares a root with #583: the SDK owns handler fibers, and integrations have no seam into them.
Reproduction
Only mcp/sdk, revolt/event-loop and nyholm/psr7. The request is served from a fiber the loop owns, as an event-loop HTTP server runs it:
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Server;
use Mcp\Server\Stateless\RequestMeta;
use Mcp\Server\Transport\StreamableHttpTransport;
use Nyholm\Psr7\Factory\Psr17Factory;
use Revolt\EventLoop;
function sleepOnTheLoop(float $seconds): void // what any async I/O does under Revolt
{
$suspension = EventLoop::getSuspension();
EventLoop::delay($seconds, static fn () => $suspension->resume());
$suspension->suspend();
}
$server = Server::builder()
->setServerInfo('event-loop-repro', '1.0.0')
->addTool(static function (): string {
$start = hrtime(true);
sleepOnTheLoop(0.2);
$first = (hrtime(true) - $start) / 1e6;
sleepOnTheLoop(0.1);
return sprintf('slept %.0f ms, then %.0f ms', $first, (hrtime(true) - $start) / 1e6 - $first);
}, name: 'sleep', description: 'Sleeps 200 ms then 100 ms on the event loop')
->build();
$factory = new Psr17Factory();
$request = $factory->createServerRequest('POST', 'http://localhost/')
->withHeader('Host', 'localhost')
->withHeader('Content-Type', 'application/json')
->withHeader('Accept', 'application/json, text/event-stream')
->withHeader('MCP-Protocol-Version', ProtocolVersion::V2026_07_28->value)
->withHeader('Mcp-Method', 'tools/call')
->withHeader('Mcp-Name', 'sleep')
->withBody($factory->createStream(json_encode([
'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call',
'params' => ['name' => 'sleep', 'arguments' => new stdClass(), '_meta' => [
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
RequestMeta::CLIENT_CAPABILITIES => new stdClass(),
]],
], JSON_THROW_ON_ERROR)));
EventLoop::queue(static function () use ($server, $request, $factory): void {
$response = $server->run(new StreamableHttpTransport($request, $factory, $factory));
echo 'answer: ', (string) $response->getBody(), "\n";
});
EventLoop::run();On v0.9.0 (PHP 8.5, revolt/event-loop 1.0.9):
answer: {"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Error while executing tool"}}
Fatal error: Uncaught Error: Interrupt from event loop must throw an exception: Revolt\EventLoop\Internal\DriverSuspension::{closure:Revolt\EventLoop\Internal\DriverSuspension::resume():70} defined in vendor/revolt/event-loop/src/EventLoop/Internal/DriverSuspension.php:70
Stack trace:
#0 vendor/revolt/event-loop/src/EventLoop.php(411): Revolt\EventLoop\Internal\AbstractDriver->run()
The handshake era (initialize, then tools/call on the session) behaves the same. StreamableHttpTransport::createStreamedResponse() and StdioTransport::resumeFiber() resume a suspended handler fiber that has no pending client request just as StatelessProtocol::run() does.
Proposal
The server must not resume a handler fiber it did not suspend. It should wait instead until the fiber suspends for the server again, or returns. How to wait depends on the runtime, so the runtime provides it.
namespace Mcp\Server\Fiber;
interface FiberSchedulerInterface
{
/**
* Lets the runtime run until $settlement settles, then returns. Called on the fiber — or `{main}` — that drives
* the handler, never while $settlement is already settled; it settles from inside the handler fiber.
*/
public function wait(FiberSettlement $settlement): void;
}$server = Server::builder()->setFiberScheduler(new RevoltFiberScheduler());
final class RevoltFiberScheduler implements FiberSchedulerInterface
{
public function wait(FiberSettlement $settlement): void
{
$suspension = EventLoop::getSuspension();
$settlement->then($suspension->resume(...));
$suspension->suspend();
}
}FiberSettlement is a one-shot signal: isSettled(), plus then(\Closure) to call a callback once it settles. A ReactPHP scheduler is the same few lines, with a Deferred and await().
Inside, one @internal class, HandlerFibers, creates, starts, resumes and suspends handler fibers. It replaces the direct new \Fiber(), ->start(), ->resume() and \Fiber::suspend() calls in Protocol, StatelessProtocol, ClientGateway, BaseTransport, StreamableHttpTransport and StdioTransport.
- Suspensions for the server are recorded.
ClientGatewaysuspends throughHandlerFibers::suspend(), so a suspension made after the loop resumed the fiber reaches the server, not the loop. ANotificationSuspensionorRequestSuspensionthat a handler passes to\Fiber::suspend()itself is still read as the server's, as the existing tests do. start()andresume()return only once the fiber suspended for the server, or returned. In between, the scheduler lets the runtime run.- Without a scheduler, a foreign suspension fails fast. It throws a
LogicExceptionnamingBuilder::setFiberScheduler(), instead of resuming withnull. The request is answered with-32603, and the loop keeps running. - Exceptions come back to the server. What a handler throws while the runtime drives its fiber is rethrown on the server's side, never inside the loop.
- Nothing else changes. No transport signature changes, the
McpFibertype stays, and a fiber not created throughHandlerFibersis driven exactly as before.Protocol::__construct()andStatelessProtocol::__construct()take the scheduler as an optional last argument.
With the scheduler, the reproduction answers:
answer: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"slept 203 ms, then 101 ms"}],"isError":false,"resultType":"complete",...}}
It also composes with #583. Context propagation still needs a hook around start and resume. But the scheduler is the place where an event-loop integration already owns the fiber, so it can live there.
Change
A branch is ready: 16 files, +763 / −14.
src/Server/Fiber/holdsFiberSchedulerInterface,FiberSettlement, and the internalHandlerFibersandHandlerFiberState.- The fiber call sites listed above are rerouted through it, and
Builder::setFiberScheduler()is added. - The
0.10.0changelog gets an entry, anddocs/run/server-builder.mda "Fiber Scheduler" section. - New tests live in
tests/Unit/Server/Fiber/:HandlerFibersTestruns a dependency-freeFakeEventLoopthat suspends the fiber with nothing and resumes it from its own turn, as Revolt does;EventLoopToolCallTestcovers a tool awaiting I/O over Streamable HTTP in both eras, with and without a scheduler.
make phpstanandmake csare clean. The unit suite (2679, 11 of them new) and the integration suite (108) pass.
Happy to open the pull request.
Activity