You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
An uncaught exception thrown from any HTTP/2 request handler (registered via addHttpHandler / addHttp2Handler / addGrpcHandler) leaves an active handle in the libuv reactor. When the server later stops, the scheduler asserts that the event loop is already stopped and the process aborts:
>>> ZEND_ASYNC_REACTOR_LOOP_ALIVE() == false && "The event loop must be stopped"
at ext/async/scheduler.c:2041
php: ext/async/scheduler.c:2041: fiber_entry:
Assertion `zend_async_reactor_loop_alive_fn() == 0 && "The event loop must be stopped"' failed.
Aborted (core dumped)
Impact
A single throwing handler can abort the whole worker on shutdown — an ordinary handler bug (uncaught exception) becomes a process crash.
The response side is fine: the dispose path derives a 500 error response from the exception. The problem is a leaked reactor handle — the connection/stream is not fully torn down after the handler coroutine finishes with an exception, so stop() returns while libuv still has an active handle.
Scope (what triggers it)
Reproduces with any h2 handler that throws, on GET and POST, over h2c (prior-knowledge). Independent of gRPC.
Does not require a request body, trailers, or streaming.
There is currently no test covering a throwing h2 handler, which is why it went unnoticed. Discovered while implementing gRPC (gRPC support (HTTP/2 + HTTP/3) #4).
Reproduction
Minimal, self-contained (needs a curl built with nghttp2):
<?phpuseTrueAsync\HttpServer;
useTrueAsync\HttpServerConfig;
usefunctionAsync\spawn;
usefunctionAsync\await;
// grab a free port$s = stream_socket_server('tcp://127.0.0.1:0', $e, $m);
$port = (int) explode(':', stream_socket_get_name($s, false))[1];
fclose($s);
$server = newHttpServer(
(newHttpServerConfig())
->addListener('127.0.0.1', $port)
->setReadTimeout(5)
->setWriteTimeout(5)
);
$server->addHttpHandler(function ($req, $resp) {
thrownew \RuntimeException('boom'); // uncaught
});
$client = spawn(function () use ($port, $server) {
usleep(50000);
// a plain GET is enough — no body neededshell_exec(sprintf(
'curl --http2-prior-knowledge -s --max-time 3 http://127.0.0.1:%d/ >/dev/null 2>&1',
$port
));
$server->stop();
});
$server->start();
await($client);
echo"reached end\n"; // never printed — process aborts before here
The leaked handle is most likely the connection's read/write watcher, not released on the exceptional exit of the handler coroutine — so stop() completes while libuv still has an active handle and the loop stays alive.
Compare the HTTP/1 vs HTTP/2 handler-coroutine dispose paths — http_handler_coroutine_dispose (src/core/http_connection.c) vs http2_handler_coroutine_dispose (src/http2/http2_strategy.c). The exception → error-response derivation is mirrored between them, but the connection/stream teardown after a coroutine exception may differ; the leak is in that teardown.
Relation to gRPC support (HTTP/2 + HTTP/3) #4 (gRPC): the gRPC dispose already maps an uncaught handler exception to grpc-status: 13 (INTERNAL), but that mapping cannot be exercised until this teardown leak is fixed. It is otherwise not a blocker for gRPC — unary success and explicit-error (Trailers-Only) paths work, and realistic gRPC handlers catch their own errors and set grpc-status explicitly.
Suggested first step
Add a regression test: a throwing h2 handler over h2c (GET) that asserts a clean process exit + an HTTP/2 500 response.
Fix the h2 handler-coroutine teardown so the connection handle is released on the exceptional path (bring it in line with the HTTP/1 path).
Summary
An uncaught exception thrown from any HTTP/2 request handler (registered via
addHttpHandler/addHttp2Handler/addGrpcHandler) leaves an active handle in the libuv reactor. When the server later stops, the scheduler asserts that the event loop is already stopped and the process aborts:Impact
500error response from the exception. The problem is a leaked reactor handle — the connection/stream is not fully torn down after the handler coroutine finishes with an exception, sostop()returns while libuv still has an active handle.Scope (what triggers it)
Reproduction
Minimal, self-contained (needs a
curlbuilt with nghttp2):Run:
php -d extension_dir="$(pwd)/modules" -d extension=true_async_server.so repro.phpreached end, exits 0. The client receives an HTTP/2500from the derived error response.ZEND_ASYNC_REACTOR_LOOP_ALIVE()assertion at teardown (Termsig=6).Backtrace at the abort:
Direction / notes
stop()completes while libuv still has an active handle and the loop stays alive.http_handler_coroutine_dispose(src/core/http_connection.c) vshttp2_handler_coroutine_dispose(src/http2/http2_strategy.c). The exception → error-response derivation is mirrored between them, but the connection/stream teardown after a coroutine exception may differ; the leak is in that teardown.grpc-status: 13(INTERNAL), but that mapping cannot be exercised until this teardown leak is fixed. It is otherwise not a blocker for gRPC — unary success and explicit-error (Trailers-Only) paths work, and realistic gRPC handlers catch their own errors and setgrpc-statusexplicitly.Suggested first step
500response.