fix(quic-interop): prepareRequests must hold streamsLock, not lifecycleLock

aioquic interop multiplexing 2026-05-06 qlog post-mortem:
  - 2898 packets sent in 60s, each carrying ONE STREAM frame
  - server log: streams created/discarded strictly serially, ~30-40ms apart
  - 1421/2000 files completed before the runner's 60s timeout
  - shape ≈ 1 RTT per stream — wire was emitting one stream per datagram

Cause: Http3GetClient.prepareRequests + HqInteropGetClient.prepareRequests
both did `conn.lock.withLock { ... openBidiStreamLocked() }`. Post the
lock-split refactor `conn.lock` is the deprecated alias for lifecycleLock.
The writer's drainOutbound takes streamsLock — not lifecycleLock — so the
send loop interleaved between every two openBidiStreamLocked calls,
draining one stream's data per pass.

Fix:
  1. Both prepareRequests impls now use conn.streamsLock.withLock.
  2. openBidiStreamLocked now `check`s streamsLock.isLocked at entry
     so this can never silently regress again — calling it without the
     lock (or with the wrong lock) throws IllegalStateException with
     a message naming streamsLock as the lock to acquire.
  3. New BatchedOpenLockContractTest pins the contract:
     - calling openBidiStreamLocked WITHOUT any lock throws
     - calling openBidiStreamLocked while holding lifecycleLock throws
       (the exact regression shape)
     - calling openBidiStreamLocked while holding streamsLock works
       (happy path)

The runtime check is the regression-proof part: future callers physically
cannot hold the wrong lock without the test (and prod) blowing up at the
first call site.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
This commit is contained in:
Claude
2026-05-07 03:18:18 +00:00
parent 226fa14882
commit 991b1a1da3
4 changed files with 191 additions and 2 deletions
@@ -57,7 +57,11 @@ class HqInteropGetClient(
@Suppress("UNUSED_PARAMETER") authority: String,
paths: List<String>,
): List<RequestHandle> =
conn.lock.withLock {
// streamsLock, NOT lifecycleLock (the deprecated `conn.lock`
// alias) — see Http3GetClient.prepareRequests for the full
// story. tl;dr: holding the wrong lock lets the send-loop
// drain between opens and emits one STREAM per packet.
conn.streamsLock.withLock {
paths.map { path ->
val stream = conn.openBidiStreamLocked()
val request = "GET $path\r\n".encodeToByteArray()
@@ -159,7 +159,19 @@ class Http3GetClient(
authority: String,
paths: List<String>,
): List<RequestHandle> =
conn.lock.withLock {
// CRITICAL: streamsLock — the lock the writer's drainOutbound
// takes. Holding lifecycleLock (the deprecated `conn.lock`
// alias) here did NOT block the send loop, so the writer
// interjected between every openBidiStreamLocked call and
// emitted ONE STREAM frame per packet. aioquic interop
// 2026-05-06 qlog: 2898 packets sent in 60s, each carrying
// exactly one stream frame; server processed streams strictly
// sequentially at ~1 RTT per stream → 1421/2000 files in 60s
// before timeout. Holding streamsLock blocks the drain so
// all N opens land before any drain runs, the writer then
// packs many STREAM frames into each datagram, and the
// server sees the burst on the wire.
conn.streamsLock.withLock {
paths.map { path ->
val stream = conn.openBidiStreamLocked()
stream.send.enqueue(encodeRequest(authority, path))