Revert "quic: suspend openUni/BidiStream on peer-cap exhaustion + emit STREAMS_BLOCKED"
This reverts commit f0705e3ab1.
This commit is contained in:
+24
-269
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user