feat(quic): wire STREAM data retransmit — token emission + ACK/loss dispatch
Closes commit C of the deferred-follow-ups pass. With the
SendBuffer rewrite in commit B (retain-until-ACK with markAcked /
markLost), the connection now wires STREAM frames into the same
RFC 9002 retransmit path that already handles flow-control
extensions.
# Writer side
QuicConnectionWriter.buildApplicationPacket records a
RecoveryToken.Stream(streamId, offset, length, fin) for every
STREAM frame it emits. The token captures the on-wire byte range
plus the FIN bit so retransmit can reproduce the same StreamFrame
on next drain.
# ACK side
New QuicConnection.onTokensAcked() mirrors onTokensLost. The
parser's AckFrame handler iterates the drained packets and routes
each to onTokensAcked, which:
- For Stream tokens: calls SendBuffer.markAcked(offset, length).
The buffer removes the range from in-flight; if the contiguous
low end is now fully ACK'd, flushedFloor advances and storage
shifts forward.
- For Crypto tokens: same shape, applied to the per-level
cryptoSend buffer (commit E will exercise this path for
handshake reliability — Crypto retransmit is wired now but the
writer's CRYPTO emission path doesn't yet record Crypto tokens;
that's commit E).
- For control-frame and Ack tokens: ACK-no-op. The frame already
did its job by reaching the peer; no per-buffer state to
release.
# Loss side
onTokensLost (commit A) already routes Stream tokens to
SendBuffer.markLost. With commit B's real implementation (was a
no-op stub), this now actually re-queues the byte range for
retransmit. The next writer drain pulls from the retransmit queue
before any fresh sends, with the original offset preserved (RFC
9000 §13.3 idempotent retransmit).
# Tests added (3, all pass)
- streamFrame_carriesStreamToken_inSentPacket: writer emits a
Stream token whose fields match the StreamFrame on the wire
- streamData_lostAndRetransmittedOnNextDrain: simulate loss via
direct dispatch, observe re-emit at the same offset in a fresh
SentPacket (different PN)
- streamData_ackedReleasesBuffer: ACK via onTokensAcked,
enqueue more bytes, observe the next send picks up at the
post-ACK offset (proves bytes were released and floor advanced)
Full :quic test suite, nestsClient moq-lite tests, amethyst Android
compile all pass.
Net result: lost STREAM data (e.g. nestsClient bidi control-stream
bytes — moq-lite Subscribe/Announce control messages travel on
QUIC bidi streams) is now recovered automatically. Audio rooms
benefit indirectly: the relay's announce/subscribe path is more
resilient to packet loss.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
This commit is contained in:
@@ -766,6 +766,58 @@ class QuicConnection(
|
|||||||
/** Caller must hold [lock]. */
|
/** Caller must hold [lock]. */
|
||||||
internal fun streamByIdLocked(id: Long): QuicStream? = streams[id]
|
internal fun streamByIdLocked(id: Long): QuicStream? = streams[id]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ACK-path counterpart to [onTokensLost]. Called by the parser
|
||||||
|
* after [com.vitorpamplona.quic.connection.recovery.drainAckedSentPackets]
|
||||||
|
* removes the carrying packet from the sent map. Tokens whose
|
||||||
|
* underlying byte ranges live in a [com.vitorpamplona.quic.stream.SendBuffer]
|
||||||
|
* (Stream / Crypto) trigger a [com.vitorpamplona.quic.stream.SendBuffer.markAcked]
|
||||||
|
* call so the buffer can advance its flushedFloor and release
|
||||||
|
* memory. Other token types are ACK-no-ops (the peer's
|
||||||
|
* acknowledgment of a control frame doesn't require any local
|
||||||
|
* action — the frame already did its job by reaching the peer).
|
||||||
|
*
|
||||||
|
* Caller must hold [lock].
|
||||||
|
*/
|
||||||
|
internal fun onTokensAcked(tokens: List<com.vitorpamplona.quic.connection.recovery.RecoveryToken>) {
|
||||||
|
for (token in tokens) {
|
||||||
|
when (token) {
|
||||||
|
com.vitorpamplona.quic.connection.recovery.RecoveryToken.Ack -> {
|
||||||
|
// ACK-of-ACK is a no-op.
|
||||||
|
}
|
||||||
|
|
||||||
|
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.MaxStreamsUni,
|
||||||
|
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.MaxStreamsBidi,
|
||||||
|
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.MaxData,
|
||||||
|
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.MaxStreamData,
|
||||||
|
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.ResetStream,
|
||||||
|
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.StopSending,
|
||||||
|
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.NewConnectionId,
|
||||||
|
-> {
|
||||||
|
// Control frames have no per-buffer state to release on
|
||||||
|
// ACK. (Pending* maps are populated only on loss; an ACK
|
||||||
|
// for a frame that never lost is naturally absent.)
|
||||||
|
}
|
||||||
|
|
||||||
|
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.Stream -> {
|
||||||
|
val stream = streamByIdLocked(token.streamId) ?: continue
|
||||||
|
stream.send.markAcked(offset = token.offset, length = token.length)
|
||||||
|
if (token.length == 0L && token.fin) {
|
||||||
|
// FIN-only ACK: treat as zero-length ACK at offset.
|
||||||
|
// markAcked already handles length==0 ⇒ FIN match.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is com.vitorpamplona.quic.connection.recovery.RecoveryToken.Crypto -> {
|
||||||
|
levelState(token.level).cryptoSend.markAcked(
|
||||||
|
offset = token.offset,
|
||||||
|
length = token.length,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 6 of `quic/plans/2026-05-04-control-frame-retransmit.md`:
|
* Step 6 of `quic/plans/2026-05-04-control-frame-retransmit.md`:
|
||||||
* dispatch the tokens of declared-lost packets to the matching
|
* dispatch the tokens of declared-lost packets to the matching
|
||||||
|
|||||||
@@ -180,6 +180,13 @@ private fun dispatchFrames(
|
|||||||
// returned list on the floor.
|
// returned list on the floor.
|
||||||
val largestSentTime = state.sentPackets[frame.largestAcknowledged]?.sentAtMillis
|
val largestSentTime = state.sentPackets[frame.largestAcknowledged]?.sentAtMillis
|
||||||
val drained = drainAckedSentPackets(state.sentPackets, frame)
|
val drained = drainAckedSentPackets(state.sentPackets, frame)
|
||||||
|
// Step C of the deferred-follow-ups pass: dispatch ACK
|
||||||
|
// to per-buffer markAcked for Stream / Crypto tokens.
|
||||||
|
// Releases SendBuffer memory and advances its
|
||||||
|
// flushedFloor as low-end ACKs accumulate.
|
||||||
|
for (drainedPacket in drained) {
|
||||||
|
conn.onTokensAcked(drainedPacket.tokens)
|
||||||
|
}
|
||||||
val advancedLargest =
|
val advancedLargest =
|
||||||
state.largestAckedPn?.let { it < frame.largestAcknowledged } ?: true
|
state.largestAckedPn?.let { it < frame.largestAcknowledged } ?: true
|
||||||
if (advancedLargest) {
|
if (advancedLargest) {
|
||||||
|
|||||||
@@ -325,6 +325,18 @@ private fun buildApplicationPacket(
|
|||||||
fin = chunk.fin,
|
fin = chunk.fin,
|
||||||
explicitLength = true,
|
explicitLength = true,
|
||||||
)
|
)
|
||||||
|
// Step C of the deferred-follow-ups pass: track this
|
||||||
|
// STREAM emission so RFC 9002 retransmit can re-queue
|
||||||
|
// the byte range on loss. SendBuffer.markLost (commit B)
|
||||||
|
// moves the range from in-flight back to the retransmit
|
||||||
|
// queue, and the next takeChunk replays it.
|
||||||
|
tokens +=
|
||||||
|
RecoveryToken.Stream(
|
||||||
|
streamId = stream.streamId,
|
||||||
|
offset = chunk.offset,
|
||||||
|
length = chunk.data.size.toLong(),
|
||||||
|
fin = chunk.fin,
|
||||||
|
)
|
||||||
packetBudget -= chunk.data.size + 32
|
packetBudget -= chunk.data.size + 32
|
||||||
connBudget -= chunk.data.size
|
connBudget -= chunk.data.size
|
||||||
conn.sendConnectionFlowConsumed += chunk.data.size
|
conn.sendConnectionFlowConsumed += chunk.data.size
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/*
|
||||||
|
* 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.connection.recovery.RecoveryToken
|
||||||
|
import com.vitorpamplona.quic.tls.InProcessTlsServer
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step C of the deferred-follow-ups pass: STREAM data retransmit
|
||||||
|
* end-to-end. Verifies the writer emits a Stream token per
|
||||||
|
* STREAM frame, the parser dispatches it to markAcked / markLost,
|
||||||
|
* and the buffer either releases or re-queues the bytes
|
||||||
|
* accordingly.
|
||||||
|
*/
|
||||||
|
class StreamRetransmitTest {
|
||||||
|
@Test
|
||||||
|
fun streamFrame_carriesStreamToken_inSentPacket() =
|
||||||
|
runBlocking {
|
||||||
|
val client = handshakedClient()
|
||||||
|
val stream = client.openUniStream()
|
||||||
|
stream.send.enqueue("hello".encodeToByteArray())
|
||||||
|
|
||||||
|
val sizeBefore = client.application.sentPackets.size
|
||||||
|
runCatching { drainOutbound(client, nowMillis = 1L) }
|
||||||
|
val newEntries =
|
||||||
|
client.application.sentPackets.entries
|
||||||
|
.sortedBy { it.key }
|
||||||
|
.drop(sizeBefore)
|
||||||
|
val streamPacket =
|
||||||
|
newEntries.firstOrNull { entry ->
|
||||||
|
entry.value.tokens.any { it is RecoveryToken.Stream }
|
||||||
|
}
|
||||||
|
assertNotNull(streamPacket, "writer must record a Stream token in the SentPacket")
|
||||||
|
val streamToken =
|
||||||
|
streamPacket.value.tokens
|
||||||
|
.filterIsInstance<RecoveryToken.Stream>()
|
||||||
|
.single()
|
||||||
|
assertEquals(stream.streamId, streamToken.streamId)
|
||||||
|
assertEquals(0L, streamToken.offset)
|
||||||
|
assertEquals(5L, streamToken.length)
|
||||||
|
assertEquals(false, streamToken.fin)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun streamData_lostAndRetransmittedOnNextDrain() =
|
||||||
|
runBlocking {
|
||||||
|
val client = handshakedClient()
|
||||||
|
val stream = client.openUniStream()
|
||||||
|
stream.send.enqueue("hello".encodeToByteArray())
|
||||||
|
// First drain — emits StreamFrame, records SentPacket.
|
||||||
|
runCatching { drainOutbound(client, nowMillis = 1L) }
|
||||||
|
|
||||||
|
// Locate the Stream token's carrying packet.
|
||||||
|
val firstPacketEntry =
|
||||||
|
client.application.sentPackets.entries.firstOrNull { entry ->
|
||||||
|
entry.value.tokens.any { it is RecoveryToken.Stream }
|
||||||
|
}
|
||||||
|
assertNotNull(firstPacketEntry, "expected a SentPacket with Stream token after first drain")
|
||||||
|
val firstPn = firstPacketEntry.key
|
||||||
|
|
||||||
|
// Simulate loss via direct dispatch.
|
||||||
|
client.lock.lock()
|
||||||
|
try {
|
||||||
|
val streamToken =
|
||||||
|
firstPacketEntry.value.tokens
|
||||||
|
.filterIsInstance<RecoveryToken.Stream>()
|
||||||
|
.single()
|
||||||
|
client.onTokensLost(listOf(streamToken))
|
||||||
|
// Remove the lost packet from the sent map (the loss
|
||||||
|
// detector would have done this).
|
||||||
|
client.application.sentPackets.remove(firstPn)
|
||||||
|
} finally {
|
||||||
|
client.lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendBuffer should have re-queued the bytes for retransmit.
|
||||||
|
assertEquals(5, stream.send.readableBytes, "lost 5 bytes should be back in the queue")
|
||||||
|
|
||||||
|
// Second drain — emits the retransmit at the same offset.
|
||||||
|
val sizeBeforeReplay = client.application.sentPackets.size
|
||||||
|
runCatching { drainOutbound(client, nowMillis = 2L) }
|
||||||
|
val replayEntries =
|
||||||
|
client.application.sentPackets.entries
|
||||||
|
.sortedBy { it.key }
|
||||||
|
.drop(sizeBeforeReplay)
|
||||||
|
val replayPacket =
|
||||||
|
replayEntries.firstOrNull { entry ->
|
||||||
|
entry.value.tokens.any { it is RecoveryToken.Stream }
|
||||||
|
}
|
||||||
|
assertNotNull(replayPacket, "retransmit must produce a fresh SentPacket carrying the Stream token")
|
||||||
|
val replayToken =
|
||||||
|
replayPacket.value.tokens
|
||||||
|
.filterIsInstance<RecoveryToken.Stream>()
|
||||||
|
.single()
|
||||||
|
assertEquals(0L, replayToken.offset, "retransmit must replay original offset (RFC 9000 §13.3 idempotent)")
|
||||||
|
assertEquals(5L, replayToken.length)
|
||||||
|
assertTrue(replayPacket.key != firstPn, "retransmit uses a fresh PN")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun streamData_ackedReleasesBuffer() =
|
||||||
|
runBlocking {
|
||||||
|
val client = handshakedClient()
|
||||||
|
val stream = client.openUniStream()
|
||||||
|
stream.send.enqueue("hello".encodeToByteArray())
|
||||||
|
runCatching { drainOutbound(client, nowMillis = 1L) }
|
||||||
|
|
||||||
|
// Bytes are in-flight. SendBuffer holds them.
|
||||||
|
// Now ACK via direct dispatch.
|
||||||
|
val packet =
|
||||||
|
client.application.sentPackets.entries
|
||||||
|
.first { it.value.tokens.any { t -> t is RecoveryToken.Stream } }
|
||||||
|
client.lock.lock()
|
||||||
|
try {
|
||||||
|
client.onTokensAcked(packet.value.tokens)
|
||||||
|
} finally {
|
||||||
|
client.lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// After ACK: enqueue more, observe that the buffer
|
||||||
|
// continues from offset 5 (proves the prior bytes were
|
||||||
|
// released, sentOffset advanced).
|
||||||
|
stream.send.enqueue("world".encodeToByteArray())
|
||||||
|
val sizeBefore = client.application.sentPackets.size
|
||||||
|
runCatching { drainOutbound(client, nowMillis = 2L) }
|
||||||
|
val newEntries =
|
||||||
|
client.application.sentPackets.entries
|
||||||
|
.sortedBy { it.key }
|
||||||
|
.drop(sizeBefore)
|
||||||
|
val nextStreamToken =
|
||||||
|
newEntries
|
||||||
|
.flatMap { it.value.tokens }
|
||||||
|
.filterIsInstance<RecoveryToken.Stream>()
|
||||||
|
.firstOrNull()
|
||||||
|
assertNotNull(nextStreamToken)
|
||||||
|
assertEquals(5L, nextStreamToken.offset, "after ACK, next send picks up at offset 5")
|
||||||
|
assertEquals(5L, nextStreamToken.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handshakedClient(): QuicConnection =
|
||||||
|
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 = 100,
|
||||||
|
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)
|
||||||
|
client
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user