refactor(quic): kill dead levelLock + add bug-resistant batched-open API

Audit follow-up. Two cleanups consolidated into one commit since they
share the goal of "make the lock contract obvious from the API".

(1) Remove LevelState.levelLock entirely.

The lock-split refactor introduced a per-level Mutex with a docstring
claiming the writer/parser would acquire it around encode + sentPackets
record + ACK observation. Neither actually does. SendBuffer's internal
synchronized(this) is what serializes cryptoSend mutations against
takeChunk and markAcked. handlePtoFired's levelLock acquisition was
the only production usage and it serialized only against itself.

Removed:
  - LevelState.levelLock
  - handlePtoFired's withLock wrapper (now a non-suspend fun)
  - All docstring references to a third lock domain

The pre-existing sentPackets HashMap race (writer mutates under
streamsLock, parser reads without sync) is unchanged — out of scope
for this PR. Acquisition order is now `lifecycleLock → streamsLock`,
flat.

(2) Add openBidiStreamsBatch + openUniStreamsBatch — the bug-resistant
high-level API for the prepareRequests / moq audio-rooms patterns.

The previous shape required callers to manually do
`streamsLock.withLock { repeat(N) { openBidiStreamLocked() ... } }`.
That contract regressed twice on this very branch: once held the wrong
lock (lifecycleLock alias), once skipped the wrap entirely. Both shapes
silently emitted one STREAM per packet under multiplex load.

The new API encapsulates the lock + the per-item init lambda:

    conn.openBidiStreamsBatch(items) { stream, item ->
        stream.send.enqueue(encode(item))
        stream.send.finish()
        Handle(stream)
    }

Callers physically cannot hold the wrong lock. Migrated:
  - Http3GetClient.prepareRequests
  - HqInteropGetClient.prepareRequests

Also added `openUniStreamLocked` + `openUniStreamsBatch` symmetric to
the bidi versions. moq audio-rooms eventually wants to open many uni
streams in burst; the same one-stream-per-packet bug lurks if every
open serializes through its own lock acquisition.

`openBidiStreamLocked` / `openUniStreamLocked` remain public (with
their `check(streamsLock.isLocked)` guards) for the rare custom-batch
callers that need to mix bidi+uni opens under a single hold. Most
callers should use the *Batch variants going forward.

Test coverage extended:
  - openBidiStreamsBatch happy path
  - openUniStreamLocked throws without streamsLock
  - openUniStreamsBatch happy path

Six tests in BatchedOpenLockContractTest now pin the contract.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
This commit is contained in:
Claude
2026-05-07 03:31:30 +00:00
parent 07dd572423
commit a0a604b8e7
6 changed files with 178 additions and 94 deletions
@@ -23,7 +23,6 @@ package com.vitorpamplona.quic.interop.runner
import com.vitorpamplona.quic.connection.QuicConnection
import com.vitorpamplona.quic.connection.QuicConnectionDriver
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.sync.withLock
/**
* HQ-interop (HTTP/0.9 over QUIC) GET client. quic-interop-runner convention
@@ -57,19 +56,14 @@ class HqInteropGetClient(
@Suppress("UNUSED_PARAMETER") authority: String,
paths: List<String>,
): List<RequestHandle> {
// Pre-format requests outside the lock — see Http3GetClient
// for the full story.
// Pre-format outside the lock — see Http3GetClient. Then
// openBidiStreamsBatch atomically opens all N streams under
// streamsLock so the writer's next drain coalesces them.
val encoded = paths.map { "GET $it\r\n".encodeToByteArray() }
// streamsLock, NOT lifecycleLock (the deprecated `conn.lock`
// alias). Holding the wrong lock lets the send-loop drain
// between opens and emits one STREAM per packet.
return conn.streamsLock.withLock {
encoded.map { request ->
val stream = conn.openBidiStreamLocked()
stream.send.enqueue(request)
stream.send.finish()
HqRequestHandle(stream)
}
return conn.openBidiStreamsBatch(encoded) { stream, request ->
stream.send.enqueue(request)
stream.send.finish()
HqRequestHandle(stream)
}
}
@@ -33,7 +33,6 @@ import com.vitorpamplona.quic.qpack.QpackDecoder
import com.vitorpamplona.quic.qpack.QpackEncoder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.sync.withLock
/** Common shape for the two interop GET clients (HTTP/3 and HQ-interop).
*
@@ -165,25 +164,16 @@ class Http3GetClient(
// non-trivial; doing it under streamsLock would stall the
// send loop for the full encode time across every chunk.
val encoded = paths.map { encodeRequest(authority, it) }
// 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.
return conn.streamsLock.withLock {
encoded.map { request ->
val stream = conn.openBidiStreamLocked()
stream.send.enqueue(request)
stream.send.finish()
Http3RequestHandle(stream)
}
// openBidiStreamsBatch holds streamsLock for the entire
// batch — the send loop can't interject between opens so
// the writer's next drain finds all N streams' frames ready
// and packs them into coalesced packets (vs. the regressed
// shape that emitted one STREAM per packet against aioquic
// on 2026-05-06).
return conn.openBidiStreamsBatch(encoded) { stream, request ->
stream.send.enqueue(request)
stream.send.finish()
Http3RequestHandle(stream)
}
}