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
@@ -116,6 +116,51 @@ class BatchedOpenLockContractTest {
}
}
@Test
fun `openBidiStreamsBatch is the bug-resistant API for the prepareRequests pattern`() {
runBlocking {
val client = handshakedClient()
// The new high-level API — caller can't hold the wrong
// lock because it doesn't take any. Encapsulates the
// streamsLock acquisition + per-item init under the lock.
val payloads = (0 until 64).map { "req-$it".encodeToByteArray() }
val streams =
client.openBidiStreamsBatch(payloads) { stream, payload ->
stream.send.enqueue(payload)
stream.send.finish()
stream
}
assertEquals(64, streams.size)
assertEquals(64, streams.map { it.streamId }.toSet().size)
}
}
@Test
fun `openUniStreamLocked throws when streamsLock is not held`() {
runBlocking {
val client = handshakedClient()
assertFailsWith<IllegalStateException> {
client.openUniStreamLocked()
}
}
}
@Test
fun `openUniStreamsBatch holds streamsLock for the whole batch`() {
runBlocking {
val client = handshakedClient()
// moq audio-rooms shape: many uni streams in burst. Without
// the batched API, each open releases the lock and the
// send loop interjects (the same shape that broke bidi
// multiplexing on 2026-05-06).
val items = List(16) { it }
val streams =
client.openUniStreamsBatch(items) { stream, _ -> stream }
assertEquals(16, streams.size)
assertEquals(16, streams.map { it.streamId }.toSet().size)
}
}
private fun handshakedClient(): QuicConnection =
runBlocking {
val client =