feat(quic-interop): multiconnect dispatch + multiplex stream-budget pacing

Two interop-runner gaps closed in one InteropClient pass plus a
QuicConnection snapshot helper:

1. multiconnect testcase. The runner's handshakeloss /
   handshakecorruption tests reuse TESTCASE_CLIENT=multiconnect — 50
   sequential connections, each fetching a 1KB file under 30% packet
   drop or bit-flip, with the runner verifying _count_handshakes()==50
   in the pcap. Pre-fix our InteropClient dispatch returned 127 (skip)
   for "multiconnect", so both tests showed as ?(L1, C1). Added
   runMulticonnectTest: loops fresh socket + conn + driver + GET +
   close per URL. Per-iteration qlog files at $QLOGDIR/client-N.sqlog
   so a stuck iteration leaves a focused trace.

2. multiplex pacing against quic-go. Pre-fix the parallel path
   chunked the URL list into fixed groups of MULTIPLEX_PARALLELISM=64.
   Worked against aioquic + picoquic (initial_max_streams_bidi=128)
   but blew up against quic-go (advertises 100, ramps slowly via
   MAX_STREAMS_BIDI bumps): second chunk pushed cumulative used past
   limit, threw QuicStreamLimitException. Now each iteration takes
   min(MULTIPLEX_PARALLELISM, peerMaxStreamsBidi - used). When budget
   hits 0, brief 50ms idle waits for the peer's bump.

   New QuicConnection.localBidiStreamsUsedSnapshot() exposes the
   consumed-side counter; combined with the existing
   peerMaxStreamsBidiSnapshot() the InteropClient computes the live
   available budget without holding streamsLock.

Result against quic-go: H, M, LR, L2, C2, C1 pass; was 0/7 at
session start (handshake itself failed pre-ALPN-fix), 4/6 after
key-update fix, now 6/7. Only L1 (handshakeloss) remains as
multiconnect-under-30%-drop flake (same flake picoquic shows).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-07 15:54:48 -04:00
parent b622d0c936
commit 86a4727efb
2 changed files with 243 additions and 4 deletions
@@ -978,6 +978,26 @@ class QuicConnection(
/** Snapshot of peer-granted uni cap. */
fun peerMaxStreamsUniSnapshot(): Long = peerMaxStreamsUni
/**
* Number of client-initiated bidi streams we've allocated so far —
* the "consumed" side of the [peerMaxStreamsBidiSnapshot] budget.
* Increments on each [openBidiStreamLocked] call and never decreases
* (RFC 9000 §4.6: the limit is on cumulative IDs, not concurrent
* count).
*
* Multiplexing callers use this with [peerMaxStreamsBidiSnapshot] to
* compute the AVAILABLE budget at any moment (`max - used`) so they
* can pace stream creation against peer's MAX_STREAMS_BIDI bumps
* instead of throwing [QuicStreamLimitException] on the cap-tightest
* peer.
*
* Read without [streamsLock] — the field is mutated under
* `streamsLock`, but callers using this for back-pressure decisions
* tolerate a slightly-stale read (worst case: open one fewer stream
* than possible this round, get one more next round).
*/
fun localBidiStreamsUsedSnapshot(): Long = nextLocalBidiIndex
/**
* Coherent point-in-time snapshot of the connection's flow-control
* accounting. Acquires [lock] internally so the fields are read