fix(quic-interop): explicitly advertise QPACK_MAX_TABLE_CAPACITY=0

aioquic multiplexing diagnosis: runner asked for 1999 files, aioquic
processed 1371 GET requests, our endpoint wrote ONLY 1 file to
/downloads. The connection stayed healthy throughout (qlog shows
STREAM frames flowing both ways at t=58s). Server response volume
indicates each request got a 200 response.

The bug: our QpackDecoder is literal-only (no dynamic table). When
aioquic primes its dynamic table after a few requests and switches
to dynamic-table references in subsequent response HEADERS, our
decoder silently mis-parses — status comes back 0, file not written.
Empty-SETTINGS gives aioquic the spec default (also 0) but it apparently
doesn't strictly enforce: it still emits dynamic-refs anyway.

Fix: explicitly advertise QPACK_MAX_TABLE_CAPACITY=0 +
QPACK_BLOCKED_STREAMS=0 in our SETTINGS. Forces aioquic onto the
literal-only QPACK path that our decoder handles correctly.

A proper fix would be to implement QPACK dynamic-table support in our
decoder + read the server's encoder stream; that's its own project.
For now this gets multiplexing past the QPACK barrier so we can see
whether anything else is broken downstream.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
This commit is contained in:
Claude
2026-05-07 01:28:00 +00:00
parent 0fea36fa56
commit 911c66f447
@@ -27,6 +27,7 @@ import com.vitorpamplona.quic.http3.Http3Frame
import com.vitorpamplona.quic.http3.Http3FrameReader
import com.vitorpamplona.quic.http3.Http3FrameType
import com.vitorpamplona.quic.http3.Http3Settings
import com.vitorpamplona.quic.http3.Http3SettingsId
import com.vitorpamplona.quic.http3.Http3StreamType
import com.vitorpamplona.quic.qpack.QpackDecoder
import com.vitorpamplona.quic.qpack.QpackEncoder
@@ -62,12 +63,27 @@ class Http3GetClient(
private val conn: QuicConnection,
) : GetClient {
suspend fun init(scope: CoroutineScope) {
// Control stream: type-0x00 prefix followed by a SETTINGS frame
// (empty body is legal — RFC 9114 §7.2.4).
// Control stream: type-0x00 prefix followed by a SETTINGS frame.
// Explicitly advertise QPACK_MAX_TABLE_CAPACITY=0 and
// QPACK_BLOCKED_STREAMS=0 — our QpackDecoder is literal-only
// (no dynamic-table support, RFC 9204 Required Insert Count = 0).
// Empty SETTINGS would let the spec default kick in (also 0),
// but aioquic's behavior on multiplexing showed that without
// explicit signal it still emits dynamic-table-referenced fields
// in response HEADERS, which our decoder silently mis-parses
// (status=0, file not written). Sending the explicit zeros
// forces aioquic onto the literal-only QPACK path.
val control = conn.openUniStream()
val w = QuicWriter()
w.writeVarint(Http3StreamType.CONTROL)
w.writeBytes(Http3Settings(emptyMap()).encodeFrame())
w.writeBytes(
Http3Settings(
mapOf(
Http3SettingsId.QPACK_MAX_TABLE_CAPACITY to 0L,
Http3SettingsId.QPACK_BLOCKED_STREAMS to 0L,
),
).encodeFrame(),
)
control.send.enqueue(w.toByteArray())
// Control stream stays open for the lifetime of the H3 connection;
// do NOT call finish() — peers treat that as H3_CLOSED_CRITICAL_STREAM.