fix(quic): RFC 9114 §6.2.1 critical-stream auto-close + WiFi-handoff stateless-reset tokens

Two related improvements for the audio-rooms moq-lite path on mobile.

** RFC 9114 §6.2.1 + RFC 9204 §4.2 critical-stream closure **

When the peer closes a critical unidirectional stream — control (0x00),
QPACK encoder (0x02), or QPACK decoder (0x03) — the spec MANDATES we
treat it as a connection error of type H3_CLOSED_CRITICAL_STREAM.
Pre-fix the demux only set `peerH3ProtocolError` flag on protocol
violations, with no autonomous close on clean FIN; the application
was expected to poll. For audio rooms running on lossy mobile paths,
relay-side control-stream drops left users staring at silent audio.

  * `Http3ErrorCode` constants for the §8.1 error-code table.
  * `WtPeerStreamDemux.drainControlStream` calls `connection.close(...)`
    on either clean FIN (H3_CLOSED_CRITICAL_STREAM) or QuicCodecException
    raised by the frame reader (specific code via
    [http3ErrorCodeForMessage] map: H3_MISSING_SETTINGS,
    H3_FRAME_UNEXPECTED, H3_SETTINGS_ERROR, H3_ID_ERROR, etc.).
  * New `drainCriticalStream` helper fires the same close on QPACK
    encoder / decoder FIN (RFC 9204 §4.2).
  * Closure intent latched on `criticalStreamClosureCode` /
    `criticalStreamClosureReason` so tests can verify the path runs
    without wiring a real driver.

** RFC 9000 §10.3 stateless-reset tokens for migrated CIDs **

The 2026-05-09 stateless-reset detection covered tokens in the
unused-CID pool but lost them the moment a CID was rotated to active
via `tryStartValidation` (WiFi handoff) or `forceRotateToHigherSequence`
(peer-forced retire). On the new path the migrated token wouldn't
match — relay crash mid-handoff would silently hang until idle timeout.

  * `PathValidator.knownStatelessResetTokens` — append-only lifetime
    store, populated in `recordPeerNewConnectionId`. Survives rotation.
  * `QuicConnection.isStatelessReset` walks the lifetime store instead
    of the unused pool.
  * §10.3.1 explicitly permits keeping tokens after retirement; cost
    is ~16 bytes per peer-issued CID.

Tests:
  * `CriticalStreamClosureTest` (5 cases) — control FIN, QPACK FIN,
    MISSING_SETTINGS, FRAME_UNEXPECTED, idempotency.
  * `StatelessResetDetectionTest` extended with 2 cases —
    `token_persists_after_path_migration_for_wifi_handoff` and
    `token_persists_through_force_rotation_for_acid_reissue`.

Plan updated: 🟦 H3_CLOSED_CRITICAL_STREAM resolved; the
"limitation: tokens for actively-used DCIDs we migrated to" caveat is
removed from the §10.3 entry and from known-limitations item #5.

https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
This commit is contained in:
Claude
2026-05-09 13:12:00 +00:00
parent d4ffe0474f
commit cdb9d87a2e
7 changed files with 564 additions and 25 deletions
+10 -6
View File
@@ -257,10 +257,14 @@ not tackled:
validation per RFC 9000 §8.2 + §9. See `PathValidator.kt` and the
three test files (`PathValidatorTest`, `PathValidationTest`,
`ClientPathMigrationTest`).
5. **Stateless reset detection.** Stateless-reset tokens are now
*recorded* (per `PeerConnectionIdEntry`) but no inbound check
compares "looks-like-noise" datagrams against the token list. RFC
9000 §10.3.
5. ~~**Stateless reset detection.**~~ **Resolved 2026-05-09**
`QuicConnection.isStatelessReset` matches every short-header-form
datagram's trailing 16 bytes against the peer's transport-param
token AND every NEW_CONNECTION_ID token retained in
`PathValidator.allKnownStatelessResetTokens` (lifetime store —
covers the WiFi-handoff case where the migrated CID's token would
otherwise be lost the moment migration started). Constant-time per
§10.3.1; silent CLOSED transition on match.
6. ~~**`AckTracker.purgeBelow` threshold semantics.**~~ **Resolved
2026-05-05.**
7. **Driver direct unit tests** require turning `UdpSocket` from `expect
@@ -292,7 +296,7 @@ file:line evidence. Severity:
| RFC 9000 §4.1 | ~~🟡~~ | ~~**Connection-level inbound flow control missing.**~~ **Resolved 2026-05-09** — `QuicStream.receiveHighestOffset` + `QuicConnection.connectionInboundOffsetSum` track the §4.1 spec quantity (sum of largest-received-offset across streams). Parser closes with FLOW_CONTROL_ERROR when the sum exceeds `advertisedMaxData`. RESET_STREAM finalSize counted toward the limit per §4.5. Tests in `ConnectionLevelFlowControlTest`. |
| RFC 9000 §3 | ~~🟡~~ | ~~**Stream state machine implicit, not validated.**~~ **Resolved 2026-05-09** — `QuicStream.peerResetReceived` latches when a RESET_STREAM arrives; the parser closes with STREAM_STATE_ERROR on any subsequent STREAM frame for the same id. (Other state transitions — FIN-size mismatch, illegal peer-initiated opens — were already caught.) Tests in `StreamAfterResetTest`. |
| RFC 9000 §10.2 | ~~🟡~~ | ~~**No DRAINING state.**~~ **Resolved 2026-05-09** — `Status.DRAINING` added; parser routes peer's CONNECTION_CLOSE through `enterDraining` (sets status + 3 × PTO deadline) instead of immediate CLOSED. Driver folds the deadline into its send-loop sleep and flips to CLOSED on expiry. Late inbound during DRAINING is silently dropped at the top of `feedDatagramInner`. Tests in `DrainingStateTest`. |
| RFC 9000 §10.3 | ~~🟡~~ | ~~**Stateless reset detection missing.**~~ **Resolved 2026-05-09** — `QuicConnection.isStatelessReset` checks the trailing 16 bytes of every short-header-form datagram against the peer's `statelessResetToken` AND every unused entry in `pathValidator`'s pool, in constant time per §10.3.1. On match, silently transitions to CLOSED. Tests in `StatelessResetDetectionTest`. |
| RFC 9000 §10.3 | ~~🟡~~ | ~~**Stateless reset detection missing.**~~ **Resolved 2026-05-09** — `QuicConnection.isStatelessReset` checks the trailing 16 bytes of every short-header-form datagram against the peer's `statelessResetToken` AND every NEW_CONNECTION_ID token in `PathValidator.allKnownStatelessResetTokens` (lifetime store — extends past CID rotation / retirement so WiFi↔cellular handoffs still detect reset on the new path), in constant time per §10.3.1. On match, silently transitions to CLOSED. Tests in `StatelessResetDetectionTest` (7 cases including handoff + force-rotation). |
| RFC 9001 §6.6 | ~~🟡~~ | ~~**AEAD invocation limit not tracked.**~~ **Resolved 2026-05-09** — `Aead.confidentialityLimit` / `integrityLimit` properties surface the §B.1 values; `QuicConnection.aeadEncryptCount` / `aeadDecryptFailureCount` track per-key usage and reset on rotation. Writer triggers a key update at half the confidentiality limit and closes with AEAD_LIMIT_REACHED if rotation can't complete; parser closes if decrypt failures hit the integrity limit. Tests in `AeadInvocationLimitTest`. |
### Transport-parameter bounds
@@ -310,7 +314,7 @@ file:line evidence. Severity:
|---|---|---|---|
| RFC 9114 §7.2.4.1 | ~~🟡~~ | ~~**HTTP/2 reserved SETTINGS ids 0x02/0x03/0x04/0x05 not rejected.**~~ **Resolved 2026-05-09** — `Http3Settings.decodeBody` raises H3_SETTINGS_ERROR for ids 0x02..0x05. Tests in `Http3ReservedSettingsTest`. |
| RFC 9221 §3 | ~~🟡~~ | ~~**Outbound DATAGRAM size not gated.**~~ **Resolved 2026-05-09** — writer drops outbound DATAGRAM frames when peer didn't advertise `max_datagram_frame_size` or when the encoded frame would exceed the advertised value. Diagnostic via `qlogObserver.onPacketDropped`. |
| RFC 9114 §6.2.1 | 🟦 | **Closing the control stream surfaces a flag rather than auto-closing.** Spec says closing the control stream is H3_CLOSED_CRITICAL_STREAM. Caller must poll `peerH3ProtocolError` and close. | `Http3FrameReader.kt`; `WtPeerStreamDemux.kt` |
| RFC 9114 §6.2.1 + RFC 9204 §4.2 | ~~🟦~~ | ~~**Closing the control stream surfaces a flag rather than auto-closing.**~~ **Resolved 2026-05-09** — `WtPeerStreamDemux` now drives `connection.close(errorCode, reason)` itself when ANY critical unidirectional stream closes (control + QPACK encoder + QPACK decoder), whether by clean FIN (H3_CLOSED_CRITICAL_STREAM) or HTTP/3 protocol violation (mapped to the specific §8.1 code where possible). New `Http3ErrorCode` constants. Tests in `CriticalStreamClosureTest` (5 cases) cover FIN-on-control, FIN-on-QPACK, MISSING_SETTINGS, FRAME_UNEXPECTED, and idempotency. Audio rooms now get an immediate disconnect event when the relay drops the control stream mid-session. |
### TLS / error reporting cosmetics
@@ -289,6 +289,48 @@ class PathValidator(
*/
fun unusedTokenForSequence(sequenceNumber: Long): ByteArray? = unusedCids[sequenceNumber]?.statelessResetToken
/**
* RFC 9000 §10.3 stateless-reset support, audio-rooms WiFi-handoff
* extension. Every stateless-reset token the peer has ever issued
* us via NEW_CONNECTION_ID — whether the corresponding CID is
* still in [unusedCids], currently active (rotated to via
* [tryStartValidation] / [forceRotateToHigherSequence]), or
* RETIRE_CONNECTION_ID'd.
*
* Why retain after rotation/retire: when the user roams between
* WiFi and cellular, we migrate the connection to a fresh DCID
* via [tryStartValidation]. If the peer (relay) loses connection
* state during the handoff (crash, restart, NAT rebinding), it
* sends a stateless reset using whichever of OUR-issued source
* CIDs it last had cached — which corresponds to whichever
* stateless-reset token it remembers issuing for that CID. We
* MUST be able to match the reset against the right token. If we
* only kept tokens for CIDs in the unused pool (the pre-fix
* shape), the rotated-to active CID's token would be lost the
* moment migration started — a stateless reset on the new path
* would look like noise and we'd hang until idle timeout.
*
* §10.3.1 explicitly allows endpoints to keep tokens after
* retirement: "An endpoint MAY drop a Stateless Reset Token
* after retiring the corresponding connection ID; this saves
* storage at the cost of being more vulnerable to spoofing if
* the same token is reused by an attacker." For audio-rooms the
* extra ~16 bytes per peer-issued CID is trivial.
*
* Returned in insertion order (oldest first); caller treats it
* as an unordered set for matching.
*/
fun allKnownStatelessResetTokens(): List<ByteArray> = knownStatelessResetTokens
/**
* Lifetime store of every stateless-reset token the peer has
* ever advertised via NEW_CONNECTION_ID. Append-only — entries
* are NOT removed when the corresponding CID is rotated out of
* [unusedCids] or RETIRE_CONNECTION_ID'd. See
* [allKnownStatelessResetTokens] for the rationale.
*/
private val knownStatelessResetTokens: ArrayList<ByteArray> = ArrayList()
/**
* Record a peer-issued `NEW_CONNECTION_ID` (RFC 9000 §19.15).
* Returns the result code so the caller (parser) can decide
@@ -375,12 +417,19 @@ class PathValidator(
if (unusedCids.size >= maxUnusedCids) return RecordResult.PoolFull
val tokenCopy = statelessResetToken.copyOf()
unusedCids[sequenceNumber] =
PeerConnectionIdEntry(
sequenceNumber = sequenceNumber,
connectionId = connectionId.copyOf(),
statelessResetToken = statelessResetToken.copyOf(),
statelessResetToken = tokenCopy,
)
// Append to the lifetime store so the token is still
// matchable after a future [tryStartValidation] /
// [forceRotateToHigherSequence] removes the entry from
// [unusedCids]. See [allKnownStatelessResetTokens] for
// rationale (WiFi-handoff stateless-reset detection).
knownStatelessResetTokens.add(tokenCopy)
return RecordResult.Stored
}
@@ -2589,16 +2589,15 @@ class QuicConnection(
*
* Token sources covered:
* - The peer's `stateless_reset_token` transport parameter (the
* sequence-0 / initial CID's token), iff this is a
* server-issued connection.
* - Each unused entry in [pathValidator]'s pool (peer issued
* via NEW_CONNECTION_ID).
*
* Not covered (limitation of the current path-migration plumbing):
* tokens for an actively-used DCID that we migrated to via
* [PathValidator.tryStartValidation] — we lose the token when the
* entry leaves the pool. The vast majority of clients never
* migrate; this gap is acceptable for the audio-rooms scope.
* sequence-0 / initial CID's token).
* - Every NEW_CONNECTION_ID token the peer has ever sent
* ([PathValidator.allKnownStatelessResetTokens]) — including
* tokens for CIDs that have since been rotated-to-active or
* RETIRE_CONNECTION_ID'd. The lifetime retention covers the
* WiFi↔cellular handoff path: when we migrate to a new DCID
* and the peer crashes mid-handoff, the stateless reset on
* the new path uses the migrated CID's token, which is no
* longer in the unused pool but IS in the lifetime store.
*/
internal fun isStatelessReset(datagram: ByteArray): Boolean {
if (datagram.size < 22) return false
@@ -2614,10 +2613,8 @@ class QuicConnection(
anyMatch = true
}
}
for (entry in pathValidator.unusedSequences()) {
// Hot-path access lives on the pool — fetch the token via
// the same code path the rest of [PathValidator] uses.
val token = pathValidator.unusedTokenForSequence(entry) ?: continue
for (token in pathValidator.allKnownStatelessResetTokens()) {
if (token.size != 16) continue
if (constantTimeEqualsRange(token, datagram, tokenStart)) {
anyMatch = true
}
@@ -60,3 +60,42 @@ object Http3SettingsId {
/** WebTransport over HTTP/3 (draft / RFC 9220). */
const val ENABLE_WEBTRANSPORT: Long = 0xc671706a
}
/**
* HTTP/3 error code identifiers per RFC 9114 §8.1. These are the values
* emitted in CONNECTION_CLOSE frames at the application-error level
* (frame type 0x1d) when an HTTP/3 invariant is violated. Quoted values
* are the spec defaults; we don't define the full table — only the
* codes the demux / frame reader actually need.
*/
object Http3ErrorCode {
/** §8.1: graceful close, no error. */
const val NO_ERROR: Long = 0x100
/** §8.1: peer violated a generic HTTP/3 invariant we can't classify more precisely. */
const val GENERAL_PROTOCOL_ERROR: Long = 0x101
/** §8.1: an internal-to-our-implementation error. */
const val INTERNAL_ERROR: Long = 0x102
/**
* RFC 9114 §6.2.1 / RFC 9204 §4.2: a critical unidirectional stream
* (control, QPACK encoder, QPACK decoder) was closed by the peer.
*/
const val CLOSED_CRITICAL_STREAM: Long = 0x104
/** §8.1: a frame appeared in a context that does not permit it. */
const val FRAME_UNEXPECTED: Long = 0x105
/** §8.1: frame layout / length / payload was invalid. */
const val FRAME_ERROR: Long = 0x106
/** §5.2: a peer's stream id violated its own GOAWAY commitment. */
const val ID_ERROR: Long = 0x108
/** §7.2.4.1: a SETTINGS frame contained an error. */
const val SETTINGS_ERROR: Long = 0x109
/** §7.2.4.1: the first frame on the control stream was not SETTINGS. */
const val MISSING_SETTINGS: Long = 0x10a
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.quic.webtransport
import com.vitorpamplona.quic.Varint
import com.vitorpamplona.quic.http3.Http3ErrorCode
import com.vitorpamplona.quic.http3.Http3Frame
import com.vitorpamplona.quic.http3.Http3FrameReader
import com.vitorpamplona.quic.http3.Http3Settings
@@ -126,6 +127,27 @@ class WtPeerStreamDemux(
var peerGoawayProtocolError: String? = null
private set
/**
* RFC 9114 §6.2.1 + RFC 9204 §4.2 critical-stream closure record.
* Latches the (errorCode, reason) pair the demux passed to its
* driver-side `connection.close` when ANY critical unidirectional
* stream — control, QPACK encoder, QPACK decoder — was closed by
* the peer (clean FIN) or violated an HTTP/3 invariant.
*
* Production callers don't need to observe this; the
* `connection.close` already drives the QUIC layer to CLOSED.
* Tests use it to assert the closure path fired without wiring a
* real driver: the flag is set inside [closeConnection] before
* (and independently of) the suspending `connection.close` call.
*/
@Volatile
var criticalStreamClosureCode: Long? = null
private set
@Volatile
var criticalStreamClosureReason: String? = null
private set
/**
* Surface RFC 9114 §7.2 frame-validation violations the
* [Http3FrameReader] catches (H3_FRAME_UNEXPECTED, H3_MISSING_SETTINGS,
@@ -214,7 +236,19 @@ class WtPeerStreamDemux(
}
Http3StreamType.QPACK_ENCODER, Http3StreamType.QPACK_DECODER -> {
drainBlackHole(chunkChannel)
// RFC 9204 §4.2: QPACK encoder + decoder
// streams are critical. Closure of either
// MUST be treated as H3_CLOSED_CRITICAL_STREAM
// — same wire-spec class as the control
// stream. We don't speak the QPACK
// dynamic-table instructions either way
// (encoder is RIC=0, decoder is best-effort
// ACKs we ignore), so the drain is still
// black-hole; only the FIN handling differs.
drainCriticalStream(
chunkChannel,
"QPACK ${if (streamType == Http3StreamType.QPACK_ENCODER) "encoder" else "decoder"} stream",
)
}
Http3StreamType.WEBTRANSPORT_UNI_STREAM -> {
@@ -271,19 +305,105 @@ class WtPeerStreamDemux(
reader.push(chunk)
consumeFrames(reader)
}
// Channel iteration exited cleanly → peer FIN'd the
// control stream. RFC 9114 §6.2.1: closure of any
// critical stream MUST be treated as a connection error
// of type H3_CLOSED_CRITICAL_STREAM.
closeConnection(
Http3ErrorCode.CLOSED_CRITICAL_STREAM,
"H3_CLOSED_CRITICAL_STREAM: peer closed CONTROL stream",
)
} catch (e: com.vitorpamplona.quic.QuicCodecException) {
// RFC 9114 §7.2 frame-validation throws (H3_FRAME_UNEXPECTED /
// H3_MISSING_SETTINGS / reserved type) land here. Record the
// diagnostic so the QUIC layer / application can close the
// connection deliberately instead of having the route() catch
// silently swallow the message. Idempotent on duplicate hits.
// diagnostic so observability tools (qlog, tests) see a
// structured message; ALSO drive the connection closed via
// [closeConnection] so the application doesn't need to poll
// [peerH3ProtocolError] separately. Idempotent on duplicate
// hits.
if (peerH3ProtocolError == null) {
peerH3ProtocolError = e.message ?: "HTTP/3 protocol violation on CONTROL stream"
}
closeConnection(
http3ErrorCodeForMessage(e.message),
e.message ?: "HTTP/3 protocol violation on CONTROL stream",
)
throw e
}
}
/**
* Drain a critical unidirectional stream (QPACK encoder / decoder)
* whose payload we don't otherwise process. The QPACK control
* messages would only matter if we ran a dynamic table — since we
* don't, the bytes themselves are dropped, but the §6.2.1
* "critical stream closed = connection error" obligation still
* applies. On peer FIN we call [closeConnection] with
* H3_CLOSED_CRITICAL_STREAM.
*/
private suspend fun drainCriticalStream(
chunkChannel: Channel<ByteArray>,
label: String,
) {
@Suppress("UNUSED_VARIABLE")
for (discarded in chunkChannel) {
// intentionally discarded — QPACK dynamic-table is off.
}
closeConnection(
Http3ErrorCode.CLOSED_CRITICAL_STREAM,
"H3_CLOSED_CRITICAL_STREAM: peer closed $label",
)
}
/**
* Close the underlying QUIC connection with an HTTP/3
* application-level error code. No-op when the demux is running
* without a [driver] (test path) — the test is then expected to
* read [peerH3ProtocolError] / observe the FIN explicitly.
*
* Launched on the demux's [scope] so the suspending close call
* doesn't block the per-stream collector that's exiting.
*/
private fun closeConnection(
errorCode: Long,
reason: String,
) {
// Latch the closure intent FIRST so tests (and qlog observers)
// can see what we tried to do, regardless of whether a
// driver/connection is wired to follow through. Idempotent: a
// duplicate fire (e.g. control stream errors then closes)
// doesn't overwrite the first reason.
if (criticalStreamClosureCode == null) {
criticalStreamClosureCode = errorCode
criticalStreamClosureReason = reason
}
val conn = driver?.connection ?: return
scope.launch {
conn.close(errorCode, reason)
driver.wakeup()
}
}
/**
* Best-effort mapping from a [QuicCodecException] message raised by
* the HTTP/3 frame reader / SETTINGS validator to the matching
* RFC 9114 §8.1 error code. We attach the code to the
* application-level CONNECTION_CLOSE so downstream observers
* (qlog, peer) get the precise spec category instead of a generic
* GENERAL_PROTOCOL_ERROR.
*/
private fun http3ErrorCodeForMessage(message: String?): Long {
if (message == null) return Http3ErrorCode.GENERAL_PROTOCOL_ERROR
return when {
message.contains("H3_FRAME_UNEXPECTED") -> Http3ErrorCode.FRAME_UNEXPECTED
message.contains("H3_MISSING_SETTINGS") -> Http3ErrorCode.MISSING_SETTINGS
message.contains("H3_SETTINGS_ERROR") -> Http3ErrorCode.SETTINGS_ERROR
message.contains("H3_ID_ERROR") -> Http3ErrorCode.ID_ERROR
message.contains("H3_FRAME_ERROR") -> Http3ErrorCode.FRAME_ERROR
else -> Http3ErrorCode.GENERAL_PROTOCOL_ERROR
}
}
private fun consumeFrames(reader: Http3FrameReader) {
while (true) {
val frame = reader.next() ?: return
@@ -133,6 +133,87 @@ class StatelessResetDetectionTest {
assertFalse(client.isStatelessReset(datagram), "long-header-form must not be classified as stateless reset")
}
@Test
fun token_persists_after_path_migration_for_wifi_handoff() {
// RFC 9000 §10.3 + audio-rooms WiFi-handoff path. The peer
// issues us a fresh CID via NEW_CONNECTION_ID. We migrate to
// it via tryStartValidation (mimics the WiFi→cellular
// handoff: client moves to a new path, requests a fresh DCID
// for unlinkability). The peer (relay) then loses connection
// state mid-handoff and emits a stateless reset using the
// token it issued for the migrated CID. We MUST detect the
// reset even though the CID has left the unused pool.
val (client, _) = newConnectedClient()
val handoffToken = ByteArray(16) { (0x88 + it).toByte() }
val newCidBytes = ByteArray(8) { (0xCC + it).toByte() }
// Step 1: peer offers a NEW_CONNECTION_ID. Token lands in
// the unused pool AND in the lifetime store.
val recordResult =
client.pathValidator.recordPeerNewConnectionId(
sequenceNumber = 1L,
retirePriorTo = 0L,
connectionId = newCidBytes,
statelessResetToken = handoffToken,
)
assertEquals(PathValidator.RecordResult.Stored, recordResult)
assertEquals(1, client.pathValidator.unusedCount())
// Step 2: client triggers a path migration (the WiFi
// handoff). The CID leaves the unused pool and becomes
// active. Pre-fix this would have lost the token.
val migration = client.pathValidator.tryStartValidation(nowMillis = 0L, currentPtoMillis = 100L)
assertEquals(PathMigrationResult.Started, migration)
assertEquals(0, client.pathValidator.unusedCount(), "unused pool drained by migration")
// Step 3: a stateless-reset datagram arrives carrying the
// handoff token in its trailing 16 bytes. The lifetime store
// still has it, so the match succeeds.
val dcid = client.sourceConnectionId.bytes
val datagram = byteArrayOf(0x40.toByte()) + dcid + ByteArray(40) + handoffToken
assertTrue(
client.isStatelessReset(datagram),
"post-migration token MUST still match for WiFi-handoff stateless-reset detection",
)
}
@Test
fun token_persists_through_force_rotation_for_acid_reissue() {
// RFC 9000 §5.1.2: when the peer raises retire_prior_to past
// our active CID, we force-rotate to a fresh entry. The
// displaced active CID's token must still be matchable in
// case the peer (or an adversary spoofing the peer)
// stateless-resets us on the prior path during the brief
// window before our retirement settles.
val (client, _) = newConnectedClient()
// Issue two CIDs (seq 1 and 2). retire_prior_to=2 will
// force a rotation to seq 2 and queue retirement for seq 1.
val tokenA = ByteArray(16) { (0x11 + it).toByte() }
val tokenB = ByteArray(16) { (0x22 + it).toByte() }
client.pathValidator.recordPeerNewConnectionId(
sequenceNumber = 1L,
retirePriorTo = 0L,
connectionId = ByteArray(8) { 0xAA.toByte() },
statelessResetToken = tokenA,
)
client.pathValidator.recordPeerNewConnectionId(
sequenceNumber = 2L,
retirePriorTo = 2L, // peer demands we retire CIDs < 2 (i.e. seq 0 + 1)
connectionId = ByteArray(8) { 0xBB.toByte() },
statelessResetToken = tokenB,
)
// Force-rotate (peer's watermark = 2 > activeCidSequence = 0).
val rotation = client.pathValidator.forceRotateToHigherSequence()
assertNotNull(rotation)
assertEquals(0, client.pathValidator.unusedCount(), "force-rotation drains the pool")
// Both tokens must still match — neither is in the unused
// pool any more, but both live on in the lifetime store.
val dcid = client.sourceConnectionId.bytes
val payload = ByteArray(40)
assertTrue(client.isStatelessReset(byteArrayOf(0x40.toByte()) + dcid + payload + tokenA))
assertTrue(client.isStatelessReset(byteArrayOf(0x40.toByte()) + dcid + payload + tokenB))
}
@Test
fun isStatelessReset_rejects_too_short_datagram() {
val (client, _) = newConnectedClient()
@@ -0,0 +1,249 @@
/*
* 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.webtransport
import com.vitorpamplona.quic.QuicWriter
import com.vitorpamplona.quic.http3.Http3ErrorCode
import com.vitorpamplona.quic.http3.Http3FrameType
import com.vitorpamplona.quic.http3.Http3Settings
import com.vitorpamplona.quic.http3.Http3SettingsId
import com.vitorpamplona.quic.http3.Http3StreamType
import com.vitorpamplona.quic.stream.QuicStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
/**
* RFC 9114 §6.2.1 + RFC 9204 §4.2 critical-stream closure.
*
* "If either control stream is closed at any point, this MUST be
* treated as a connection error of type H3_CLOSED_CRITICAL_STREAM."
*
* RFC 9204 §4.2 extends this to the QPACK encoder + decoder streams.
*
* Pre-fix the demux recorded a `peerH3ProtocolError` flag on
* frame-validation throws but did NOT auto-close the connection on
* peer-side stream FIN at all — the application was expected to poll
* the flag and call close(). For WiFi↔cellular handoff and other
* lossy paths where the relay can drop the control stream
* mid-session, the audio user saw stalled output with no error event.
*
* The fix: the demux now drives `connection.close(errorCode, reason)`
* itself when any critical stream closes (clean FIN OR protocol
* violation). The closure intent is latched on
* `criticalStreamClosureCode` / `criticalStreamClosureReason` so tests
* can verify the path runs without wiring a real driver.
*/
class CriticalStreamClosureTest {
private fun controlStreamPrefix(): ByteArray = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray()
private fun qpackEncoderPrefix(): ByteArray = QuicWriter().also { it.writeVarint(Http3StreamType.QPACK_ENCODER) }.toByteArray()
private fun validSettingsFrame(): ByteArray =
Http3Settings(
mapOf(
Http3SettingsId.ENABLE_CONNECT_PROTOCOL to 1L,
Http3SettingsId.ENABLE_WEBTRANSPORT to 1L,
),
).encodeFrame()
@Test
fun control_stream_FIN_triggers_h3_closed_critical_stream() {
runBlocking {
val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL)
val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope)
demux.process(stream)
// Push a valid SETTINGS so the demux is happy with the
// stream contents, then FIN it cleanly.
stream.deliverIncoming(controlStreamPrefix())
stream.deliverIncoming(validSettingsFrame())
stream.closeIncoming()
val ok =
withTimeoutOrNull(2_000L) {
while (demux.criticalStreamClosureCode == null) delay(5)
true
}
assertEquals(true, ok, "demux should latch H3_CLOSED_CRITICAL_STREAM within timeout")
assertEquals(Http3ErrorCode.CLOSED_CRITICAL_STREAM, demux.criticalStreamClosureCode)
val reason = demux.criticalStreamClosureReason
assertNotNull(reason)
assertEquals(true, reason.contains("CONTROL"), "reason should mention CONTROL stream: $reason")
demuxScope.cancel()
}
}
@Test
fun qpack_encoder_FIN_triggers_h3_closed_critical_stream() {
runBlocking {
val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL)
val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope)
demux.process(stream)
// QPACK encoder stream — we drain its bytes (we don't run a
// dynamic table) but its FIN is still a critical-stream
// closure per RFC 9204 §4.2.
stream.deliverIncoming(qpackEncoderPrefix())
stream.deliverIncoming(byteArrayOf(0x00, 0x00, 0x00)) // junk QPACK we ignore
stream.closeIncoming()
val ok =
withTimeoutOrNull(2_000L) {
while (demux.criticalStreamClosureCode == null) delay(5)
true
}
assertEquals(true, ok, "demux should latch H3_CLOSED_CRITICAL_STREAM for QPACK encoder")
assertEquals(Http3ErrorCode.CLOSED_CRITICAL_STREAM, demux.criticalStreamClosureCode)
val reason = demux.criticalStreamClosureReason
assertNotNull(reason)
assertEquals(true, reason.contains("QPACK encoder"), "reason should mention QPACK encoder: $reason")
demuxScope.cancel()
}
}
@Test
fun control_stream_settings_violation_triggers_close_with_specific_code() {
// Control stream's first frame is REQUIRED to be SETTINGS per
// RFC 9114 §7.2.4.1. A peer that opens with a different frame
// is H3_MISSING_SETTINGS — the demux's frame reader throws,
// and our auto-close should propagate the specific code (not
// just the generic H3_GENERAL_PROTOCOL_ERROR).
runBlocking {
val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL)
val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope)
demux.process(stream)
// First (and only) frame on the control stream is GOAWAY
// — illegal: SETTINGS must be first. Reader should throw
// H3_MISSING_SETTINGS.
val goawayBody = QuicWriter().also { it.writeVarint(0L) }.toByteArray()
val goaway =
QuicWriter()
.apply {
writeVarint(Http3FrameType.GOAWAY)
writeVarint(goawayBody.size.toLong())
writeBytes(goawayBody)
}.toByteArray()
stream.deliverIncoming(controlStreamPrefix())
stream.deliverIncoming(goaway)
stream.closeIncoming()
val ok =
withTimeoutOrNull(2_000L) {
while (demux.criticalStreamClosureCode == null) delay(5)
true
}
assertEquals(true, ok)
assertEquals(Http3ErrorCode.MISSING_SETTINGS, demux.criticalStreamClosureCode)
assertNotNull(demux.peerH3ProtocolError)
demuxScope.cancel()
}
}
@Test
fun control_stream_unexpected_data_frame_maps_to_frame_unexpected() {
runBlocking {
val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL)
val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope)
demux.process(stream)
// Valid SETTINGS first, then an illegal DATA frame. RFC
// 9114 §7.2.1: DATA on the control stream is
// H3_FRAME_UNEXPECTED.
val data =
QuicWriter()
.apply {
writeVarint(Http3FrameType.DATA)
writeVarint(4L)
writeBytes(byteArrayOf(1, 2, 3, 4))
}.toByteArray()
stream.deliverIncoming(controlStreamPrefix())
stream.deliverIncoming(validSettingsFrame())
stream.deliverIncoming(data)
stream.closeIncoming()
val ok =
withTimeoutOrNull(2_000L) {
while (demux.criticalStreamClosureCode == null) delay(5)
true
}
assertEquals(true, ok)
assertEquals(Http3ErrorCode.FRAME_UNEXPECTED, demux.criticalStreamClosureCode)
demuxScope.cancel()
}
}
@Test
fun closure_code_is_idempotent_first_call_wins() {
runBlocking {
val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL)
val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope)
demux.process(stream)
// Inject a protocol violation (sets the code) then FIN
// (would set CLOSED_CRITICAL_STREAM if we didn't latch).
// The latch keeps the violation code, not the FIN code.
val data =
QuicWriter()
.apply {
writeVarint(Http3FrameType.DATA)
writeVarint(0L)
}.toByteArray()
stream.deliverIncoming(controlStreamPrefix())
stream.deliverIncoming(validSettingsFrame())
stream.deliverIncoming(data)
stream.closeIncoming()
val ok =
withTimeoutOrNull(2_000L) {
while (demux.criticalStreamClosureCode == null) delay(5)
true
}
assertEquals(true, ok)
// Either code is acceptable depending on timing; both
// routes go through closeConnection(). The point is
// [criticalStreamClosureCode] doesn't FLIP back and forth
// — first writer wins.
val firstCode = demux.criticalStreamClosureCode
delay(50) // give the FIN-path coroutine time to also try
assertEquals(firstCode, demux.criticalStreamClosureCode, "closure code should not flip after first observation")
demuxScope.cancel()
}
}
}