~0x64616E69656C
nxTLS · EXP 02 / Response QPACK · 2026-10-01

Response QPACK.
Dynamic-table experiment.

An isolated HTTP/3 response encoder demonstrates bounded dynamic reuse, feedback tracking and safe eviction against the pinned nginx baseline. The report keeps QPACK's state machine distinct from HPACK policy.

Gate B · revalidated 18 October 2026.
560 stock/experimental H3 responses passed. The historical decoder mismatch was resolved on saved bytes with upstream PR #60; production integration remains a separate decision.
source · experiments/qpack-response-encoding/README.md pinned evidence · explicit limits · no implicit production adoption
Published engineering report. Rendered from experiments/qpack-response-encoding/README.md. The README remains the canonical report text; this page changes presentation, not evidence.
Dated addendum: This page preserves the original 1.31.6 report and its valid Debian-decoder failure. The October 18 results below use the refreshed pins and an independently verified upstream decoder fix; they do not rewrite the historical capture.

Upstream refresh and decoder fix — 18 October 2026

The official nginx 1.31.7 release with BoringSSL 594a44291086dcc7cef06cabec4c0b47afd674c0 passed an isolated QPACK response-encoder A/B matrix: 280 stock and 280 experimental HTTP/3 responses, using only h3. Stable stock field sections stayed at 202 B; the experimental sequence was 202 B first and 104 B thereafter. Decoder feedback, reference accounting and bounded fallback checks passed within the exercised suite.

The extended 8192/4096 RIC-wrap discrepancy still reproduced in Debian pylsqpack 0.3.23, and was separately resolved on identical saved bytes by the fix from issue #58 / PR #60:

DecoderFive RIC fixtures172 captured sections
Debian pylsqpack 0.3.230/5 passed171/172 accepted
nghttp3 1.17.05/5 passed172/172 accepted
Fixed pylsqpack commit 9e705dfeadeb4aef5f610cd96257898b7272bce35/5 passed172/172 accepted

Packaging distinction: PyPI pylsqpack 0.3.24 does not yet contain PR #60. The validated build used that exact merged Git commit in an isolated Python environment; its version metadata alone is insufficient to identify the fixed code. Debian packages were not replaced. This was an offline decoder replay, not a new live H3 matrix with the fixed decoder.

That specific decoder disagreement is resolved at the verified commit, without any change to the experimental nxTLS encoder. Broader client interoperability, full concurrency and production adoption remain outside the established evidence. See experiments/qpack-response-encoding/DECODER_VALIDATION.md and docs/UPSTREAM_REFRESH_2026-10-18.md.

Inputs, authority and references

Inspected nxTLS HEAD: 99dc88b4d9a6334918b379bca5743d22e1a1798c. third_party.lock pins nginx 1.31.6, archive SHA-256 974ed5298a5e398e008704ed5db284e655fc270c596493dbccada452448fc9f1, and BoringSSL b75f405cde1cc3c9fb811be155eecabe38f379bb. These pins were not changed.

Repository instructions, accepted decisions, architecture, charter, upstream policy, vendoring documentation/tool, and the complete HPACK report, patch, runner and build tool were inspected before implementation. The requested public docs/16_CONFIGURATION_POLICY.md does not exist. Public D-014 is the recorded configuration authority; the private counterpart was read only. No private policy text, historical implementation, credentials or deployment configuration was copied into this experiment. No external symlink target was modified.

Semantics were checked against:

  • RFC 9204: sections 2.1–2.2 (feedback, blocking and eviction), 3.2 (separate dynamic tables and indexing), 4.2–4.5 (streams, instructions and field representations), 5 (peer settings), and 7 (security considerations).
  • RFC 9114: sections 3.2–3.3 (TLS/ALPN and connection reuse), 4.1–4.2 (informational/final headers and trailers), 6.2 (unidirectional streams), and 7.2.2/7.2.4 (HEADERS/SETTINGS).
  • Current OWASP C toolchain hardening and logging guidance, tailored to allocation/error handling, bounded state and avoiding values in new diagnostics. This is not a compliance or production-hardening claim.

QPACK field representations do not insert entries. Insertions and capacity changes travel on a separate encoder stream. Dynamic entries remain protected until insertion receipt and outstanding references permit eviction. These are material differences from HPACK's ordered header-block updates.

Exact pinned source inventory

Here V/ means third_party/nginx/src/src/http/v3/; all line references below refer to unmodified pinned sources. The entire 13-file directory was searched for encoder/decoder state, field writers, dynamic references, settings, blocked streams, insertion counts, acknowledgment and cancellation, before concluding that outbound functionality is absent.

File Relevant responsibility
ngx_http_v3.{h,c} Session structure, creation, cleanup, keepalive/flood accounting
ngx_http_v3_module.c HTTP/3 configuration and local decoder limits
ngx_http_v3_request.c Request lifecycle, header decoding and cancellation feedback
ngx_http_v3_filter_module.c Ordinary response, 103, body and trailer serialization
ngx_http_v3_encode.{h,c} Integer/string and field-representation writers
ngx_http_v3_parse.{h,c} HEADERS/prefix/representation, SETTINGS and QPACK stream parsers
ngx_http_v3_table.{h,c} Static table, inbound dynamic table and request blocking
ngx_http_v3_uni.{h,c} Unidirectional stream registration/creation, SETTINGS and feedback

The QUIC send implementation in src/event/quic/ngx_event_quic_streams.c:925–1010 and connection destruction in src/event/quic/ngx_event_quic.c were also inspected. No QUIC redesign is needed: ngx_http_v3_get_uni_stream already accepts encoder stream type 2, and sc->send copies bytes into the existing ordered stream buffer before reporting acceptance.

A. Request decoding

  • Ownership: session member table (ngx_http_v3.h:125) is ngx_http_v3_dynamic_table_t (table.h:24–35). It holds ordered entries, size/capacity, base/insert counters, a pending feedback event and an insertion buffer. Parent-connection initialization/cleanup is in ngx_http_v3.c:18–93. Heap entries are actually freed by cleanup_table / evict (table.c:357,377).
  • Client encoder stream: type 2 is registered in uni.c:105–169; uni parsing dispatches to parse_encoder (parse.c:1370–1471,1893+). The parser supports Insert With Name Reference (1475–1580, table.c:185), Insert With Literal Name / Without Name Reference (1583–1703, table.c:223), Duplicate (parse.c:1455–1466, table.c:412), and Set Dynamic Table Capacity (parse.c:1438–1451, table.c:308). Names can reference static or relative dynamic entries. Table insertion accounts name length + value length + 32, then evicts oldest entries and wakes blocked request streams.
  • Local limits: module.c:201 chooses local decoder maximum 4096; merge configuration derives its blocked-stream limit from the concurrent request limit, default 128. uni.c:397–449 advertises these server settings. They do not authorize server response indexing.
  • Required Insert Count/Base: parse.c:408–495 parses the eight-bit RIC and sign/seven-bit Delta Base, unwraps RIC through table.c:533–583, computes Base = RIC + Delta or RIC - Delta - 1, and rejects a negative Base. Relative and post-base request representations are decoded by parse.c:499–1174.
  • Blocking: parse.c:354–364 calls check_insert_count (table.c:586–655). Insufficient insertions queue the request with bounded local blocked-stream accounting and stream-pool cleanup. New entries post waiting readers (table.c:686–708); unblock removes the queue entry/counter (673–683).
  • Feedback nginx sends: completed request sections with nonzero RIC produce Section Acknowledgment (parse.c:394–399, uni.c:495); insertion feedback uses Insert Count Increment (table.c:280, uni.c:576); abandoned/reset requests can send Stream Cancellation (request.c:419, uni.c:536). All travel on the server decoder stream, type 3. These describe request decoding, not response compression evidence.

B. Stock response encoding

No outbound dynamic-table state or response reuse exists in this pin. The session contains the request decoder table only. All response filter calls to indexed/name-reference writers use dynamic=0; arbitrary fields use the literal new-name writer. Serializer support for dynamic=1 and post-base fields is not an encoder table or an active caller.

Response path Prefix and representations Unmodified locations
Ordinary HEADERS Encoded RIC=0, sign=0, Delta Base=0, hence RIC/Base=0. Static indexed status 200 (25), zero content length (4), gzip vary (59). Other status values and generated server/date/content-type/positive length/last-modified/location use literal values with hardcoded static names. General header list uses literal names and values. Filter 150,327,334–477,499–504; final exact length 519
103 Early Hints Same zero prefix. Static indexed status 103 (24); general fields use literal names and values. Filter 642,654–693; final exact length 702
Trailers Same zero prefix; all fields are literals with literal names. Filter 914,921,946–952

Hardcoded generated name indexes include date 6, server 92, content-type 53, content-length 4, last-modified 10, location 12 and vary 59 (filter:14–30). Content-type 53 supplies a name reference, not a full indexed match whenever the value happens to be text/plain. No general static-table search is present.

encode.c:137–245 uses existing HTTP Huffman encoding when shorter, otherwise raw strings, lowercasing literal names. Positive generated content-length and non-200 status digits are written raw in the filter; indexed fields transmit no string. The presence of Huffman coding does not establish dynamic reuse.

uni.c:312–392 can open a server encoder stream, but stock response code never requests it. Stock runtime opens server control type 0 and decoder type 3 only. The client may independently open its own encoder stream; that is not evidence of a server encoder stream.

parse.c:1706–1796 already parses incoming client decoder-stream instructions. table.c:438–458 rejects Section Acknowledgment and Insert Count Increment with QPACK_DECODER_STREAM_ERROR, explicitly because it does not use dynamic tables. uni.c:617–627 accepts Stream Cancellation as a no-op. Peer QPACK capacity and blocked-stream settings are parsed and logged only, without retaining outbound policy/state (table.c:714–745, parse.c:1311–1367). Stock defaults therefore cannot be mistaken for an active response table.

Feature matrix

Feature Stock request decoder Stock response encoder Experimental response encoder
Separate dynamic state Present Absent New connection-owned state
Static references / literals / selective Huffman Decodes them Writes them Stock behavior retained for unrelated fields
Insertion and FIFO eviction All four encoder instructions None Capacity + literal-name insertion only; protected FIFO eviction
Dynamic full-field reuse Decodes it None Ordinary x-nxtls-qpack- fields only
Dynamic-name / post-base reuse Decodes it No filter caller Omitted
RIC/Base Decodes and checks them Zero in all three paths Computed for ordinary headers; zero for 103/trailers
Peer capacity / blocked limit Server advertises local limits Logs client limits only Stores client limits; caps table at 4096 and outstanding sections at one
QPACK feedback Sends Ack / Increment / Cancellation Ack/Increment rejected; Cancellation no-op Ack/Increment/Cancellation processed
Encoder stream Consumes client's type 2 Never opens server type 2 Opens on first insertion attempt

Installed tooling and Phase 2 gate

No packages were installed or dependencies added to the product build.

Installed tool/library Observed capability
curl 8.22.0 HTTP3 feature, ngtcp2 1.24.0 / nghttp3 1.17.0, OpenSSL 3.6.4. --http3-only, connection IDs, decoded field/body trace, QLOGDIR transport qlog. No QPACK SETTINGS control found in CLI help.
aioquic 1.3.0 (python3-aioquic 1.3.0-2) Installed asyncio QUIC/H3 client library, adjustable settings through an experiment adapter, existing HTTP/3 qlog with exact decoded HEADERS field-section lengths
pylsqpack 0.3.23 (python3-pylsqpack 0.3.23-1) Installed QPACK implementation used by aioquic; all encoding/decoding stays in that library
nghttp / h2load 1.70.0 nghttp is HTTP/2 only. h2load help lists --h3, but this installed binary has no ngtcp2/nghttp3 linkage or QUIC implementation symbols and failed against the live UDP baseline. Help alone was insufficient evidence.
tcpdump Installed; packet byte counts would not measure field sections
Standalone H3 clients / tshark / Wireshark No usable installed binary found in the inspected executable paths

Actual baseline SETTINGS from nginx's peer logs were curl: capacity 0, blocked streams 0, and aioquic: capacity 4096, blocked streams 16. The server selected ALPN h3 and TLSv1.3; each baseline client completed twenty 200 responses on one connection, with all fields and the complete 23-byte body. Curl's trace exposes decoded header lengths, which are not QPACK bytes. Its ngtcp2 qlog contained only transport/recovery events, without H3 field-section lengths. Stock nginx's http3 header len is a pre-Huffman allocation upper bound; the separate write buffer debug length is exact for the inspected chain layout. aioquic's existing http:frame_parsed event is the most direct usable measurement and is used by the committed runner for both binaries. No QUIC decryption or QPACK parser was written in Python.

The initial ad hoc return-body baseline, with the three required fields and Set-Cookie, measured 181-byte sections throughout. The final fixture also sends x-unrelated and fixes Date through a public local origin; its 202-byte sections are a different fixture, not an apparent change in stock compression.

All implementation-gate conditions passed: stock lacks response reuse; an installed usable peer offers nonzero capacity; existing stream machinery can accept the narrow instructions; and client qlog distinguishes exact literal versus indexed field-section lengths.

Experimental mechanics

The explicit patch changes seven staged files: ngx_http_v3.{h,c}, ngx_http_v3_encode.{h,c}, ngx_http_v3_filter_module.c, ngx_http_v3_table.c, and ngx_http_v3_uni.c. It adds outbound state, helpers, ordinary-filter hooks, minimal feedback processing and cleanup. Inbound table operations and QUIC source remain unchanged. Root CMake and accepted policy are not changed; only the isolated tool applies this patch.

Scope, capacity and stream ordering

Only ordinary response-list fields whose name begins x-nxtls-qpack- (case insensitive before lowercasing) enter the helper. All other fields retain their stock writers, including the synthetic Set-Cookie and x-unrelated fields. There is no production sensitive-field policy or Never-Indexed change.

Peer maximum starts at its QPACK default zero. Selected capacity is min(peer maximum, 4096) and state is allocated lazily for eligible insertions. Zero permits no insertion, reference or encoder stream. The patch stores both peer QPACK settings and rejects duplicate occurrences of those settings, avoiding ambiguous changes after table initialization. HTTP/3 does not permit a second SETTINGS frame; this is not an HPACK-style mid-connection resize mechanism.

On first insertion the existing uni-stream helper opens server stream type 2, then sends Set Dynamic Table Capacity. Insert With Literal Name uses raw, lowercased name/value strings; it adds no new Huffman implementation. Duplicate, encoder-stream name references and dynamic-name field references are omitted. Ordinary literal fallback continues to use nginx's existing Huffman helper.

Allocation and eviction eligibility checks precede an instruction send. Local entry insertion/eviction occurs only after sc->send accepts the entire instruction into the ordered QUIC stream buffer. This does not mean network delivery or acknowledgment. An untouched-buffer NGX_AGAIN falls back to a literal; partial/error sends terminate the connection rather than abandoning a partially committed critical-stream instruction. Successful capacity signaling is remembered even if a subsequent insertion cannot be sent. Unused speculative insertions are legal if later header-frame construction fails. No reference is transmitted merely because a lookup or insertion began.

References, RIC/Base and the observed blocking strategy

A full match uses Base - absolute_index - 1; Base is the insert count at the start of that section. Entries inserted during the same section remain literal until a later section, so post-base representations are unnecessary. RIC is one greater than the largest referenced absolute index, or zero without references.

The eight-bit prefix receives:

EncodedRIC = 0                              if RIC == 0
EncodedRIC = RIC % (2 * (peer_max / 32)) + 1 otherwise
Sign = 0
DeltaBase = Base - RIC                      when RIC != 0
DeltaBase = 0                               otherwise (Base = 0)

The modulo uses the peer maximum, not selected capacity. Twenty-two bytes reserve nginx's maximum two prefix integers; compaction after encoding preserves the field order and fits within the allocation. Peer capacity too small to hold an eligible entry cannot create RIC>0 or divide by a zero MaxEntries.

The preferred acknowledged-only case advertises capacity 4096 / blocked limit 0. Three entries are inserted, but this installed pylsqpack adapter sends no Insert Count Increment merely upon receiving encoder instructions. All twenty responses remain literals. Waiting alone does not establish Known Received Count.

The observable variant therefore uses the requested bounded alternative: at most one outstanding dynamic section and one potentially blocked stream. When the peer permits at least one blocked stream, the first reusable section can reference unacknowledged entries. Its acknowledgment advances Known Received Count; later stable references are acknowledged-only. When the peer limit is zero, all references require insertion receipt feedback. If the sole tracking slot is occupied, subsequent sections use literals. This also bounds acknowledged but not-yet-acknowledged field-section references.

Stable debug sequence: response 1 has RIC/EncodedRIC/Base 0/0/0; responses 2–20 have 3/4/3, relative indexes 2,1,0. Response 2 is potentially blocked; subsequent stable responses are not. The increment-injection case establishes Known Received Count=3 before response 2 and has zero potentially blocked sections despite blocked limit 0. A potential block is accounting evidence, not a claim that the client actually stalled; no loss/reordering was forced.

Feedback, eviction and memory bounds

Existing decoder-stream parsing dispatches into the new outbound helpers:

  • Section Acknowledgment must match the one active stream ID. It raises Known Received Count to at least the section's RIC and releases the slot; unknown or duplicate acknowledgment is a decoder-stream error.
  • Insert Count Increment must be positive and no greater than inserted - known_received; it advances Known Received Count without releasing outstanding section references.
  • Stream Cancellation releases the matching slot without implying insertion receipt. Cancellation without tracked references remains harmless.

No request pointer or per-request heap list is retained. Request FIN/destruction alone cannot release an unacknowledged QPACK reference: the peer may still need that entry. Only one bounded stream ID/reference interval can survive a finished request while feedback is withheld. Ack, cancellation and connection destruction release tracking; new references fall back while the slot is occupied.

The table is a simple FIFO pointer array with linear full-field lookup, at most 128 entries, selected-capacity accounting name + value + 32, and heap-owned contiguous name/value bytes. Eviction requires acknowledged insertion and an absolute index below every active/current section's first reference. This conservatively pins some unreferenced newer entries too; it cannot prematurely evict a referenced one. Allocation is bounded before length addition. Insert count is capped below the protocol integer limit; RIC arithmetic uses uint64.

Metadata is one fixed 128-pointer array plus counters and one section slot. Eviction frees nodes, not just pool bookkeeping. Connection cleanup frees every remaining node and the encoder allocation. Table accounting is not an RSS or allocator-overhead measurement: node metadata, one prospective entry, an instruction stack buffer and ordinary request buffers add bounded overhead. Withheld feedback cannot add an unbounded stream list or table allocation.

103 and trailers

These two paths retain stock zero-RIC/zero-Base static/literal encoding. They neither insert nor create dynamic references, so an outstanding ordinary section and its encoder-stream instructions do not impose a table-update ordering requirement on their field sections. The response-2 trailer test sends its trailer before ordinary-section acknowledgment arrives; valid decoding and zero-prefix debug evidence confirm coexistence. Repeated 103/final response pairs also coexist with shared outbound table state. There is no pending resize inside an otherwise literal trailer block, as in the HPACK spike's known caveat.

Reproduce the isolated build and runner

With pinned inputs already materialized, run from the repository root:

tools/spike-qpack-response-encoding

The tool checks lock pins/source markers, records SHA-256 manifests before any build, builds pinned static BoringSSL, stages two clean nginx copies, applies only the second copy's explicit patch with --fuzz=0 and no offsets, and compares original manifests on EXIT even after failures. It requires no network after vendoring. Dedicated stage/object directories are regenerated under build/qpack-response-encoding/ab/ only. BoringSSL commands are:

cmake -S third_party/boringssl/src \
  -B build/qpack-response-encoding/ab/boringssl/b75f405cde1cc3c9fb811be155eecabe38f379bb \
  -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \
  '-DCMAKE_CXX_FLAGS=-stdlib=libc++' -DCMAKE_EXE_LINKER_FLAGS=-fuse-ld=lld \
  -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DBUILD_SHARED_LIBS=OFF
cmake --build build/qpack-response-encoding/ab/boringssl/b75f405cde1cc3c9fb811be155eecabe38f379bb \
  --target ssl crypto --parallel 8

For each staged tree, the tool runs ./configure followed by make -s -f <objects>/Makefile -j8, with these identical flags:

--builddir=<absolute variant objects directory>
--with-cc=clang --with-debug --with-http_ssl_module --with-http_v3_module
--with-cc-opt=-I<absolute vendored BoringSSL source>/include
--with-ld-opt=-L<absolute static BoringSSL build> -fuse-ld=lld -Wl,--no-as-needed -lc++ -lc++abi

Exact resolved commands are in build/qpack-response-encoding/full-spike.log; configuration/build/patch/version/dependency logs and source manifests are under ab/. The committed runner command is:

python3 tests/qpack_response_encoding.py \
  --upstream build/qpack-response-encoding/ab/upstream/objs/nginx \
  --patched build/qpack-response-encoding/ab/patched/objs/nginx \
  --repeat 3 --output build/qpack-response-encoding/ab/results.json

The Python runner requires the already-installed aioquic 1.3.0 / pylsqpack 0.3.23; it does not install them. Its version-specific H3 subclass configures the real decoder alongside SETTINGS and restores the library's initial header state after a decoded 103, which aioquic otherwise treats as the final response. All framing, QUIC, TLS and QPACK processing remains in installed libraries. Existing qlog lengths are checked against patched nginx's completed-buffer lengths.

Each case starts a local public HTTP/1.1 fixture and isolated nginx with private temporary runtime files, daemon off, master_process off, debug logging, TLSv1.3 only, no session tickets, QUIC loopback listener, early_hints 1, gzip off, proxy buffering off and proxy_pass_header Date for deterministic time. The existing public/insecure certificate/key fixtures are copied with private key mode 0600. No deployment config is used. Each client completes all sequential requests on one h3 connection, request stream IDs 0,4,8,.... Ten-millisecond pacing permits feedback delivery; this is scheduling, not latency evidence.

Per-case configs, exact server commands, settings/case parameters, decoded responses, client HTTP/3 qlog, feedback hex and nginx debug/process/validation logs are retained under ab/results/run-N/{upstream,patched}/<case>/. Handshake, request, process and cleanup waits are bounded. The runner waits for actual QUIC close completion before SIGQUIT, verifies encoder cleanup, reaps nginx, stops the fixture and removes temporary state.

Measured A/B results

The fixture sends the required alpha/beta/gamma fields, Set-Cookie and x-unrelated, fixed Date, text/plain Content-Type, exact Content-Length and the 23-byte body nxTLS QPACK spike body\n. Every final response is 200 and all header/body bytes are checked. Churn repeats each generation twice, using three values churn-NNN- plus 100 a/b/c characters.

The table below records exact field-section sizes, including QPACK prefix and excluding HTTP/3 frame headers, DATA, QUIC/TLS overhead, and encoder/decoder instructions. B x N means N sections of B bytes. Churn's full sequences are defined below. They are not performance, latency, browser or throughput claims.

Case Actual peer capacity / blocked limit Unmodified ordinary bytes Patched ordinary bytes Additional stock sections, both binaries
zero 0 / 0 202 x 20 202 x 20 none
ack-only 4096 / 0 202 x 20 202 x 20 none
stable 4096 / 16 202 x 20 202, 104 x 19 none
smaller 1024 / 1 202 x 20 202, 104 x 19 none
capped 8192 / 1; selected 4096 202 x 20 202, 104 x 19 none
churn, 60 responses 1024 / 1 [L(g), L(g)] for g=1..30 [L(g), 104] for g=1..30 none
trailers 4096 / 1 202 x 20 202, 104 x 19 29 x 20 trailer sections
early hints 4096 / 1 202 x 20 202, 104 x 19 36 x 20 103 sections
withheld, 40 responses 1024 / 1 [L(g), L(g)] for g=1..20 same, except response 2 = 104 none
cancellation 4096 / 1 202 x 20 202, 104 x 19 none
increment 4096 / 0 202 x 20 202, 104 x 19 none

L(g) = 374 for g in {1,2,10,11,12,20,21,22}; it is 375 for every other g=1..30. The one-byte variation follows nginx's existing Huffman coding of the changing decimal digits, not dynamic reuse. This formula specifies every churn section. Stable 20-section totals are 4040 control bytes versus 2178 patched bytes; later sections are 98 bytes smaller. Churn totals are 22484 versus 14362. Withheld totals are 14988 versus 14718. Encoder-stream insertion bytes and decoder feedback are additional costs and are not included in these field-section totals.

Patched case, per connection Insertions Full hits Evictions Literal emissions Section Acks Increment / Cancellation
zero 0 0 0 60 0 0 / 0
ack-only 3 0 0 60 0 0 / 0
stable / smaller / capped / trailers / 103 3 57 0 3 19 0 / 0
churn 90 90 84 90 30 0 / 0
withheld 6 3 0 117 0 0 / 0
cancellation 3 57 0 3 18 0 / 1
increment 3 57 0 3 19 1 / 0

Literal counts include the first response's stock representations after successful speculative insertion; they are not all allocation/send failures. There is no dynamic-name implementation or corresponding hit count.

The three stable entries account for 220 bytes. The churn table ends with six entries / 964 bytes and no outstanding section. Its RIC sequence is [0, 3*g] for g=1..30, Base=RIC for the referenced sections. With peer maximum 1024 the full range is 64: RIC=63 encodes 64; RIC=66 encodes 3; RIC=90 encodes 27. Existing client decoding succeeds across this wrap.

With all decoder feedback withheld, only response 2 references dynamically. The table reaches six entries / 964 bytes, never evicts an unacknowledged entry, and retains exactly one section until connection cleanup. Thirty-eight later responses complete as literals. This is bounded degradation, not a claim about optimal behavior against a malicious peer.

The cancellation and increment cases inject explicit RFC test instructions on the client's existing decoder stream: 44 cancels stream 4 after withholding its generated acknowledgment; 03 acknowledges three insertions before response 2. These exercise server handling, not a claim that pylsqpack naturally emits either instruction. The runner records both generated feedback and injections without implementing a QPACK parser.

Extended 8192-byte peer diagnostic and limitations

An additional, non-passing development test requested 180 churn responses with peer maximum 8192, selected capacity 4096, blocked limit 1. Requests 1–171 completed; response 172 (stream 684) had RIC=258, EncodedRIC=259, Base=258, 105-byte section, beginning ff 04 00, and the installed client closed with QPACK_DECOMPRESSION_FAILED (0x200). The preceding dynamic section with RIC=255, EncodedRIC=256 began ff 01 00 and decoded successfully. RFC arithmetic uses full range 512 here; replacing it with the selected table's 256 would be incorrect and was not used as a workaround.

Evidence is retained under build/qpack-response-encoding/diagnostics/peer-range/, peer-range.{json,log}, and library-capacity-probe.{py,log}. The separate probe uses the installed pylsqpack encoder/decoder and replaces only the initial capacity instruction with the RFC encoding for 4096; unique changing names and fresh-entry post-base references avoid reusing older evicted fields. Its 4096/4096 and 8192/8192 pairs complete 220 requests. Its 8192/4096 pair fails at request 172, stream 684, prefix ff0482101112. Reproduce that diagnostic with:

python3 build/qpack-response-encoding/diagnostics/library-capacity-probe.py

This suggests an installed-decoder limitation when selected capacity differs from maximum, but does not replace validation with a second independent decoder. The helper's reference encoder internally retains its higher selected capacity; the probe is supporting evidence, not a full conformance harness. The committed capped case validates twenty stable responses only; it does not claim that this extended case passed. The failed diagnostic is separate from the passing minimum A/B suite and remains a correctness/validation concern.

Other limits: sequential synthetic streams, one Linux host, one principal QPACK client library, version-specific private client hooks, conservative pinning and one outstanding section. No forced allocation failure, partial send, stream buffer saturation, loss/reordering, 0-RTT, concurrent cancellation race or long load test was performed. These error paths received source review only. Invalid acknowledgment/increment rejection was source-reviewed, not fuzzed. No RSS/CPU, latency, page rendering or performance benchmark was run. Stock debug output can log header values; all fixtures here are public. New experiment logs contain only counters, indexes, lengths and lifecycle state.

Validation and comparison with HPACK

The clean full tool exited 0, including all three complete A/B repetitions: 66 QUIC/TLSv1.3 connections and 1680 final 200 responses. All bodies, ordinary fields, 103 sections and trailers were complete. All 66 nginx processes exited 0 after actual QUIC close completion, with temporary state removed. Per-case field-section sequences, feedback, event counts, RIC/Base values and cleanup state were identical across all three repetitions. The table above describes each repetition exactly. The extended failing diagnostic remains outside this passing suite.

Executed successfully from the repository root:

tools/spike-qpack-response-encoding > build/qpack-response-encoding/full-spike.log 2>&1
bash -n tools/spike-qpack-response-encoding
python3 -m py_compile tests/qpack_response_encoding.py
cmp build/qpack-response-encoding/ab/sources-before.json build/qpack-response-encoding/ab/sources-after.json
cmp build/qpack-response-encoding/baseline/sources-before.json build/qpack-response-encoding/ab/sources-after.json
readelf -d build/qpack-response-encoding/ab/upstream/objs/nginx
readelf -d build/qpack-response-encoding/ab/patched/objs/nginx
git diff --check
git status --short
git diff --stat

Loopback runtime commands ran outside the socket-restricted sandbox. No build or test dependency was installed. Both original-source manifest comparisons matched all 521 nginx and 8660 BoringSSL files, including the manifest saved before the initial baseline. The explicit patch applied to seven pristine files with zero fuzz/offset. Both binaries' NEEDED lists contain libc++, libc++abi, PCRE2, zlib, libgcc_s and libc, with no dynamic libssl/libcrypto dependency. Clang 21.1.8 builds succeeded; nginx used -Wall -Wextra -Werror. Inspection with rg -n 'warning:' of the BoringSSL/control/patched build logs found no compiler warnings (rg exit 1 means no matches); configure's intentional probes are separate. An additional JSON comparison verified repetition identity.

Final source review covered QPACK relative-index arithmetic, peer-maximum RIC modulo, Base/prefix bounds, instruction buffering before mutation, feedback and potential-block accounting, insertion acknowledgment before eviction, current and outstanding reference protection, bounded withholding, actual connection cleanup, unchanged excluded-field writers and the 103/trailer paths. This is a focused review, not a production audit.

Early development failures were: the default nginx proxy hid the fixed upstream Date until proxy_pass_header Date was added; shutdown before QUIC's draining timer expired omitted connection-pool cleanup evidence; an instrumentation edit duplicated a staged filter fragment and failed to compile, then was corrected; and the extended 8192 diagnostic failed as detailed above. None was reported as a passing check.

The HPACK experiment demonstrated dynamic response reuse, but its stock generated incremental literals must be mirrored and can churn out otherwise stable eligible entries. Stock QPACK literals do not insert: only this experiment's explicit encoder-stream instructions consume space, so the stable entries survive at 1024 without periodic refill. QPACK adds feedback, reference lifetime and potential-block accounting that HPACK does not require. The literal 103/trailer paths coexist here through zero-RIC sections and separate table instructions. No data structures or implementation are shared, and these fixtures' absolute byte totals are not cross-protocol benchmarks.

Gate B: dynamic QPACK works, but the evidence does not justify further product design yet. Resolve the maximum-versus-selected capacity diagnostic with an independent existing decoder, then test feedback delay/concurrency and stream backpressure before reconsidering a joint HPACK/QPACK response-compression policy discussion. Even such a discussion would not select a shared implementation or adopt either patch.