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.