Skip to content

HTTP/2 handler: an uncaught exception leaks a reactor handle and aborts the worker at teardown #101

Description

@EdmondDantes

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:

>>> 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):

<?php
use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;
use function Async\spawn;
use function Async\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 = new HttpServer(
    (new HttpServerConfig())
        ->addListener('127.0.0.1', $port)
        ->setReadTimeout(5)
        ->setWriteTimeout(5)
);

$server->addHttpHandler(function ($req, $resp) {
    throw new \RuntimeException('boom');   // uncaught
});

$client = spawn(function () use ($port, $server) {
    usleep(50000);
    // a plain GET is enough — no body needed
    shell_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

Run:

php -d extension_dir="$(pwd)/modules" -d extension=true_async_server.so repro.php
  • Expected: prints reached end, exits 0. The client receives an HTTP/2 500 from the derived error response.
  • Actual: process aborts with the ZEND_ASYNC_REACTOR_LOOP_ALIVE() assertion at teardown (Termsig=6).

Backtrace at the abort:

#0 fiber_entry                ext/async/scheduler.c:2041
#1 zend_fiber_trampoline      Zend/zend_fibers.c:393
#2 make_fcontext              Zend/asm/make_x86_64_sysv_elf_gas.S

Direction / notes

  • 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

  1. Add a regression test: a throwing h2 handler over h2c (GET) that asserts a clean process exit + an HTTP/2 500 response.
  2. 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).

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Fields

No fields configured for Bug.

Projects

Status
In Progress

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions