From bfd3dfe5f6107c173a3dc834ee72455e40293a40 Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Fri, 9 Oct 2026 11:01:46 +0300 Subject: [PATCH 1/5] Lock the session allocator: workers and the event thread share it Worker threads allocate from stream subpools while the event thread allocates from the session pool. All of them use one allocator, which had no mutex, so concurrent allocations corrupted the heap and crashed the child under load. Set a mutex on the allocator, as mod_http2 does. --- changes-entries/h3_session_allocator_race.txt | 3 +++ modules/http3/h3_io.c | 9 +++++++++ 2 files changed, 12 insertions(+) create mode 100644 changes-entries/h3_session_allocator_race.txt diff --git a/changes-entries/h3_session_allocator_race.txt b/changes-entries/h3_session_allocator_race.txt new file mode 100644 index 00000000000..87ebb36e22b --- /dev/null +++ b/changes-entries/h3_session_allocator_race.txt @@ -0,0 +1,3 @@ + *) mod_http3: Fixed a heap race that crashed the child: worker threads and + the event thread allocated from one session allocator that had no mutex. + [Alexander Gerasimov ] diff --git a/modules/http3/h3_io.c b/modules/http3/h3_io.c index 22f618b98ec..5f9a3ab0a2b 100644 --- a/modules/http3/h3_io.c +++ b/modules/http3/h3_io.c @@ -269,6 +269,15 @@ static apr_status_t spawn_serviced_session(h3_io_t* io, h3q_conn* conn) } apr_allocator_owner_set(allocator, session_pool); apr_pool_tag(session_pool, "h3_session"); + /* Workers allocate from stream subpools while this thread makes its own: share the allocator safely. */ + apr_thread_mutex_t* alloc_mutex = NULL; + if (apr_thread_mutex_create(&alloc_mutex, APR_THREAD_MUTEX_DEFAULT, session_pool) != APR_SUCCESS) + { + apr_pool_destroy(session_pool); + h3q_conn_free(conn); + return APR_EGENERAL; + } + apr_allocator_mutex_set(allocator, alloc_mutex); h3_session* session = NULL; if (h3_session_create(&session, io->server, conn, session_pool) != APR_SUCCESS) From 4bd4a579bd697f975954651e78441d1d158605da Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Fri, 9 Oct 2026 11:01:56 +0300 Subject: [PATCH 2/5] Advance pending handshakes while draining A pending handshake counts as an MPM connection. The event thread advanced pending handshakes only when not draining, so a handshake in flight at a graceful restart did not finish or time out. The old child then never reached zero connections and kept the port. --- changes-entries/h3_drain_pending_handshakes.txt | 4 ++++ modules/http3/h3_threads.c | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 changes-entries/h3_drain_pending_handshakes.txt diff --git a/changes-entries/h3_drain_pending_handshakes.txt b/changes-entries/h3_drain_pending_handshakes.txt new file mode 100644 index 00000000000..c38c37f7041 --- /dev/null +++ b/changes-entries/h3_drain_pending_handshakes.txt @@ -0,0 +1,4 @@ + *) mod_http3: Fixed a graceful restart that never ended: a handshake still + pending when the child started to drain did not finish or time out, so + the old child kept its connection count and the port. + [Alexander Gerasimov ] diff --git a/modules/http3/h3_threads.c b/modules/http3/h3_threads.c index 7d186e0fa39..6c992e3c747 100644 --- a/modules/http3/h3_threads.c +++ b/modules/http3/h3_threads.c @@ -96,6 +96,10 @@ void* APR_THREAD_FUNC h3_event_thread(apr_thread_t* thread, void* data) h3q_conn_free(conn); } } + } + if (io->thread_running) + { + /* Also while draining: a pending handshake counts as an MPM connection, so it must finish or time out. */ progress_pending_handshakes(io); } From 4a44dc9e20f5c4e1c02a031b8293c2c2d9f589a5 Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Fri, 9 Oct 2026 11:01:57 +0300 Subject: [PATCH 3/5] Block a write-blocked stream again each time nghttp3 offers it nghttp3_conn_resume_stream puts a blocked stream back in the write queue. The module blocked the stream only on the first refusal, so when the peer kept its window shut, nghttp3 offered the stream again and again and the event thread used 100% CPU. --- changes-entries/h3_blocked_stream_spin.txt | 4 ++++ modules/http3/h3_stream.c | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 changes-entries/h3_blocked_stream_spin.txt diff --git a/changes-entries/h3_blocked_stream_spin.txt b/changes-entries/h3_blocked_stream_spin.txt new file mode 100644 index 00000000000..1b230bfa5d4 --- /dev/null +++ b/changes-entries/h3_blocked_stream_spin.txt @@ -0,0 +1,4 @@ + *) mod_http3: Fixed an event-thread spin while a client holds its + flow-control window shut: a blocked stream is now blocked again each time + nghttp3 offers it. + [Alexander Gerasimov ] diff --git a/modules/http3/h3_stream.c b/modules/http3/h3_stream.c index b078ad7bae9..fc67e04048d 100644 --- a/modules/http3/h3_stream.c +++ b/modules/http3/h3_stream.c @@ -126,13 +126,14 @@ void flush_nghttp3(h3_session* session) nghttp3_conn_add_ack_offset(session->ngh3, sid, res.accepted); if (res.blocked) { - /* Send buffer full: skip this stream instead of busy-looping on the same vec. */ + /* Send buffer full: skip this stream instead of busy-looping on the same vec. + * Block it again every time: resume_stream reschedules a blocked stream. */ if (!h3s->write_blocked) { h3s->write_blocked = 1; session->blocked_streams++; - nghttp3_conn_block_stream(session->ngh3, sid); } + nghttp3_conn_block_stream(session->ngh3, sid); continue; } } From 82955e01d794c760258172f57c0328f1aacd95e3 Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Wed, 11 Nov 2026 21:45:59 +0200 Subject: [PATCH 4/5] mod_http3: clear port_acquire_running when the port acquirer cannot make its pool Else h3_c1_child_stopping waits for ever. --- modules/http3/h3_server.c | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/http3/h3_server.c b/modules/http3/h3_server.c index 176f1fb95e1..0b124a61628 100644 --- a/modules/http3/h3_server.c +++ b/modules/http3/h3_server.c @@ -54,6 +54,7 @@ static void* APR_THREAD_FUNC port_acquire_thread_fn(apr_thread_t* thread H3_UNUS 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"); + apr_atomic_set32(&port_acquire_running, 0); return NULL; } while (!child_stopping) From c69347ed2fde019d37c310e6be5a4ff3cf7cc546 Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Wed, 11 Nov 2026 21:46:00 +0200 Subject: [PATCH 5/5] test: make an HTTP/3 request in each test_h3_014_04 stop cycle A live session at stop also tears down its control streams, which is where the shutdown crashes were. --- test/modules/http3/test_014_lifecycle.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/modules/http3/test_014_lifecycle.py b/test/modules/http3/test_014_lifecycle.py index 31c04591dc8..c23e3c20482 100644 --- a/test/modules/http3/test_014_lifecycle.py +++ b/test/modules/http3/test_014_lifecycle.py @@ -43,9 +43,14 @@ def test_h3_014_04_stop_cleanly(self, env): H3Conf(env).add_h3_vhost().install() log = env.httpd_error_log start = log.current_pos() + url = env.mkurl("https", "test1", "/001.html") for _ in range(20): assert env.apache_restart() == 0 assert env.wait_udp_port(env.https_port) + # a live session, so stop also tears down its control streams + r = env.h3_get(url) + assert r.exit_code == 0, f"{r}" + assert r.response["protocol"] == "HTTP/3", f"{r}" assert env.apache_stop() == 0 # crashed, or still there when the parent gave up waiting unclean = [log.RE_ERRLOG_CRASH.pattern, r'.*AH0004[56]: child process']