Skip to content

QPACK: free arena strings in reverse allocation order - #13636

Open
brbzull0 wants to merge 3 commits into
apache:masterfrom
brbzull0:qpack-arena-free-order
Open

QPACK: free arena strings in reverse allocation order#13636
brbzull0 wants to merge 3 commits into
apache:masterfrom
brbzull0:qpack-arena-free-order

Conversation

@brbzull0

@brbzull0 brbzull0 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Motivation

Arena::free() (src/tscore/Arena.cc:129) only rewinds when the freed range
ends exactly at the block's water level:

if (b->m_water_level == (static_cast<char *>(mem) + size)) {
  b->m_water_level = static_cast<char *>(mem);
  return;
}

Anything else is a silent no-op: the space stays outstanding until the arena is
destroyed. HPACK and QPACK each keep one arena per connection for the strings
they decode, so every release that does not land on the water level makes that
connection's arena a little larger, for as long as the connection lives. Two
things in the header decode paths did exactly that.

Free order. Two QPACK sites allocated name then value and released
name first. With value still outstanding, the name free never reached the
water level, so only value was reclaimed:

  • _decode_literal_header_field_without_name_ref() (QPACK.cc:822)
  • the Insert Without Name Ref branch of _on_encoder_stream_read_ready()
    (QPACK.cc:1175), which also never freed value at all

Failure path. xpack_decode_string() allocates the huffman temporary area
before calling huffman_decode(), and on a decode failure returned without
releasing it. That is shared code, so the same thing happened at every caller:
HPACK.cc:622 and 648, and QPACK.cc:770, 802, 809 and 901.

Change

Both QPACK sites now free value before name, so each release lands on the
water level and both rewind.

Following @maskit's review, the failure-path release moved into
xpack_decode_string() rather than being repeated at each call site. That
covers HPACK with no change to HPACK.cc, and it is the only option for
QPACK.cc:770 and 901, where the output pointer is uninitialised on failure
and the caller has nothing it could release.

While there, the function got a clearer contract. The temporary is decoded
through a local and only assigned to *str once huffman_decode() succeeds,
so on any error return neither output is written and the released pointer never
reaches a caller. The header now says so, and also records that
max_string_len bounds the encoded length: a huffman-coded string may decode
to more than the limit, and the existing limit above encoded length allows
test depends on that.

QPACK.cc:809 additionally frees name on the value-error path. name came
from an earlier successful decode, so the callee cannot release it.

Tests

test_XPACK.cc:

  • failed huffman decoding releases the temporary area — decodes malformed
    huffman input 100 times and checks the arena did not grow. Arena has no
    water-level accessor; the address str_alloc() returns is the proxy. Fails
    against master at test_XPACK.cc:191.
  • outputs are written only on success — a sentinel pointer and length survive
    both a post-allocation failure (bad huffman) and a pre-allocation failure
    (truncated literal). Fails against the first revision of this PR with
    actual == <arena address>, i.e. it catches the released temporary being
    handed back.

test_arena.cc gains three cases (129 assertions) pinning the invariant the
free-order fix relies on: the most recent allocation rewinds, two allocations
freed in reverse order both come back, two freed in allocation order do not.
Swapping the free order in the test fails it.

test_QPACK.cc gains a case decoding a Literal Header Field Without Name
Reference, the representation whose name and value come from the arena, 200
times and asserting the decoded field each time (804 assertions). test_qpack
previously ran zero assertions: both existing cases return early when the QIF
fixture directories are absent, and Catch2 reports them as passed.

Full unit suite passes (85/85 excluding the verify_* plugin-load tests, which
need an install root). Built without quiche.

Note on Arena::free

Recording rather than fixing here: Arena::free() walks the block list with
while (b->next), so it never inspects the last block. While an arena holds a
single block every free is a no-op, including the ones in this change. Both
fixes take effect once the arena has two or more blocks (DEFAULT_BLOCK_SIZE
is 1000 bytes), which is where the unbounded growth was. The tests pad the arena
past its first block for this reason.

Sequencing with #13655

The three encoder-stream call sites at QPACK.cc:1528, 1552 and 1559
guard with xpack_decode_string(...) < 0 && tmp > N. tmp is only written on
success, so those guards read an indeterminate value after a failure and can
fall through to use the outputs. #13655 corrects them. This PR does not touch
those guards and should merge after #13655; the two branches merge cleanly.

Arena::free only rewinds when the freed range ends at the block's water
level, so releasing an earlier allocation before a later one is a silent
no-op and its space is never reclaimed.
_decode_literal_header_field_without_name_ref() freed name before value,
so the name never came back, and the Insert Without Name Ref branch of
_on_encoder_stream_read_ready() never freed value at all.

Free value then name at both sites, and release whatever
xpack_decode_string allocated before failing on the Huffman path.
@brbzull0 brbzull0 added the HTTP/3 label Sep 3, 2026
@brbzull0 brbzull0 self-assigned this Sep 3, 2026
@brbzull0 brbzull0 added this to the 11.0.0 milestone Sep 3, 2026
@brbzull0
brbzull0 marked this pull request as ready for review September 4, 2026 09:14
Copilot AI lite review requested due to automatic review settings September 4, 2026 09:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The changes are localized, align with Arena::free()’s rewind semantics, and add necessary failure-path cleanup without altering QPACK decode/encode logic.

Pull request overview

This PR fixes long-lived per-connection arena growth in the HTTP/3 QPACK implementation by ensuring QPACK-decoded header name/value strings are freed in strict LIFO order so Arena::free() can actually rewind the block water level.

Changes:

  • Free value before name in _decode_literal_header_field_without_name_ref() so both allocations are reclaimed.
  • Free value before name in the “Insert Without Name Ref” branch of _on_encoder_stream_read_ready() to reclaim both strings (and avoid leaving value outstanding).
  • On xpack_decode_string() failure when decoding value, free any partially-allocated value (Huffman path) before freeing name.
File summaries
File Description
src/proxy/http3/QPACK.cc Fixes QPACK arena string frees to follow reverse allocation order and cleans up partial allocations on decode failure.
Review details
  • Files reviewed: 1/1 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/proxy/http3/QPACK.cc Outdated
char *value = nullptr;
uint64_t value_len;
if ((ret = xpack_decode_string(this->_arena, &value, value_len, buf + read_len, buf + buf_len, _header_field_max_size, 7)) < 0) {
// xpack_decode_string may allocate before returning failure (Huffman

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like HPACK has the same issue. We may want to change xpack_decode_string?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the free is now in xpack_decode_string() rather than at the call site.

You were right about HPACK: HPACK.cc:622 and 648 both returned without
releasing the huffman temporary area. Fixing it in the callee means neither
needs a change of its own. It also picks up QPACK.cc:770 and 901, where
value is uninitialised on the error return, so a caller-side free was not
possible there at all.

One difference worth noting: in HPACK the compression error tears the
connection down, so the arena goes with it. QPACK swallows the decode failure
at Http3HeaderVIOAdaptor.cc:97 under // FIXME: handle error, so there the
same leak repeats on a live connection.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The release is in xpack_decode_string() now rather than at the call
site, reshaped slightly so the released temporary never reaches a caller: the
huffman output is decoded into a local, freed on failure, and assigned to the
outputs only once huffman_decode() succeeds. The header states that contract,
and that max_string_len bounds the encoded length (the existing "limit above
encoded length allows" test depends on that).

You were right about HPACK: HPACK.cc:622 and 648 both returned without
releasing the temporary. Fixing it in the callee means neither needs a change.
It also picks up QPACK.cc:770 and 901, where value is uninitialised on
failure, so a caller-side free was not possible there at all.

One thing I left alone deliberately: the encoder stream call sites at
QPACK.cc:1528, 1552, 1559 read tmp after a failed decode because their
guards are < 0 && tmp > N. #13655 fixes those, so this should merge after it.

Copilot AI review requested due to automatic review settings September 9, 2026 09:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@brbzull0
brbzull0 requested a lite review from Copilot September 9, 2026 11:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

Comment thread src/proxy/hdrs/XPACK.cc Outdated
Comment thread src/proxy/hdrs/unit_tests/test_XPACK.cc
Comment thread src/proxy/http3/QPACK.cc Outdated
Comment thread src/proxy/http3/QPACK.cc Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

Comment thread src/proxy/hdrs/XPACK.cc
Comment thread src/proxy/hdrs/unit_tests/test_XPACK.cc
Comment thread src/proxy/http3/QPACK.cc Outdated
Comment thread src/proxy/http3/QPACK.cc Outdated
Damian Meden added 2 commits September 10, 2026 18:38
xpack_decode_string() allocates the huffman temporary area out of the
arena before calling huffman_decode(), and on a decode failure returned
without releasing it. Every caller's arena lives for the connection, so
each malformed huffman string left a little more of it permanently
outstanding until the connection closed.

Freeing in the callee rather than at each call site covers HPACK, whose
two sites had the same leak, and the two QPACK sites where the output
pointer is uninitialised on failure and a caller-side free is not
possible.

The temporary is decoded through a local and only assigned to the
output on success, so a failure writes neither output and the released
pointer never reaches a caller. The header now states that contract,
and that max_string_len bounds the encoded length: a huffman string may
decode to more than the limit, and the existing test relies on that.

QPACK's literal-without-name-ref path also frees name on the value
error path; name came from an earlier successful decode, so the callee
cannot release it.
Arena::free() only rewinds when the freed range ends at the block's
water level, so releasing in the wrong order silently keeps a
per-connection arena growing. Nothing asserted that, and test_qpack ran
no assertions at all because both of its cases bail out when the QIF
directories are absent.

test_arena covers the rewind and both free orders directly. test_qpack
decodes a literal header field without a name reference, which is the
representation whose name and value come from the arena.

Also softens two comments: on a single-block arena Arena::free() never
inspects the only block, so the release is not guaranteed to rewind.
Copilot AI review requested due to automatic review settings September 10, 2026 16:40
@brbzull0
brbzull0 force-pushed the qpack-arena-free-order branch from fc1229c to 834443e Compare September 10, 2026 16:40
@brbzull0

Copy link
Copy Markdown
Contributor Author

copilot is getting quite nit and stubborn, hope I am not getting into a rat-hole 🤣 .. lets see

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are still concrete correctness/robustness issues to address (notably a length-narrowing hazard in xpack_decode_string() and a unit-test heap allocation leak).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread src/proxy/hdrs/XPACK.cc
Comment thread src/proxy/http3/test/test_QPACK.cc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants