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:
+5
-1
@@ -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()
|
||||
|
||||
+13
-1
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user