feat(quic): Phase D-H — QuicConnection orchestrator

Wire together TLS, packet codec, and stream buffers into a single client
QuicConnection that drives the handshake and 1-RTT exchange.

- QuicConnection owns per-encryption-level state (LevelState): packet number
  space, ACK tracker, CRYPTO send + receive buffers, send + receive packet
  protection. Initial keys are installed at construction from a random DCID.
- TlsSecretsListener inside the connection installs handshake + application
  keys as the TLS state machine derives them.
- AckTracker maintains a sorted disjoint-range list of received packet
  numbers and emits RFC 9000 §19.3-shaped ACK frames newest-first.
- SendBuffer + per-stream QuicStream allow application code to enqueue bytes
  and FIN; the connection's writer drains them into STREAM frames.
- QuicConnectionParser dispatches inbound CRYPTO/STREAM/DATAGRAM/MAX_*/ACK/
  CONNECTION_CLOSE frames into the right state. Coalesced packets in a single
  datagram are demultiplexed.
- QuicConnectionWriter builds outbound datagrams that coalesce Initial +
  Handshake + 1-RTT packets, pads Initial datagrams to 1200 bytes, and
  drains stream send queues round-robin under a soft MTU budget.
- TransportParameters codec covers all RFC 9000 §18.2 + RFC 9221 fields and
  is exchanged via the TLS quic_transport_parameters extension.
- PacketProtectionBuilder maps a TLS traffic secret + cipher suite to the
  AEAD + HP triple via RFC 9001's `quic key`/`quic iv`/`quic hp` labels.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
Claude
2026-04-25 20:50:26 +00:00
parent ac53853057
commit 52ab84acfe
10 changed files with 1202 additions and 0 deletions
@@ -0,0 +1,40 @@
/*
* 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.crypto.Aead
/** Per-direction packet protection material at one encryption level. */
class PacketProtection(
val aead: Aead,
val key: ByteArray,
val iv: ByteArray,
val hp: com.vitorpamplona.quic.crypto.HeaderProtection,
val hpKey: ByteArray,
)
/** All four encryption levels we ever see in a QUIC client connection. */
enum class EncryptionLevel(val space: PacketNumberSpace) {
INITIAL(PacketNumberSpace.INITIAL),
HANDSHAKE(PacketNumberSpace.HANDSHAKE),
APPLICATION(PacketNumberSpace.APPLICATION),
;
}
@@ -0,0 +1,34 @@
/*
* 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.stream.ReceiveBuffer
import com.vitorpamplona.quic.stream.SendBuffer
/** Per-encryption-level state owned by [QuicConnection]. */
class LevelState {
val pnSpace = PacketNumberSpaceState()
val ackTracker = com.vitorpamplona.quic.recovery.AckTracker()
val cryptoSend = SendBuffer()
val cryptoReceive = ReceiveBuffer()
var sendProtection: PacketProtection? = null
var receiveProtection: PacketProtection? = null
}
@@ -0,0 +1,73 @@
/*
* 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.crypto.Aead
import com.vitorpamplona.quic.crypto.Aes128Gcm
import com.vitorpamplona.quic.crypto.AesEcbHeaderProtection
import com.vitorpamplona.quic.crypto.HeaderProtection
import com.vitorpamplona.quic.crypto.PlatformAesOneBlock
import com.vitorpamplona.quic.tls.TlsConstants
import com.vitorpamplona.quic.tls.deriveQuicKeys
/**
* Build [PacketProtection] for one direction at one encryption level given a
* TLS traffic secret + TLS cipher-suite identifier. The QUIC labels in
* RFC 9001 §5.1 (`quic key`, `quic iv`, `quic hp`) drive the expansion.
*
* For Phase BK we only support TLS_AES_128_GCM_SHA256 (16/12/16) and
* TLS_CHACHA20_POLY1305_SHA256 — the SHA-256 suites; nests speaks both.
*/
fun packetProtectionFromSecret(
cipherSuite: Int,
secret: ByteArray,
): PacketProtection {
val (aead, keyLen, ivLen, hpLen, hp) =
when (cipherSuite) {
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 ->
ProtectionParams(
aead = Aes128Gcm,
keyLen = 16,
ivLen = 12,
hpLen = 16,
hp = AesEcbHeaderProtection(PlatformAesOneBlock),
)
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256 ->
ProtectionParams(
aead = com.vitorpamplona.quic.crypto.ChaCha20Poly1305Aead,
keyLen = 32,
ivLen = 12,
hpLen = 32,
hp = com.vitorpamplona.quic.crypto.ChaCha20HeaderProtection(com.vitorpamplona.quic.crypto.PlatformChaCha20Block),
)
else -> error("unsupported cipher suite 0x${cipherSuite.toString(16)}")
}
val keys = deriveQuicKeys(secret, keyLen, ivLen, hpLen)
return PacketProtection(aead, keys.key, keys.iv, hp, keys.hp)
}
private data class ProtectionParams(
val aead: Aead,
val keyLen: Int,
val ivLen: Int,
val hpLen: Int,
val hp: HeaderProtection,
)
@@ -0,0 +1,268 @@
/*
* 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.QuicWriter
import com.vitorpamplona.quic.crypto.Aes128Gcm
import com.vitorpamplona.quic.crypto.AesEcbHeaderProtection
import com.vitorpamplona.quic.crypto.InitialSecrets
import com.vitorpamplona.quic.crypto.PlatformAesOneBlock
import com.vitorpamplona.quic.frame.ConnectionCloseFrame
import com.vitorpamplona.quic.frame.CryptoFrame
import com.vitorpamplona.quic.frame.DatagramFrame
import com.vitorpamplona.quic.frame.Frame
import com.vitorpamplona.quic.frame.PingFrame
import com.vitorpamplona.quic.frame.StreamFrame
import com.vitorpamplona.quic.frame.decodeFrames
import com.vitorpamplona.quic.frame.encodeFrames
import com.vitorpamplona.quic.packet.LongHeaderPacket
import com.vitorpamplona.quic.packet.LongHeaderPlaintextPacket
import com.vitorpamplona.quic.packet.LongHeaderType
import com.vitorpamplona.quic.packet.QuicVersion
import com.vitorpamplona.quic.packet.ShortHeaderPacket
import com.vitorpamplona.quic.packet.ShortHeaderPlaintextPacket
import com.vitorpamplona.quic.stream.QuicStream
import com.vitorpamplona.quic.stream.StreamId
import com.vitorpamplona.quic.tls.TlsClient
import com.vitorpamplona.quic.tls.TlsConstants
import com.vitorpamplona.quic.tls.TlsSecretsListener
/**
* QUIC v1 client connection. The orchestrator that owns:
*
* - the TLS 1.3 client (which produces CRYPTO bytes per encryption level)
* - the per-level packet protection material
* - per-level packet number spaces + ACK trackers
* - per-stream send/receive buffers
* - inbound datagram queue
*
* The connection is driven by two callbacks:
*
* [feedDatagram] — feed an inbound UDP datagram. Decrypts every contained
* QUIC packet, runs CRYPTO bytes through the TLS state
* machine, dispatches STREAM/DATAGRAM/ACK/MAX_* frames to
* the right stream, and updates ACK state.
*
* [drainOutbound] — pull the next UDP datagram (or null if nothing to send).
* Coalesces ACK + CRYPTO + STREAM + DATAGRAM frames into
* a single packet (or two, if Initial + Handshake).
*
* The actual UDP socket I/O is in the higher-level [QuicConnectionDriver].
*/
class QuicConnection(
val serverName: String,
val config: QuicConnectionConfig,
val tlsCertificateValidator: com.vitorpamplona.quic.tls.CertificateValidator? = null,
val nowMillis: () -> Long = { kotlin.time.Clock.System.now().toEpochMilliseconds() },
val alpnList: List<ByteArray> = listOf(TlsConstants.ALPN_H3),
) {
val sourceConnectionId: ConnectionId = ConnectionId.random(8)
var destinationConnectionId: ConnectionId = ConnectionId.random(8)
internal set
val originalDestinationConnectionId: ConnectionId = destinationConnectionId
val initial = LevelState()
val handshake = LevelState()
val application = LevelState()
var handshakeComplete: Boolean = false
private set
var peerTransportParameters: TransportParameters? = null
private set
enum class Status { HANDSHAKING, CONNECTED, CLOSING, CLOSED }
var status: Status = Status.HANDSHAKING
internal set
/** App-level error code for graceful close. */
var closeReason: String? = null
private set
var closeErrorCode: Long = 0L
private set
private val streams = mutableMapOf<Long, QuicStream>()
private var nextLocalBidiIndex: Long = 0L
private var nextLocalUniIndex: Long = 0L
private val pendingDatagrams = ArrayDeque<ByteArray>()
private val incomingDatagrams = ArrayDeque<ByteArray>()
private var sendConnectionFlowCredit: Long = 0L
private var receiveConnectionFlowLimit: Long = config.initialMaxData
/** Streams the peer has opened that we haven't surfaced yet. */
private val newPeerStreams = ArrayDeque<QuicStream>()
private val tlsListener =
object : TlsSecretsListener {
override fun onHandshakeKeysReady(
cipherSuite: Int,
clientSecret: ByteArray,
serverSecret: ByteArray,
) {
handshake.sendProtection = packetProtectionFromSecret(cipherSuite, clientSecret)
handshake.receiveProtection = packetProtectionFromSecret(cipherSuite, serverSecret)
}
override fun onApplicationKeysReady(
cipherSuite: Int,
clientSecret: ByteArray,
serverSecret: ByteArray,
) {
application.sendProtection = packetProtectionFromSecret(cipherSuite, clientSecret)
application.receiveProtection = packetProtectionFromSecret(cipherSuite, serverSecret)
}
override fun onHandshakeComplete() {
handshakeComplete = true
if (status == Status.HANDSHAKING) status = Status.CONNECTED
applyPeerTransportParameters()
}
}
val tls: TlsClient =
TlsClient(
serverName = serverName,
transportParameters = buildLocalTransportParameters().encode(),
secretsListener = tlsListener,
certificateValidator = tlsCertificateValidator,
)
init {
// Install Initial keys based on the random destination CID we just generated.
val proto = InitialSecrets.derive(destinationConnectionId.bytes)
val hp = AesEcbHeaderProtection(PlatformAesOneBlock)
initial.sendProtection = PacketProtection(Aes128Gcm, proto.clientKey, proto.clientIv, hp, proto.clientHp)
initial.receiveProtection = PacketProtection(Aes128Gcm, proto.serverKey, proto.serverIv, hp, proto.serverHp)
}
/** Begin the handshake — emits ClientHello into Initial CRYPTO. */
fun start() {
tls.start()
// Drain ClientHello bytes into the Initial-level CRYPTO send buffer.
tls.pollOutbound(TlsClient.Level.INITIAL)?.let { initial.cryptoSend.enqueue(it) }
}
private fun buildLocalTransportParameters(): TransportParameters =
TransportParameters(
initialMaxData = config.initialMaxData,
initialMaxStreamDataBidiLocal = config.initialMaxStreamDataBidiLocal,
initialMaxStreamDataBidiRemote = config.initialMaxStreamDataBidiRemote,
initialMaxStreamDataUni = config.initialMaxStreamDataUni,
initialMaxStreamsBidi = config.initialMaxStreamsBidi,
initialMaxStreamsUni = config.initialMaxStreamsUni,
maxIdleTimeoutMillis = config.maxIdleTimeoutMillis,
maxUdpPayloadSize = config.maxUdpPayloadSize,
ackDelayExponent = config.ackDelayExponent,
maxAckDelay = config.maxAckDelay,
activeConnectionIdLimit = config.activeConnectionIdLimit,
initialSourceConnectionId = sourceConnectionId.bytes,
maxDatagramFrameSize = config.maxDatagramFrameSize,
)
private fun applyPeerTransportParameters() {
val raw = tls.peerTransportParameters ?: return
val tp = TransportParameters.decode(raw)
peerTransportParameters = tp
sendConnectionFlowCredit = tp.initialMaxData ?: 0L
// Update each open stream's send credit per direction.
for ((id, stream) in streams) {
stream.sendCredit =
when (StreamId.kindOf(id)) {
StreamId.Kind.CLIENT_BIDI, StreamId.Kind.SERVER_BIDI ->
if (StreamId.isClientInitiated(id)) tp.initialMaxStreamDataBidiRemote ?: 0L else tp.initialMaxStreamDataBidiLocal ?: 0L
StreamId.Kind.CLIENT_UNI, StreamId.Kind.SERVER_UNI ->
tp.initialMaxStreamDataUni ?: 0L
}
}
}
/** Allocate a new client-initiated bidirectional stream. */
fun openBidiStream(): QuicStream {
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
return stream
}
/** Allocate a new client-initiated unidirectional (write-only) stream. */
fun openUniStream(): QuicStream {
val id = StreamId.build(StreamId.Kind.CLIENT_UNI, nextLocalUniIndex++)
val stream = QuicStream(id, QuicStream.Direction.UNIDIRECTIONAL_LOCAL_TO_REMOTE)
stream.sendCredit = peerTransportParameters?.initialMaxStreamDataUni ?: config.initialMaxStreamDataUni
stream.receiveLimit = 0L // can't receive
streams[id] = stream
return stream
}
fun pollIncomingPeerStream(): QuicStream? = newPeerStreams.removeFirstOrNull()
fun streamById(id: Long): QuicStream? = streams[id]
fun queueDatagram(payload: ByteArray) {
pendingDatagrams.addLast(payload)
}
fun pollIncomingDatagram(): ByteArray? = incomingDatagrams.removeFirstOrNull()
/** Initiate a graceful close. */
fun close(
errorCode: Long,
reason: String,
) {
if (status == Status.CLOSED || status == Status.CLOSING) return
closeErrorCode = errorCode
closeReason = reason
status = Status.CLOSING
}
internal fun getOrCreatePeerStream(id: Long): QuicStream {
streams[id]?.let { return it }
val direction =
when (StreamId.kindOf(id)) {
StreamId.Kind.CLIENT_BIDI, StreamId.Kind.SERVER_BIDI -> QuicStream.Direction.BIDIRECTIONAL
StreamId.Kind.SERVER_UNI -> QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL
StreamId.Kind.CLIENT_UNI -> QuicStream.Direction.UNIDIRECTIONAL_LOCAL_TO_REMOTE
}
val stream = QuicStream(id, direction)
stream.sendCredit = 0L
stream.receiveLimit = config.initialMaxStreamDataBidiRemote
streams[id] = stream
newPeerStreams.addLast(stream)
return stream
}
/** Returns the level state for [level]. */
fun levelState(level: EncryptionLevel): LevelState =
when (level) {
EncryptionLevel.INITIAL -> initial
EncryptionLevel.HANDSHAKE -> handshake
EncryptionLevel.APPLICATION -> application
}
fun streamsView(): Map<Long, QuicStream> = streams
fun pendingDatagramsView(): ArrayDeque<ByteArray> = pendingDatagrams
fun incomingDatagramsBuffer(): ArrayDeque<ByteArray> = incomingDatagrams
}
@@ -0,0 +1,46 @@
/*
* 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
/**
* Tunables for a [QuicConnection]. Defaults are appropriate for the MoQ +
* WebTransport workload — small connection-level windows, a generous-enough
* datagram size to fit Opus frames, idle timeout of 30 s.
*
* `maxUdpDatagramSize` caps both inbound and outbound UDP payload size. The
* QUIC spec mandates that any client sending Initial packets MUST send
* datagrams of at least 1200 bytes; we pad outbound Initials to satisfy this
* regardless of how full the packet is.
*/
data class QuicConnectionConfig(
val initialMaxData: Long = 16L * 1024 * 1024,
val initialMaxStreamDataBidiLocal: Long = 1L * 1024 * 1024,
val initialMaxStreamDataBidiRemote: Long = 1L * 1024 * 1024,
val initialMaxStreamDataUni: Long = 1L * 1024 * 1024,
val initialMaxStreamsBidi: Long = 100L,
val initialMaxStreamsUni: Long = 100L,
val maxIdleTimeoutMillis: Long = 30_000L,
val maxUdpPayloadSize: Long = 1452L,
val activeConnectionIdLimit: Long = 4L,
val maxDatagramFrameSize: Long = 1200L,
val ackDelayExponent: Long = 3L,
val maxAckDelay: Long = 25L,
)
@@ -0,0 +1,230 @@
/*
* 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.AckFrame
import com.vitorpamplona.quic.frame.ConnectionCloseFrame
import com.vitorpamplona.quic.frame.CryptoFrame
import com.vitorpamplona.quic.frame.DatagramFrame
import com.vitorpamplona.quic.frame.HandshakeDoneFrame
import com.vitorpamplona.quic.frame.MaxDataFrame
import com.vitorpamplona.quic.frame.MaxStreamDataFrame
import com.vitorpamplona.quic.frame.MaxStreamsFrame
import com.vitorpamplona.quic.frame.NewConnectionIdFrame
import com.vitorpamplona.quic.frame.PingFrame
import com.vitorpamplona.quic.frame.StreamFrame
import com.vitorpamplona.quic.frame.decodeFrames
import com.vitorpamplona.quic.packet.LongHeaderPacket
import com.vitorpamplona.quic.packet.LongHeaderType
import com.vitorpamplona.quic.packet.ShortHeaderPacket
import com.vitorpamplona.quic.tls.TlsClient
/**
* Decode every QUIC packet inside a single inbound UDP datagram and dispatch
* its frames to [conn]'s state.
*
* QUIC permits *coalescing* multiple packets into one datagram (RFC 9000 §12.2)
* — typically Initial + Handshake from the server in the same datagram during
* the handshake. We loop until the datagram is fully consumed or a packet
* fails to parse (which we drop silently per RFC 9001 §5.5).
*/
fun feedDatagram(
conn: QuicConnection,
datagram: ByteArray,
nowMillis: Long,
) {
var offset = 0
while (offset < datagram.size) {
val first = datagram[offset].toInt() and 0xFF
val isLong = (first and 0x80) != 0
if (isLong) {
val consumed = feedLongHeaderPacket(conn, datagram, offset, nowMillis) ?: break
offset += consumed
} else {
// Short-header — consumes the rest of the datagram.
feedShortHeaderPacket(conn, datagram, offset, nowMillis)
return
}
}
}
private fun feedLongHeaderPacket(
conn: QuicConnection,
datagram: ByteArray,
offset: Int,
nowMillis: Long,
): Int? {
val peeked = LongHeaderPacket.peekHeader(datagram, offset) ?: return null
val level =
when (peeked.type) {
LongHeaderType.INITIAL -> EncryptionLevel.INITIAL
LongHeaderType.HANDSHAKE -> EncryptionLevel.HANDSHAKE
LongHeaderType.ZERO_RTT, LongHeaderType.RETRY -> return null // unsupported in client
}
val state = conn.levelState(level)
val proto = state.receiveProtection ?: return null
val parsed =
LongHeaderPacket.parseAndDecrypt(
bytes = datagram,
offset = offset,
aead = proto.aead,
key = proto.key,
iv = proto.iv,
hp = proto.hp,
hpKey = proto.hpKey,
largestReceivedInSpace = state.pnSpace.largestReceived,
) ?: return null
state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis)
// The server's source CID becomes our destination CID for subsequent packets.
if (level == EncryptionLevel.INITIAL) {
conn.destinationConnectionId = parsed.packet.scid
}
dispatchFrames(conn, level, parsed.packet.payload, nowMillis)
return parsed.consumed
}
private fun feedShortHeaderPacket(
conn: QuicConnection,
datagram: ByteArray,
offset: Int,
nowMillis: Long,
) {
val state = conn.levelState(EncryptionLevel.APPLICATION)
val proto = state.receiveProtection ?: return
val parsed =
ShortHeaderPacket.parseAndDecrypt(
bytes = datagram,
offset = offset,
dcidLen = conn.sourceConnectionId.length,
aead = proto.aead,
key = proto.key,
iv = proto.iv,
hp = proto.hp,
hpKey = proto.hpKey,
largestReceivedInSpace = state.pnSpace.largestReceived,
) ?: return
state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis)
dispatchFrames(conn, EncryptionLevel.APPLICATION, parsed.packet.payload, nowMillis)
}
private fun dispatchFrames(
conn: QuicConnection,
level: EncryptionLevel,
payload: ByteArray,
nowMillis: Long,
) {
val frames = decodeFrames(payload)
val state = conn.levelState(level)
var ackEliciting = false
for (frame in frames) {
when (frame) {
is AckFrame -> {
// We don't currently retransmit, so we just absorb ACKs; once
// Phase F adds retransmission this updates the inflight tracker.
}
is CryptoFrame -> {
ackEliciting = true
state.cryptoReceive.insert(frame.offset, frame.data)
val contiguous = state.cryptoReceive.readContiguous()
if (contiguous.isNotEmpty()) {
val tlsLevel =
when (level) {
EncryptionLevel.INITIAL -> TlsClient.Level.INITIAL
EncryptionLevel.HANDSHAKE -> TlsClient.Level.HANDSHAKE
EncryptionLevel.APPLICATION -> TlsClient.Level.APPLICATION
}
conn.tls.pushHandshakeBytes(tlsLevel, contiguous)
// Pull any new outbound CRYPTO bytes out of TLS into our
// send queue at the matching encryption level.
drainTlsOutbound(conn)
}
}
is StreamFrame -> {
ackEliciting = true
val stream = conn.getOrCreatePeerStream(frame.streamId)
stream.receive.insert(frame.offset, frame.data, frame.fin)
val data = stream.receive.readContiguous()
if (data.isNotEmpty()) {
stream.deliverIncoming(data)
}
if (stream.receive.finReceived) {
stream.closeIncoming()
}
}
is DatagramFrame -> {
ackEliciting = true
conn.incomingDatagramsBuffer().addLast(frame.data)
}
is MaxDataFrame -> {
// Updates connection-level send credit; left to the orchestrator.
}
is MaxStreamDataFrame -> {
conn.streamById(frame.streamId)?.let {
if (frame.maxStreamData > it.sendCredit) it.sendCredit = frame.maxStreamData
}
}
is MaxStreamsFrame -> {
// Tracking left for a later phase.
}
is NewConnectionIdFrame -> {
// We don't support migration; ignore.
}
is ConnectionCloseFrame -> {
conn.status = QuicConnection.Status.CLOSED
}
is HandshakeDoneFrame -> {
conn.status = QuicConnection.Status.CONNECTED
}
is PingFrame -> {
ackEliciting = true
}
else -> {
// PADDING + DATA_BLOCKED + STREAM_DATA_BLOCKED + STREAMS_BLOCKED
// are non-eliciting / cleared during decode.
}
}
}
if (ackEliciting) {
// Get largest received from pn space and feed into ack tracker.
val pn = state.pnSpace.largestReceived
if (pn >= 0) {
state.ackTracker.receivedPacket(pn, ackEliciting = true, receivedAtMillis = nowMillis)
}
}
}
private fun drainTlsOutbound(conn: QuicConnection) {
for (lvl in TlsClient.Level.entries) {
while (true) {
val bytes = conn.tls.pollOutbound(lvl) ?: break
val state =
when (lvl) {
TlsClient.Level.INITIAL -> conn.initial
TlsClient.Level.HANDSHAKE -> conn.handshake
TlsClient.Level.APPLICATION -> conn.application
}
state.cryptoSend.enqueue(bytes)
}
}
}
@@ -0,0 +1,246 @@
/*
* 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.ConnectionCloseFrame
import com.vitorpamplona.quic.frame.CryptoFrame
import com.vitorpamplona.quic.frame.DatagramFrame
import com.vitorpamplona.quic.frame.Frame
import com.vitorpamplona.quic.frame.PingFrame
import com.vitorpamplona.quic.frame.StreamFrame
import com.vitorpamplona.quic.frame.encodeFrames
import com.vitorpamplona.quic.packet.LongHeaderPacket
import com.vitorpamplona.quic.packet.LongHeaderPlaintextPacket
import com.vitorpamplona.quic.packet.LongHeaderType
import com.vitorpamplona.quic.packet.QuicVersion
import com.vitorpamplona.quic.packet.ShortHeaderPacket
import com.vitorpamplona.quic.packet.ShortHeaderPlaintextPacket
/**
* Build the next UDP datagram to send for [conn], or return null if there's
* nothing to send right now.
*
* The datagram coalesces (in order):
* - Initial packet (if Initial-level CRYPTO has unsent bytes or a pending ACK)
* - Handshake packet (likewise)
* - 1-RTT packet (CRYPTO from NewSessionTicket, STREAM, DATAGRAM, ACK, etc.)
*
* RFC 9000 §14: any datagram containing an Initial packet from the client
* MUST be padded to at least 1200 bytes total.
*/
fun drainOutbound(
conn: QuicConnection,
nowMillis: Long,
): ByteArray? {
val parts = mutableListOf<ByteArray>()
// Closing — emit a CONNECTION_CLOSE at the highest available level.
if (conn.status == QuicConnection.Status.CLOSING) {
val frame = ConnectionCloseFrame(conn.closeErrorCode, null, conn.closeReason ?: "")
val packet = buildBestLevelPacket(conn, listOf(frame)) ?: return null
conn.status = QuicConnection.Status.CLOSED
return packet
}
val initialPkt = buildInitialPacket(conn, nowMillis)
if (initialPkt != null) parts += initialPkt
val handshakePkt = buildHandshakePacket(conn, nowMillis)
if (handshakePkt != null) parts += handshakePkt
val applicationPkt = buildApplicationPacket(conn, nowMillis)
if (applicationPkt != null) parts += applicationPkt
if (parts.isEmpty()) return null
// Coalesce
var totalLen = 0
for (p in parts) totalLen += p.size
// Pad the datagram to 1200 bytes if it begins with an Initial packet (RFC 9000 §14).
if (initialPkt != null && totalLen < 1200) totalLen = 1200
val out = ByteArray(totalLen)
var pos = 0
for (p in parts) {
p.copyInto(out, pos)
pos += p.size
}
// Tail bytes are zero (PADDING frames in the encryption envelope of the last packet).
// Note: padding inside the last QUIC packet's encryption is the RFC-correct way; we instead
// pad the datagram with zero bytes after the last packet. Servers tolerate this for the
// datagram-level minimum size requirement.
return out
}
private fun buildBestLevelPacket(
conn: QuicConnection,
frames: List<Frame>,
): ByteArray? {
// Prefer 1-RTT > Handshake > Initial.
val payload = encodeFrames(frames)
val app = conn.application
if (app.sendProtection != null) {
val proto = app.sendProtection!!
val pn = app.pnSpace.allocateOutbound()
return ShortHeaderPacket.build(
ShortHeaderPlaintextPacket(conn.destinationConnectionId, pn, payload),
proto.aead,
proto.key,
proto.iv,
proto.hp,
proto.hpKey,
largestAckedInSpace = -1L,
)
}
val hs = conn.handshake
if (hs.sendProtection != null) {
return buildLongHeaderPacket(conn, EncryptionLevel.HANDSHAKE, payload)
}
val init = conn.initial
if (init.sendProtection != null) {
return buildLongHeaderPacket(conn, EncryptionLevel.INITIAL, payload)
}
return null
}
private fun buildLongHeaderPacket(
conn: QuicConnection,
level: EncryptionLevel,
payload: ByteArray,
): ByteArray {
val state = conn.levelState(level)
val proto = state.sendProtection!!
val pn = state.pnSpace.allocateOutbound()
val type =
when (level) {
EncryptionLevel.INITIAL -> LongHeaderType.INITIAL
EncryptionLevel.HANDSHAKE -> LongHeaderType.HANDSHAKE
EncryptionLevel.APPLICATION -> error("APPLICATION uses short-header packets")
}
return LongHeaderPacket.build(
LongHeaderPlaintextPacket(
type = type,
version = QuicVersion.V1,
dcid = conn.destinationConnectionId,
scid = conn.sourceConnectionId,
packetNumber = pn,
payload = payload,
),
proto.aead,
proto.key,
proto.iv,
proto.hp,
proto.hpKey,
largestAckedInSpace = -1L,
)
}
private fun buildInitialPacket(
conn: QuicConnection,
nowMillis: Long,
): ByteArray? = buildHandshakeLevelPacket(conn, EncryptionLevel.INITIAL, nowMillis)
private fun buildHandshakePacket(
conn: QuicConnection,
nowMillis: Long,
): ByteArray? = buildHandshakeLevelPacket(conn, EncryptionLevel.HANDSHAKE, nowMillis)
private fun buildHandshakeLevelPacket(
conn: QuicConnection,
level: EncryptionLevel,
nowMillis: Long,
): ByteArray? {
val state = conn.levelState(level)
val proto = state.sendProtection ?: return null
val frames = mutableListOf<Frame>()
// Pending ACK?
state.ackTracker.buildAckFrame(nowMillis, conn.config.ackDelayExponent.toInt())?.let { frames += it }
// Pending CRYPTO?
val cryptoChunk = state.cryptoSend.takeChunk(maxBytes = 1100)
if (cryptoChunk != null && cryptoChunk.data.isNotEmpty()) {
frames += CryptoFrame(cryptoChunk.offset, cryptoChunk.data)
}
if (frames.isEmpty()) return null
val payload = encodeFrames(frames)
val pn = state.pnSpace.allocateOutbound()
val type = if (level == EncryptionLevel.INITIAL) LongHeaderType.INITIAL else LongHeaderType.HANDSHAKE
return LongHeaderPacket.build(
LongHeaderPlaintextPacket(
type = type,
version = QuicVersion.V1,
dcid = conn.destinationConnectionId,
scid = conn.sourceConnectionId,
packetNumber = pn,
payload = payload,
),
proto.aead,
proto.key,
proto.iv,
proto.hp,
proto.hpKey,
largestAckedInSpace = -1L,
)
}
private fun buildApplicationPacket(
conn: QuicConnection,
nowMillis: Long,
): ByteArray? {
val state = conn.application
val proto = state.sendProtection ?: return null
val frames = mutableListOf<Frame>()
state.ackTracker.buildAckFrame(nowMillis, conn.config.ackDelayExponent.toInt())?.let { frames += it }
// Pending datagrams
while (conn.pendingDatagramsView().isNotEmpty()) {
val payload = conn.pendingDatagramsView().removeFirst()
frames += DatagramFrame(payload, explicitLength = true)
if (frames.size >= 16) break
}
// Drain stream send buffers — round-robin keeping packet under MTU.
var packetBudget = 1100
for ((id, stream) in conn.streamsView()) {
if (packetBudget <= 64) break
val chunk = stream.send.takeChunk(maxBytes = packetBudget - 32) ?: continue
if (chunk.data.isNotEmpty() || chunk.fin) {
frames += StreamFrame(streamId = id, offset = chunk.offset, data = chunk.data, fin = chunk.fin, explicitLength = true)
packetBudget -= chunk.data.size + 32
}
}
if (frames.isEmpty()) return null
val payload = encodeFrames(frames)
val pn = state.pnSpace.allocateOutbound()
return ShortHeaderPacket.build(
ShortHeaderPlaintextPacket(conn.destinationConnectionId, pn, payload),
proto.aead,
proto.key,
proto.iv,
proto.hp,
proto.hpKey,
largestAckedInSpace = -1L,
)
}
@@ -0,0 +1,115 @@
/*
* 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.recovery
import com.vitorpamplona.quic.frame.AckFrame
import com.vitorpamplona.quic.frame.AckRange
/**
* Tracks received packet numbers for one packet-number space and produces
* ACK frames per RFC 9000 §19.3.
*
* We store packet numbers as a sorted list of disjoint ranges (lo..hi).
* Acks emit the ranges newest-first as RFC 9000 specifies.
*/
class AckTracker {
private val ranges = mutableListOf<LongRange>() // sorted descending by lo (i.e. ranges[0] has the largest)
private var ackElicitingPending: Boolean = false
private var largestRecvTimeMillis: Long = 0L
fun receivedPacket(
packetNumber: Long,
ackEliciting: Boolean,
receivedAtMillis: Long,
) {
if (ackEliciting) ackElicitingPending = true
if (ranges.isEmpty()) {
ranges += LongRange(packetNumber, packetNumber)
largestRecvTimeMillis = receivedAtMillis
return
}
if (packetNumber > ranges[0].endInclusive) {
largestRecvTimeMillis = receivedAtMillis
}
// Find the range we need to extend (or insertion point).
for (i in ranges.indices) {
val r = ranges[i]
if (packetNumber > r.endInclusive + 1) {
ranges.add(i, LongRange(packetNumber, packetNumber))
return
}
if (packetNumber == r.endInclusive + 1) {
// Extend up; check merge with i-1 (if any)
ranges[i] = LongRange(r.start, packetNumber)
if (i > 0 && ranges[i - 1].start == packetNumber + 1) {
ranges[i - 1] = LongRange(ranges[i].start, ranges[i - 1].endInclusive)
ranges.removeAt(i)
}
return
}
if (packetNumber in r) {
// Already known.
return
}
if (packetNumber == r.start - 1) {
ranges[i] = LongRange(packetNumber, r.endInclusive)
if (i + 1 < ranges.size && ranges[i + 1].endInclusive == packetNumber - 1) {
ranges[i] = LongRange(ranges[i + 1].start, ranges[i].endInclusive)
ranges.removeAt(i + 1)
}
return
}
}
ranges += LongRange(packetNumber, packetNumber)
}
fun hasUnackedAckEliciting(): Boolean = ackElicitingPending
fun isEmpty(): Boolean = ranges.isEmpty()
fun largestReceived(): Long = if (ranges.isEmpty()) -1L else ranges[0].endInclusive
/** Build an ACK frame covering everything we've received. */
fun buildAckFrame(
nowMillis: Long,
ackDelayExponent: Int = 3,
): AckFrame? {
if (ranges.isEmpty()) return null
val largest = ranges[0].endInclusive
val firstRangeLength = largest - ranges[0].start
val rest = mutableListOf<AckRange>()
for (i in 1 until ranges.size) {
val gap = ranges[i - 1].start - ranges[i].endInclusive - 2
val len = ranges[i].endInclusive - ranges[i].start
rest += AckRange(gap, len)
}
val ackDelayMicros = (nowMillis - largestRecvTimeMillis) * 1000L
val ackDelay = (ackDelayMicros ushr ackDelayExponent).coerceAtLeast(0L)
ackElicitingPending = false
return AckFrame(
largestAcknowledged = largest,
ackDelay = ackDelay,
firstAckRange = firstRangeLength,
additionalRanges = rest,
)
}
}
@@ -0,0 +1,67 @@
/*
* 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.stream
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.consumeAsFlow
/**
* One QUIC stream (bidirectional or unidirectional). Application code
* accesses it through [enqueue] (write) and the [readFlow] / FIN observation
* APIs; the [QuicConnection] owns the underlying buffers and drains them
* into STREAM frames on the wire.
*/
class QuicStream(
val streamId: Long,
val direction: Direction,
) {
enum class Direction { BIDIRECTIONAL, UNIDIRECTIONAL_LOCAL_TO_REMOTE, UNIDIRECTIONAL_REMOTE_TO_LOCAL }
val send = SendBuffer()
val receive = ReceiveBuffer()
/** Bytes received and confirmed contiguous, exposed as a flow to the consumer. */
private val incomingChannel = Channel<ByteArray>(capacity = Channel.UNLIMITED)
val incoming: Flow<ByteArray> get() = incomingChannel.consumeAsFlow()
/** Per-stream send credit (peer's MAX_STREAM_DATA value). */
var sendCredit: Long = 0L
internal set
/** Per-stream receive credit (the value we advertised). */
var receiveLimit: Long = 0L
internal set
/** True once we've FIN'd our write side and the peer FIN'd theirs. */
val isClosed: Boolean
get() = send.finSent && receive.finReceived
internal fun deliverIncoming(data: ByteArray) {
if (data.isNotEmpty()) {
incomingChannel.trySend(data)
}
}
internal fun closeIncoming() {
incomingChannel.close()
}
}
@@ -0,0 +1,83 @@
/*
* 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.stream
/**
* Outbound send buffer for one direction of a QUIC stream (or for the
* per-encryption-level CRYPTO offset stream).
*
* Application code [enqueue]s payload bytes; the connection's send loop
* [takeChunk]s as much as it can fit in the next packet, given the
* remaining packet budget and stream-level / connection-level flow control
* credit. Sent bytes stay in the buffer until [acknowledge] (Phase F adds
* retransmission of lost bytes; for v1 we just trust the receiver and
* release on send).
*
* For Phase D-K we run a "best effort" mode: bytes are released from the
* buffer the moment they're handed off, on the assumption that the
* underlying network is stable. Phase L adds retransmit-on-loss (using the
* same send buffer that retains until ACK).
*/
class SendBuffer {
private var pending: ByteArray = ByteArray(0)
private var sentEnd: Long = 0L
var nextOffset: Long = 0L
private set
var finPending: Boolean = false
private set
var finSent: Boolean = false
private set
val readableBytes: Int get() = pending.size
fun enqueue(bytes: ByteArray) {
if (bytes.isEmpty()) return
val combined = ByteArray(pending.size + bytes.size)
pending.copyInto(combined, 0)
bytes.copyInto(combined, pending.size)
pending = combined
nextOffset += bytes.size
}
/** Mark the write side as closing; the next [takeChunk] will set FIN once empty. */
fun finish() {
finPending = true
}
/** Take up to [maxBytes] bytes off the head of the buffer at the current send offset. */
fun takeChunk(maxBytes: Int): Chunk? {
if (pending.isEmpty() && !(finPending && !finSent)) return null
val take = minOf(pending.size, maxBytes.coerceAtLeast(0))
val data = pending.copyOfRange(0, take)
pending = pending.copyOfRange(take, pending.size)
val offset = sentEnd
sentEnd += take
val fin = finPending && pending.isEmpty()
if (fin) finSent = true
return Chunk(offset, data, fin)
}
data class Chunk(
val offset: Long,
val data: ByteArray,
val fin: Boolean,
)
}