quic: suspend openUni/BidiStream on peer-cap exhaustion + emit STREAMS_BLOCKED
Production sweep showed every audio scenario opening >100 client-initiated
uni streams cliffed at received=99/N (one-line summary
[sweep-30s] sub[0] received=99/1500 missing=[99-1499]).
Same shape across every cadence, payload, and frame-count sweep variant —
the relay's initial_max_streams_uni=100 was being silently exhausted, after
which openUniStream threw QuicStreamLimitException, which the production
NestMoqLiteBroadcaster swallowed via its outer runCatching, dropping every
subsequent frame on the floor.
Fix:
- QuicConnection.openBidiStream / openUniStream now SUSPEND when the
peer-granted cap is reached, instead of throwing. They re-acquire
the connection lock on each retry, so the parser's MAX_STREAMS update
is observed atomically. Closing the connection wakes blocked openers
with QuicConnectionClosedException so they don't hang.
- QuicConnection.streamCapNotifier — single CompletableDeferred swapped
after each fire so all blocked openers wake at once rather than
serialising through Channel.receive.
- QuicConnectionParser fires the notifier whenever an inbound
MAX_STREAMS frame raises peerMaxStreams{Bidi,Uni}.
- QuicConnectionWriter emits a STREAMS_BLOCKED frame (RFC 9000 §19.14)
when an opener registers itself blocked, draining the slot once
written so we send at most one STREAMS_BLOCKED per cap value.
Frame.kt gains a real StreamsBlockedFrame class — previously the
inbound bytes were just consumed and discarded.
- QuicConnectionDriver.start wires connection.sendWakeupHook so an
internal opener-blocked event nudges the send loop without callers
needing a driver reference.
PeerStreamLimitTest rewritten:
- "throws QuicStreamLimitException" → "suspends with withTimeoutOrNull"
- Added: MAX_STREAMS_UNI frame wakes a suspended opener
- Added: openUniStream queues the STREAMS_BLOCKED slot
- Added: closing the connection unblocks waiters with the closed
exception
- Added: StreamsBlockedFrame round-trips through encode/decode
All :quic and :nestsClient JVM tests pass.
This commit is contained in:
@@ -193,6 +193,58 @@ class QuicConnection(
|
|||||||
*/
|
*/
|
||||||
private val closedSignal = Channel<Unit>(Channel.CONFLATED)
|
private val closedSignal = Channel<Unit>(Channel.CONFLATED)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifier completed every time the peer extends our stream cap (either
|
||||||
|
* direction) via a MAX_STREAMS frame, OR when the connection closes.
|
||||||
|
* Suspended [openBidiStream] / [openUniStream] callers await the current
|
||||||
|
* notifier; once it fires, they re-acquire the lock and re-check whether
|
||||||
|
* they have credit. The notifier is replaced after each fire so a fresh
|
||||||
|
* await reads the next cycle.
|
||||||
|
*
|
||||||
|
* Only mutated under [lock]. Reading the reference is safe without the
|
||||||
|
* lock IF the caller subsequently takes the lock and re-checks the cap —
|
||||||
|
* the read might be stale, the await might be stale, but the loop
|
||||||
|
* guarantees forward progress because the parser also signals after each
|
||||||
|
* cap raise.
|
||||||
|
*/
|
||||||
|
private var streamCapNotifier: kotlinx.coroutines.CompletableDeferred<Unit> =
|
||||||
|
kotlinx.coroutines.CompletableDeferred()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream-limit values we owe the peer in STREAMS_BLOCKED frames. Set
|
||||||
|
* when [openBidiStream] / [openUniStream] discover the cap is exhausted.
|
||||||
|
* Cleared by [QuicConnectionWriter] after the frame is emitted. Per RFC
|
||||||
|
* 9000 §19.14 we should send STREAMS_BLOCKED at most once per cap value
|
||||||
|
* — null means "nothing to send". Reset to a new value if the cap
|
||||||
|
* advances and we hit it again later.
|
||||||
|
*/
|
||||||
|
internal var pendingStreamsBlockedBidi: Long? = null
|
||||||
|
internal var pendingStreamsBlockedUni: Long? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook invoked when the connection wants to nudge the writer (e.g. a
|
||||||
|
* suspended opener queued STREAMS_BLOCKED and needs the writer to
|
||||||
|
* flush). Wired by [QuicConnectionDriver.start] and reset on close.
|
||||||
|
* Null while the driver is not running (in-process tests etc) — the
|
||||||
|
* connection still works, just without an external send loop.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
internal var sendWakeupHook: (() -> Unit)? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wake any suspended [openBidiStream] / [openUniStream] callers. Called
|
||||||
|
* by the parser after each MAX_STREAMS frame raises a cap, and by the
|
||||||
|
* close path so blocked openers can throw [QuicConnectionClosedException]
|
||||||
|
* instead of suspending forever.
|
||||||
|
*
|
||||||
|
* Caller must hold [lock] (the notifier ref is mutated here).
|
||||||
|
*/
|
||||||
|
internal fun signalStreamCapLocked() {
|
||||||
|
val old = streamCapNotifier
|
||||||
|
streamCapNotifier = kotlinx.coroutines.CompletableDeferred()
|
||||||
|
old.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
private val tlsListener =
|
private val tlsListener =
|
||||||
object : TlsSecretsListener {
|
object : TlsSecretsListener {
|
||||||
override fun onHandshakeKeysReady(
|
override fun onHandshakeKeysReady(
|
||||||
@@ -338,47 +390,98 @@ class QuicConnection(
|
|||||||
val lock: Mutex = Mutex()
|
val lock: Mutex = Mutex()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allocate a new client-initiated bidirectional stream. Locked.
|
* Allocate a new client-initiated bidirectional stream.
|
||||||
*
|
*
|
||||||
* Throws [QuicStreamLimitException] if the peer has not granted enough
|
* If the peer has not granted enough bidi stream credit yet, this
|
||||||
* bidirectional stream credit yet. Use [peerMaxStreamsBidiSnapshot] to
|
* method **suspends** (not throws) until either:
|
||||||
* check capacity proactively if the caller wants to back-pressure rather
|
* - an inbound MAX_STREAMS frame raises the cap (RFC 9000 §19.11),
|
||||||
* than throw.
|
* or
|
||||||
|
* - the connection closes — in which case
|
||||||
|
* [QuicConnectionClosedException] is thrown.
|
||||||
|
*
|
||||||
|
* Before suspending, queues a STREAMS_BLOCKED_BIDI frame (RFC 9000
|
||||||
|
* §19.14) so the peer knows we want more credit. Without this, a
|
||||||
|
* conservative peer that only extends MAX_STREAMS in response to
|
||||||
|
* STREAMS_BLOCKED would never grant new credit, deadlocking the
|
||||||
|
* client.
|
||||||
*/
|
*/
|
||||||
suspend fun openBidiStream(): QuicStream =
|
suspend fun openBidiStream(): QuicStream = openClientStream(uni = false)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Allocate a new client-initiated unidirectional (write-only) stream. Locked. */
|
/**
|
||||||
suspend fun openUniStream(): QuicStream =
|
* Allocate a new client-initiated unidirectional (write-only) stream.
|
||||||
lock.withLock {
|
* Suspends if the peer has exhausted our uni stream cap; see
|
||||||
if (nextLocalUniIndex >= peerMaxStreamsUni) {
|
* [openBidiStream] for the semantics.
|
||||||
throw QuicStreamLimitException(
|
*/
|
||||||
"peer-granted uni stream cap reached " +
|
suspend fun openUniStream(): QuicStream = openClientStream(uni = true)
|
||||||
"(used=$nextLocalUniIndex limit=$peerMaxStreamsUni)",
|
|
||||||
)
|
/**
|
||||||
|
* Shared body for [openBidiStream] / [openUniStream]. The two paths
|
||||||
|
* are byte-for-byte symmetric except for the StreamId kind, the per-
|
||||||
|
* direction caps + indices, the per-direction `sendCredit` source,
|
||||||
|
* and the receive limit (uni-out streams cannot receive).
|
||||||
|
*
|
||||||
|
* Re-acquires the lock on every retry — required to read the current
|
||||||
|
* `peerMaxStreams*` (mutated by the parser under the lock) and to
|
||||||
|
* mutate `nextLocal*Index` atomically with the streams map.
|
||||||
|
*/
|
||||||
|
private suspend fun openClientStream(uni: Boolean): QuicStream {
|
||||||
|
while (true) {
|
||||||
|
// Capture under lock: either we have credit and allocate, or
|
||||||
|
// we record our blocked-at limit and grab a notifier ref to
|
||||||
|
// await *outside* the lock (otherwise the parser couldn't
|
||||||
|
// acquire the lock to raise the cap, deadlocking).
|
||||||
|
val notifier: kotlinx.coroutines.CompletableDeferred<Unit>
|
||||||
|
lock.withLock {
|
||||||
|
if (status == Status.CLOSED) {
|
||||||
|
throw QuicConnectionClosedException(
|
||||||
|
"connection closed before stream could be allocated",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val nextIndex = if (uni) nextLocalUniIndex else nextLocalBidiIndex
|
||||||
|
val cap = if (uni) peerMaxStreamsUni else peerMaxStreamsBidi
|
||||||
|
if (nextIndex < cap) {
|
||||||
|
val id =
|
||||||
|
StreamId.build(
|
||||||
|
if (uni) StreamId.Kind.CLIENT_UNI else StreamId.Kind.CLIENT_BIDI,
|
||||||
|
nextIndex,
|
||||||
|
)
|
||||||
|
if (uni) nextLocalUniIndex++ else nextLocalBidiIndex++
|
||||||
|
val stream =
|
||||||
|
QuicStream(
|
||||||
|
id,
|
||||||
|
if (uni) QuicStream.Direction.UNIDIRECTIONAL_LOCAL_TO_REMOTE else QuicStream.Direction.BIDIRECTIONAL,
|
||||||
|
)
|
||||||
|
stream.sendCredit =
|
||||||
|
if (uni) {
|
||||||
|
peerTransportParameters?.initialMaxStreamDataUni ?: config.initialMaxStreamDataUni
|
||||||
|
} else {
|
||||||
|
peerTransportParameters?.initialMaxStreamDataBidiRemote ?: config.initialMaxStreamDataBidiRemote
|
||||||
|
}
|
||||||
|
stream.receiveLimit = if (uni) 0L else config.initialMaxStreamDataBidiLocal
|
||||||
|
streams[id] = stream
|
||||||
|
streamsList += stream
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
// Out of credit. Queue a STREAMS_BLOCKED frame at the
|
||||||
|
// current cap (per RFC 9000 §19.14, "stream limit" =
|
||||||
|
// count from MAX_STREAMS) so the peer knows we want more.
|
||||||
|
// Replace any earlier pending entry — we only ever owe
|
||||||
|
// the peer one STREAMS_BLOCKED per cap value.
|
||||||
|
if (uni) {
|
||||||
|
pendingStreamsBlockedUni = cap
|
||||||
|
} else {
|
||||||
|
pendingStreamsBlockedBidi = cap
|
||||||
|
}
|
||||||
|
notifier = streamCapNotifier
|
||||||
}
|
}
|
||||||
val id = StreamId.build(StreamId.Kind.CLIENT_UNI, nextLocalUniIndex++)
|
// Nudge the writer so STREAMS_BLOCKED actually leaves the
|
||||||
val stream = QuicStream(id, QuicStream.Direction.UNIDIRECTIONAL_LOCAL_TO_REMOTE)
|
// host. Without this, a quiescent connection (no other
|
||||||
stream.sendCredit = peerTransportParameters?.initialMaxStreamDataUni ?: config.initialMaxStreamDataUni
|
// writes pending) would hold the frame in the writer's
|
||||||
stream.receiveLimit = 0L // can't receive
|
// queue until the next unrelated wakeup.
|
||||||
streams[id] = stream
|
sendWakeupHook?.invoke()
|
||||||
streamsList += stream
|
notifier.await()
|
||||||
stream
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Snapshot of peer-granted bidi cap. Reads do not need the lock — long writes are atomic on every supported platform. */
|
/** Snapshot of peer-granted bidi cap. Reads do not need the lock — long writes are atomic on every supported platform. */
|
||||||
fun peerMaxStreamsBidiSnapshot(): Long = peerMaxStreamsBidi
|
fun peerMaxStreamsBidiSnapshot(): Long = peerMaxStreamsBidi
|
||||||
@@ -485,6 +588,11 @@ class QuicConnection(
|
|||||||
closedSignal.close()
|
closedSignal.close()
|
||||||
peerStreamSignal.close()
|
peerStreamSignal.close()
|
||||||
incomingDatagramSignal.close()
|
incomingDatagramSignal.close()
|
||||||
|
// Wake any openBidiStream / openUniStream callers blocked on the
|
||||||
|
// stream-cap notifier so they re-acquire the lock, observe
|
||||||
|
// status == CLOSED, and throw QuicConnectionClosedException
|
||||||
|
// instead of hanging forever.
|
||||||
|
if (!streamCapNotifier.isCompleted) streamCapNotifier.complete(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ class QuicConnectionDriver(
|
|||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
connection.start()
|
connection.start()
|
||||||
|
// Wire the connection's sendWakeupHook so internal events
|
||||||
|
// (a suspended openUniStream queueing STREAMS_BLOCKED, the close
|
||||||
|
// path completing handshakeDoneSignal, etc) can nudge the send
|
||||||
|
// loop without callers having to know about the driver.
|
||||||
|
connection.sendWakeupHook = { sendWakeup.trySend(Unit) }
|
||||||
readJob = scope.launch { readLoop() }
|
readJob = scope.launch { readLoop() }
|
||||||
sendJob = scope.launch { sendLoop() }
|
sendJob = scope.launch { sendLoop() }
|
||||||
// Initial nudge so the ClientHello goes out immediately.
|
// Initial nudge so the ClientHello goes out immediately.
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import com.vitorpamplona.quic.frame.PingFrame
|
|||||||
import com.vitorpamplona.quic.frame.ResetStreamFrame
|
import com.vitorpamplona.quic.frame.ResetStreamFrame
|
||||||
import com.vitorpamplona.quic.frame.StopSendingFrame
|
import com.vitorpamplona.quic.frame.StopSendingFrame
|
||||||
import com.vitorpamplona.quic.frame.StreamFrame
|
import com.vitorpamplona.quic.frame.StreamFrame
|
||||||
|
import com.vitorpamplona.quic.frame.StreamsBlockedFrame
|
||||||
import com.vitorpamplona.quic.frame.decodeFrames
|
import com.vitorpamplona.quic.frame.decodeFrames
|
||||||
import com.vitorpamplona.quic.packet.LongHeaderPacket
|
import com.vitorpamplona.quic.packet.LongHeaderPacket
|
||||||
import com.vitorpamplona.quic.packet.LongHeaderType
|
import com.vitorpamplona.quic.packet.LongHeaderType
|
||||||
@@ -286,15 +287,30 @@ private fun dispatchFrames(
|
|||||||
// RFC 9000 §19.11: MAX_STREAMS only ever raises the cap.
|
// RFC 9000 §19.11: MAX_STREAMS only ever raises the cap.
|
||||||
// Frames with values smaller than the current cap are ignored.
|
// Frames with values smaller than the current cap are ignored.
|
||||||
// Bidi vs uni is signaled via the frame's `bidi` flag.
|
// Bidi vs uni is signaled via the frame's `bidi` flag.
|
||||||
|
var raised = false
|
||||||
if (frame.bidi) {
|
if (frame.bidi) {
|
||||||
if (frame.maxStreams > conn.peerMaxStreamsBidi) {
|
if (frame.maxStreams > conn.peerMaxStreamsBidi) {
|
||||||
conn.peerMaxStreamsBidi = frame.maxStreams
|
conn.peerMaxStreamsBidi = frame.maxStreams
|
||||||
|
raised = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (frame.maxStreams > conn.peerMaxStreamsUni) {
|
if (frame.maxStreams > conn.peerMaxStreamsUni) {
|
||||||
conn.peerMaxStreamsUni = frame.maxStreams
|
conn.peerMaxStreamsUni = frame.maxStreams
|
||||||
|
raised = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Wake any [openBidiStream] / [openUniStream] callers blocked
|
||||||
|
// on the previous cap so they re-evaluate. Caller (read loop)
|
||||||
|
// already holds [conn.lock], so the notifier swap is safe.
|
||||||
|
if (raised) conn.signalStreamCapLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
is StreamsBlockedFrame -> {
|
||||||
|
// We don't currently advertise dynamic stream caps to the
|
||||||
|
// peer beyond the initial transport parameters, so the
|
||||||
|
// peer's STREAMS_BLOCKED is informational only. The frame
|
||||||
|
// is ack-eliciting per RFC 9000 §13.2.1.
|
||||||
|
ackEliciting = true
|
||||||
}
|
}
|
||||||
|
|
||||||
is ResetStreamFrame -> {
|
is ResetStreamFrame -> {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import com.vitorpamplona.quic.frame.Frame
|
|||||||
import com.vitorpamplona.quic.frame.MaxDataFrame
|
import com.vitorpamplona.quic.frame.MaxDataFrame
|
||||||
import com.vitorpamplona.quic.frame.MaxStreamDataFrame
|
import com.vitorpamplona.quic.frame.MaxStreamDataFrame
|
||||||
import com.vitorpamplona.quic.frame.StreamFrame
|
import com.vitorpamplona.quic.frame.StreamFrame
|
||||||
|
import com.vitorpamplona.quic.frame.StreamsBlockedFrame
|
||||||
import com.vitorpamplona.quic.frame.encodeFrames
|
import com.vitorpamplona.quic.frame.encodeFrames
|
||||||
import com.vitorpamplona.quic.packet.LongHeaderPacket
|
import com.vitorpamplona.quic.packet.LongHeaderPacket
|
||||||
import com.vitorpamplona.quic.packet.LongHeaderPlaintextPacket
|
import com.vitorpamplona.quic.packet.LongHeaderPlaintextPacket
|
||||||
@@ -250,6 +251,19 @@ private fun buildApplicationPacket(
|
|||||||
// stream and MAX_DATA at the connection level.
|
// stream and MAX_DATA at the connection level.
|
||||||
appendFlowControlUpdates(conn, frames)
|
appendFlowControlUpdates(conn, frames)
|
||||||
|
|
||||||
|
// RFC 9000 §19.14: tell the peer we're starving for stream IDs.
|
||||||
|
// Drained on each emission so we send at most one STREAMS_BLOCKED per
|
||||||
|
// cap value per direction (re-set by [openClientStream] only if we
|
||||||
|
// hit the cap again with a higher value).
|
||||||
|
conn.pendingStreamsBlockedBidi?.let { limit ->
|
||||||
|
frames += StreamsBlockedFrame(bidi = true, streamLimit = limit)
|
||||||
|
conn.pendingStreamsBlockedBidi = null
|
||||||
|
}
|
||||||
|
conn.pendingStreamsBlockedUni?.let { limit ->
|
||||||
|
frames += StreamsBlockedFrame(bidi = false, streamLimit = limit)
|
||||||
|
conn.pendingStreamsBlockedUni = null
|
||||||
|
}
|
||||||
|
|
||||||
// Pending datagrams
|
// Pending datagrams
|
||||||
while (conn.pendingDatagramsLocked().isNotEmpty()) {
|
while (conn.pendingDatagramsLocked().isNotEmpty()) {
|
||||||
val payload = conn.pendingDatagramsLocked().removeFirst()
|
val payload = conn.pendingDatagramsLocked().removeFirst()
|
||||||
|
|||||||
@@ -242,6 +242,27 @@ class MaxStreamsFrame(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 9000 §19.14. Sent by an endpoint that wants to open a stream of the
|
||||||
|
* given direction but has exhausted its peer-granted cap. Carries the
|
||||||
|
* highest stream id the sender currently believes it is allowed to open
|
||||||
|
* (i.e. the count it received in the last MAX_STREAMS frame for that
|
||||||
|
* direction).
|
||||||
|
*
|
||||||
|
* Required for proper flow-control bookkeeping — without it, the peer
|
||||||
|
* has no signal that we're starved and may delay extending credit. The
|
||||||
|
* frame itself is informational; it doesn't mutate the cap.
|
||||||
|
*/
|
||||||
|
class StreamsBlockedFrame(
|
||||||
|
val bidi: Boolean,
|
||||||
|
val streamLimit: Long,
|
||||||
|
) : Frame() {
|
||||||
|
override fun encode(out: QuicWriter) {
|
||||||
|
out.writeByte(if (bidi) FrameType.STREAMS_BLOCKED_BIDI.toInt() else FrameType.STREAMS_BLOCKED_UNI.toInt())
|
||||||
|
out.writeVarint(streamLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class DatagramFrame(
|
class DatagramFrame(
|
||||||
val data: ByteArray,
|
val data: ByteArray,
|
||||||
val explicitLength: Boolean = true,
|
val explicitLength: Boolean = true,
|
||||||
@@ -391,8 +412,12 @@ fun decodeFrames(data: ByteArray): List<Frame> {
|
|||||||
r.readVarint()
|
r.readVarint()
|
||||||
}
|
}
|
||||||
|
|
||||||
type == FrameType.STREAMS_BLOCKED_BIDI || type == FrameType.STREAMS_BLOCKED_UNI -> {
|
type == FrameType.STREAMS_BLOCKED_BIDI -> {
|
||||||
r.readVarint()
|
out += StreamsBlockedFrame(bidi = true, streamLimit = r.readVarint())
|
||||||
|
}
|
||||||
|
|
||||||
|
type == FrameType.STREAMS_BLOCKED_UNI -> {
|
||||||
|
out += StreamsBlockedFrame(bidi = false, streamLimit = r.readVarint())
|
||||||
}
|
}
|
||||||
|
|
||||||
type == FrameType.NEW_CONNECTION_ID -> {
|
type == FrameType.NEW_CONNECTION_ID -> {
|
||||||
|
|||||||
+269
-24
@@ -21,29 +21,46 @@
|
|||||||
package com.vitorpamplona.quic.connection
|
package com.vitorpamplona.quic.connection
|
||||||
|
|
||||||
import com.vitorpamplona.quic.frame.MaxStreamsFrame
|
import com.vitorpamplona.quic.frame.MaxStreamsFrame
|
||||||
|
import com.vitorpamplona.quic.frame.StreamsBlockedFrame
|
||||||
|
import com.vitorpamplona.quic.frame.decodeFrames
|
||||||
import com.vitorpamplona.quic.tls.InProcessTlsServer
|
import com.vitorpamplona.quic.tls.InProcessTlsServer
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFailsWith
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies the audit-3 fix: peer-granted stream concurrency limits are now
|
* Verifies the per-direction stream-cap flow-control behaviour:
|
||||||
* tracked and enforced.
|
|
||||||
*
|
*
|
||||||
* Two paths feed the cap:
|
* - Peer transport parameters at handshake (`initial_max_streams_bidi/uni`)
|
||||||
* 1. Peer transport parameters at handshake (`initial_max_streams_bidi/uni`)
|
* bound how many client-initiated streams [QuicConnection.openBidiStream]
|
||||||
* 2. Subsequent MAX_STREAMS frames (RFC 9000 §19.11)
|
* / [QuicConnection.openUniStream] may allocate before suspending.
|
||||||
|
* - Subsequent inbound MAX_STREAMS frames (RFC 9000 §19.11) raise the cap
|
||||||
|
* and wake suspended openers.
|
||||||
|
* - When an opener suspends, a STREAMS_BLOCKED frame (RFC 9000 §19.14)
|
||||||
|
* is queued so the peer knows we want more credit.
|
||||||
|
* - Closing the connection wakes any blocked opener with a
|
||||||
|
* [QuicConnectionClosedException] rather than hanging it.
|
||||||
*
|
*
|
||||||
* Without this enforcement, [QuicConnection.openBidiStream] silently allocated
|
* The previous behaviour — throwing [QuicStreamLimitException] synchronously
|
||||||
* stream IDs past the cap, and the peer eventually closed the connection with
|
* when the cap was exhausted — pushed the back-pressure problem onto every
|
||||||
* STREAM_LIMIT_ERROR — a failure that surfaced as "connection randomly drops
|
* caller (which usually swallowed it via `runCatching` and silently dropped
|
||||||
* after a burst of opens" rather than a clean error.
|
* data). See nestsClient sweep results for the symptom.
|
||||||
*/
|
*/
|
||||||
class PeerStreamLimitTest {
|
class PeerStreamLimitTest {
|
||||||
@Test
|
@Test
|
||||||
fun open_bidi_throws_when_peer_advertises_zero_bidi_streams() {
|
fun open_bidi_suspends_when_peer_advertises_zero_bidi_streams() {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val client =
|
val client =
|
||||||
QuicConnection(
|
QuicConnection(
|
||||||
@@ -53,8 +70,6 @@ class PeerStreamLimitTest {
|
|||||||
com.vitorpamplona.quic.tls
|
com.vitorpamplona.quic.tls
|
||||||
.PermissiveCertificateValidator(),
|
.PermissiveCertificateValidator(),
|
||||||
)
|
)
|
||||||
// Explicitly advertise zero bidi streams. (The pipe's default TPs
|
|
||||||
// grant 16, so we override.)
|
|
||||||
val serverScid = ConnectionId.random(8)
|
val serverScid = ConnectionId.random(8)
|
||||||
val tlsServer =
|
val tlsServer =
|
||||||
InProcessTlsServer(
|
InProcessTlsServer(
|
||||||
@@ -81,14 +96,18 @@ class PeerStreamLimitTest {
|
|||||||
pipe.drive(maxRounds = 16)
|
pipe.drive(maxRounds = 16)
|
||||||
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||||
|
|
||||||
assertFailsWith<QuicStreamLimitException> {
|
// openBidiStream now SUSPENDS instead of throwing — the peer
|
||||||
client.openBidiStream()
|
// hasn't granted any credit, so we should never resolve.
|
||||||
}
|
val opened =
|
||||||
|
withTimeoutOrNull(150L) {
|
||||||
|
client.openBidiStream()
|
||||||
|
}
|
||||||
|
assertNull(opened, "openBidiStream must suspend when peer cap = 0; got $opened")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun open_bidi_succeeds_within_peer_advertised_cap_and_throws_at_boundary() {
|
fun open_bidi_succeeds_within_peer_cap_then_suspends_at_boundary() {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val client =
|
val client =
|
||||||
QuicConnection(
|
QuicConnection(
|
||||||
@@ -98,8 +117,6 @@ class PeerStreamLimitTest {
|
|||||||
com.vitorpamplona.quic.tls
|
com.vitorpamplona.quic.tls
|
||||||
.PermissiveCertificateValidator(),
|
.PermissiveCertificateValidator(),
|
||||||
)
|
)
|
||||||
// Build the TLS server with the audit-4 #7 required CIDs plus
|
|
||||||
// tight stream caps for the boundary test.
|
|
||||||
val serverScid = ConnectionId.random(8)
|
val serverScid = ConnectionId.random(8)
|
||||||
val serverTpBytes =
|
val serverTpBytes =
|
||||||
TransportParameters(
|
TransportParameters(
|
||||||
@@ -125,15 +142,226 @@ class PeerStreamLimitTest {
|
|||||||
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||||
assertEquals(3L, client.peerMaxStreamsBidiSnapshot())
|
assertEquals(3L, client.peerMaxStreamsBidiSnapshot())
|
||||||
|
|
||||||
// Three opens should succeed.
|
// Three opens within the cap should resolve instantly.
|
||||||
client.openBidiStream()
|
client.openBidiStream()
|
||||||
client.openBidiStream()
|
client.openBidiStream()
|
||||||
client.openBidiStream()
|
client.openBidiStream()
|
||||||
|
|
||||||
// Fourth must throw — we'd otherwise violate the peer's cap.
|
// Fourth must SUSPEND (not throw) — we'd otherwise violate the
|
||||||
assertFailsWith<QuicStreamLimitException> {
|
// peer's cap and trigger STREAM_LIMIT_ERROR on their side.
|
||||||
client.openBidiStream()
|
val fourth =
|
||||||
}
|
withTimeoutOrNull(150L) {
|
||||||
|
client.openBidiStream()
|
||||||
|
}
|
||||||
|
assertNull(fourth, "fourth openBidiStream must suspend; got $fourth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun max_streams_uni_frame_wakes_suspended_open_uni_stream() {
|
||||||
|
runBlocking {
|
||||||
|
val client =
|
||||||
|
QuicConnection(
|
||||||
|
serverName = "example.test",
|
||||||
|
config = QuicConnectionConfig(),
|
||||||
|
tlsCertificateValidator =
|
||||||
|
com.vitorpamplona.quic.tls
|
||||||
|
.PermissiveCertificateValidator(),
|
||||||
|
)
|
||||||
|
val serverScid = ConnectionId.random(8)
|
||||||
|
val serverTpBytes =
|
||||||
|
TransportParameters(
|
||||||
|
initialMaxData = 1_000_000,
|
||||||
|
initialMaxStreamDataBidiLocal = 100_000,
|
||||||
|
initialMaxStreamDataBidiRemote = 100_000,
|
||||||
|
initialMaxStreamDataUni = 100_000,
|
||||||
|
// Peer initially grants ZERO uni streams — opener will
|
||||||
|
// suspend until a MAX_STREAMS_UNI frame raises the cap.
|
||||||
|
initialMaxStreamsBidi = 100,
|
||||||
|
initialMaxStreamsUni = 0,
|
||||||
|
initialSourceConnectionId = serverScid.bytes,
|
||||||
|
originalDestinationConnectionId = client.destinationConnectionId.bytes,
|
||||||
|
).encode()
|
||||||
|
val tlsServer = InProcessTlsServer(transportParameters = serverTpBytes)
|
||||||
|
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)
|
||||||
|
assertEquals(0L, client.peerMaxStreamsUniSnapshot())
|
||||||
|
|
||||||
|
// Launch a coroutine that tries to open a uni stream. It must
|
||||||
|
// suspend immediately because the cap is 0.
|
||||||
|
val supervisor = SupervisorJob()
|
||||||
|
val scope = CoroutineScope(supervisor + Dispatchers.Default)
|
||||||
|
val openResult = CompletableDeferred<com.vitorpamplona.quic.stream.QuicStream>()
|
||||||
|
val opener =
|
||||||
|
scope.launch {
|
||||||
|
val s = client.openUniStream()
|
||||||
|
openResult.complete(s)
|
||||||
|
}
|
||||||
|
// Give the launcher a moment to actually park on the notifier.
|
||||||
|
delay(50L)
|
||||||
|
assertTrue(opener.isActive, "opener should still be suspended pending credit")
|
||||||
|
|
||||||
|
// Now feed a MAX_STREAMS_UNI(2) frame and process it as if it
|
||||||
|
// had arrived from the peer. The parser raises peerMaxStreamsUni
|
||||||
|
// and signals the cap notifier; the suspended opener wakes up
|
||||||
|
// and allocates stream id 2 (CLIENT_UNI #0).
|
||||||
|
// The MAX_STREAMS_UNI(2) frame on its own is only ~2 bytes;
|
||||||
|
// QUIC packets need ≥4 bytes of protected payload after the
|
||||||
|
// packet number for the HP sample (RFC 9001 §5.4.2). Pad
|
||||||
|
// with a PING (1 byte) and a few PaddingFrames-equivalents.
|
||||||
|
val datagram =
|
||||||
|
pipe.buildServerApplicationDatagram(
|
||||||
|
listOf(
|
||||||
|
com.vitorpamplona.quic.frame.PingFrame,
|
||||||
|
com.vitorpamplona.quic.frame.PingFrame,
|
||||||
|
com.vitorpamplona.quic.frame.PingFrame,
|
||||||
|
com.vitorpamplona.quic.frame.PingFrame,
|
||||||
|
MaxStreamsFrame(bidi = false, maxStreams = 2),
|
||||||
|
),
|
||||||
|
) ?: error("server has no application keys yet")
|
||||||
|
feedDatagram(client, datagram, nowMillis = 0L)
|
||||||
|
|
||||||
|
val opened =
|
||||||
|
withTimeoutOrNull(500L) { openResult.await() }
|
||||||
|
assertNotNull(opened, "opener should have resumed after MAX_STREAMS_UNI raised cap")
|
||||||
|
assertEquals(2L, opened.streamId, "first client uni stream id is 2 (uni-low encoding)")
|
||||||
|
opener.cancelAndJoin()
|
||||||
|
supervisor.cancelAndJoin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun open_uni_stream_queues_streams_blocked_frame_when_cap_is_zero() {
|
||||||
|
runBlocking {
|
||||||
|
val client =
|
||||||
|
QuicConnection(
|
||||||
|
serverName = "example.test",
|
||||||
|
config = QuicConnectionConfig(),
|
||||||
|
tlsCertificateValidator =
|
||||||
|
com.vitorpamplona.quic.tls
|
||||||
|
.PermissiveCertificateValidator(),
|
||||||
|
)
|
||||||
|
val serverScid = ConnectionId.random(8)
|
||||||
|
val tlsServer =
|
||||||
|
InProcessTlsServer(
|
||||||
|
transportParameters =
|
||||||
|
TransportParameters(
|
||||||
|
initialMaxData = 1_000_000,
|
||||||
|
initialMaxStreamDataBidiLocal = 100_000,
|
||||||
|
initialMaxStreamDataBidiRemote = 100_000,
|
||||||
|
initialMaxStreamDataUni = 100_000,
|
||||||
|
initialMaxStreamsBidi = 100,
|
||||||
|
initialMaxStreamsUni = 0,
|
||||||
|
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 supervisor = SupervisorJob()
|
||||||
|
val scope = CoroutineScope(supervisor + Dispatchers.Default)
|
||||||
|
scope.launch { client.openUniStream() }
|
||||||
|
delay(50L)
|
||||||
|
|
||||||
|
// Opener should have recorded the cap value it hit so the
|
||||||
|
// writer emits a STREAMS_BLOCKED_UNI on the next drain.
|
||||||
|
assertEquals(
|
||||||
|
0L,
|
||||||
|
client.pendingStreamsBlockedUni,
|
||||||
|
"openUniStream must queue STREAMS_BLOCKED_UNI(0) when peer cap = 0",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Drain rounds eventually clear the slot once the writer
|
||||||
|
// emits the frame. We don't decode the wire bytes here (a
|
||||||
|
// single STREAMS_BLOCKED frame is too small for the QUIC HP
|
||||||
|
// sample on its own); instead we trust the writer integration
|
||||||
|
// covered by [streams_blocked_frame_roundtrips_via_decode_frames]
|
||||||
|
// and verify the queue-and-clear bookkeeping at the
|
||||||
|
// connection level.
|
||||||
|
// Drain may produce no packet under HP-sample padding rules,
|
||||||
|
// but the field-clear behaviour is exercised by the writer
|
||||||
|
// having access to and zeroing the slot once the frame is
|
||||||
|
// appended to the outbound list. [drainOutbound] returns null
|
||||||
|
// when the resulting packet is too small for HP sampling, but
|
||||||
|
// by that point the writer has already moved the value out of
|
||||||
|
// pendingStreamsBlockedUni into the local frames list.
|
||||||
|
|
||||||
|
supervisor.cancelAndJoin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun closing_connection_wakes_blocked_open_with_closed_exception() {
|
||||||
|
runBlocking {
|
||||||
|
val client =
|
||||||
|
QuicConnection(
|
||||||
|
serverName = "example.test",
|
||||||
|
config = QuicConnectionConfig(),
|
||||||
|
tlsCertificateValidator =
|
||||||
|
com.vitorpamplona.quic.tls
|
||||||
|
.PermissiveCertificateValidator(),
|
||||||
|
)
|
||||||
|
val serverScid = ConnectionId.random(8)
|
||||||
|
val tlsServer =
|
||||||
|
InProcessTlsServer(
|
||||||
|
transportParameters =
|
||||||
|
TransportParameters(
|
||||||
|
initialMaxData = 1_000_000,
|
||||||
|
initialMaxStreamDataBidiLocal = 100_000,
|
||||||
|
initialMaxStreamDataBidiRemote = 100_000,
|
||||||
|
initialMaxStreamDataUni = 100_000,
|
||||||
|
initialMaxStreamsBidi = 0,
|
||||||
|
initialMaxStreamsUni = 0,
|
||||||
|
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 supervisor = SupervisorJob()
|
||||||
|
val scope = CoroutineScope(supervisor + Dispatchers.Default)
|
||||||
|
val openCall =
|
||||||
|
scope.async {
|
||||||
|
runCatching { client.openBidiStream() }
|
||||||
|
}
|
||||||
|
delay(50L)
|
||||||
|
assertTrue(openCall.isActive, "opener must be suspended pending credit")
|
||||||
|
|
||||||
|
client.markClosedExternally("test")
|
||||||
|
val r =
|
||||||
|
withTimeoutOrNull(500L) { openCall.await() }
|
||||||
|
assertNotNull(r, "opener should have resumed once connection closed")
|
||||||
|
assertTrue(
|
||||||
|
r.exceptionOrNull() is QuicConnectionClosedException,
|
||||||
|
"closed connection must throw QuicConnectionClosedException, got ${r.exceptionOrNull()}",
|
||||||
|
)
|
||||||
|
supervisor.cancelAndJoin()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,4 +382,21 @@ class PeerStreamLimitTest {
|
|||||||
assertTrue(frame.bidi)
|
assertTrue(frame.bidi)
|
||||||
assertEquals(100L, frame.maxStreams)
|
assertEquals(100L, frame.maxStreams)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun streams_blocked_frame_roundtrips_via_decode_frames() {
|
||||||
|
val encoded =
|
||||||
|
com.vitorpamplona.quic.frame.encodeFrames(
|
||||||
|
listOf(
|
||||||
|
StreamsBlockedFrame(bidi = true, streamLimit = 7),
|
||||||
|
StreamsBlockedFrame(bidi = false, streamLimit = 99),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val decoded = decodeFrames(encoded).filterIsInstance<StreamsBlockedFrame>()
|
||||||
|
assertEquals(2, decoded.size)
|
||||||
|
assertEquals(true, decoded[0].bidi)
|
||||||
|
assertEquals(7L, decoded[0].streamLimit)
|
||||||
|
assertEquals(false, decoded[1].bidi)
|
||||||
|
assertEquals(99L, decoded[1].streamLimit)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user