From 71c166e81da884dbf319089d141f1d57296ff745 Mon Sep 17 00:00:00 2001 From: "Michiel W. Beijen" Date: Sun, 1 Nov 2026 09:58:47 +0100 Subject: [PATCH 01/12] test: Add a pytest package for mod_http3 test/modules/http3 runs requests over HTTP/3 with curl --http3-only against a QUIC listener on the https port number. It covers static files, HEAD, 404, a 1 MB response, several requests on one connection, virtual host selection by Host, POST content to CGI up to 1 MB and the CGI environment. The package is skipped when mod_http3, mod_ssl or curl's HTTP3 feature is missing. Generated-by: Claude Code (Claude Opus 5.5) --- test/modules/http3/__init__.py | 0 test/modules/http3/conftest.py | 35 +++++++++++ test/modules/http3/env.py | 86 +++++++++++++++++++++++++++ test/modules/http3/htdocs/cgi/echo.py | 13 ++++ test/modules/http3/test_001_get.py | 78 ++++++++++++++++++++++++ test/modules/http3/test_002_cgi.py | 40 +++++++++++++ 6 files changed, 252 insertions(+) create mode 100644 test/modules/http3/__init__.py create mode 100644 test/modules/http3/conftest.py create mode 100644 test/modules/http3/env.py create mode 100644 test/modules/http3/htdocs/cgi/echo.py create mode 100644 test/modules/http3/test_001_get.py create mode 100644 test/modules/http3/test_002_cgi.py diff --git a/test/modules/http3/__init__.py b/test/modules/http3/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/modules/http3/conftest.py b/test/modules/http3/conftest.py new file mode 100644 index 00000000000..d19c034c160 --- /dev/null +++ b/test/modules/http3/conftest.py @@ -0,0 +1,35 @@ +import logging +import os +import sys + +import pytest + +sys.path.append(os.path.join(os.path.dirname(__file__), '../..')) + +from .env import H3TestEnv + + +def pytest_report_header(config, start_path): + env = H3TestEnv() + return f"mod_http3 [apache: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]" + + +@pytest.fixture(scope="package") +def env(pytestconfig) -> H3TestEnv: + level = logging.INFO + console = logging.StreamHandler() + console.setLevel(level) + console.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) + logging.getLogger('').addHandler(console) + logging.getLogger('').setLevel(level=level) + env = H3TestEnv(pytestconfig=pytestconfig) + env.require(env.has_shared_module("http3"), "mod_http3 is not built", + dep="mod_http3") + env.require(env.has_shared_module("ssl"), "mod_ssl is not built", + dep="mod_ssl") + env.require(env.curl_has_http3(), "curl lacks HTTP/3 support", + dep="curl HTTP3", detected=env.curl_version_str()) + env.setup_httpd() + env.apache_access_log_clear() + env.httpd_error_log.clear_log() + return env diff --git a/test/modules/http3/env.py b/test/modules/http3/env.py new file mode 100644 index 00000000000..84eb2746b80 --- /dev/null +++ b/test/modules/http3/env.py @@ -0,0 +1,86 @@ +import inspect +import logging +import os +import re +import subprocess +from typing import Dict, Any, List, Optional + +from pyhttpd.conf import HttpdConf +from pyhttpd.env import HttpdTestEnv, HttpdTestSetup + +log = logging.getLogger(__name__) + + +class H3TestSetup(HttpdTestSetup): + + def __init__(self, env: 'HttpdTestEnv'): + super().__init__(env=env) + self.add_source_dir(os.path.dirname(inspect.getfile(H3TestSetup))) + self.add_modules(["http3", "ssl"]) + self.add_cgi_module() + + +class H3TestEnv(HttpdTestEnv): + + def curl_has_http3(self) -> bool: + """Whether the curl in use can speak HTTP/3.""" + try: + p = subprocess.run([self.curl, "-V"], + capture_output=True, text=True) + except OSError: + return False + m = re.search(r'^Features:(.*)$', p.stdout, re.MULTILINE) + return m is not None and "HTTP3" in m.group(1).split() + + def __init__(self, pytestconfig=None): + super().__init__(pytestconfig=pytestconfig) + self.add_httpd_log_modules(["http3"]) + + def setup_httpd(self, setup: HttpdTestSetup = None): + super().setup_httpd(setup=H3TestSetup(env=self)) + + @property + def h3_port(self) -> int: + # the QUIC listener shares the number of the TLS port, over UDP + return self.https_port + + def h3_get(self, url: str, options: Optional[List[str]] = None): + return self.curl_raw([url], options=["--http3-only"] + (options or [])) + + +class H3Conf(HttpdConf): + """A configuration with HTTP/3 on the https port of the test1 vhost. + + mod_http3 opens one UDP listener, with the certificate of the first + virtual host that has one configured, so test1 carries the H3 + directives and every other vhost on the https port is reached through + the same QUIC connection by its Host header. The default certificate + covers all test domains. + """ + + def __init__(self, env: H3TestEnv, extras: Dict[str, Any] = None): + super().__init__(env=env, extras=extras) + + def add_h3_vhosts(self, test1: List[str] = None, test2: List[str] = None, + cgi: List[str] = None): + cred = self.env.get_credentials_for_name(f"test1.{self.env.http_tld}")[0] + self.start_vhost(domains=[f"test1.{self.env.http_tld}"], + port=self.env.https_port, doc_root="htdocs/test1") + self.add([ + "Protocols h3 http/1.1", + f"H3CertificatePath {cred.cert_file}", + f"H3CertificateKeyPath {cred.pkey_file}", + f"H3Port {self.env.h3_port}", + ]) + self.add(test1 or []) + self.end_vhost() + self.start_vhost(domains=[f"test2.{self.env.http_tld}"], + port=self.env.https_port, doc_root="htdocs/test2") + self.add(test2 or []) + self.end_vhost() + self.start_vhost(domains=[f"cgi.{self.env.http_tld}"], + port=self.env.https_port, doc_root="htdocs/cgi") + self.add(["AddHandler cgi-script .py"]) + self.add(cgi or []) + self.end_vhost() + return self diff --git a/test/modules/http3/htdocs/cgi/echo.py b/test/modules/http3/htdocs/cgi/echo.py new file mode 100644 index 00000000000..0f10967ac08 --- /dev/null +++ b/test/modules/http3/htdocs/cgi/echo.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +import json +import os +import sys + +content = sys.stdin.buffer.read() +names = ["REQUEST_METHOD", "SERVER_PROTOCOL", "SERVER_NAME", "HTTPS", + "CONTENT_TYPE", "CONTENT_LENGTH", "QUERY_STRING"] +result = {n.lower(): os.environ.get(n, "") for n in names} +result["body_len"] = len(content) +result["body_tail"] = content[-16:].decode("latin-1") +sys.stdout.write("Content-Type: application/json\n\n") +sys.stdout.write(json.dumps(result)) diff --git a/test/modules/http3/test_001_get.py b/test/modules/http3/test_001_get.py new file mode 100644 index 00000000000..91da4c1cd0b --- /dev/null +++ b/test/modules/http3/test_001_get.py @@ -0,0 +1,78 @@ +import os + +import pytest + +from .env import H3Conf + + +class TestGet: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + env.make_data_file(os.path.join(env.server_docs_dir, "test1"), + "data-1m", 1024 * 1024) + H3Conf(env).add_h3_vhosts().install() + assert env.apache_restart() == 0 + + # a static file is served over HTTP/3 + def test_h3_001_01(self, env): + r = env.h3_get(env.mkurl("https", "test1", "/alive.json")) + assert r.exit_code == 0, f"{r}" + assert r.response["protocol"] == "HTTP/3" + assert r.response["status"] == 200 + assert r.json["host"] == "test1" + + # the TLS port advertises HTTP/3 on the same port number + def test_h3_001_02(self, env): + r = env.curl_get(env.mkurl("https", "test1", "/alive.json"), + options=["--http1.1"]) + assert r.response["status"] == 200 + alt_svc = r.response["header"].get("alt-svc", "") + assert alt_svc.startswith(f'h3=":{env.h3_port}"'), f"{r.response}" + + # a missing resource gives 404 + def test_h3_001_03(self, env): + r = env.h3_get(env.mkurl("https", "test1", "/does-not-exist")) + assert r.exit_code == 0, f"{r}" + assert r.response["protocol"] == "HTTP/3" + assert r.response["status"] == 404 + + # HEAD gives the headers of a GET without the content + def test_h3_001_04(self, env): + url = env.mkurl("https", "test1", "/index.html") + r = env.h3_get(url, options=["-I"]) + assert r.exit_code == 0, f"{r}" + assert r.response["status"] == 200 + size = os.path.getsize(os.path.join(env.server_docs_dir, + "test1", "index.html")) + assert r.response["header"]["content-length"] == str(size) + # curl -I prints the headers as the body; nothing else may follow + assert r.response["body"].startswith(b"HTTP/3 200") + assert b" Date: Sat, 31 Oct 2026 11:33:54 +0100 Subject: [PATCH 02/12] mod_http3: Run the post_read_request hooks for HTTP/3 requests stream_worker() built the request_rec and went straight to ap_process_request(), with only ap_update_vhost_from_headers() in between. It skipped the two steps that HTTP/1.1 (ap_read_request()) and HTTP/2 (h2_create_request_rec()) run on every request: * ap_check_request_header(): vhost selection with StrictHostCheck, the missing Host check and Expect handling. * ap_post_read_request(): the post_read_request hooks of mod_setenvif, mod_remoteip, mod_headers ("early"), mod_proxy (forward proxy detection), mod_md, mod_unique_id and others, plus the core checks on the complete request. mod_http3's own h3_hook_post_read_request never ran for its own requests either. So configuration behaved differently over HTTP/3, including access control: with "SetEnvIf User-Agent ^evilbot bad_bot" and "Require not env bad_bot", a request was denied with 403 over HTTP/1.1 but served with 200 over HTTP/3. Both now give 403. Add test_003_access.py to the mod_http3 tests for this. Generated-by: Claude Code (Claude Opus 5.5) --- modules/http3/h3_request.c | 15 +++++++++- test/modules/http3/env.py | 2 +- test/modules/http3/test_003_access.py | 43 +++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 test/modules/http3/test_003_access.py diff --git a/modules/http3/h3_request.c b/modules/http3/h3_request.c index a41d49bf81d..4e2e16c0bff 100644 --- a/modules/http3/h3_request.c +++ b/modules/http3/h3_request.c @@ -342,10 +342,15 @@ static void* APR_THREAD_FUNC stream_worker(apr_thread_t* thd, void* data) ap_add_output_filter_handle(h3_proto_out_filter_handle, h3ctx, r, r->connection); - ap_update_vhost_from_headers(r); + /* Validate the request and select the vhost like HTTP/1.1 and HTTP/2 do + * (StrictHostCheck, a missing Host, Expect); on failure r->status holds + * the error. + */ + ap_check_request_header(r); r->per_dir_config = r->server->lookup_defaults; ap_log_error(APLOG_MARK, APLOG_INFO, 0, s, "before ap_process_request"); + int access_status; if (r->status != HTTP_OK) { int err = r->status; @@ -356,6 +361,14 @@ static void* APR_THREAD_FUNC stream_worker(apr_thread_t* thd, void* data) { ap_die(HTTP_REQUEST_ENTITY_TOO_LARGE, r); } + /* Run the post_read_request hooks (mod_setenvif, mod_remoteip, + * mod_headers "early", mod_proxy forward proxy detection, ...) and the + * core checks on the complete request, as HTTP/1.1 and HTTP/2 do. + */ + else if ((access_status = ap_post_read_request(r)) != OK) + { + ap_die(access_status, r); + } else { ap_process_request(r); diff --git a/test/modules/http3/env.py b/test/modules/http3/env.py index 84eb2746b80..c9bc0ac41a6 100644 --- a/test/modules/http3/env.py +++ b/test/modules/http3/env.py @@ -16,7 +16,7 @@ class H3TestSetup(HttpdTestSetup): def __init__(self, env: 'HttpdTestEnv'): super().__init__(env=env) self.add_source_dir(os.path.dirname(inspect.getfile(H3TestSetup))) - self.add_modules(["http3", "ssl"]) + self.add_modules(["http3", "ssl", "setenvif", "headers"]) self.add_cgi_module() diff --git a/test/modules/http3/test_003_access.py b/test/modules/http3/test_003_access.py new file mode 100644 index 00000000000..9ed3343da9b --- /dev/null +++ b/test/modules/http3/test_003_access.py @@ -0,0 +1,43 @@ +import pytest + +from .env import H3Conf + + +class TestAccess: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + H3Conf(env).add_h3_vhosts(test1=[ + 'SetEnvIf User-Agent "^blocked-agent$" blocked', + '', + ' ', + ' Require all granted', + ' Require not env blocked', + ' ', + '', + 'SetEnvIf Request_Protocol "^(.+)$" seen_proto=$1', + 'Header always set X-Seen-Protocol "%{seen_proto}e"', + # HTTP/3 field names are lowercase on the wire, as in HTTP/2 + 'Header echo "(?i)^x-h3-echo$"', + ]).install() + assert env.apache_restart() == 0 + + # SetEnvIf runs in post_read_request, so Require sees its variable + def test_h3_003_01(self, env): + url = env.mkurl("https", "test1", "/alive.json") + r = env.h3_get(url, options=["-A", "blocked-agent"]) + assert r.exit_code == 0, f"{r}" + assert r.response["protocol"] == "HTTP/3" + assert r.response["status"] == 403 + r = env.h3_get(url, options=["-A", "other-agent"]) + assert r.response["status"] == 200 + env.httpd_error_log.ignore_recent(lognos=["AH01630"]) + + # SetEnvIf sees the request protocol that mod_http3 sets, and + # mod_headers can use its variables and echo request headers + def test_h3_003_02(self, env): + r = env.h3_get(env.mkurl("https", "test1", "/alive.json"), + options=["-H", "X-H3-Echo: hello"]) + assert r.exit_code == 0, f"{r}" + assert r.response["header"].get("x-seen-protocol") == "HTTP/3.0" + assert r.response["header"].get("x-h3-echo") == "hello" From 2c6630a4658ef6d98a64b29fce4b41e8bf2af415 Mon Sep 17 00:00:00 2001 From: "Michiel W. Beijen" Date: Sat, 31 Oct 2026 11:33:54 +0100 Subject: [PATCH 03/12] mod_http3: Fix the build against nghttp3 1.x configure checked for nghttp3_conn_server_new(), which nghttp3 1.x only provides as a macro around nghttp3_conn_server_new_versioned(), so the link test failed and --enable-http3 always reported "nghttp3 library is unusable". Check for the versioned function instead. h3_io.c calls nghttp3_conn_is_drained2(), which was added in nghttp3 1.16.0 while configure accepts nghttp3 >= 1.0.0. Check for it and fall back to nghttp3_conn_is_drained(), which has the same semantics, so the module builds against e.g. the nghttp3 1.8.0 in Debian 13. Generated-by: Claude Code (Claude Opus 5.5) --- modules/http3/config2.m4 | 5 ++++- modules/http3/h3_io.c | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/modules/http3/config2.m4 b/modules/http3/config2.m4 index eb155cd70b3..ac80bfc5ee0 100644 --- a/modules/http3/config2.m4 +++ b/modules/http3/config2.m4 @@ -139,7 +139,10 @@ AC_DEFUN([APACHE_CHECK_NGHTTP3],[ dnl Run library and function checks liberrors="" AC_CHECK_HEADERS([nghttp3/nghttp3.h]) - AC_CHECK_FUNCS([nghttp3_conn_server_new], [], [liberrors="yes"]) + dnl nghttp3_conn_server_new() is a macro around the versioned function + AC_CHECK_FUNCS([nghttp3_conn_server_new_versioned], [], [liberrors="yes"]) + dnl nghttp3 >= 1.16.0 replaces nghttp3_conn_is_drained() + AC_CHECK_FUNCS([nghttp3_conn_is_drained2]) if test "x$liberrors" != "x"; then AC_MSG_WARN([nghttp3 library is unusable]) fi diff --git a/modules/http3/h3_io.c b/modules/http3/h3_io.c index 22f618b98ec..98aedfd2d75 100644 --- a/modules/http3/h3_io.c +++ b/modules/http3/h3_io.c @@ -45,6 +45,11 @@ #include "quic/h3q_conn.h" #include "quic/h3q_stream.h" +#ifndef HAVE_NGHTTP3_CONN_IS_DRAINED2 +/* nghttp3 < 1.16.0 has the same function, without the const argument */ +#define nghttp3_conn_is_drained2 nghttp3_conn_is_drained +#endif + h3_io_t* child_h3_io = NULL; int h3_io_at_connection_limit(h3_io_t* io) From 190c8854a07a82f991c27e10dbe9736e7d9a41b5 Mon Sep 17 00:00:00 2001 From: "Michiel W. Beijen" Date: Mon, 2 Nov 2026 20:02:48 +0100 Subject: [PATCH 04/12] mod_http3: Do not ask for the write state of a concluded stream OpenSSL before 3.5.7 frees a stream's send buffer once the peer acknowledges the FIN sent by SSL_stream_conclude(), and SSL_get_stream_write_state() then dereferences it (fixed upstream by "Make SSL_get_stream_write_state() safe for concluded streams"). Children crashed this way in drain_one_stream() on Ubuntu 26.04, which ships OpenSSL 3.5.5. Mark a stream through its SSL app data when it is concluded, and treat its sending half as finished without asking OpenSSL. Generated-by: Claude Code (Claude Opus 5.5) --- modules/http3/quic/h3q_stream.c | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/modules/http3/quic/h3q_stream.c b/modules/http3/quic/h3q_stream.c index 44ba7cb3263..fb78641eeb6 100644 --- a/modules/http3/quic/h3q_stream.c +++ b/modules/http3/quic/h3q_stream.c @@ -23,6 +23,14 @@ #include "quic/h3q_stream.h" +/* + * Marks a stream, through its SSL app data, whose sending half was + * concluded. OpenSSL before 3.5.7 frees the send buffer once the peer + * acknowledges the FIN, and SSL_get_stream_write_state() then dereferences + * it, so the write state of such a stream must not be asked for. + */ +static char concluded_marker; + h3q_write_result h3q_stream_write(h3q_stream* st, const h3q_vec* vec, size_t nvec, int fin) { SSL* ssl = (SSL*)st; @@ -57,7 +65,10 @@ h3q_write_result h3q_stream_write(h3q_stream* st, const h3q_vec* vec, size_t nve } if (fin && !res.blocked && !res.broken && res.accepted == expected) { - SSL_stream_conclude(ssl, 0); + if (SSL_stream_conclude(ssl, 0)) + { + SSL_set_app_data(ssl, &concluded_marker); + } } return res; } @@ -95,7 +106,8 @@ void h3q_stream_is_read_finished(h3q_stream* st, int* read_finished, int* write_ int rstate = SSL_get_stream_read_state(ssl); *read_finished = (rstate == SSL_STREAM_STATE_FINISHED || rstate == SSL_STREAM_STATE_RESET_REMOTE || rstate == SSL_STREAM_STATE_CONN_CLOSED); int wstate = SSL_STREAM_STATE_FINISHED; - if (rstate != SSL_STREAM_STATE_CONN_CLOSED && rstate != SSL_STREAM_STATE_RESET_REMOTE) + if (rstate != SSL_STREAM_STATE_CONN_CLOSED && rstate != SSL_STREAM_STATE_RESET_REMOTE && + SSL_get_app_data(ssl) != &concluded_marker) { wstate = SSL_get_stream_write_state(ssl); } From 6b7ff9a5c09f02c7e60bbbb40a986b463bb8d8f7 Mon Sep 17 00:00:00 2001 From: "Michiel W. Beijen" Date: Sun, 1 Nov 2026 16:04:09 +0100 Subject: [PATCH 05/12] mod_http3: Keep the child's threads off the child pool The threads of mod_http3 allocated from the child pool, which is not thread-safe, and kept running while it was destroyed: - The port acquirer thread, in every child that does not own the UDP port, created a socket in the child pool every 200 ms, racing with the main thread and never freeing it. - Under prefork the child pool's allocator has no mutex and is shared with the request pools, which the event thread raced with when it created connection pools. - prefork does not run the child_stopping hook when a child exits from a signal handler, so neither thread was stopped before the child pool was destroyed and exit() ran OpenSSL's cleanup under them. These crashed children with SIGSEGV on stop, mostly under prefork. Give mod_http3 a pool of its own with a locked allocator, give each port acquisition attempt a scratch pool that is cleared on retry, and stop the threads from a pre-cleanup of the child pool as well. Generated-by: Claude Code (Claude Opus 5.5) --- modules/http3/h3_server.c | 81 ++++++++++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 10 deletions(-) diff --git a/modules/http3/h3_server.c b/modules/http3/h3_server.c index 9a7ef5fb121..4c74ef66b49 100644 --- a/modules/http3/h3_server.c +++ b/modules/http3/h3_server.c @@ -38,7 +38,7 @@ static apr_thread_t* port_acquire_thread = NULL; struct port_acquire_args { - apr_pool_t* pchild; + apr_pool_t* pool; server_rec* vhost; h3_server_conf* conf; }; @@ -46,12 +46,20 @@ struct port_acquire_args static void* APR_THREAD_FUNC port_acquire_thread_fn(apr_thread_t* thread H3_UNUSED, void* data) { struct port_acquire_args* args = data; + /* Each attempt allocates; clear them, or retrying leaks for the life + * of the child. A successful attempt's socket stays in ptry. */ + apr_pool_t* ptry = NULL; + if (apr_pool_create(&ptry, args->pool) != APR_SUCCESS) + { + return NULL; + } while (!child_stopping) { int udp_fd = -1; - apr_status_t rv = h3_socket_open(args->conf->h3_port, args->conf->h3_socket_buffer_size, args->pchild, &udp_fd); + apr_status_t rv = h3_socket_open(args->conf->h3_port, args->conf->h3_socket_buffer_size, ptry, &udp_fd); if (rv == APR_EAGAIN) { + apr_pool_clear(ptry); apr_sleep(apr_time_from_msec(H3_PORT_ACQUIRE_RETRY_MS)); continue; } @@ -65,7 +73,7 @@ static void* APR_THREAD_FUNC port_acquire_thread_fn(apr_thread_t* thread H3_UNUS h3_socket_close(udp_fd); break; } - if (h3_io_listen_start(args->pchild, args->vhost, args->conf, udp_fd) != APR_SUCCESS) + if (h3_io_listen_start(args->pool, args->vhost, args->conf, udp_fd) != APR_SUCCESS) { ap_log_error(APLOG_MARK, APLOG_ERR, 0, args->vhost, "port acquirer: h3_io_listen_start failed"); h3_socket_close(udp_fd); @@ -96,6 +104,47 @@ static h3_server_conf* find_h3_server(server_rec* s, server_rec** out_server) return conf; } +static void stop_child_threads(int graceful); + +/* mod_http3 allocates from its own threads, so it gets a pool of its own + * whose allocator has a mutex. The child pool's allocator may have none: + * prefork shares it, unlocked, with the pool of every request. */ +static apr_status_t create_h3_pool(apr_pool_t* pchild, apr_pool_t** out) +{ + apr_allocator_t* allocator = NULL; + apr_thread_mutex_t* mutex = NULL; + apr_status_t rv = apr_allocator_create(&allocator); + if (rv != APR_SUCCESS) + { + return rv; + } + rv = apr_pool_create_ex(out, pchild, NULL, allocator); + if (rv != APR_SUCCESS) + { + apr_allocator_destroy(allocator); + return rv; + } + apr_allocator_owner_set(allocator, *out); + apr_pool_tag(*out, "h3_child"); + rv = apr_thread_mutex_create(&mutex, APR_THREAD_MUTEX_DEFAULT, *out); + if (rv != APR_SUCCESS) + { + return rv; + } + apr_allocator_mutex_set(allocator, mutex); + return APR_SUCCESS; +} + +/* The threads must be gone before the child pool they run from is + * destroyed, and before exit() runs OpenSSL's cleanup under them. Most + * MPMs stop them through the child_stopping hook, but prefork skips that + * hook when a child exits from a signal handler. */ +static apr_status_t child_pool_cleanup(void* data H3_UNUSED) +{ + stop_child_threads(0); + return APR_SUCCESS; +} + void h3_child_init(apr_pool_t* pchild, server_rec* s) { server_rec* vhost = NULL; @@ -105,18 +154,25 @@ void h3_child_init(apr_pool_t* pchild, server_rec* s) ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, s, "h3_child_init: no H3 cert/key configured, skipping"); return; } + apr_pool_pre_cleanup_register(pchild, NULL, child_pool_cleanup); + apr_pool_t* pool = NULL; + if (create_h3_pool(pchild, &pool) != APR_SUCCESS) + { + ap_log_error(APLOG_MARK, APLOG_ERR, 0, vhost, APLOGNO() "h3_child_init: could not create the HTTP/3 pool"); + return; + } int udp_fd = -1; - apr_status_t rv = h3_socket_open(conf->h3_port, conf->h3_socket_buffer_size, pchild, &udp_fd); + apr_status_t rv = h3_socket_open(conf->h3_port, conf->h3_socket_buffer_size, pool, &udp_fd); if (rv == APR_EAGAIN) { ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, vhost, "h3_child_init: pid=%d port already owned, will keep retrying in background", h3_getpid()); - struct port_acquire_args* args = apr_palloc(pchild, sizeof(*args)); - args->pchild = pchild; + struct port_acquire_args* args = apr_palloc(pool, sizeof(*args)); + args->pool = pool; args->vhost = vhost; args->conf = conf; apr_threadattr_t* attr = NULL; - if (apr_threadattr_create(&attr, pchild) != APR_SUCCESS || apr_thread_create(&port_acquire_thread, attr, port_acquire_thread_fn, args, pchild) != APR_SUCCESS) + if (apr_threadattr_create(&attr, pool) != APR_SUCCESS || apr_thread_create(&port_acquire_thread, attr, port_acquire_thread_fn, args, pool) != APR_SUCCESS) { ap_log_error(APLOG_MARK, APLOG_ERR, 0, vhost, "h3_child_init: failed to start port acquirer thread"); port_acquire_thread = NULL; @@ -128,16 +184,15 @@ void h3_child_init(apr_pool_t* pchild, server_rec* s) ap_log_error(APLOG_MARK, APLOG_ERR, 0, vhost, "h3_child_init: h3_socket_open failed"); return; } - if (h3_io_listen_start(pchild, vhost, conf, udp_fd) != APR_SUCCESS) + if (h3_io_listen_start(pool, vhost, conf, udp_fd) != APR_SUCCESS) { ap_log_error(APLOG_MARK, APLOG_ERR, 0, vhost, "h3_child_init: h3_io_listen_start failed"); h3_socket_close(udp_fd); } } -void h3_c1_child_stopping(apr_pool_t* p H3_UNUSED, int graceful) +static void stop_child_threads(int graceful) { - ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, NULL, "mod_http3: child stopping (graceful=%d)", graceful); child_stopping = 1; if (port_acquire_thread) { @@ -153,6 +208,12 @@ void h3_c1_child_stopping(apr_pool_t* p H3_UNUSED, int graceful) h3_io_listen_stop(child_h3_io); } +void h3_c1_child_stopping(apr_pool_t* p H3_UNUSED, int graceful) +{ + ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, NULL, "mod_http3: child stopping (graceful=%d)", graceful); + stop_child_threads(graceful); +} + #if H3_DEVEL void h3_c1_child_stopped(apr_pool_t* p H3_UNUSED, int graceful) From ecc75d733e4cb222855bda46d9c50e34fd742dc1 Mon Sep 17 00:00:00 2001 From: "Michiel W. Beijen" Date: Sun, 1 Nov 2026 16:04:09 +0100 Subject: [PATCH 06/12] mod_http3: Do not wait for stream data to be acknowledged on stop When a child stops, connections still open after the GOAWAY grace period are shut down with SSL_SHUTDOWN_FLAG_RAPID. That flag skips the terminating period, but OpenSSL still waits for all stream data to be acknowledged first. The GOAWAY just sent to a client that has already gone away never is, so the event thread waited for the idle timeout and the parent killed the child with SIGKILL (AH00046). Add SSL_SHUTDOWN_FLAG_NO_STREAM_FLUSH to the rapid shutdown. Generated-by: Claude Code (Claude Opus 5.5) --- modules/http3/quic/h3q_conn.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/http3/quic/h3q_conn.c b/modules/http3/quic/h3q_conn.c index 64ca406b72e..9319b64b1af 100644 --- a/modules/http3/quic/h3q_conn.c +++ b/modules/http3/quic/h3q_conn.c @@ -106,7 +106,11 @@ int h3q_conn_shutdown(h3q_conn* conn, int is_rapid, uint64_t app_error, const ch { return 1; } - uint64_t flags = is_rapid ? (uint64_t)SSL_SHUTDOWN_FLAG_RAPID : 0; + /* A rapid shutdown happens when the child stops, after the GOAWAY grace + * period: do not wait for unacknowledged stream data either, or a peer + * that has gone away keeps the connection, and the child, alive until + * the idle timeout. */ + uint64_t flags = is_rapid ? (uint64_t)(SSL_SHUTDOWN_FLAG_RAPID | SSL_SHUTDOWN_FLAG_NO_STREAM_FLUSH) : 0; int ret = 0; if (reason) { From 5cd151a45a47965eaab915f2bb329b34e633b160 Mon Sep 17 00:00:00 2001 From: "Michiel W. Beijen" Date: Sun, 1 Nov 2026 16:43:18 +0100 Subject: [PATCH 07/12] mod_http3: Send the final GOAWAY at once when the child stops On a stop, every connection waited the full GOAWAY grace period of H3_GOAWAY_GRACE_SECS, even when no request was in flight: nghttp3_conn_is_drained() only reports a drained connection after nghttp3_conn_shutdown(), and that was only called once the connection was drained or the grace period was over. A child with an idle HTTP/3 connection therefore outlived the parent's first wait and was sent SIGTERM (AH00045). On an ungraceful stop, call nghttp3_conn_shutdown() right after the shutdown notice, so that new streams are refused and is_drained reports only the streams in flight. Idle connections close at once; requests in flight still get the grace period. Generated-by: Claude Code (Claude Opus 5.5) --- modules/http3/h3_io.c | 11 ++++++++++- modules/http3/include/h3_session.h | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/modules/http3/h3_io.c b/modules/http3/h3_io.c index 98aedfd2d75..5965ffbd591 100644 --- a/modules/http3/h3_io.c +++ b/modules/http3/h3_io.c @@ -432,6 +432,11 @@ int service_session_pass(h3_io_t* io, h3_session* session) { nghttp3_conn_submit_shutdown_notice(session->ngh3); } + if (!io->thread_running && !session->final_goaway_sent) + { + nghttp3_conn_shutdown(session->ngh3); + session->final_goaway_sent = 1; + } flush_nghttp3(session); int drained = nghttp3_conn_is_drained2(session->ngh3); apr_thread_mutex_unlock(session->lock); @@ -448,7 +453,11 @@ int service_session_pass(h3_io_t* io, h3_session* session) if (drained || (session->goaway_deadline && apr_time_now() >= session->goaway_deadline)) { apr_thread_mutex_lock(session->lock); - nghttp3_conn_shutdown(session->ngh3); + if (!session->final_goaway_sent) + { + nghttp3_conn_shutdown(session->ngh3); + session->final_goaway_sent = 1; + } flush_nghttp3(session); apr_thread_mutex_unlock(session->lock); session->aborted = 1; diff --git a/modules/http3/include/h3_session.h b/modules/http3/include/h3_session.h index 02cc9d7ea89..35b0e38d441 100644 --- a/modules/http3/include/h3_session.h +++ b/modules/http3/include/h3_session.h @@ -74,6 +74,7 @@ struct h3_session int control_streams_created; int goaway_sent; + int final_goaway_sent; apr_time_t goaway_deadline; apr_time_t last_activity; From 1e670d3cc2a7d418c358ae62feea804ac2e71653 Mon Sep 17 00:00:00 2001 From: "Michiel W. Beijen" Date: Sun, 1 Nov 2026 17:00:52 +0100 Subject: [PATCH 08/12] CI: Run the mod_http3 tests Add a "mod_http3" job on Ubuntu 26.04, which has OpenSSL 3.5 and libnghttp3, that builds with --enable-http3 and runs modules/http3. The runner's curl has no HTTP/3, so TEST_CURL_H3 builds the given curl release with OpenSSL QUIC and libnghttp3 into the cached ~/root, and the run points pyhttpd at it through $CURL, failing early when that curl lacks HTTP3. The job sets PYHTTPD_STRICT_OPTIONAL, so a missing mod_http3 or HTTP/3 curl fails it instead of skipping the suite. Generated-by: Claude Code (Claude Opus 5.5) --- .github/workflows/linux.yml | 14 ++++++++++++++ test/README.ci | 5 +++++ test/gha-resolve-deps.sh | 4 ++++ test/travis_before_linux.sh | 22 ++++++++++++++++++++++ test/travis_run_linux.sh | 9 +++++++++ 5 files changed, 54 insertions(+) diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 7a11e7b1d1a..e8eaab63c36 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -179,6 +179,20 @@ jobs: TEST_PYTEST=1 PYTEST_ARGS=modules/http2 # ------------------------------------------------------------------------- + # mod_http3 needs OpenSSL >= 3.5 and libnghttp3, which Ubuntu + # 26.04 has. Its curl has no HTTP/3, so one is built from source. + # Strict mode turns a skip for a missing dependency into a failure. + - name: mod_http3 + os: ubuntu-26.04 + config: --enable-mods-shared=reallyall --enable-maintainer-mode --with-mpm=event --enable-http3 + pkgs: libnghttp3-dev + env: | + TEST_CURL_H3=8.18.0 + NO_TEST_FRAMEWORK=1 + TEST_PYTEST=1 + PYTEST_ARGS=modules/http3 + PYHTTPD_STRICT_OPTIONAL=1 + # ------------------------------------------------------------------------- - name: Default # Check the defaults expected for a default build on Linux. config-output: >- diff --git a/test/README.ci b/test/README.ci index 30b2045088e..5b64ae798ff 100644 --- a/test/README.ci +++ b/test/README.ci @@ -80,6 +80,11 @@ The CI scripts use the following environment variables: HTTP/2 workflow to test against nghttp2 versions other than the one packaged for the runner image. +* TEST_CURL_H3 - if set to a version string (e.g. "8.18.0"), that + release of curl is built with HTTP/3 (OpenSSL QUIC and libnghttp3) + and used by the pytest suites through $CURL. Used by the mod_http3 + job, since the runner's curl has no HTTP/3. + * TEST_MD - set together with TEST_PYTEST; installs pebble first so that modules/md runs instead of skipping. See the "Python pytest test suites" job in .github/workflows/linux.yml. diff --git a/test/gha-resolve-deps.sh b/test/gha-resolve-deps.sh index 8c38ccb26ce..46bd8a85c40 100755 --- a/test/gha-resolve-deps.sh +++ b/test/gha-resolve-deps.sh @@ -65,6 +65,10 @@ if test -n "${TEST_NGHTTP2-}"; then root="${root} nghttp2-${TEST_NGHTTP2}" fi +if test -n "${TEST_CURL_H3-}"; then + root="${root} curl-h3-${TEST_CURL_H3}" +fi + if test -n "$root"; then # The compiler and every flag which reaches the build are baked into # the libraries, NOTEST_* included: APR adds those to the build too, diff --git a/test/travis_before_linux.sh b/test/travis_before_linux.sh index 7db459cb49c..05f6e8539e0 100755 --- a/test/travis_before_linux.sh +++ b/test/travis_before_linux.sh @@ -246,6 +246,28 @@ if test -v TEST_NGHTTP2; then grep -H '^Version' $HOME/root/nghttp2/lib/pkgconfig/libnghttp2.pc fi +# Build the requested version of curl with HTTP/3, over OpenSSL's QUIC +# stack and the packaged libnghttp3, if it's not already installed in +# the cached ~/root. The packaged curl has no HTTP/3. +if test -v TEST_CURL_H3; then + if ! test -x $HOME/root/curl/bin/curl; then + mkdir -p build/curl + pushd build/curl + curl -L "https://curl.se/download/curl-${TEST_CURL_H3}.tar.xz" | + tar -xJf - + cd curl-${TEST_CURL_H3} + ./configure --prefix=$HOME/root/curl --disable-shared \ + --with-openssl --with-openssl-quic --with-nghttp3 \ + --without-libpsl + make $MFLAGS + make install + popd + fi + + : -- Using curl from $HOME/root/curl -- + $HOME/root/curl/bin/curl -V +fi + if test -v APR_VERSION; then install_apx apr ${APR_VERSION} "${APR_CONFIG}" ldd $HOME/root/apr-${APR_VERSION}/lib/libapr-?.so || true diff --git a/test/travis_run_linux.sh b/test/travis_run_linux.sh index 54ee551e8d5..7b1ecef7ff8 100755 --- a/test/travis_run_linux.sh +++ b/test/travis_run_linux.sh @@ -79,6 +79,15 @@ if test -v TEST_OPENSSL3; then openssl version fi +if test -v TEST_CURL_H3; then + # pyhttpd runs $CURL in place of the curl from config.ini. + export CURL=$HOME/root/curl/bin/curl + if ! $CURL -V | grep -q '^Features:.* HTTP3'; then + : $CURL was built without HTTP/3 + exit 1 + fi +fi + if test -v TEST_NGHTTP2; then # APACHE_CHECK_NGHTTP2 only yields -L, so an RPATH is needed here or # mod_http2.so resolves to the system libnghttp2 at run-time. From 9046c7459ba4406c90e35b2be365d2aa3aae9707 Mon Sep 17 00:00:00 2001 From: "Michiel W. Beijen" Date: Wed, 4 Nov 2026 19:39:09 +0100 Subject: [PATCH 09/12] mod_http3: Name the nghttp3_conn_is_drained2() fallback in upper case Review feedback: define H3_NGHTTP3_CONN_IS_DRAINED() for both nghttp3 versions instead of redefining the lowercase nghttp3 function name. * modules/http3/h3_io.c: Add H3_NGHTTP3_CONN_IS_DRAINED() and use it. Generated-by: Claude Code (Claude Opus 5.5) --- modules/http3/h3_io.c | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/http3/h3_io.c b/modules/http3/h3_io.c index 5965ffbd591..59cc6979ee7 100644 --- a/modules/http3/h3_io.c +++ b/modules/http3/h3_io.c @@ -45,9 +45,11 @@ #include "quic/h3q_conn.h" #include "quic/h3q_stream.h" -#ifndef HAVE_NGHTTP3_CONN_IS_DRAINED2 +#ifdef HAVE_NGHTTP3_CONN_IS_DRAINED2 +#define H3_NGHTTP3_CONN_IS_DRAINED(conn) nghttp3_conn_is_drained2(conn) +#else /* nghttp3 < 1.16.0 has the same function, without the const argument */ -#define nghttp3_conn_is_drained2 nghttp3_conn_is_drained +#define H3_NGHTTP3_CONN_IS_DRAINED(conn) nghttp3_conn_is_drained(conn) #endif h3_io_t* child_h3_io = NULL; @@ -438,7 +440,7 @@ int service_session_pass(h3_io_t* io, h3_session* session) session->final_goaway_sent = 1; } flush_nghttp3(session); - int drained = nghttp3_conn_is_drained2(session->ngh3); + int drained = H3_NGHTTP3_CONN_IS_DRAINED(session->ngh3); apr_thread_mutex_unlock(session->lock); if (!session->goaway_sent) { From fba237c1f4567a55f4fd798a9c2b2363c8062b8f Mon Sep 17 00:00:00 2001 From: "Michiel W. Beijen" Date: Wed, 4 Nov 2026 19:53:19 +0100 Subject: [PATCH 10/12] mod_http3: Pick the nghttp3 drained function by version Review feedback: the HAVE_NGHTTP3_CONN_IS_DRAINED2 check existed only in the autoconf build, so CMake builds always fell back to the old function. Test NGHTTP3_VERSION_NUM instead, which every build gets from . Also keep the declaration of "drained" at the top of its block, as C89 requires. * modules/http3/config2.m4: Drop the nghttp3_conn_is_drained2 check. * modules/http3/h3_io.c (H3_NGHTTP3_CONN_IS_DRAINED): Use nghttp3_conn_is_drained2() from nghttp3 1.16.0 on. (service_session_pass): Declare "drained" at the top of the block. Generated-by: Claude Code (Claude Opus 5.5) --- modules/http3/config2.m4 | 2 -- modules/http3/h3_io.c | 5 +++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/modules/http3/config2.m4 b/modules/http3/config2.m4 index ac80bfc5ee0..e84607b8c3e 100644 --- a/modules/http3/config2.m4 +++ b/modules/http3/config2.m4 @@ -141,8 +141,6 @@ AC_DEFUN([APACHE_CHECK_NGHTTP3],[ AC_CHECK_HEADERS([nghttp3/nghttp3.h]) dnl nghttp3_conn_server_new() is a macro around the versioned function AC_CHECK_FUNCS([nghttp3_conn_server_new_versioned], [], [liberrors="yes"]) - dnl nghttp3 >= 1.16.0 replaces nghttp3_conn_is_drained() - AC_CHECK_FUNCS([nghttp3_conn_is_drained2]) if test "x$liberrors" != "x"; then AC_MSG_WARN([nghttp3 library is unusable]) fi diff --git a/modules/http3/h3_io.c b/modules/http3/h3_io.c index 59cc6979ee7..232412e3e85 100644 --- a/modules/http3/h3_io.c +++ b/modules/http3/h3_io.c @@ -45,7 +45,7 @@ #include "quic/h3q_conn.h" #include "quic/h3q_stream.h" -#ifdef HAVE_NGHTTP3_CONN_IS_DRAINED2 +#if NGHTTP3_VERSION_NUM >= 0x011000 #define H3_NGHTTP3_CONN_IS_DRAINED(conn) nghttp3_conn_is_drained2(conn) #else /* nghttp3 < 1.16.0 has the same function, without the const argument */ @@ -429,6 +429,7 @@ int service_session_pass(h3_io_t* io, h3_session* session) if (io->draining || !io->thread_running) { + int drained; apr_thread_mutex_lock(session->lock); if (!session->goaway_sent) { @@ -440,7 +441,7 @@ int service_session_pass(h3_io_t* io, h3_session* session) session->final_goaway_sent = 1; } flush_nghttp3(session); - int drained = H3_NGHTTP3_CONN_IS_DRAINED(session->ngh3); + drained = H3_NGHTTP3_CONN_IS_DRAINED(session->ngh3); apr_thread_mutex_unlock(session->lock); if (!session->goaway_sent) { From 829ffd251494113a281c35647aff8acc7c04fb13 Mon Sep 17 00:00:00 2001 From: "Michiel W. Beijen" Date: Wed, 4 Nov 2026 19:39:12 +0100 Subject: [PATCH 11/12] mod_http3: Move the thread-safe pool helper to h3_threads Review feedback: keep the body of stop_child_threads() in h3_c1_child_stopping(), which the child pool cleanup now calls, so no forward declaration is needed. Move the pool creation to h3_threads.c so other code can reuse it. * modules/http3/h3_server.c (h3_c1_child_stopping): Fold in stop_child_threads(). * modules/http3/h3_threads.c, modules/http3/include/h3_threads.h (h3_thread_safe_pool_create): New, from create_h3_pool() in h3_server.c. Generated-by: Claude Code (Claude Opus 5.5) --- modules/http3/h3_server.c | 45 ++++-------------------------- modules/http3/h3_threads.c | 28 +++++++++++++++++++ modules/http3/include/h3_threads.h | 14 ++++++++++ 3 files changed, 47 insertions(+), 40 deletions(-) diff --git a/modules/http3/h3_server.c b/modules/http3/h3_server.c index 4c74ef66b49..3de6360be06 100644 --- a/modules/http3/h3_server.c +++ b/modules/http3/h3_server.c @@ -30,6 +30,7 @@ #include "h3_os.h" #include "h3_server.h" #include "h3_socket.h" +#include "h3_threads.h" #include "mod_http3.h" static volatile int child_stopping = 0; @@ -104,44 +105,13 @@ static h3_server_conf* find_h3_server(server_rec* s, server_rec** out_server) return conf; } -static void stop_child_threads(int graceful); - -/* mod_http3 allocates from its own threads, so it gets a pool of its own - * whose allocator has a mutex. The child pool's allocator may have none: - * prefork shares it, unlocked, with the pool of every request. */ -static apr_status_t create_h3_pool(apr_pool_t* pchild, apr_pool_t** out) -{ - apr_allocator_t* allocator = NULL; - apr_thread_mutex_t* mutex = NULL; - apr_status_t rv = apr_allocator_create(&allocator); - if (rv != APR_SUCCESS) - { - return rv; - } - rv = apr_pool_create_ex(out, pchild, NULL, allocator); - if (rv != APR_SUCCESS) - { - apr_allocator_destroy(allocator); - return rv; - } - apr_allocator_owner_set(allocator, *out); - apr_pool_tag(*out, "h3_child"); - rv = apr_thread_mutex_create(&mutex, APR_THREAD_MUTEX_DEFAULT, *out); - if (rv != APR_SUCCESS) - { - return rv; - } - apr_allocator_mutex_set(allocator, mutex); - return APR_SUCCESS; -} - /* The threads must be gone before the child pool they run from is * destroyed, and before exit() runs OpenSSL's cleanup under them. Most * MPMs stop them through the child_stopping hook, but prefork skips that * hook when a child exits from a signal handler. */ static apr_status_t child_pool_cleanup(void* data H3_UNUSED) { - stop_child_threads(0); + h3_c1_child_stopping(NULL, 0); return APR_SUCCESS; } @@ -156,7 +126,7 @@ void h3_child_init(apr_pool_t* pchild, server_rec* s) } apr_pool_pre_cleanup_register(pchild, NULL, child_pool_cleanup); apr_pool_t* pool = NULL; - if (create_h3_pool(pchild, &pool) != APR_SUCCESS) + if (h3_thread_safe_pool_create(pchild, "h3_child", &pool) != APR_SUCCESS) { ap_log_error(APLOG_MARK, APLOG_ERR, 0, vhost, APLOGNO() "h3_child_init: could not create the HTTP/3 pool"); return; @@ -191,8 +161,9 @@ void h3_child_init(apr_pool_t* pchild, server_rec* s) } } -static void stop_child_threads(int graceful) +void h3_c1_child_stopping(apr_pool_t* p H3_UNUSED, int graceful) { + ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, NULL, "mod_http3: child stopping (graceful=%d)", graceful); child_stopping = 1; if (port_acquire_thread) { @@ -208,12 +179,6 @@ static void stop_child_threads(int graceful) h3_io_listen_stop(child_h3_io); } -void h3_c1_child_stopping(apr_pool_t* p H3_UNUSED, int graceful) -{ - ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, NULL, "mod_http3: child stopping (graceful=%d)", graceful); - stop_child_threads(graceful); -} - #if H3_DEVEL void h3_c1_child_stopped(apr_pool_t* p H3_UNUSED, int graceful) diff --git a/modules/http3/h3_threads.c b/modules/http3/h3_threads.c index 137e9531123..cc01d44e2b7 100644 --- a/modules/http3/h3_threads.c +++ b/modules/http3/h3_threads.c @@ -20,6 +20,8 @@ #include #include +#include +#include #include "h3.h" #include "h3_io.h" @@ -29,6 +31,32 @@ #include "quic/h3q.h" #include "quic/h3q_conn.h" +apr_status_t h3_thread_safe_pool_create(apr_pool_t* parent, const char* tag, apr_pool_t** out) +{ + apr_allocator_t* allocator = NULL; + apr_thread_mutex_t* mutex = NULL; + apr_status_t rv = apr_allocator_create(&allocator); + if (rv != APR_SUCCESS) + { + return rv; + } + rv = apr_pool_create_ex(out, parent, NULL, allocator); + if (rv != APR_SUCCESS) + { + apr_allocator_destroy(allocator); + return rv; + } + apr_allocator_owner_set(allocator, *out); + apr_pool_tag(*out, tag); + rv = apr_thread_mutex_create(&mutex, APR_THREAD_MUTEX_DEFAULT, *out); + if (rv != APR_SUCCESS) + { + return rv; + } + apr_allocator_mutex_set(allocator, mutex); + return APR_SUCCESS; +} + void* APR_THREAD_FUNC h3_event_thread(apr_thread_t* thread, void* data) { (void)thread; diff --git a/modules/http3/include/h3_threads.h b/modules/http3/include/h3_threads.h index 90de5541a6f..b594add38b9 100644 --- a/modules/http3/include/h3_threads.h +++ b/modules/http3/include/h3_threads.h @@ -18,6 +18,7 @@ #define H3_THREADS_H #include "h3_io.h" +#include #include /** @@ -51,4 +52,17 @@ void* APR_THREAD_FUNC worker_thread(apr_thread_t* thread, void* data); */ void* APR_THREAD_FUNC h3_event_thread(apr_thread_t* thread, void* data); +/** + * Create a pool that may be allocated from by more than one thread: it has + * an allocator of its own, with a mutex. Pools such as the child pool may + * have an allocator without one; prefork shares it, unlocked, with the + * pool of every request. + * + * @param parent The parent pool. + * @param tag The tag for the new pool. + * @param out Receives the new pool. + * @return APR_SUCCESS, or the error from APR. + */ +apr_status_t h3_thread_safe_pool_create(apr_pool_t* parent, const char* tag, apr_pool_t** out); + #endif /* H3_THREADS_H */ From 95797199b3dfb52a05ff9a381a6d5e580e2a81e8 Mon Sep 17 00:00:00 2001 From: "Michiel W. Beijen" Date: Wed, 4 Nov 2026 19:53:20 +0100 Subject: [PATCH 12/12] mod_http3: Do not allocate from the HTTP/3 pool from two threads Review feedback: the mutex on the allocator of the HTTP/3 pool covers subpools, not allocations from the pool itself. h3_io_listen_start() registered its pool cleanup after starting the event thread, which allocates from the same pool, so the two could allocate at once when the port acquirer thread started listening. Register it first. Also destroy the pool when its mutex cannot be created, log when the port acquirer cannot create its pool, drop an empty APLOGNO() that no other message in the file has, and keep the declarations in h3_child_init() at the top of their blocks, as C89 requires. * modules/http3/h3_io.c (h3_io_listen_start): Register the cleanup before starting the event thread. * modules/http3/h3_server.c (port_acquire_thread_fn): Log the pool creation failure. (h3_child_init): Declare variables first; drop the APLOGNO(). * modules/http3/h3_threads.c (h3_thread_safe_pool_create): Destroy the pool if its mutex cannot be created. * modules/http3/include/h3_threads.h: Say what the mutex does not cover. Generated-by: Claude Code (Claude Opus 5.5) --- modules/http3/h3_io.c | 4 +++- modules/http3/h3_server.c | 14 ++++++++------ modules/http3/h3_threads.c | 2 ++ modules/http3/include/h3_threads.h | 10 ++++++---- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/modules/http3/h3_io.c b/modules/http3/h3_io.c index 232412e3e85..2ec81eeb4a5 100644 --- a/modules/http3/h3_io.c +++ b/modules/http3/h3_io.c @@ -162,7 +162,10 @@ apr_status_t h3_io_listen_start(apr_pool_t* pchild, server_rec* s, h3_server_con io->thread_running = 1; child_h3_io = io; + /* The event thread allocates from pchild too, so allocate everything + * from it here before the thread starts. */ apr_threadattr_t* attr = NULL; + apr_pool_pre_cleanup_register(pchild, io, listen_stop_cleanup); if (apr_threadattr_create(&attr, pchild) != APR_SUCCESS || apr_thread_create(&io->event_thread, attr, h3_event_thread, io, pchild) != APR_SUCCESS) { io->thread_running = 0; @@ -170,7 +173,6 @@ apr_status_t h3_io_listen_start(apr_pool_t* pchild, server_rec* s, h3_server_con child_h3_io = NULL; return APR_EGENERAL; } - apr_pool_pre_cleanup_register(pchild, io, listen_stop_cleanup); ap_log_error(APLOG_MARK, APLOG_INFO, 0, s, "mod_http3 loaded with version: %d (%s) on pid=%d port=%d; built against httpd %d.%d.%d MMN %d:%d (%s)", MOD_HTTP3_VERSION, MOD_HTTP3_VERSION_STRING, h3_getpid(), (int)conf->h3_port, AP_SERVER_MAJORVERSION_NUMBER, AP_SERVER_MINORVERSION_NUMBER, AP_SERVER_PATCHLEVEL_NUMBER, MODULE_MAGIC_NUMBER_MAJOR, MODULE_MAGIC_NUMBER_MINOR, H3_DEVEL ? "devel" : "stable"); return APR_SUCCESS; diff --git a/modules/http3/h3_server.c b/modules/http3/h3_server.c index 3de6360be06..df2ecf3dfe4 100644 --- a/modules/http3/h3_server.c +++ b/modules/http3/h3_server.c @@ -52,6 +52,7 @@ static void* APR_THREAD_FUNC port_acquire_thread_fn(apr_thread_t* thread H3_UNUS apr_pool_t* ptry = NULL; if (apr_pool_create(&ptry, args->pool) != APR_SUCCESS) { + ap_log_error(APLOG_MARK, APLOG_ERR, 0, args->vhost, "port acquirer: could not create a pool"); return NULL; } while (!child_stopping) @@ -119,29 +120,30 @@ void h3_child_init(apr_pool_t* pchild, server_rec* s) { server_rec* vhost = NULL; h3_server_conf* conf = find_h3_server(s, &vhost); + apr_pool_t* pool = NULL; + int udp_fd = -1; + apr_status_t rv; if (!conf) { ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, s, "h3_child_init: no H3 cert/key configured, skipping"); return; } apr_pool_pre_cleanup_register(pchild, NULL, child_pool_cleanup); - apr_pool_t* pool = NULL; if (h3_thread_safe_pool_create(pchild, "h3_child", &pool) != APR_SUCCESS) { - ap_log_error(APLOG_MARK, APLOG_ERR, 0, vhost, APLOGNO() "h3_child_init: could not create the HTTP/3 pool"); + ap_log_error(APLOG_MARK, APLOG_ERR, 0, vhost, "h3_child_init: could not create the HTTP/3 pool"); return; } - int udp_fd = -1; - apr_status_t rv = h3_socket_open(conf->h3_port, conf->h3_socket_buffer_size, pool, &udp_fd); + rv = h3_socket_open(conf->h3_port, conf->h3_socket_buffer_size, pool, &udp_fd); if (rv == APR_EAGAIN) { - ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, vhost, "h3_child_init: pid=%d port already owned, will keep retrying in background", h3_getpid()); struct port_acquire_args* args = apr_palloc(pool, sizeof(*args)); + apr_threadattr_t* attr = NULL; + ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, vhost, "h3_child_init: pid=%d port already owned, will keep retrying in background", h3_getpid()); args->pool = pool; args->vhost = vhost; args->conf = conf; - apr_threadattr_t* attr = NULL; if (apr_threadattr_create(&attr, pool) != APR_SUCCESS || apr_thread_create(&port_acquire_thread, attr, port_acquire_thread_fn, args, pool) != APR_SUCCESS) { ap_log_error(APLOG_MARK, APLOG_ERR, 0, vhost, "h3_child_init: failed to start port acquirer thread"); diff --git a/modules/http3/h3_threads.c b/modules/http3/h3_threads.c index cc01d44e2b7..32ace936d39 100644 --- a/modules/http3/h3_threads.c +++ b/modules/http3/h3_threads.c @@ -51,6 +51,8 @@ apr_status_t h3_thread_safe_pool_create(apr_pool_t* parent, const char* tag, apr rv = apr_thread_mutex_create(&mutex, APR_THREAD_MUTEX_DEFAULT, *out); if (rv != APR_SUCCESS) { + apr_pool_destroy(*out); + *out = NULL; return rv; } apr_allocator_mutex_set(allocator, mutex); diff --git a/modules/http3/include/h3_threads.h b/modules/http3/include/h3_threads.h index b594add38b9..c057d66c5b6 100644 --- a/modules/http3/include/h3_threads.h +++ b/modules/http3/include/h3_threads.h @@ -53,10 +53,12 @@ void* APR_THREAD_FUNC worker_thread(apr_thread_t* thread, void* data); void* APR_THREAD_FUNC h3_event_thread(apr_thread_t* thread, void* data); /** - * Create a pool that may be allocated from by more than one thread: it has - * an allocator of its own, with a mutex. Pools such as the child pool may - * have an allocator without one; prefork shares it, unlocked, with the - * pool of every request. + * Create a pool with an allocator of its own, which has a mutex, so that + * threads can create and destroy subpools of it concurrently. Pools such + * as the child pool may have an allocator without one; prefork shares it, + * unlocked, with the pool of every request. The mutex does not make + * allocating from the pool itself thread-safe: only one thread at a time + * may do that. * * @param parent The parent pool. * @param tag The tag for the new pool.