From 8acfa223463f6b940aebfd3a6f721b897d8eb0e3 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:22:45 +0000 Subject: [PATCH] h3: replenish bidi stream credit on stream close ngtcp2 does not auto-extend MAX_STREAMS when a stream closes, so the server must call ngtcp2_conn_extend_max_streams_bidi() itself. Without it every QUIC connection was permanently capped at initial_max_streams_bidi (default 100) request streams: each served fast (TTFB ~4ms), then the connection stalled with the server idle. HttpArena baseline-h3/static-h3 collapsed to ~20 req/s per connection (1277 total at c=64) while frankenphp-trueasync hit 201k on the same client. Call ngtcp2_conn_extend_max_streams_bidi(conn, 1) for each client-initiated bidi stream (id & 3 == 0) in stream_close_cb. A/B at c=64 (-m 32, n=64000): 6400 done in 30s -> 60000 done in 10s. Add test 036 driving 150 sequential streams over one reused QUIC connection; pre-fix it stalls at 100. --- src/http3/http3_callbacks.c | 8 +- .../h3/036-h3-stream-credit-replenish.phpt | 92 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 tests/phpt/server/h3/036-h3-stream-credit-replenish.phpt diff --git a/src/http3/http3_callbacks.c b/src/http3/http3_callbacks.c index 0c5703f..76e91b1 100644 --- a/src/http3/http3_callbacks.c +++ b/src/http3/http3_callbacks.c @@ -1290,7 +1290,7 @@ static int stream_close_cb(ngtcp2_conn *conn, uint32_t flags, int64_t stream_id, uint64_t app_error_code, void *user_data, void *stream_user_data) { - (void)conn; (void)stream_user_data; + (void)stream_user_data; http3_connection_t *c = (http3_connection_t *)user_data; if (c == NULL || c->nghttp3_conn == NULL) { @@ -1308,6 +1308,12 @@ static int stream_close_cb(ngtcp2_conn *conn, uint32_t flags, if (stats != NULL) stats->h3_stream_close++; + /* ngtcp2 never auto-extends MAX_STREAMS on close, so without this each + * connection caps at initial_max_streams_bidi. id&3==0 = client bidi. */ + if ((stream_id & 0x03) == 0) { + ngtcp2_conn_extend_max_streams_bidi(conn, 1); + } + if (rv != 0 && rv != NGHTTP3_ERR_STREAM_NOT_FOUND) { return NGTCP2_ERR_CALLBACK_FAILURE; } diff --git a/tests/phpt/server/h3/036-h3-stream-credit-replenish.phpt b/tests/phpt/server/h3/036-h3-stream-credit-replenish.phpt new file mode 100644 index 0000000..d7b8873 --- /dev/null +++ b/tests/phpt/server/h3/036-h3-stream-credit-replenish.phpt @@ -0,0 +1,92 @@ +--TEST-- +HttpServer: HTTP/3 replenishes bidi stream credit beyond initial_max_streams_bidi +--EXTENSIONS-- +true_async_server +true_async +--ENV-- +PHP_HTTP3_DISABLE_RETRY=1 +--SKIPIF-- + true, 'h3client' => true]); +?> +--FILE-- + the 100 default) over a + * single reused QUIC connection. Pre-fix: completed stalls at 100. + * Post-fix: all 150 complete and the connection is accepted once. */ + +use TrueAsync\HttpServer; +use TrueAsync\HttpServerConfig; +use function Async\spawn; +use function Async\await; + +$tmp = __DIR__ . '/tmp-036'; +@mkdir($tmp, 0700, true); +$cert = $tmp . '/cert.pem'; +$key = $tmp . '/key.pem'; +$rc = 0; +exec(sprintf('openssl req -x509 -newkey rsa:2048 -sha256 -days 1 -nodes ' + . '-subj "/CN=localhost" -keyout %s -out %s 2>/dev/null', + escapeshellarg($key), escapeshellarg($cert)), $_, $rc); +if ($rc !== 0) { echo "cert gen failed\n"; exit(1); } + +$port = 20400 + (getmypid() % 40) + 16; + +$config = (new HttpServerConfig()) + ->addListener('127.0.0.1', $port + 1) + ->addHttp3Listener('127.0.0.1', $port) + ->enableTls(true)->setCertificate($cert)->setPrivateKey($key); +$server = new HttpServer($config); +$server->addHttpHandler(function ($req, $res) { + $res->setStatusCode(200) + ->setHeader('content-type', 'text/plain') + ->setBody('ok'); +}); + +$client_bin = __DIR__ . '/../../../h3client/h3client'; + +$client = spawn(function () use ($server, $port, $client_bin) { + usleep(80000); + + $cmd = sprintf('H3CLIENT_REQUEST_COUNT=150 H3CLIENT_QUIET=1 %s 127.0.0.1 %d / GET 2>&1', + escapeshellarg($client_bin), $port); + $out = shell_exec($cmd) ?? ''; + + $completed = -1; + if (preg_match('/^COMPLETED=(\d+)$/m', $out, $m)) $completed = (int)$m[1]; + echo "completed=", $completed, "\n"; + + $s = $server->getHttp3Stats()[0] ?? []; + echo "streams_opened=", (int)($s['h3_streams_opened'] ?? -1), "\n"; + echo "request_received=", (int)($s['h3_request_received'] ?? -1), "\n"; + echo "response_submitted=", (int)($s['h3_response_submitted'] ?? -1), "\n"; + /* One connection, 150 streams — reuse must hold (no per-request + * handshake) or the credit-replenish path isn't what's exercised. */ + echo "conn_accepted=", (int)($s['quic_conn_accepted'] ?? -1), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); + +@unlink($cert); @unlink($key); @rmdir($tmp); +echo "done\n"; +?> +--EXPECT-- +completed=150 +streams_opened=150 +request_received=150 +response_submitted=150 +conn_accepted=1 +done