revert(quic-interop): drop explicit QPACK SETTINGS — made multiplexing worse

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
This commit is contained in:
Claude
2026-05-07 01:33:23 +00:00
parent 911c66f447
commit 4e00c8db07
2 changed files with 27 additions and 26 deletions
@@ -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
@@ -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.