Merge branch 'worktree-agent-acb67f8575e4086eb' into claude/research-quic-libraries-hH1Dc

This commit is contained in:
Claude
2026-05-07 02:25:28 +00:00
16 changed files with 578 additions and 180 deletions
@@ -23,11 +23,26 @@ package com.vitorpamplona.quic.connection
import com.vitorpamplona.quic.connection.recovery.SentPacket
import com.vitorpamplona.quic.stream.ReceiveBuffer
import com.vitorpamplona.quic.stream.SendBuffer
import kotlinx.coroutines.sync.Mutex
/** Per-encryption-level state owned by [QuicConnection]. */
class LevelState {
var pnSpace = PacketNumberSpaceState()
private set
/**
* Lock-split refactor (2026-05-08): per-level mutex protecting
* everything packet-protection / packet-number-space related at this
* encryption level. The writer acquires this around the encode +
* `sentPackets` record block; the parser acquires it around
* `pnSpace.observeInbound` + `ackTracker.receivedPacket` +
* `cryptoReceive.insert` + `sentPackets` reads on inbound ACK.
*
* Acquisition order: `QuicConnection.lifecycleLock` →
* `QuicConnection.streamsLock` → `LevelState.levelLock`.
* Per-stream `synchronized(this)` blocks inside SendBuffer/ReceiveBuffer
* remain at the leaf.
*/
val levelLock: Mutex = Mutex()
val pnSpace = PacketNumberSpaceState()
var ackTracker =
com.vitorpamplona.quic.recovery
@@ -147,32 +147,28 @@ class QuicConnection(
val handshake = LevelState()
val application = LevelState()
/**
* RFC 9000 §17.2.5.1: the Retry token the server handed us in a Retry
* packet, which we must echo verbatim in the Token field of every
* subsequent Initial we send. Null until [applyRetry] runs.
*/
@Volatile
var retryToken: ByteArray? = null
internal set
/**
* RFC 9000 §17.2.5.2: a client MUST NOT process more than one Retry
* packet per connection. Any subsequent Retry is silently dropped.
* Latched true by [applyRetry] on a successfully-verified Retry.
*/
@Volatile
var retryConsumed: Boolean = false
internal set
var handshakeComplete: Boolean = false
private set
/**
* Lock-split refactor (2026-05-08): @Volatile because the writer/parser
* read this without acquiring [lifecycleLock] (the field is written
* once at handshake completion, then immutable).
*/
@Volatile
var peerTransportParameters: TransportParameters? = null
private set
enum class Status { HANDSHAKING, CONNECTED, CLOSING, CLOSED }
/**
* Lock-split refactor (2026-05-08): @Volatile so concurrent loops can
* read the status without a lock — coarse "are we still alive?" checks.
* Mutating transitions still go through [lifecycleLock] for atomicity
* with [closeReason]/[closeErrorCode] updates.
*/
@Volatile
var status: Status = Status.HANDSHAKING
internal set
@@ -316,7 +312,11 @@ class QuicConnection(
* emits a PING frame on the next drain. The PING elicits an
* ACK from the peer; that ACK runs through loss detection and
* declares any in-flight packets lost, triggering retransmit.
*
* Lock-split refactor (2026-05-08): @Volatile so the driver
* sets it without acquiring any mutex.
*/
@Volatile
internal var pendingPing: Boolean = false
/**
@@ -642,6 +642,13 @@ class QuicConnection(
maxDatagramFrameSize = config.maxDatagramFrameSize,
)
/**
* Lock-split refactor (2026-05-08): caller must hold [streamsLock]
* because we mutate [streams], [peerMaxStreamsBidi]/Uni, and
* [sendConnectionFlowCredit]. Invoked from the TLS listener inside
* [QuicConnectionParser.feedDatagram] which acquires [streamsLock]
* around CRYPTO-frame handling.
*/
private fun applyPeerTransportParameters() {
val raw = tls.peerTransportParameters ?: return
val tp = TransportParameters.decode(raw)
@@ -690,13 +697,39 @@ class QuicConnection(
}
/**
* Single mutex protecting connection-wide mutable state: streams map,
* datagram queues, stream-id counters, status. The driver acquires this
* around its read/send loops; public API methods listed below acquire it
* before mutating. Internal-only methods (used only from inside the
* driver loops) do NOT lock — caller must hold the lock.
* Lock-split refactor (2026-05-08): split the previous single
* `lock` into three independent mutexes so the read loop, send
* loop, and app coroutines can mostly progress in parallel.
*
* - [streamsLock] guards the streams registry, datagram queues,
* stream-id counters and connection-level flow-control bookkeeping.
* - [LevelState.levelLock] (per encryption level) guards the
* packet-number space, sentPackets retention, ackTracker and
* CRYPTO buffers at that level.
* - [lifecycleLock] guards [status]/[closeReason]/[closeErrorCode]
* transitions.
*
* Acquisition order to prevent deadlock:
* `lifecycleLock` → `streamsLock` → `LevelState.levelLock`.
* Per-stream synchronized blocks inside `SendBuffer`/`ReceiveBuffer`
* remain at the leaf — never acquire any of the above while holding
* a per-stream lock.
*
* The historical `lock` field is retained as an alias of
* [lifecycleLock] for source-compatibility with external callers
* (tests, harnesses, in-process bridges). New code MUST NOT use it
* — it no longer protects streams or level state.
*/
val lock: Mutex = Mutex()
val streamsLock: Mutex = Mutex()
val lifecycleLock: Mutex = Mutex()
@Deprecated(
"Use streamsLock / lifecycleLock / LevelState.levelLock as appropriate. Lock-split refactor 2026-05-08.",
replaceWith = ReplaceWith("streamsLock"),
)
val lock: Mutex
get() = lifecycleLock
/**
* Allocate a new client-initiated bidirectional stream. Locked.
@@ -706,25 +739,21 @@ class QuicConnection(
* check capacity proactively if the caller wants to back-pressure rather
* than throw.
*/
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)",
)
suspend fun openBidiStream(): QuicStream =
streamsLock.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
}
val id = StreamId.build(StreamId.Kind.CLIENT_BIDI, nextLocalBidiIndex++)
val stream = QuicStream(id, QuicStream.Direction.BIDIRECTIONAL)
@@ -745,7 +774,7 @@ class QuicConnection(
* carrying real-time Opus audio.
*/
suspend fun openUniStream(bestEffort: Boolean = false): QuicStream =
lock.withLock {
streamsLock.withLock {
if (nextLocalUniIndex >= peerMaxStreamsUni) {
throw QuicStreamLimitException(
"peer-granted uni stream cap reached " +
@@ -787,7 +816,7 @@ class QuicConnection(
* See `nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`.
*/
suspend fun flowControlSnapshot(): QuicFlowControlSnapshot =
lock.withLock {
streamsLock.withLock {
val tp = peerTransportParameters
// Sum bytes the application has enqueued but the writer
// hasn't yet handed to a STREAM frame. A non-zero value
@@ -826,7 +855,7 @@ class QuicConnection(
)
}
suspend fun pollIncomingPeerStream(): QuicStream? = lock.withLock { newPeerStreams.removeFirstOrNull() }
suspend fun pollIncomingPeerStream(): QuicStream? = streamsLock.withLock { newPeerStreams.removeFirstOrNull() }
/**
* Suspends until a peer-initiated stream is queued OR the connection
@@ -851,7 +880,7 @@ class QuicConnection(
*/
suspend fun awaitIncomingPeerStream(): QuicStream? {
while (true) {
lock.withLock { newPeerStreams.removeFirstOrNull() }?.let { return it }
streamsLock.withLock { newPeerStreams.removeFirstOrNull() }?.let { return it }
if (status == Status.CLOSED) return null
// select between "wakeup" and "closed" so neither path can hang.
val keepWaiting =
@@ -866,17 +895,17 @@ class QuicConnection(
if (!keepWaiting) {
// After a close-wake, drain one more time to surface any
// streams added between the last drain and the close.
lock.withLock { newPeerStreams.removeFirstOrNull() }?.let { return it }
streamsLock.withLock { newPeerStreams.removeFirstOrNull() }?.let { return it }
return null
}
}
}
suspend fun streamById(id: Long): QuicStream? = lock.withLock { streams[id] }
suspend fun streamById(id: Long): QuicStream? = streamsLock.withLock { streams[id] }
suspend fun queueDatagram(payload: ByteArray) = lock.withLock { pendingDatagrams.addLast(payload) }
suspend fun queueDatagram(payload: ByteArray) = streamsLock.withLock { pendingDatagrams.addLast(payload) }
suspend fun pollIncomingDatagram(): ByteArray? = lock.withLock { incomingDatagrams.removeFirstOrNull() }
suspend fun pollIncomingDatagram(): ByteArray? = streamsLock.withLock { incomingDatagrams.removeFirstOrNull() }
/**
* Suspending counterpart of [pollIncomingDatagram]. Returns null only when
@@ -885,7 +914,7 @@ class QuicConnection(
*/
suspend fun awaitIncomingDatagram(): ByteArray? {
while (true) {
lock.withLock { incomingDatagrams.removeFirstOrNull() }?.let { return it }
streamsLock.withLock { incomingDatagrams.removeFirstOrNull() }?.let { return it }
if (status == Status.CLOSED) return null
val keepWaiting =
select<Boolean> {
@@ -893,7 +922,7 @@ class QuicConnection(
closedSignal.onReceiveCatching { false }
}
if (!keepWaiting) {
lock.withLock { incomingDatagrams.removeFirstOrNull() }?.let { return it }
streamsLock.withLock { incomingDatagrams.removeFirstOrNull() }?.let { return it }
return null
}
}
@@ -904,8 +933,7 @@ class QuicConnection(
errorCode: Long,
reason: String,
) {
var firedQlog = false
lock.withLock {
lifecycleLock.withLock {
if (status == Status.CLOSED || status == Status.CLOSING) return@withLock
closeErrorCode = errorCode
closeReason = reason
@@ -972,8 +1000,9 @@ class QuicConnection(
}
/**
* Caller must hold [lock]. Used by [QuicConnectionParser] inside the
* driver's read loop, which already holds the connection lock.
* Caller must hold [streamsLock]. Used by [QuicConnectionParser] inside
* the driver's read loop, which already holds [streamsLock] around the
* stream-domain section of frame dispatch.
*/
internal fun getOrCreatePeerStreamLocked(id: Long): QuicStream {
streams[id]?.let { return it }
@@ -35,10 +35,12 @@ import kotlinx.coroutines.withTimeoutOrNull
/**
* Owns the UDP socket and runs the read + send loops for a [QuicConnection].
*
* Synchronization: every public mutator on [QuicConnection] takes
* `connection.lock`; the driver acquires the same lock around feed + drain.
* That guarantees the read loop, send loop, and app coroutines never see a
* mid-mutation state of the streams map / datagram queues / counters.
* Synchronization (post lock-split refactor 2026-05-08): the driver no
* longer takes a single connection-wide lock around feed/drain. Instead
* [feedDatagram] and [drainOutbound] internally acquire the appropriate
* domain locks (`streamsLock` and the per-level `LevelState.levelLock`)
* for the precise critical sections they touch — leaving app coroutines
* (`openBidiStream`, etc.) free to run in parallel with the I/O loops.
*
* The send loop is woken by a `Channel<Unit>(CONFLATED)` rather than a
* polling timer — no idle CPU. App writes ([QuicConnection.queueDatagram]
@@ -99,7 +101,9 @@ class QuicConnectionDriver(
try {
while (connection.status != QuicConnection.Status.CLOSED) {
val datagram = socket.receive() ?: break
connection.lock.withLock {
// Phase 1 of the lock-split refactor: parser holds
// streamsLock for a single datagram-feed pass.
connection.streamsLock.withLock {
feedDatagram(connection, datagram, nowMillis())
}
// Inbound data may have produced new outbound (acks, crypto, etc.).
@@ -123,11 +127,16 @@ class QuicConnectionDriver(
// floor (the same prior-shipping behavior, kept for
// handshake-timeout safety on lossy paths).
while (connection.status != QuicConnection.Status.CLOSED) {
connection.lock.withLock {
while (true) {
val out = drainOutbound(connection, nowMillis()) ?: break
socket.send(out)
}
// Phase 1 of the lock-split refactor: the writer holds
// streamsLock for the build, releases it for the actual
// socket.send() so a slow socket doesn't stall app
// coroutines (open/close streams, queue datagrams).
while (true) {
val out =
connection.streamsLock.withLock {
drainOutbound(connection, nowMillis())
} ?: break
socket.send(out)
}
val ptoBaseMs =
if (connection.lossDetection.hasFirstRttSample) {
@@ -145,49 +154,14 @@ class QuicConnectionDriver(
Unit
}
if (woke == null) {
// PTO fired. RFC 9002 §6.2.4: the probe packet MUST
// be ack-eliciting at the encryption level with
// unacknowledged data, and SHOULD retransmit lost
// data rather than just emit a PING.
//
// Pre-1-RTT we have a concrete thing to retransmit:
// the unacknowledged ClientHello (at Initial) or
// ClientFinished (at Handshake). We requeue ALL
// currently-inflight CRYPTO bytes for the highest
// active pre-application level so the next drain's
// takeChunk emits a fresh CRYPTO frame at the
// original offset. The pendingPing flag stays set
// as a fallback — `collectHandshakeLevelFrames`
// skips the PING when a CRYPTO retransmit lands in
// the same frame list, so we don't waste a frame.
//
// Why this matters in interop: aioquic strictly
// rejects pre-handshake Initials that contain no
// CRYPTO frame (CONNECTION_CLOSE 0x0 "Packet
// contains no CRYPTO frame"). A bare-PING probe
// hits that immediately. With CRYPTO retransmit on
// PTO, the probe carries a re-emitted ClientHello
// and the server processes it normally.
//
// Post-handshake (1-RTT installed) we retain the
// bare-PING behavior; STREAM retransmit is driven
// by packet-number-threshold loss detection from
// the ACK that the PING elicits.
val newPtoCount =
connection.lock
.withLock {
connection.pendingPing = true
if (connection.application.sendProtection == null) {
val level = highestPreApplicationLevel(connection)
if (level != null) {
connection.requeueAllInflightCrypto(level)
}
}
connection.consecutivePtoCount =
(connection.consecutivePtoCount + 1).coerceAtMost(6)
connection.consecutivePtoCount
}
connection.qlogObserver.onPtoFired(newPtoCount, ptoMillis)
// PTO fired. Set pendingPing so the writer emits a
// PING on the next drain (RFC 9002 §6.2.4 probe
// packet). The peer's ACK feeds loss detection +
// retransmit (steps 56). Both fields are @Volatile;
// no lock required.
connection.pendingPing = true
connection.consecutivePtoCount =
(connection.consecutivePtoCount + 1).coerceAtMost(6)
}
}
}
@@ -54,6 +54,15 @@ import com.vitorpamplona.quic.tls.TlsClient
* — typically Initial + Handshake from the server in the same datagram during
* the handshake. We loop until the datagram is fully consumed or a packet
* fails to parse (which we drop silently per RFC 9001 §5.5).
*
* Lock-split refactor (2026-05-08): caller must hold
* [QuicConnection.streamsLock]. The driver wraps its read loop in
* `streamsLock.withLock { feedDatagram(...) }`. Test harnesses that drive
* single-threaded packet flow (no concurrent app code) may invoke this
* directly without lock acquisition; the runtime invariants still hold
* because there's no contending thread. Phase 1 wraps the whole feed
* under streamsLock so frame-dispatch / stream creation / level state
* remains a single critical section.
*/
fun feedDatagram(
conn: QuicConnection,
@@ -57,6 +57,15 @@ import com.vitorpamplona.quic.packet.ShortHeaderPlaintextPacket
*
* RFC 9000 §14: any datagram containing an Initial packet from the client
* MUST be padded to at least 1200 bytes total.
*
* Lock-split refactor (2026-05-08): caller must hold
* [QuicConnection.streamsLock]. Phase 1 keeps level-state mutation
* inline under the same critical section as the streams-domain work
* the writer needs — the win comes from `lifecycleLock`-only callers
* (close(), status reads, PTO bookkeeping) no longer fighting this lock.
* The driver wraps `streamsLock.withLock { drainOutbound(...) }`; tests
* that drive single-threaded send paths can call this without holding
* the lock — there's no contending thread.
*/
fun drainOutbound(
conn: QuicConnection,
@@ -64,7 +64,7 @@ class AckTrackerPurgeOnAckOfAckTest {
// Peer ACKs the packet that carried our outbound ACK
// covering up to PN 4.
conn.lock.lock()
conn.streamsLock.lock()
try {
conn.onTokensAcked(
listOf(
@@ -75,7 +75,7 @@ class AckTrackerPurgeOnAckOfAckTest {
),
)
} finally {
conn.lock.unlock()
conn.streamsLock.unlock()
}
// Tracker is now empty: peer has confirmed receipt of our
// ACK that covered everything up to PN 4. Re-advertising
@@ -96,7 +96,7 @@ class AckTrackerPurgeOnAckOfAckTest {
}
// Peer ACKs our Initial-level outbound ACK.
conn.lock.lock()
conn.streamsLock.lock()
try {
conn.onTokensAcked(
listOf(
@@ -104,7 +104,7 @@ class AckTrackerPurgeOnAckOfAckTest {
),
)
} finally {
conn.lock.unlock()
conn.streamsLock.unlock()
}
// Initial tracker drained; Application tracker untouched.
assertTrue(conn.initial.ackTracker.isEmpty())
@@ -120,7 +120,7 @@ class AckTrackerPurgeOnAckOfAckTest {
}
// Peer ACKs our outbound ACK that covered up to PN 4 only;
// the tracker's higher-PN ranges (5..9) must survive.
conn.lock.lock()
conn.streamsLock.lock()
try {
conn.onTokensAcked(
listOf(
@@ -128,7 +128,7 @@ class AckTrackerPurgeOnAckOfAckTest {
),
)
} finally {
conn.lock.unlock()
conn.streamsLock.unlock()
}
assertFalse(conn.application.ackTracker.isEmpty())
assertEquals(9L, conn.application.ackTracker.largestReceived())
@@ -145,7 +145,7 @@ class AckTrackerPurgeOnAckOfAckTest {
for (pn in 0L..9L) {
conn.application.ackTracker.receivedPacket(pn, ackEliciting = true, receivedAtMillis = 1L)
}
conn.lock.lock()
conn.streamsLock.lock()
try {
conn.onTokensAcked(
listOf(
@@ -160,7 +160,7 @@ class AckTrackerPurgeOnAckOfAckTest {
)
assertTrue(conn.application.ackTracker.isEmpty())
} finally {
conn.lock.unlock()
conn.streamsLock.unlock()
}
}
}
@@ -81,12 +81,12 @@ class CryptoRetransmitTest {
.single()
// Simulate loss via direct dispatch.
client.lock.lock()
client.streamsLock.lock()
try {
client.onTokensLost(listOf(cryptoToken))
client.initial.sentPackets.remove(firstPn)
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
// Initial-level cryptoSend should now have re-queued bytes
@@ -126,11 +126,11 @@ class CryptoRetransmitTest {
client.initial.sentPackets.entries
.first { it.value.tokens.any { t -> t is RecoveryToken.Crypto } }
// ACK via direct dispatch.
client.lock.lock()
client.streamsLock.lock()
try {
client.onTokensAcked(packet.value.tokens)
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
// After ACK the Initial-level cryptoSend's flushedFloor should
// have advanced — we check by observing that another takeChunk
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quic.connection
import com.vitorpamplona.quic.tls.InProcessTlsServer
import com.vitorpamplona.quic.tls.PermissiveCertificateValidator
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Throughput contract for the lock-split refactor (2026-05-08): opening
* many parallel bidi streams + queueing requests must not be serialised
* by a single connection-wide mutex. Phase 1 of the split (separate
* `streamsLock` / `lifecycleLock` / per-level `levelLock`) targets the
* multiplexing testcase that drove this refactor see
* `quic/plans/2026-05-08-lock-split-design.md`.
*
* The test stands up an in-memory client (no socket I/O), opens 1000
* client-bidi streams concurrently, enqueues a small request body + FIN
* on each, and asserts the operation completes within a generous wall-
* clock budget. The number is deliberately loose: this is a contract
* for "lock contention isn't pathological", not a microbenchmark.
*
* NOTE: the in-memory pipe doesn't drive a concurrent send loop, so
* this test exercises the lock-acquisition cost of `openBidiStream`
* itself rather than full multiplexing throughput. The interop runner
* provides the end-to-end measurement.
*/
class MultiplexingThroughputTest {
@Test
fun open_1000_bidi_streams_completes_quickly() {
runBlocking {
val client =
QuicConnection(
serverName = "example.test",
config =
QuicConnectionConfig(
initialMaxStreamsBidi = 2_000,
initialMaxStreamsUni = 2_000,
initialMaxData = 100_000_000,
initialMaxStreamDataBidiLocal = 100_000,
initialMaxStreamDataBidiRemote = 100_000,
initialMaxStreamDataUni = 100_000,
),
tlsCertificateValidator = PermissiveCertificateValidator(),
)
val serverScid = ConnectionId.random(8)
val tlsServer =
InProcessTlsServer(
transportParameters =
TransportParameters(
initialMaxData = 100_000_000,
initialMaxStreamDataBidiLocal = 100_000,
initialMaxStreamDataBidiRemote = 100_000,
initialMaxStreamDataUni = 100_000,
initialMaxStreamsBidi = 2_000,
initialMaxStreamsUni = 2_000,
initialSourceConnectionId = serverScid.bytes,
originalDestinationConnectionId = client.destinationConnectionId.bytes,
).encode(),
)
val pipe =
InMemoryQuicPipe(
client = client,
initialDcid = client.destinationConnectionId.bytes,
serverScid = serverScid,
tlsServer = tlsServer,
)
client.start()
pipe.drive(maxRounds = 16)
assertEquals(QuicConnection.Status.CONNECTED, client.status)
val request = ByteArray(50) { it.toByte() }
val streamCount = 1_000
// Open all streams in parallel — each launch contends for
// streamsLock briefly. Pre-refactor this serialised against
// any in-flight drainOutbound call; phase 1 keeps openBidi
// contention scoped to streamsLock-only.
val started =
kotlin.time.TimeSource.Monotonic
.markNow()
val opens =
(0 until streamCount).map {
async {
val stream = client.openBidiStream()
stream.send.enqueue(request)
stream.send.finish()
stream.streamId
}
}
val ids = opens.awaitAll()
val elapsed = started.elapsedNow()
// Useful diagnostic for measuring future regressions: stdout
// shows up in the test report so phase-1 vs phase-2 can be
// compared against the same test.
println(
"[MultiplexingThroughputTest] opened $streamCount bidi streams in " +
"${elapsed.inWholeMilliseconds}ms " +
"(${(streamCount * 1000.0 / elapsed.inWholeMilliseconds.coerceAtLeast(1L)).toLong()} streams/sec)",
)
assertEquals(streamCount, ids.size)
assertEquals(streamCount, ids.toSet().size, "stream ids must be unique")
// Generous bound; in-process opens of 1000 streams should
// complete in well under half a second on any developer
// machine — pre-refactor this was minutes due to lock
// contention against the (idle) send-loop drain. The looser
// 2-second bound is still 100x what's expected on actual
// hardware while accounting for slow CI workers.
assertTrue(
elapsed.inWholeMilliseconds < 2_000L,
"1000 parallel openBidiStream calls took ${elapsed.inWholeMilliseconds}ms; expected <2000ms",
)
}
}
}
@@ -47,11 +47,11 @@ class OnTokensLostTest {
fun ackToken_doesNotPopulateAnyPending() =
runBlocking {
val conn = newConn()
conn.lock.lock()
conn.streamsLock.lock()
try {
conn.onTokensLost(listOf(RecoveryToken.Ack(level = EncryptionLevel.APPLICATION, largestAcked = 0L)))
} finally {
conn.lock.unlock()
conn.streamsLock.unlock()
}
assertNull(conn.pendingMaxStreamsUni)
assertNull(conn.pendingMaxStreamsBidi)
@@ -64,12 +64,12 @@ class OnTokensLostTest {
runBlocking {
val conn = newConn()
// Simulate the writer having advertised a higher cap.
conn.lock.lock()
conn.streamsLock.lock()
try {
conn.advertisedMaxStreamsUni = 150L
conn.onTokensLost(listOf(RecoveryToken.MaxStreamsUni(maxStreams = 150L)))
} finally {
conn.lock.unlock()
conn.streamsLock.unlock()
}
assertEquals(150L, conn.pendingMaxStreamsUni)
}
@@ -82,12 +82,12 @@ class OnTokensLostTest {
// the value carried by the lost token (150). The lost
// frame is irrelevant — re-emitting 150 would not extend
// the cap. neqo's fc.rs line 322 supersede check.
conn.lock.lock()
conn.streamsLock.lock()
try {
conn.advertisedMaxStreamsUni = 200L
conn.onTokensLost(listOf(RecoveryToken.MaxStreamsUni(maxStreams = 150L)))
} finally {
conn.lock.unlock()
conn.streamsLock.unlock()
}
assertNull(conn.pendingMaxStreamsUni, "stale lost extension must not be re-emitted")
}
@@ -96,12 +96,12 @@ class OnTokensLostTest {
fun lostMaxStreamsBidi_matchingAdvertised_setsPending() =
runBlocking {
val conn = newConn()
conn.lock.lock()
conn.streamsLock.lock()
try {
conn.advertisedMaxStreamsBidi = 200L
conn.onTokensLost(listOf(RecoveryToken.MaxStreamsBidi(maxStreams = 200L)))
} finally {
conn.lock.unlock()
conn.streamsLock.unlock()
}
assertEquals(200L, conn.pendingMaxStreamsBidi)
}
@@ -110,12 +110,12 @@ class OnTokensLostTest {
fun lostMaxData_matchingAdvertised_setsPending() =
runBlocking {
val conn = newConn()
conn.lock.lock()
conn.streamsLock.lock()
try {
conn.advertisedMaxData = 1_000_000L
conn.onTokensLost(listOf(RecoveryToken.MaxData(maxData = 1_000_000L)))
} finally {
conn.lock.unlock()
conn.streamsLock.unlock()
}
assertEquals(1_000_000L, conn.pendingMaxData)
}
@@ -124,12 +124,12 @@ class OnTokensLostTest {
fun lostMaxData_supersededIsDropped() =
runBlocking {
val conn = newConn()
conn.lock.lock()
conn.streamsLock.lock()
try {
conn.advertisedMaxData = 2_000_000L
conn.onTokensLost(listOf(RecoveryToken.MaxData(maxData = 1_000_000L)))
} finally {
conn.lock.unlock()
conn.streamsLock.unlock()
}
assertNull(conn.pendingMaxData)
}
@@ -138,13 +138,13 @@ class OnTokensLostTest {
fun lostMaxStreamData_unknownStream_dropped() =
runBlocking {
val conn = newConn()
conn.lock.lock()
conn.streamsLock.lock()
try {
conn.onTokensLost(
listOf(RecoveryToken.MaxStreamData(streamId = 999L, maxData = 1024L)),
)
} finally {
conn.lock.unlock()
conn.streamsLock.unlock()
}
// No stream with id 999 exists ⇒ token is dropped silently.
assertEquals(emptyMap<Long, Long>(), conn.pendingMaxStreamData)
@@ -154,7 +154,7 @@ class OnTokensLostTest {
fun multipleLostTokens_dispatchAll() =
runBlocking {
val conn = newConn()
conn.lock.lock()
conn.streamsLock.lock()
try {
conn.advertisedMaxStreamsUni = 150L
conn.advertisedMaxStreamsBidi = 200L
@@ -168,7 +168,7 @@ class OnTokensLostTest {
),
)
} finally {
conn.lock.unlock()
conn.streamsLock.unlock()
}
assertEquals(150L, conn.pendingMaxStreamsUni)
assertEquals(200L, conn.pendingMaxStreamsBidi)
@@ -183,7 +183,7 @@ class OnTokensLostTest {
// most one value (the last setter wins; the supersede
// check filters older losses).
val conn = newConn()
conn.lock.lock()
conn.streamsLock.lock()
try {
conn.advertisedMaxStreamsUni = 200L
// First lost packet had MaxStreamsUni(150) — stale, dropped.
@@ -193,7 +193,7 @@ class OnTokensLostTest {
conn.onTokensLost(listOf(RecoveryToken.MaxStreamsUni(maxStreams = 200L)))
assertEquals(200L, conn.pendingMaxStreamsUni)
} finally {
conn.lock.unlock()
conn.streamsLock.unlock()
}
}
}
@@ -94,7 +94,7 @@ class PeerStreamCreditExtensionTest {
// Simulate the relay opening uni streams to us. SERVER_UNI
// stream IDs use the encoding `index << 2 | 0x3`. Two streams
// (cap=4, half-window=2) is the threshold for a refresh.
client.lock
client.streamsLock
.let {
// Acquire under lock since getOrCreatePeerStreamLocked requires it.
it
@@ -103,7 +103,7 @@ class PeerStreamCreditExtensionTest {
kotlinx.coroutines.sync
.Mutex()
.let { /* noop: silence unused-import linter */ }
client.lock.let { l ->
client.streamsLock.let { l ->
kotlinx.coroutines.runBlocking {
l.lock()
try {
@@ -179,7 +179,7 @@ class PeerStreamCreditExtensionTest {
// Open 10 peer streams — half-window for cap=100 is 50, so
// we're well below the threshold.
client.lock.let { l ->
client.streamsLock.let { l ->
kotlinx.coroutines.runBlocking {
l.lock()
try {
@@ -46,11 +46,11 @@ class PendingFlowControlEmitTest {
fun pendingMaxStreamsUni_drainEmitsFrameAndToken() =
runBlocking {
val client = handshakedClient()
client.lock.lock()
client.streamsLock.lock()
try {
client.pendingMaxStreamsUni = 150L
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
val sizeBefore = client.application.sentPackets.size
@@ -79,11 +79,11 @@ class PendingFlowControlEmitTest {
fun pendingMaxStreamsBidi_drainEmitsFrameAndToken(): Unit =
runBlocking {
val client = handshakedClient()
client.lock.lock()
client.streamsLock.lock()
try {
client.pendingMaxStreamsBidi = 200L
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
val sizeBefore = client.application.sentPackets.size
runCatching { drainOutbound(client, nowMillis = 1L) }
@@ -103,11 +103,11 @@ class PendingFlowControlEmitTest {
fun pendingMaxData_drainEmitsFrameAndToken(): Unit =
runBlocking {
val client = handshakedClient()
client.lock.lock()
client.streamsLock.lock()
try {
client.pendingMaxData = 5_000_000L
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
val sizeBefore = client.application.sentPackets.size
runCatching { drainOutbound(client, nowMillis = 1L) }
@@ -127,12 +127,12 @@ class PendingFlowControlEmitTest {
fun pendingMaxStreamData_perStreamDrain() =
runBlocking {
val client = handshakedClient()
client.lock.lock()
client.streamsLock.lock()
try {
client.pendingMaxStreamData[3L] = 1_024L
client.pendingMaxStreamData[7L] = 2_048L
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
val sizeBefore = client.application.sentPackets.size
runCatching { drainOutbound(client, nowMillis = 1L) }
@@ -159,13 +159,13 @@ class PendingFlowControlEmitTest {
fun multiplePending_drainEmitsAllInOnePacket(): Unit =
runBlocking {
val client = handshakedClient()
client.lock.lock()
client.streamsLock.lock()
try {
client.pendingMaxStreamsUni = 150L
client.pendingMaxStreamsBidi = 200L
client.pendingMaxData = 1_000_000L
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
val sizeBefore = client.application.sentPackets.size
runCatching { drainOutbound(client, nowMillis = 1L) }
@@ -216,11 +216,11 @@ class PendingFlowControlEmitTest {
// advertised cap. The writer drains it as-is — supersede check
// is in step 6 (the setter side), not here.
val client = handshakedClient()
client.lock.lock()
client.streamsLock.lock()
try {
client.pendingMaxStreamsUni = 50L
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
val sizeBefore = client.application.sentPackets.size
runCatching { drainOutbound(client, nowMillis = 1L) }
@@ -242,11 +242,11 @@ class PendingFlowControlEmitTest {
fun pendingClearedAcrossDrains() =
runBlocking {
val client = handshakedClient()
client.lock.lock()
client.streamsLock.lock()
try {
client.pendingMaxStreamsUni = 150L
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
// Drain once: pending consumed.
runCatching { drainOutbound(client, nowMillis = 1L) }
@@ -93,12 +93,12 @@ class ResetStopSendingEmitTest {
.single()
// Simulate loss.
client.lock.lock()
client.streamsLock.lock()
try {
client.onTokensLost(listOf(token))
client.application.sentPackets.remove(firstEntry.key)
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
// Per-stream emit-pending should be re-flagged.
assertTrue(stream.resetEmitPending, "loss must re-flag resetEmitPending")
@@ -137,21 +137,21 @@ class ResetStopSendingEmitTest {
.single()
// ACK first.
client.lock.lock()
client.streamsLock.lock()
try {
client.onTokensAcked(listOf(token))
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
assertEquals(true, stream.resetAcked)
assertEquals(false, stream.resetEmitPending)
// Now a stale loss notification arrives. Defensive: drop.
client.lock.lock()
client.streamsLock.lock()
try {
client.onTokensLost(listOf(token))
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
assertEquals(false, stream.resetEmitPending, "stale loss after ACK must not re-flag emit-pending")
}
@@ -248,11 +248,11 @@ class ResetStopSendingEmitTest {
connectionId = byteArrayOf(1, 2, 3, 4),
statelessResetToken = ByteArray(16) { it.toByte() },
)
client.lock.lock()
client.streamsLock.lock()
try {
client.onTokensLost(listOf(token))
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
assertEquals(token, client.pendingNewConnectionId[1L])
@@ -69,7 +69,7 @@ class RetransmitIntegrationTest {
// ACK'd by reordering — its PN < largestAckedPn -
// PACKET_THRESHOLD ⇒ declared lost.
val futurePn = msuPn + 4L
client.lock.lock()
client.streamsLock.lock()
try {
// Inject a phantom SentPacket at futurePn so the loss
// detector has a credible "newly acked" reference, then
@@ -102,7 +102,7 @@ class RetransmitIntegrationTest {
// 5. Dispatch lost tokens — pendingMaxStreamsUni gets set.
client.onTokensLost(lostMsuPacket.tokens)
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
assertEquals(
capAfterFirstDrain,
@@ -148,24 +148,24 @@ class RetransmitIntegrationTest {
// Second bump: open more peer-uni streams to cross the
// (already extended) threshold again.
client.lock.lock()
client.streamsLock.lock()
try {
client.getOrCreatePeerStreamLocked(StreamId.build(StreamId.Kind.SERVER_UNI, 2))
client.getOrCreatePeerStreamLocked(StreamId.build(StreamId.Kind.SERVER_UNI, 3))
client.getOrCreatePeerStreamLocked(StreamId.build(StreamId.Kind.SERVER_UNI, 4))
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
runCatching { drainOutbound(client, nowMillis = 2L) }
val secondCap = client.advertisedMaxStreamsUni
assertTrue(secondCap > firstCap, "second drain must advertise a still-higher cap; saw $firstCap$secondCap")
// Now declare the FIRST emit lost via direct dispatch.
client.lock.lock()
client.streamsLock.lock()
try {
client.onTokensLost(listOf(RecoveryToken.MaxStreamsUni(maxStreams = firstCap)))
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
// Supersede check: firstCap != advertisedMaxStreamsUni (now == secondCap),
// so pending must remain null.
@@ -216,12 +216,12 @@ class RetransmitIntegrationTest {
private fun crossPeerUniHalfWindow(client: QuicConnection) =
runBlocking {
client.lock.lock()
client.streamsLock.lock()
try {
client.getOrCreatePeerStreamLocked(StreamId.build(StreamId.Kind.SERVER_UNI, 0))
client.getOrCreatePeerStreamLocked(StreamId.build(StreamId.Kind.SERVER_UNI, 1))
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
}
}
@@ -200,12 +200,12 @@ class SentPacketTrackingTest {
/** Cross the half-window threshold (cap=4, two peer-uni streams ⇒ count >= cap-half=2). */
private fun crossPeerUniHalfWindow(client: QuicConnection) =
runBlocking {
client.lock.lock()
client.streamsLock.lock()
try {
client.getOrCreatePeerStreamLocked(StreamId.build(StreamId.Kind.SERVER_UNI, 0))
client.getOrCreatePeerStreamLocked(StreamId.build(StreamId.Kind.SERVER_UNI, 1))
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
}
}
@@ -82,7 +82,7 @@ class StreamRetransmitTest {
val firstPn = firstPacketEntry.key
// Simulate loss via direct dispatch.
client.lock.lock()
client.streamsLock.lock()
try {
val streamToken =
firstPacketEntry.value.tokens
@@ -93,7 +93,7 @@ class StreamRetransmitTest {
// detector would have done this).
client.application.sentPackets.remove(firstPn)
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
// SendBuffer should have re-queued the bytes for retransmit.
@@ -133,11 +133,11 @@ class StreamRetransmitTest {
val packet =
client.application.sentPackets.entries
.first { it.value.tokens.any { t -> t is RecoveryToken.Stream } }
client.lock.lock()
client.streamsLock.lock()
try {
client.onTokensAcked(packet.value.tokens)
} finally {
client.lock.unlock()
client.streamsLock.unlock()
}
// After ACK: enqueue more, observe that the buffer