fix(quic): batch openBidiStream under one lock hold for multiplexing
Diagnosis from yet another qlog round: streams/packet still ~1 even
with the prepareRequest/awaitResponse split. Root cause: openBidiStream
is suspend due to lock.withLock, and each call releases the lock between
iterations. The send loop is queued on the lock; it grabs it the moment
we release, drains the one stream of data we just enqueued, and the
next prepareRequest call has to re-acquire after the send loop releases.
Net: one stream per drain per packet, same useless coalescing as before.
Fix is structural:
- QuicConnection.openBidiStreamLocked() — public, lock-not-acquired
version of openBidiStream. Caller MUST hold conn.lock.
- GetClient.prepareRequests(authority, paths) — batch API that
holds conn.lock once, opens + enqueues all N streams in a single
critical section, releases. Send loop can't interject; when it
next drains it sees ALL N streams' data ready and packs them
into coalesced packets.
- Http3GetClient + HqInteropGetClient: implement prepareRequests
using openBidiStreamLocked under conn.lock.withLock { ... }.
- InteropClient's chunked-multiplex loop: uses prepareRequests
(batch) instead of N x prepareRequest.
Single-stream paths still use prepareRequest / get(); behavior unchanged.
The fundamental architectural improvement (per-stream / per-level lock
split, or actor-model dispatch) is a follow-up; this commit gets us
the throughput we need from the existing single-mutex shape by holding
the lock for the full chunk's worth of work.
https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
This commit is contained in:
@@ -706,22 +706,34 @@ class QuicConnection(
|
||||
* check capacity proactively if the caller wants to back-pressure rather
|
||||
* than throw.
|
||||
*/
|
||||
suspend fun openBidiStream(): QuicStream =
|
||||
lock.withLock {
|
||||
if (nextLocalBidiIndex >= peerMaxStreamsBidi) {
|
||||
throw QuicStreamLimitException(
|
||||
"peer-granted bidi stream cap reached " +
|
||||
"(used=$nextLocalBidiIndex limit=$peerMaxStreamsBidi)",
|
||||
)
|
||||
}
|
||||
val id = StreamId.build(StreamId.Kind.CLIENT_BIDI, nextLocalBidiIndex++)
|
||||
val stream = QuicStream(id, QuicStream.Direction.BIDIRECTIONAL)
|
||||
stream.sendCredit = peerTransportParameters?.initialMaxStreamDataBidiRemote ?: config.initialMaxStreamDataBidiRemote
|
||||
stream.receiveLimit = config.initialMaxStreamDataBidiLocal
|
||||
streams[id] = stream
|
||||
streamsList += stream
|
||||
stream
|
||||
suspend fun openBidiStream(): QuicStream = lock.withLock { openBidiStreamLocked() }
|
||||
|
||||
/**
|
||||
* The connection-lock-holding part of [openBidiStream]. Public so
|
||||
* batched-multiplex callers (interop multiplexing testcase, MoQ
|
||||
* audio rooms emitting many group streams in quick succession) can
|
||||
* acquire [lock] ONCE and bracket multiple stream opens — preventing
|
||||
* the send loop from interjecting between opens and draining one
|
||||
* stream's data per packet. Caller MUST hold [lock].
|
||||
*
|
||||
* The non-batched paths use [openBidiStream] which holds the lock
|
||||
* for one call only.
|
||||
*/
|
||||
fun openBidiStreamLocked(): QuicStream {
|
||||
if (nextLocalBidiIndex >= peerMaxStreamsBidi) {
|
||||
throw QuicStreamLimitException(
|
||||
"peer-granted bidi stream cap reached " +
|
||||
"(used=$nextLocalBidiIndex limit=$peerMaxStreamsBidi)",
|
||||
)
|
||||
}
|
||||
val id = StreamId.build(StreamId.Kind.CLIENT_BIDI, nextLocalBidiIndex++)
|
||||
val stream = QuicStream(id, QuicStream.Direction.BIDIRECTIONAL)
|
||||
stream.sendCredit = peerTransportParameters?.initialMaxStreamDataBidiRemote ?: config.initialMaxStreamDataBidiRemote
|
||||
stream.receiveLimit = config.initialMaxStreamDataBidiLocal
|
||||
streams[id] = stream
|
||||
streamsList += stream
|
||||
return stream
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate a new client-initiated unidirectional (write-only) stream.
|
||||
|
||||
Reference in New Issue
Block a user