From 4e00c8db0718aeb2d34abdbe250926e2aeda3353 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 01:33:23 +0000 Subject: [PATCH] =?UTF-8?q?revert(quic-interop):=20drop=20explicit=20QPACK?= =?UTF-8?q?=20SETTINGS=20=E2=80=94=20made=20multiplexing=20worse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The QPACK_MAX_TABLE_CAPACITY=0 advertisement worsened the result (1 file → 0 files written). Empirical: empty SETTINGS = spec defaults (both 0) and aioquic was already sending literal-encoded responses under that condition. Real bug isn't QPACK — diagnostic showed 1359 GETs processed in 58s (~23 streams/sec). Throughput is hard-bottlenecked by :quic's single conn.lock serializing send loop + read loop + openBidiStream across 1999 waiting coroutines. Even at maximum throughput we'd only complete ~1400 of 1999 in 60s. The 1 file we managed previously was just the first response landing before lock contention spiked. Filed multiplexing as a known-throughput-limit follow-up. Possible fixes (per-level lock split / Semaphore-bounded concurrency / QPACK dynamic-table support) are all non-trivial and the testcase is a stress test, not a real-world load shape. For now: revert to empty SETTINGS, accept multiplexing as the lone deferred testcase. Aioquic + picoquic stay at 6/7. https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT --- .../plans/2026-05-06-interop-runner.md | 26 +++++++++++++----- .../quic/interop/runner/Http3GetClient.kt | 27 ++++++------------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/quic/interop/plans/2026-05-06-interop-runner.md b/quic/interop/plans/2026-05-06-interop-runner.md index 1694c7586f..3d07f186ec 100644 --- a/quic/interop/plans/2026-05-06-interop-runner.md +++ b/quic/interop/plans/2026-05-06-interop-runner.md @@ -181,13 +181,25 @@ the marquee result. Implementing v2 is its own project (different Initial-secret derivation, different transport-parameter encoding, different long-header type bits — not just a version-number swap). -- **Multiplexing throughput on Mac+Rosetta** — the timeout bump - unblocks tests that were finishing in time. The deeper fix is - per-stream backpressure (parser suspends when channel near - capacity instead of tearing down or dropping). See `:quic`'s - `Audit-4 #3` decision: prefer fail-fast over silent-drop on - per-stream channel saturation. Needs a re-think for - multi-stream-heavy workloads. +- **Multiplexing throughput on Mac+Rosetta** — measured at ~23 streams/sec + (aioquic processed 1359 GETs in 58s). The runner's multiplexing test + generates 1999 files; even with a 60s budget we only get 70% of them + open before the test ends, with most coroutines failing to surface a + status=200 in time for the file-write loop. Throughput is hard-bottlenecked + by `:quic`'s single `conn.lock` serializing the send loop, the read + loop, and `openBidiStream` across all 1999 awaiting coroutines. Under + this lock contention pattern, parallelism doesn't help — coroutines + queue on the lock anyway. + Possible fixes (non-trivial): + - Per-level / per-stream lock split (big refactor; touches almost + everything in `:quic/connection`). + - Batch concurrency via a `Semaphore` capping in-flight `client.get()` + calls to e.g. 64 at a time. Doesn't lift the throughput ceiling but + reduces lock thrash and probably lets MORE files complete in 60s. + - QPACK dynamic-table support so aioquic doesn't need to fall back + to literal encoding (would help on the response-decode side). + None are urgent; this is a stress-test scenario, not a normal-traffic + one. Filed for follow-up. - **Server role** — entire stack is client-only. Implementing the server side would unlock `handshakeloss` against aioquic (currently `?` because aioquic-server doesn't support it), and 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 d8cb2c12a0..734be314a0 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,7 +27,6 @@ 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 @@ -63,27 +62,17 @@ class Http3GetClient( private val conn: QuicConnection, ) : GetClient { suspend fun init(scope: CoroutineScope) { - // 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. + // Control stream: type-0x00 prefix followed by a SETTINGS frame + // (empty body is legal — RFC 9114 §7.2.4). Empty body = spec + // defaults: QPACK_MAX_TABLE_CAPACITY=0, QPACK_BLOCKED_STREAMS=0, + // MAX_FIELD_SECTION_SIZE=unlimited. We deliberately do NOT + // explicitly send QPACK_MAX_TABLE_CAPACITY=0 — empirically + // (aioquic 2026-05-07) that worsened the multiplexing case + // from 1 file to 0 files. val control = conn.openUniStream() val w = QuicWriter() w.writeVarint(Http3StreamType.CONTROL) - w.writeBytes( - Http3Settings( - mapOf( - Http3SettingsId.QPACK_MAX_TABLE_CAPACITY to 0L, - Http3SettingsId.QPACK_BLOCKED_STREAMS to 0L, - ), - ).encodeFrame(), - ) + w.writeBytes(Http3Settings(emptyMap()).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.