Revert "quic: suspend openUni/BidiStream on peer-cap exhaustion + emit STREAMS_BLOCKED"

This reverts commit f0705e3ab1.
This commit is contained in:
Claude
2026-05-01 13:59:38 +00:00
parent f0705e3ab1
commit 96a585a67e
6 changed files with 63 additions and 476 deletions
@@ -21,46 +21,29 @@
package com.vitorpamplona.quic.connection
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 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.withTimeoutOrNull
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* Verifies the per-direction stream-cap flow-control behaviour:
* Verifies the audit-3 fix: peer-granted stream concurrency limits are now
* tracked and enforced.
*
* - Peer transport parameters at handshake (`initial_max_streams_bidi/uni`)
* bound how many client-initiated streams [QuicConnection.openBidiStream]
* / [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.
* Two paths feed the cap:
* 1. Peer transport parameters at handshake (`initial_max_streams_bidi/uni`)
* 2. Subsequent MAX_STREAMS frames (RFC 9000 §19.11)
*
* The previous behaviour — throwing [QuicStreamLimitException] synchronously
* when the cap was exhausted — pushed the back-pressure problem onto every
* caller (which usually swallowed it via `runCatching` and silently dropped
* data). See nestsClient sweep results for the symptom.
* Without this enforcement, [QuicConnection.openBidiStream] silently allocated
* stream IDs past the cap, and the peer eventually closed the connection with
* STREAM_LIMIT_ERROR — a failure that surfaced as "connection randomly drops
* after a burst of opens" rather than a clean error.
*/
class PeerStreamLimitTest {
@Test
fun open_bidi_suspends_when_peer_advertises_zero_bidi_streams() {
fun open_bidi_throws_when_peer_advertises_zero_bidi_streams() {
runBlocking {
val client =
QuicConnection(
@@ -70,6 +53,8 @@ class PeerStreamLimitTest {
com.vitorpamplona.quic.tls
.PermissiveCertificateValidator(),
)
// Explicitly advertise zero bidi streams. (The pipe's default TPs
// grant 16, so we override.)
val serverScid = ConnectionId.random(8)
val tlsServer =
InProcessTlsServer(
@@ -96,18 +81,14 @@ class PeerStreamLimitTest {
pipe.drive(maxRounds = 16)
assertEquals(QuicConnection.Status.CONNECTED, client.status)
// openBidiStream now SUSPENDS instead of throwing — the peer
// 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")
assertFailsWith<QuicStreamLimitException> {
client.openBidiStream()
}
}
}
@Test
fun open_bidi_succeeds_within_peer_cap_then_suspends_at_boundary() {
fun open_bidi_succeeds_within_peer_advertised_cap_and_throws_at_boundary() {
runBlocking {
val client =
QuicConnection(
@@ -117,6 +98,8 @@ class PeerStreamLimitTest {
com.vitorpamplona.quic.tls
.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 serverTpBytes =
TransportParameters(
@@ -142,226 +125,15 @@ class PeerStreamLimitTest {
assertEquals(QuicConnection.Status.CONNECTED, client.status)
assertEquals(3L, client.peerMaxStreamsBidiSnapshot())
// Three opens within the cap should resolve instantly.
// Three opens should succeed.
client.openBidiStream()
client.openBidiStream()
client.openBidiStream()
// Fourth must SUSPEND (not throw) — we'd otherwise violate the
// peer's cap and trigger STREAM_LIMIT_ERROR on their side.
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()
// Fourth must throw — we'd otherwise violate the peer's cap.
assertFailsWith<QuicStreamLimitException> {
client.openBidiStream()
}
}
}
@@ -382,21 +154,4 @@ class PeerStreamLimitTest {
assertTrue(frame.bidi)
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)
}
}