From 911c66f447ad4dc8892e3c110e474a3dfdb16d00 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 01:28:00 +0000 Subject: [PATCH] fix(quic-interop): explicitly advertise QPACK_MAX_TABLE_CAPACITY=0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../quic/interop/runner/Http3GetClient.kt | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt index 7bbdc3370..d8cb2c12a 100644 --- a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt +++ b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt @@ -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.