Fix leaked QUIC blocking-section refs on SSL_poll abort path#32054
Open
johnbuckman wants to merge 1 commit into
Open
Fix leaked QUIC blocking-section refs on SSL_poll abort path#32054johnbuckman wants to merge 1 commit into
johnbuckman wants to merge 1 commit into
Conversation
poll_translate() takes a reactor blocking-section reference for each QUIC poll item it translates, to be released later by postpoll_translation_cleanup(). The failure path unwinds correctly, but the early-abort path -- taken when an item is found already ready, which is the common case -- returned without releasing the references taken for the items translated so far. Item i releases its own reference before the abort check, so items 0..i-1 each leak one blocking-section reference. QUIC_REACTOR's cur_blocking_waiters count then only ever grows; the owning wait context is gone, so nothing decrements it. ossl_quic_reactor_wait_ctx_cleanup() only asserts the count is zero, and that assert is compiled out under -DNDEBUG (release builds), so the leak is silent. A later rtor_notify_other_threads() then waits forever on signalled_notifier, which is only cleared once cur_blocking_waiters reaches zero -- and so the reactor deadlocks for the life of the process. The effect scales with pollset size, so it manifests quickly under concurrent QUIC/HTTP-3 load and not at all for single sequential polls. Release the references on the abort path exactly as the failure path does, bounded by i (item i has already released its own). Found via the NaviServer HTTP/3 driver, where h3 wedged under concurrency within seconds. The fix was independently reproduced and confirmed against master by Gustaf Neumann (NaviServer). The same defect is present in the 4.0 branch (poll_immediate.c), so a backport is desirable.
johnbuckman
force-pushed
the
quic-poll-immediate-blocking-ref-leak
branch
from
July 23, 2026 14:04
21436fd to
dbad702
Compare
Contributor
|
this seems to be a duplicate to PR submitted by @mattcaswell I think we should close this one and get reviews done on Matt's PR which comes witht test too. thanks for understanding. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
poll_translate()inssl/rio/poll_immediate.cleaks one reactor blocking-section reference per already-ready QUIC poll item, on the ordinarySSL_poll()return path. The leaked references leaveQUIC_REACTOR.cur_blocking_waiterspermanently above zero, sortor_notify_other_threads()eventually blocks forever and the whole QUIC reactor deadlocks — anSSL_poll()call that was given a finite timeout ends up parked in an untimed condition-variable wait. It reproduces in seconds under concurrent QUIC/HTTP-3 load and never for a single sequential poller. The fix is one statement: release the references on the abort path exactly as the existing failure path already does.You can confirm this by reading
ssl/rio/poll_immediate.calone (next section) — no reproducer required, and no need to take the patch on faith. I've also included a runtime backtrace, a reproduction recipe, a scope note, and a suggested regression test. The fix was independently reproduced and confirmed on master by @gustafn (NaviServer maintainer).The defect, by inspection
Two functions in the same file.
(1) Each translated item takes one blocking-section reference (
poll_translate_ssl_quic(), ~line 130):On the already-ready outcome it releases its own reference and sets
*abort_blocking. It does not touch the references taken for the items translated before it.(2)
poll_translate()handles its two exits asymmetrically. The failure path unwinds correctly:postpoll_translation_cleanup()walks items0..i-1callingossl_quic_leave_blocking_section()on each. But the abort path (pre-patch) returns without it:So on abort, items
0..i-1each keep a blocking-section reference forever. (Itemialready released its own in step 1 — which is exactly why the correct cleanup bound isi, identical to the failure path.)Why this is the common case, not a corner case:
*abort_blockingis set whenever any polled QUIC object is found already readable between the initial readout and arming the notifier. On a busy pollset that is the normal result of a poll, not an exception — so the leak accrues on essentially every poll that has ≥2 items and any readiness.Why the leak deadlocks the reactor
ossl_quic_enter_/leave_blocking_section()balance the reactor's blocking-waiter accounting. Inssl/quic/quic_reactor.c,signalled_notifieris cleared only oncecur_blocking_waitersreturns to zero (the last waiter to leave clears it — see the block-until path, ~lines 608–628, and the design comment at ~544–576). A thread trying to notify does:Leaked
enters keepcur_blocking_waiterspermanently > 0, sosignalled_notifieris never cleared, so this loop never returns. The QUIC reactor is dead for the life of the process.The only guard that would have caught the imbalance is an
assert()(ossl_quic_reactor_wait_ctx_cleanup()asserts the count is zero), and release builds compile with-DNDEBUG— so it is silent in production and surfaces only as a mysterious hang.How we found it (train of thought)
SSL_poll(), HTTP/3 would wedge permanently under load while HTTP/1.1 and HTTP/2 in the same process kept answering200and the process never crashed. One client doing requests one-at-a-time never triggered it; ~6 concurrent h3 connections wedged it in under 2 seconds, every time.gdb -p <pid> -batch -ex "thread apply all bt"on the wedged process:SSL_poll()given a finite timeout has no business parking in an untimed condvar wait. That located the bug inside libssl, not the caller.blocking_mode=0— non-blocking is inherited as documented. Not it.SSL_handle_events()preprocessing is at fault." OpenSSL's owndemos/quic/poll-server/never calls it. Removing ours just moved the same hang intoSSL_poll()itself, via the sameossl_quic_reactor_tickpath — two entry points, one broken reactor.rtor_notify_other_threads()spinning onsignalled_notifier, which clears only whencur_blocking_waitersreaches zero. So something increments that count without a matching decrement. The enter/leave blocking-section calls are that count — andpoll_translate()'s abort path is the one place that returns without the pairedleave. That is the leak; the fix is to mirror the failure path.The fix
Fixed here rather than in
wait_ctx_cleanup()because the enter/leave wrappers take the reactor lock while cleanup runs unlocked — moving it there would race.Reproducing it
A. By inspection — the two functions above. No build required; this is the most direct check.
B. End-to-end, with a real browser. A stdlib-only Python script drives headless Chrome forced onto HTTP/3:
then over the DevTools protocol it confirms the navigation actually used h3 (
performance.getEntries()[0].nextHopProtocol === "h3") andfetch()es a small file plus a multi-MB file, concurrently, printing the instant h3 stops answering while a plain-HTTPS canary on the same server stays200(which distinguishes "h3 reactor wedged" from "server down"). Under ~6 concurrent streams it wedges in <2s. We also have an aioquic-based harness that does the same without a browser. Happy to share both scripts, or the exact server build, on request.Scope note (re "other servers"): the client is server-agnostic, but this defect is in OpenSSL's QUIC
SSL_poll()blocking path, so it only reproduces against a server that drives QUIC throughSSL_poll()in a blocking event loop — i.e. something built on OpenSSL's native QUIC, of whichdemos/quic/poll-server/is the reference. Servers on other QUIC stacks (ngtcp2, quiche, msquic, …) do not exercise this code and will not reproduce it. We hit it on the NaviServer OpenSSL-QUIC driver; @gustafn independently reproduced and confirmed the fix on master (with the patch, 20 alternating 5 MiB and range requests then completed in one process).C. Suggested regression test. Poll two or more QUIC stream items in blocking mode with a finite timeout where at least one is already readable, then assert the reactor's blocking-waiter count is back to zero after
SSL_poll()returns (pre-patch it is left at N−1, where N is the number of items before the ready one). Glad to help turn this into a propertest/case if you'd like it in the PR.Backport
The same code is on the 4.0 branch (
ssl/rio/poll_immediate.c) — a backport is desirable.