fix(quic): RFC 9000 §4.1 connection-level inbound flow-control enforcement
Pre-fix the receiver enforced ONLY per-stream `receiveLimit`. The
aggregate cap (`initial_max_data` / latest MAX_DATA) was advertised and
respected on the SEND side but never checked on the RECEIVE side — a
peer that opened many streams and pushed each up to its per-stream cap
could collectively exceed `initial_max_data` without us closing.
Implementation:
* `QuicStream.receiveHighestOffset`: per-stream high-water mark of
"largest stream offset received" — the spec quantity that gets
summed across streams for the connection-level check.
* `QuicConnection.connectionInboundOffsetSum`: running sum across
all streams of `receiveHighestOffset`. Updated in the parser at
STREAM and RESET_STREAM frame ingest time.
* Parser closes with FLOW_CONTROL_ERROR whenever the running sum
exceeds `advertisedMaxData`.
* RESET_STREAM final-size accounting: per §4.5 the finalSize counts
toward connection-level flow control even though no STREAM frame
delivered those bytes — a peer that resets a stream at a finalSize
larger than it had previously sent gets the extra bytes added to
the running sum at RESET arrival.
Different from the writer's MAX_DATA threshold logic (which uses the
cheaper contiguous-end approximation): the receiver-side enforcement
needs the strict spec quantity to close before bookkeeping diverges.
Test: `ConnectionLevelFlowControlTest` (5 cases) covers below-cap pass,
over-cap close, retransmit-doesn't-double-count, RESET_STREAM
finalSize counts toward limit, fresh-handshake invariants.
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
This commit is contained in:
@@ -481,6 +481,24 @@ class QuicConnection(
|
||||
*/
|
||||
internal var advertisedMaxData: Long = config.initialMaxData
|
||||
|
||||
/**
|
||||
* RFC 9000 §4.1 connection-level inbound flow-control accounting.
|
||||
* Sum across all streams (live + retired) of the largest stream
|
||||
* offset ever observed on an inbound STREAM / RESET_STREAM frame.
|
||||
* The receiver MUST close the connection with FLOW_CONTROL_ERROR
|
||||
* when this sum exceeds [advertisedMaxData]; that's enforced in
|
||||
* the parser at frame-ingest time.
|
||||
*
|
||||
* Different from the writer's `totalRecvAdvanced` which uses
|
||||
* `stream.receive.contiguousEnd()` (a slight under-count) for
|
||||
* MAX_DATA threshold logic. Here we track the strict spec
|
||||
* quantity — necessary to close before bookkeeping diverges
|
||||
* if the peer sends data with gaps that pushes total received
|
||||
* past the limit.
|
||||
*/
|
||||
@Volatile
|
||||
internal var connectionInboundOffsetSum: Long = 0L
|
||||
|
||||
/**
|
||||
* Cumulative count of peer-initiated unidirectional streams we've
|
||||
* accepted (incremented in [getOrCreatePeerStreamLocked]). Compared
|
||||
|
||||
@@ -675,6 +675,25 @@ private fun dispatchFrames(
|
||||
)
|
||||
return
|
||||
}
|
||||
// RFC 9000 §4.1 connection-level enforcement: the SUM
|
||||
// across all streams of "largest stream offset received"
|
||||
// MUST NOT exceed the most recently advertised MAX_DATA.
|
||||
// We update [conn.connectionInboundOffsetSum] only when
|
||||
// this frame's end-offset advances the per-stream
|
||||
// high-water mark, so duplicate / reordered frames don't
|
||||
// double-count.
|
||||
if (frameEnd > stream.receiveHighestOffset) {
|
||||
val delta = frameEnd - stream.receiveHighestOffset
|
||||
stream.receiveHighestOffset = frameEnd
|
||||
conn.connectionInboundOffsetSum += delta
|
||||
if (conn.connectionInboundOffsetSum > conn.advertisedMaxData) {
|
||||
conn.markClosedExternally(
|
||||
"FLOW_CONTROL_ERROR: peer exceeded connection MAX_DATA " +
|
||||
"(${conn.connectionInboundOffsetSum} > ${conn.advertisedMaxData})",
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
// RFC 9000 §4.5: enforce final-size invariants. The
|
||||
// [com.vitorpamplona.quic.stream.ReceiveBuffer.insert]
|
||||
// surface returns a typed result; map any non-OK result
|
||||
@@ -830,6 +849,25 @@ private fun dispatchFrames(
|
||||
return
|
||||
}
|
||||
}
|
||||
// RFC 9000 §4.5: a RESET_STREAM's finalSize counts toward
|
||||
// connection-level flow control even though no STREAM frame
|
||||
// ever delivered those bytes — it represents what the peer
|
||||
// committed to send before aborting. Top up
|
||||
// [conn.connectionInboundOffsetSum] when finalSize advances
|
||||
// beyond what STREAM frames already counted, then re-check
|
||||
// against the connection cap.
|
||||
if (target != null && frame.finalSize > target.receiveHighestOffset) {
|
||||
val delta = frame.finalSize - target.receiveHighestOffset
|
||||
target.receiveHighestOffset = frame.finalSize
|
||||
conn.connectionInboundOffsetSum += delta
|
||||
if (conn.connectionInboundOffsetSum > conn.advertisedMaxData) {
|
||||
conn.markClosedExternally(
|
||||
"FLOW_CONTROL_ERROR: peer RESET_STREAM finalSize pushed connection past MAX_DATA " +
|
||||
"(${conn.connectionInboundOffsetSum} > ${conn.advertisedMaxData})",
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Mark the peer's stream aborted and close our read side; the
|
||||
// application sees a truncated incoming flow.
|
||||
target?.closeIncoming()
|
||||
|
||||
@@ -128,6 +128,20 @@ class QuicStream(
|
||||
var receiveLimit: Long = 0L
|
||||
internal set
|
||||
|
||||
/**
|
||||
* RFC 9000 §4.1: highest stream offset (offset + length) ever seen
|
||||
* on an inbound STREAM or RESET_STREAM frame. Used by
|
||||
* [QuicConnection]'s connection-level flow-control accounting —
|
||||
* the spec requires the receiver to compare the SUM across all
|
||||
* streams of "largest received offset" against the advertised
|
||||
* `initial_max_data` / latest MAX_DATA, NOT the contiguous read
|
||||
* frontier. The writer's MAX_DATA threshold logic still uses the
|
||||
* cheaper contiguous-end approximation; this field is purely the
|
||||
* enforcement signal on receive.
|
||||
*/
|
||||
@Volatile
|
||||
internal var receiveHighestOffset: Long = 0L
|
||||
|
||||
/**
|
||||
* Marker the parser sets whenever [receive.contiguousEnd] advances; the
|
||||
* writer's appendFlowControlUpdates consumes it to skip streams that
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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.frame.ResetStreamFrame
|
||||
import com.vitorpamplona.quic.frame.StreamFrame
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* RFC 9000 §4.1: connection-level inbound flow-control enforcement.
|
||||
*
|
||||
* "All data sent in STREAM frames counts toward this limit. The sum of
|
||||
* the largest received offsets on all streams — including streams in
|
||||
* the Reset Recvd state — MUST NOT exceed the value advertised by a
|
||||
* receiver."
|
||||
*
|
||||
* Pre-fix the receiver enforced ONLY per-stream `receiveLimit` (each
|
||||
* stream individually couldn't push past `initial_max_stream_data_*`).
|
||||
* The aggregate connection-level cap (`initial_max_data`) was advertised
|
||||
* AND respected on the SEND side, but never checked on the RECEIVE
|
||||
* side. A peer that opened many streams and pushed each one up to its
|
||||
* per-stream cap could collectively exceed `initial_max_data` without
|
||||
* us closing.
|
||||
*/
|
||||
class ConnectionLevelFlowControlTest {
|
||||
private fun connectClient(
|
||||
maxData: Long = 16 * 1024,
|
||||
maxStreamData: Long = 8 * 1024,
|
||||
maxStreamsUni: Long = 16,
|
||||
): Pair<QuicConnection, InMemoryQuicPipe> =
|
||||
newConnectedClient(
|
||||
maxData = maxData,
|
||||
maxStreamData = maxStreamData,
|
||||
maxStreamsUni = maxStreamsUni,
|
||||
maxStreamsBidi = 16,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun connection_recv_sum_below_max_data_accepted() {
|
||||
val (client, pipe) = connectClient(maxData = 16 * 1024, maxStreamData = 8 * 1024)
|
||||
// Two server-uni streams (id 3, 7) each carrying 4 KiB. Sum = 8 KiB
|
||||
// < 16 KiB connection cap. Should sail through.
|
||||
val frame1 = StreamFrame(streamId = 3L, offset = 0L, data = ByteArray(4096), fin = false)
|
||||
val frame2 = StreamFrame(streamId = 7L, offset = 0L, data = ByteArray(4096), fin = false)
|
||||
val datagram = pipe.buildServerApplicationDatagram(listOf(frame1, frame2))!!
|
||||
feedDatagram(client, datagram, nowMillis = 0L)
|
||||
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||
assertEquals(8L * 1024, client.connectionInboundOffsetSum)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connection_recv_sum_exceeding_max_data_closes() {
|
||||
val (client, pipe) = connectClient(maxData = 16 * 1024, maxStreamData = 16 * 1024)
|
||||
// Three server-uni streams each shipping 6 KiB → 18 KiB > 16 KiB cap.
|
||||
// Per-stream limit (16 KiB) is fine; the aggregate breaches MAX_DATA.
|
||||
val frame1 = StreamFrame(streamId = 3L, offset = 0L, data = ByteArray(6 * 1024), fin = false)
|
||||
val frame2 = StreamFrame(streamId = 7L, offset = 0L, data = ByteArray(6 * 1024), fin = false)
|
||||
val frame3 = StreamFrame(streamId = 11L, offset = 0L, data = ByteArray(6 * 1024), fin = false)
|
||||
val datagram = pipe.buildServerApplicationDatagram(listOf(frame1, frame2, frame3))!!
|
||||
feedDatagram(client, datagram, nowMillis = 0L)
|
||||
assertEquals(QuicConnection.Status.CLOSED, client.status)
|
||||
val reason = client.closeReason
|
||||
assertNotNull(reason)
|
||||
assertTrue(reason.contains("FLOW_CONTROL_ERROR"), "expected FLOW_CONTROL_ERROR, got: $reason")
|
||||
assertTrue(reason.contains("MAX_DATA"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retransmitted_stream_frame_does_not_double_count() {
|
||||
val (client, pipe) = connectClient(maxData = 16 * 1024, maxStreamData = 8 * 1024)
|
||||
val frame = StreamFrame(streamId = 3L, offset = 0L, data = ByteArray(4 * 1024), fin = false)
|
||||
// First arrival.
|
||||
feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(frame))!!, nowMillis = 0L)
|
||||
val sumAfterFirst = client.connectionInboundOffsetSum
|
||||
assertEquals(4L * 1024, sumAfterFirst)
|
||||
// Identical retransmit — does NOT advance the per-stream high-water,
|
||||
// so connection counter stays put.
|
||||
feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(frame))!!, nowMillis = 0L)
|
||||
assertEquals(sumAfterFirst, client.connectionInboundOffsetSum)
|
||||
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reset_stream_final_size_counts_toward_connection_limit() {
|
||||
val (client, pipe) = connectClient(maxData = 16 * 1024, maxStreamData = 16 * 1024)
|
||||
// Stream 3 ships 4 KiB then resets at finalSize = 20 KiB. The
|
||||
// RESET_STREAM commits the peer to those 20 KiB even though we
|
||||
// haven't seen them on the wire — they count toward MAX_DATA per
|
||||
// §4.5. 20 KiB on a single stream alone breaches the 16 KiB
|
||||
// connection cap, so we MUST close.
|
||||
val streamData = StreamFrame(streamId = 3L, offset = 0L, data = ByteArray(4 * 1024), fin = false)
|
||||
val reset = ResetStreamFrame(streamId = 3L, applicationErrorCode = 0L, finalSize = 20L * 1024)
|
||||
feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(streamData, reset))!!, nowMillis = 0L)
|
||||
assertEquals(QuicConnection.Status.CLOSED, client.status)
|
||||
val reason = client.closeReason
|
||||
assertNotNull(reason)
|
||||
assertTrue(reason.contains("FLOW_CONTROL_ERROR"), "expected FLOW_CONTROL_ERROR, got: $reason")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handshake_fixture_starts_with_zero_inbound_offset_sum() {
|
||||
// Sanity check on the bookkeeping: a fresh handshake-only
|
||||
// connection has not received any STREAM bytes, so the sum is 0
|
||||
// even though [advertisedMaxData] is the configured initial.
|
||||
val (client, pipe) = connectClient()
|
||||
runBlocking {
|
||||
// Make sure pipe-driven handshake completes to CONNECTED before
|
||||
// we read the field — otherwise we'd be looking at the value
|
||||
// mid-handshake with no peer streams in play.
|
||||
pipe.drive(maxRounds = 4)
|
||||
}
|
||||
assertEquals(0L, client.connectionInboundOffsetSum)
|
||||
assertTrue(client.advertisedMaxData > 0L)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user