Learning objective. Reproduce a QPACK blocked stream and its recovery on demand by driving the codec directly, and learn to read the two signals that matter when debugging blocking: a header block's Required Insert Count prefix and the encoder-stream insert that clears it.
The debugging problem #
Blocking is hard to observe in the wild because it depends on timing between streams — an encoder-stream insert losing a race to the request-stream reference that needs it (§6.3). On a loopback network nothing reorders, so the bug never fires; on a real network it fires intermittently and is gone by the time you look. The reliable way to study it is to take the network out of the loop and drive the QPACK codec directly, controlling the one variable that matters: whether the insert or the header block is delivered first.
This lab uses pylsqpack (the ls-qpack binding shipped inside aioquic), which exposes the
encoder and decoder as objects whose encode, feed_encoder, feed_header, and
resume_header calls are the encoder-stream and request-stream traffic. Reordering becomes a
one-line choice.
Running it #
settings: MAX_TABLE_CAPACITY=4096 BLOCKED_STREAMS=16
request 1 (stream 0, first sight)
encoder-stream insert : (none)
header block : 0000d1d750882f91d35d055c87a7c12f02f2b20a8418f52c693f8d1c64089990b2b4db934e675e7f (40 B)
RIC prefix byte : 0x00 -> RIC 0, static/literal only
request 2 (stream 4, repeat)
encoder-stream insert : c0882f91d35d055c87a769f2b20a8418f52c693f8d1c64089990b2b4db934e675e7f (34 B, Insert w/ Name Ref)
header block : 0381d1d710c111 (7 B)
RIC prefix byte : 0x03 -> RIC non-zero, references dynamic table
compression : 40 B -> 7 B for the same headers
decode stream 0 : decoded 5 headers, decoder-stream ack=(none) (RIC 0 -> no ack)
== reordered delivery: header block arrives before the encoder-stream insert ==
feed_header(stream 4) -> StreamBlocked [stream 4 is BLOCKED, section held]
feed_encoder(insert) -> unblocked streams: [4]
resume_header(4) -> decoded 5 headers
decoder-stream ack : 0x84 = Section Ack, stream 4 (leading bit 1, 7-bit stream id)
== contrast: decoder advertises BLOCKED_STREAMS=0 ==
stream 0: header block 40 B, RIC 0x00, insert 0 B -> stays literal, never blocks
stream 4: header block 40 B, RIC 0x00, insert 34 B -> stays literal, never blocks
stream 8: header block 40 B, RIC 0x00, insert 0 B -> stays literal, never blocks
Reading the trace #
Every claim from this chapter is visible in those bytes.
First sight is literal, and safe. Request 1 on stream 0 produces no encoder-stream insert
and a 40-byte block whose first byte is 0x00: a Required Insert Count of zero. With no
dynamic dependency it decodes standalone and needs no acknowledgment (§6.2). This is
the general rule: ls-qpack spells a field out the first time it sees it, so a fresh field can
never block.
The repeat inserts and shrinks. Request 2 for the same headers on stream 4 emits a 34-byte
run of two instructions on the encoder stream — an Insert with Name Reference (static name
:authority, index 0) followed by an Insert with Literal Name for x-session-id — and
collapses the header block from 40 bytes to 7. The block's prefix is now 0x03: a non-zero Required Insert Count. That single fact is
the whole story of blocking: a non-zero RIC means the section cannot be decoded until the
referenced insertions arrive ([RFC 9204 §4.5.1]).
The block, and the recovery. Delivering stream 4's block first, feed_header(4) raises
StreamBlocked: the decoder holds the section rather than failing ([RFC 9204 §2.2.1]). Feeding
the delayed insert with feed_encoder returns [4]: the list of streams that just unblocked.
resume_header(4) then completes the decode and the decoder emits 0x84 on its decoder stream,
which decodes bit-for-bit as a Section Acknowledgment (leading bit 1, 7-bit stream id 4),
exactly the instruction §6.2 described ([RFC 9204 §4.4.1]).
In fact the whole 7-byte block from the trace decodes by hand:
The Required Insert Count is transmitted plus-one (0x03 means RIC 2, since 0 marks a
section with no dynamic references), and the S=1, Delta Base 1 byte puts the Base at
2 − 1 − 1 = 0, so the two post-base indices 0 and 1 point at absolute entries 0 and 1:
:authority and x-session-id, the two insertions the 34-byte encoder-stream run carries
([RFC 9204 §4.5.1]).
Budget 0 removes the risk, and the win. With BLOCKED_STREAMS=0, every block stays 40
bytes at RIC 0x00, so no stream can ever block. Note stream 4 still shows a 34-byte insert: the
encoder populates the dynamic table, but it will not reference an entry the decoder has not
acknowledged, so until an acknowledgment round-trips, every request ships literals — the exact
high-latency cost §6.4 quantified.
A debugging checklist #
When a real capture shows a stalled request that later completes, or a peer that closes with
QPACK_DECOMPRESSION_FAILED (0x0200), work these in order:
- Read the RIC prefix of the stalled block. Zero means QPACK is not your problem. The section had no dynamic dependency. Non-zero means it was waiting on an insert.
- Find the matching encoder-stream insert. If it arrived after the block, you saw normal, recoverable blocking. If it never arrived (lost, or on a reset-affected path), the stream was stuck until retransmission: the long-path failure of §6.4.
- Count concurrently blocked streams against the advertised budget. More blocked streams
than
SETTINGS_QPACK_BLOCKED_STREAMSis a spec violation by the encoder and aQPACK_DECOMPRESSION_FAILEDby the decoder (§6.3). - Check acknowledgment latency. Persistent literals on a healthy connection mean the encoder never gets far enough ahead of acknowledgments to reference dynamic entries (§6.2).
Driving the codec directly is a debugging technique, not how HTTP/3 runs. In production the insert and the block ride real QUIC streams and the transport delivers them. But the codec-level trace isolates QPACK's own state machine from packet loss, retransmission, and flow control, so you can prove whether a stall is a QPACK dependency or a transport problem. Reproduce it here first, then correlate with the packet trace (§11.3).
editorial Keep a tiny codec-level harness like this in your test suite. Feeding a header block before its insert is the cheapest possible regression test for "does my decoder block instead of crash, and recover instead of leak?" It runs in microseconds with no network. The failure you are guarding against is a decoder that treats a larger-than-expected Required Insert Count as a hard error instead of a block, which turns an ordinary reorder into a dropped connection ([RFC 9204 §2.2.1]).
Takeaways #
A blocked stream is reproducible on demand by delivering a header block before the encoder-stream insert it references, and the two bytes that explain it are the block's Required Insert Count prefix (non-zero ⇒ dynamic dependency ⇒ blockable) and the insert that clears it (recovery, confirmed by a Section Acknowledgment). Budget 0 makes blocking impossible at the cost of literal-only headers until acknowledgments arrive. With QPACK's behavior now visible end to end, the chapter quiz checks the model, and Chapter 7 turns to the transport parameters that configure the connection these frames ride on.