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

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;
}

So releasing an earlier allocation while a later one is still outstanding is a
silent no-op, and that space is not reclaimed for the lifetime of the arena.
For HPACK and QPACK that arena lives as long as the connection.

Free order

Two QPACK sites got the order wrong:

  • _decode_literal_header_field_without_name_ref() (QPACK.cc:822) allocated
    name, then value, then freed name first. The name free did nothing and
    only value was reclaimed.
  • The Insert Without Name Ref branch of _on_encoder_stream_read_ready()
    (QPACK.cc:1175) freed name and never freed value at all.

Both now free value before name, so each free lands at the water level and
both entries rewind.

Failure path

xpack_decode_string() allocates the huffman temporary area before calling
huffman_decode(), and returned XPACK_ERROR_COMPRESSION_ERROR without
releasing it when that decode failed. Malformed huffman input is client
controlled, so this is reachable per header field.

The first revision handled it at the one QPACK call site. Following @maskit's
review it is fixed in xpack_decode_string() instead, which covers every
caller:

call site before this change
HPACK.cc:622, HPACK.cc:648 leaked, though the connection is then torn down on HTTP2_ERROR_COMPRESSION_ERROR
QPACK.cc:770, QPACK.cc:901 leaked; value is uninitialised at both, so a caller-side free is not possible
QPACK.cc:809 leaked

QPACK.cc:809 still frees name itself, since that came from an earlier
successful call. LIFO still holds there because value has already been
rewound by the callee.

No caller frees on an error return, so there is no double free. On failure the
output is also cleared, *str to nullptr and str_length to 0, so a caller
cannot pick up a stale pointer or an indeterminate length.

That clearing is only safe because of the guard fix below. It has to land with
it, not before it.

Test

test_XPACK.cc gains a section that decodes malformed huffman input 100 times
and checks the arena has not grown. Arena exposes no water level accessor,
but the address str_alloc() hands back is a usable proxy for it, so this
needs no new API. The existing huffman cases in that file all trip the
max_string_len check, which returns before anything is allocated, so none of
them covered this path.

Verified in both directions:

  • with the change: All tests passed (338 assertions in 3 test cases)
  • with the XPACK.cc hunk reversed:
    test_XPACK.cc:191: FAILED: REQUIRE( static_cast<void *>(arena.str_alloc(1)) == static_cast<void *>(baseline) )

The full unit suite passes, 126/126, test_qpack and test_http3 included.
Built without quiche, so that configuration is not covered here.

The free-order fix above is still not covered by a test. test_qpack does
drive a real QPACK through decode(), but QPACK::_arena is private, so
asserting on the water level would mean widening that for the test alone.

The autests were run against the first revision only, 4/4 pass:
h3_proxy_verifier, h3_python_client, h3_stream_lifetime,
h3_flow_control.

Note on Arena::free

Recording this rather than fixing it here: Arena::free() walks the block list
with while (b->next), so it never inspects the last block. While an arena
still holds a single block every free is a no-op, including the ones in this
change. Both fixes take effect once the arena holds two or more blocks
(DEFAULT_BLOCK_SIZE is 1000 bytes), which is where the unbounded growth was,
but "the free is reclaimed" is not true from the first allocation. The new test
has to allocate past the first block before it can observe anything, which is
why that loop is there.

Insert instruction guards

The three encoder stream call sites were guarded by
xpack_decode_string(...) < 0 && tmp > 0xFFFF. tmp is only written on
success, so on a failed decode that second operand is an indeterminate read:
when it happens to be false, control falls through and the caller goes on to
use a string that was never decoded. _read_insert_without_name_ref() then
allocates the value over the space the name occupied, so the two alias.

Each is now split, so a decode failure returns and the length check frees what
it rejects:

if ((ret = xpack_decode_string(arena, name, tmp, input, input + input_len,
                               _header_field_max_size, 5)) < 0) {
  return -1;
}
if (tmp > 0xFFFF) {
  arena.str_free(*name);
  return -1;
}

A plain && to || swap would not do: ret >= 0 && tmp > 0xFFFF returns -1
with the string allocated and nobody freeing it.

The first revision left these alone on the grounds that they belonged to a
separate change. That reasoning does not survive xpack_decode_string()
clearing its output, which turns an indeterminate read into a reliably false
one.

Note on scope

Seven more guards share the same shape and are not touched here:
QPACK.cc:287, 927, 936, 1521, 1594, 1616 and 1638. All of them
call xpack_decode_integer(), which allocates nothing, so none leak or alias;
they are bounds checks that do not work. 936 additionally has the comparison
inverted, delta_base_index < 0xFFFF.

QPACK.cc:1528 caps the value length at 0xFF where every sibling uses
0xFFFF. That looks like a typo, but correcting it changes which instructions
are rejected, so it is left as it is.

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.

xpack_decode_string() allocates the huffman temporary area out of the
arena before calling huffman_decode(), and returned the error without
releasing it. Freeing it in the callee rather than at each call site
covers HPACK and the two QPACK sites that leave the pointer
uninitialised, where the caller cannot do it at all.

The pointer is deliberately left set, not nulled: the encoder stream
paths guarded by `< 0 && tmp > 0xFFFF` fall through on failure and read
it, and those guards need fixing first.
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
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
The three encoder stream guards read `xpack_decode_string(...) < 0 &&
tmp > 0xFFFF`, but tmp is only written on success, so a failed decode
could fall through on an indeterminate read and go on to use the
string. Each is now split so a decode failure returns, and the length
check frees what it rejects.

With no path left that uses the output after a failure,
xpack_decode_string() can also clear it, which the previous commit
could not safely do.
Copilot AI review requested due to automatic review settings September 9, 2026 14:18

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 on lines 135 to 143

len = huffman_decode(*str, str_len, p, encoded_string_len);
if (len < 0) {
// Release the temporary area, otherwise it is left outstanding until the arena dies.
arena.str_free(*str);
*str = nullptr;
str_length = 0;
return XPACK_ERROR_COMPRESSION_ERROR;
}
Comment on lines +163 to +164
uint8_t bad_huffman[] = "\x88\xff\xff\xff\xff\xff\xff\xff\xff";
int bad_huffman_len = 9;
Comment thread src/proxy/http3/QPACK.cc
Comment on lines +1531 to 1534
if (tmp > 0xFF) {
arena.str_free(*value);
return -1;
}
Comment thread src/proxy/http3/QPACK.cc
Comment on lines +1566 to 1574
if ((ret = xpack_decode_string(arena, value, tmp, input + read_len, input + input_len, _header_field_max_size, 7)) < 0) {
arena.str_free(*name);
return -1;
}
if (tmp > 0xFFFF) {
arena.str_free(*value);
arena.str_free(*name);
return -1;
}
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