Adds Fix#6 (L2 — SubscribeOk narrowing), Fix#7 (L3 —
subscriber-driven Probe API), and Fix#8 (L1 — version-aware
Lite-03/04 codec + ALPN negotiation) to the compliance audit
document. Updates the gap matrix (L1/L2/L3 → ✅) and the TL;DR
(now reads "nine shipped fixes + M6 closed; every gap is now
resolved, no items remain deferred").
The "what's deliberately deferred" section now lists only the
external follow-up — a `kixelated/moq` feature request
suggesting per-deployment tuning of moq-rs's per-subscriber
forward-queue starvation behavior. That's an upstream-relay
concern, not a moq-lite gap.
Updates the audio-rooms completion-plan pointer to reflect the
shipped count and the absence of remaining deferrals. Audit doc
title acknowledges Lite-04 alongside Lite-03 since the codec
now speaks both.
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
Lite-03 audit L1: completes the moq-lite version surface so the
session speaks the wire-format version the server picks via
WebTransport ALPN negotiation. Pre-fix the codec hardcoded Lite-03;
advertising `moq-lite-04` was a footgun because a Lite-04-preferring
relay would desync on the very first Announce exchange.
Verified diff between Lite-03 and Lite-04 by WebFetching kixelated's
Rust reference (`rs/moq-lite/src/lite/{announce,subscribe,probe}.rs`,
`rs/moq-lite/src/version.rs`, `rs/moq-lite/src/model/origin.rs`):
exactly three fields differ; everything else is byte-identical.
- `Announce.hops`: Lite-03 wire = single varint count (the spec
explicitly fills with `Origin::UNKNOWN` placeholders on decode);
Lite-04 wire = `varint(count) + count × varint(originId)` (the
`OriginList`). MAX_HOPS = 32.
- `AnnouncePlease.excludeHop`: Lite-03 absent; Lite-04 single
varint after `prefix`. Sentinel `0` = no exclusion.
- `Probe.rtt`: Lite-03 absent; Lite-04 single varint after
`bitrate`. Sentinel `0` = unknown (decoded as null). Outgoing
`Some(0)` is clamped to `Some(1)` to avoid colliding with the
sentinel — mirrors kixelated's `encode_msg` clamp.
New surface:
- `MoqLiteVersion` enum (LITE_03, LITE_04) with `fromAlpn` lookup.
- `MoqLiteCodec.{encode,decode}{AnnouncePlease,Announce,Probe}`
take `version: MoqLiteVersion = LITE_03` parameter, branch on
it. Default LITE_03 keeps existing call sites compiling.
- `MoqLiteSession.client(transport, scope, version)` carries the
version through every codec invocation.
- `MoqLiteAnnouncePlease.excludeHop: Long = 0L` (default).
- `MoqLiteAnnounce.hops: List<Long>` (was `Long`) — list of
origin IDs, bounded to MAX_HOPS=32. Existing call sites
migrate `hops = 0L` → `hops = emptyList()`, `hops = 7L` →
`hops = List(7) { 0L }`.
- `MoqLiteProbe.rtt: Long? = null` (default).
Negotiation:
- `WebTransportSession.negotiatedSubProtocol: String?` exposes
the server's `wt-protocol` selection. `QuicWebTransportFactory`
parses the response HEADERS, extracts the SF-string from
`wt-protocol`, and threads it into `QuicWebTransportSession`.
`FakeWebTransport.pair(negotiatedSubProtocol = …)` lets tests
drive the same path.
- Default advertised list now `[moq-lite-04, moq-lite-03]` (was
`[moq-lite-03]`). Lite-04 sits first to match kixelated's
preference; servers that don't support it fall back to
Lite-03 cleanly.
- `connectNestsListener` / `connectNestsSpeaker` resolve the
version via `resolveMoqLiteVersion(webTransport.negotiatedSubProtocol)`
and pass it to `MoqLiteSession.client(...)`. Falls back to
Lite-03 when the server doesn't echo `wt-protocol` (older
deployments) or echoes something unrecognised (forward-compat).
- New `MOQ_LITE_04_VERSION = 0x6D71_6C04L` synthetic version
code; `versionCode(version)` mapping function.
Regression tests (all green):
- `MoqLiteCodecTest.announcePlease_lite04_round_trips_excludeHop`
- `MoqLiteCodecTest.announcePlease_lite03_omits_excludeHop_on_wire`
- `MoqLiteCodecTest.announce_lite04_round_trips_full_origin_list`
- `MoqLiteCodecTest.announce_lite03_drops_origin_ids_keeps_count`
- `MoqLiteCodecTest.announce_decoder_rejects_oversize_hop_count`
(MAX_HOPS bounds check)
- `MoqLiteCodecTest.probe_lite04_round_trips_rtt`
- `MoqLiteCodecTest.probe_lite04_clamps_some_zero_to_one_to_avoid_unknown_sentinel`
- `MoqLiteCodecTest.probe_lite04_decodes_zero_rtt_as_null`
- `MoqLiteCodecTest.probe_lite03_wire_omits_rtt`
- `MoqLiteSessionTest.lite04_announce_round_trips_full_origin_list_through_session`
(end-to-end Lite-04 session)
Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (L1).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
Lite-03 audit L3: completes the subscriber-side Probe surface to
mirror kixelated's `Subscriber::run_probe_stream`
(`rs/moq-lite/src/lite/subscriber.rs`).
`MoqLiteSession.probe()` opens a bidi, writes
`ControlType::Probe` (varint 4), and returns a `MoqLiteProbeHandle`
whose `updates` flow yields each size-prefixed `MoqLiteProbe`
message the publisher pushes. The handle's `close()` FINs the
bidi and cancels the pump.
`updates` is a `MutableSharedFlow(replay=8)` so a collector that
attaches after the publisher's first emit doesn't miss it
(matches the same shape the announce-watch uses).
No production consumer for the API today — Amethyst's nests
listener doesn't run ABR on a fixed-rate Opus encoder. The API
exists to round out the moq-lite Lite-03 surface: a diagnostic
tool can now read a publisher's bitrate without subscribing to
its data track.
The publisher-side handler (existing) was already correct: it
writes one `MoqLiteProbe` (32 kbps Opus voice hint) and FINs.
Regression test:
`MoqLiteSessionTest.subscriber_probe_writes_control_type_and_decodes_publisher_bitrate`.
Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (L3).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
Lite-03 audit L2: when accepting a SUBSCRIBE, the publisher MAY
narrow the subscriber's `startGroup` / `endGroup` request bounds.
Pre-fix we always echoed `null/null`, which lost the diagnostic
"which group am I about to start sending?" information — particularly
useful for hot-swap continuations where the publisher's
[MoqLitePublisherHandle.nextSequence] is non-zero (the seeded
`startSequence` from the previous moq-lite session).
The fix narrows `startGroup` to `targetPublisher.nextSequence`. The
subscriber decodes this as "the next group on this subscription
will be sequence N" and can log / surface the join-point. `endGroup`
stays null because live audio rooms have no end in sight; the
subscriber's request bound is honoured implicitly when the publisher
closes.
Regression test:
`MoqLiteSessionTest.publisher_subscribeOk_narrows_startGroup_to_next_sequence`.
Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (L2).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
Adds Fix#5 (M2 + M3 — STOP_SENDING for single-group cancel +
RESET_STREAM with typed code on Drop replies) to the compliance
audit doc. The two items shipped together because the plumbing is
shared — extending `:quic`'s StrippedWtStream with reset/stopSending
closures, extending the WebTransport interfaces, and routing through
all adapters was the prerequisite for both.
Updates the gap matrix (M2 + M3 → ✅), the TL;DR (now reads "six
shipped fixes + M6 closed; no 🟡 items remain open"), and the
deferred-items list (only L1 / L2 / L3 remain, all 🟦 with explicit
rationale).
Updates the audio-rooms completion-plan pointer with the new
shipped count.
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
Lite-03 audit M2: when a group's uni stream arrives for a
`subscribeId` whose subscription has already been removed (typical
case: listener canceled / unsubscribed before the publisher
observed it), the listener MUST signal the publisher to abandon
in-flight retransmits via `STOP_SENDING(applicationErrorCode)` on
that uni stream. Pre-fix, `drainOneGroup` silently dropped every
frame (`droppedNoSub++`) and let the publisher keep pushing until
natural FIN — wasted bandwidth on bytes the listener would discard,
plus relay queue pressure on the per-subscriber forward pipeline
that already sits behind the production stream-cliff.
Wire the latched `stopSending(MoqLiteStreamCancelCode.SUBSCRIPTION_GONE)`
on the first frame that observes `sub == null`. Latched (one-shot)
so concurrent frames in the same drain don't re-fire — RFC 9000
§3.5 says subsequent STOP_SENDINGs are ignored anyway, but it saves
the syscall and keeps the trace clean.
New error-code constant `MoqLiteStreamCancelCode.SUBSCRIPTION_GONE
= 0x10L` lives next to `MoqLiteSubscribeDropCode` in
`MoqLiteMessages.kt`. moq-lite leaves application error codes
undefined; we follow the same "small non-zero varint" convention.
Test seam: `ChannelWriteStream` is now a public class (was
private) with a `peerStopSendingCode` accessor, so a test that
holds the writer side (`serverSide.openUniStream() as
ChannelWriteStream`) can assert the listener's stopSending code
without racing for the peer-side `FakeReadStream` reference.
Regression test:
`MoqLiteSessionTest.listener_stopSending_group_uni_when_subscription_already_canceled`.
Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M2).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
moq-lite Lite-03 conveys application-level errors on any stream via
`RESET_STREAM(application_error_code u32)` (audit M3). Pre-fix, our
publisher's two SubscribeDrop reply paths
(`BROADCAST_DOES_NOT_EXIST`, `TRACK_DOES_NOT_EXIST`) wrote the Drop
body and then `bidi.finish()` — a graceful FIN — which overlapped
with "publisher gracefully shut down." A subscriber watching only
the QUIC layer (no body decode) couldn't tell rejection from
shutdown.
Replace the trailing `bidi.finish()` with `bidi.reset(errorCode)`
in both Drop paths. The errorCode matches the body's `errorCode`
field so a peer that decodes the Drop sees the same number as one
that only sees the RESET_STREAM frame. The plumbing landed in the
prior commit (`feat(quic,nestsclient): plumb RESET_STREAM +
STOP_SENDING through WebTransport`).
Tests: existing `publisher_replies_subscribeDrop_when_*` regressions
gain an additional `lastPeerResetCode` assertion via
`FakeBidiStream`, locking the typed-error contract.
Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M3).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
Extends the WebTransport interface and its three implementations
(QUIC-backed, in-memory fake, peer-stripped demux) with typed
error-code cancel paths so moq-lite Lite-03's M2/M3 audit items can
land.
QUIC layer (`:quic`):
- `StrippedWtStream` gains optional `reset` and `stopSending`
closures, wired by `WtPeerStreamDemux.emitStripped` through
`QuicStream.resetStream(errorCode)` /
`QuicStream.stopSending(errorCode)` + `driver.wakeup()` so the
frames actually leave the connection. `reset` is null on
peer-initiated uni streams (no send half on our side);
`stopSending` is unconditional (every surfaced stream has a
read side).
WebTransport interface (`:nestsClient`):
- `WebTransportReadStream.stopSending(errorCode: Long)` —
suspending; first-call-wins.
- `WebTransportWriteStream.reset(errorCode: Long)` —
suspending; first-call-wins. Distinct from the existing
graceful `finish()` / FIN.
Adapters:
- `QuicBidiStreamAdapter`, `QuicReadStreamAdapter`,
`QuicUniWriteStreamAdapter` route directly to the underlying
`QuicStream` API.
- `StrippedWtReadStreamAdapter` /
`StrippedWtBidiStreamAdapter` route through the demux's
closures; defensive `?:` fallbacks defend against a future
demux bug that forgets to wire a closure.
- `FakeBidiStream`, `FakeReadStream`, `ChannelWriteStream` (the
in-memory test transport) record reset / stopSending codes
in shared `AtomicLong` cells so tests can assert "the peer
reset with code X" / "the listener stopSending with code Y"
via new `lastResetCode` / `lastStopSendingCode` /
`lastPeerResetCode` / `lastPeerStopSendingCode` properties.
Sentinel `NO_CODE = Long.MIN_VALUE` distinguishes "not called"
from a legitimate code 0.
Pure plumbing — no moq-lite call sites use the new methods yet.
Subsequent commits wire M3 (publisher Drop reply uses
reset(errorCode)) and M2 (listener group-cancel uses
stopSending(errorCode)).
Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M2 + M3).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
Adds Fix#3 (M1 — trackPriority + sequence bit-pack matching kixelated
PriorityHandle), Fix#4 (L5 — fresh publisher list at dispatch
time), and the M6 closure (verified via WebFetch that Goaway has no
body schema in moq-lite Lite-03 — `rs/moq-lite/src/lite/{stream,
client}.rs`; our recognise/log/FIN handler is canonical) to the
compliance audit document.
Also marks L4 closed (subsumed by M1 — the saturating cast guard
collapsed into the per-track high byte logic).
Updates the audio-rooms completion-plan pointer with the new shipped
count: four publisher-side spec tightenings + one closure (M6) +
five 🟡 / 🟦 items explicitly deferred with rationale.
Remaining deferrals: M2 + M3 (need WebTransport/`:quic` interface
extension, locked per audit prompt), L1 (Lite-04 codec rewrite, no
relay forces it), L2 + L3 (no consumer for the API).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
`handleInboundBidi` previously snapshotted the publisher list at the
TOP of the function (= bidi-arrival time) and used the snapshot for
the rest of the bidi's lifetime. A publisher registered between
bidi-open and the first inbound chunk would miss the dispatcher's
view, even though the publisher was already live in `activePublishers`
by the time we needed to dispatch.
Practical impact today: zero — both nests publishers (`audio/data`
and `catalog.json`) register from `MoqLiteNestsSpeaker.startBroadcasting`
before the relay's SUBSCRIBE bidi for either track lands. The
~few-ms gap is below the network round-trip floor.
But the contract is narrower if we read the list at first-byte time:
we then see every publisher that was registered up to the moment we
needed to make a routing decision. Closes the L5 row of the audit doc.
Move the publishers fetch from the function-top to inside the
`if (!dispatched)` block, after the control-type byte has been read.
On empty publisher list (the case we previously short-circuited at
function-top), we now FIN cleanly so the peer's wait resolves
instead of hanging on an idle bidi.
Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (L5).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
Per moq-lite Lite-03 (kixelated/moq `rs/moq-lite/src/lite/priority.rs`),
`Publisher::serve_group` calls `priority.insert(track.priority,
sequence)` and feeds the resulting position into `stream.set_priority`.
Priority sorts first by `track.priority u8` (higher track = drains
ahead under congestion), then by group `sequence` within a track
(newer = drains ahead).
Pre-fix we passed raw `sequence.toInt()` directly to setPriority,
ignoring the per-track byte. For our single-Opus-track production case
this was unobservable — newer-first ordering held by sequence
monotonicity — but a future multi-track broadcast (audio + companion
catalog / status track) would have starved the lower-rate track the
moment audio's outbound queue got congested.
Fix: add `trackPriority: Int = DEFAULT_TRACK_PRIORITY` parameter to
`MoqLiteSession.publish()`, store on PublisherStateImpl, and bit-pack
in `openGroupStream`:
bits 31..24 trackPriority u8 (0..255)
bits 23..0 sequence low 24 bits
The 24-bit sequence window is ample (≈ 6 days at 1 group/sec, beyond
which all newer groups within a single track tie — but they still
beat older groups of any LOWER-priority track via the top byte).
Production sessions cycle on JWT refresh every 9 min, so the wrap is
defensive only.
`DEFAULT_TRACK_PRIORITY = 0x80` matches the existing subscriber-side
DEFAULT_PRIORITY midpoint, so all existing call sites keep their
prior behavior.
Test seam: `FakeWebTransport.openUniStream` now records the most-
recent `setPriority` value via a shared `AtomicInteger` cell that the
peer-side `FakeReadStream` exposes as `lastSetPriority`. Lets the new
regression test verify the bit-pack formula on the actual peer-side
read stream rather than peeking into private state.
Regression test:
`MoqLiteSessionTest.publisher_packs_trackPriority_and_sequence_into_setPriority_value`.
Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M1).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
Records the cross-spec compliance audit of the moq-lite Lite-03
implementation against kixelated's reference Rust impl
(github.com/kixelated/moq, rs/moq-lite/). Methodology mirrors the
recent QUIC RFC review: walked every Kotlin source under
`moq/lite/`, traced data flow speaker → relay → listener, and
cross-referenced each on-wire message + control byte against the
Rust reference.
Outcome: no 🔴 wire-incompatibilities found. Every codec primitive —
control type discriminators, AnnouncePlease, Announce, Subscribe
(incl. the `Option<u64>` off-by-one and `Duration` millis-as-varint
conventions), SubscribeOk/Drop with the type-outside-size-prefix
framing peculiar to Lite-03, GroupHeader, Probe — matches byte-for-
byte.
Six 🟡 (spec-loose / future-fragile) and four 🟦 (diagnostic /
observability) gaps documented:
- M1 stream priority parity vs kixelated's PriorityHandle
(single-track Opus impact: invisible)
- M2 STOP_SENDING for single-group cancel not exposed
- M3 RESET_STREAM with Error::to_code not used
- M4 AnnouncePlease prefix-mismatch falsely emits Active — fixed
in 6c2d7efc
- M5 inbound Subscribe doesn't validate broadcast field — fixed
in 3dcee2a4
- M6 Goaway body / migration handler
Also references the new audit from `2026-04-26-audio-rooms-completion.md`
so future readers find it via the completion-plan pointer.
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
The publisher-side dispatcher in handleInboundBidi previously matched
inbound Subscribe bidis on `track` only, never checking whether the
requested `broadcast` field matched the suffix our publisher claimed
on this session. A peer (or buggy relay) could open a Subscribe under
broadcast="otherPubkey" track="audio/data" and we would route OUR
audio frames to them — the dispatcher only filtered on track.
Production never bit because moq-rs's relay routes Subscribe messages
to the specific publisher whose broadcast matches the requested path
upstream of us, so an off-target broadcast never landed on our
inbound bidi pump. But a peer-to-peer connection without a relay
in between would have been able to siphon our audio under any
broadcast string they invented.
The fix: validate `sub.broadcast == publisher.suffix` (both already
path-normalised by the codec) before track matching. On mismatch,
reply `SubscribeDrop(errorCode=BROADCAST_DOES_NOT_EXIST,
reasonPhrase="<requested> not published on this session
(we publish <ours>)")` and FIN. New error code
`MoqLiteSubscribeDropCode.BROADCAST_DOES_NOT_EXIST = 0x05L` mirrors
the IETF MoQ-transport `TRACK_NAMESPACE_DOES_NOT_EXIST` semantic so
a watcher can tell apart "wrong room" from "wrong rendition".
Regression test:
`MoqLiteSessionTest.publisher_replies_subscribeDrop_when_broadcast_does_not_match`.
Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M5).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
Per moq-lite Lite-03 (kixelated/moq `rs/moq-lite/src/lite/announce.rs`),
a publisher MUST only emit `Announce(Active)` for broadcasts whose path
starts with the requested AnnouncePlease prefix. Pre-fix, when
`MoqLitePath.stripPrefix(please.prefix, ourSuffix)` returned `null`, we
fell through to `?: announcePublisher.suffix` and emitted Active under
our full suffix anyway, falsely advertising the broadcast under a
prefix the subscriber didn't request.
Production never bit because the relay always opens its announce bidi
to us with `prefix=""` (which always matches), but a future peer-to-
peer or namespace-scoped subscriber would have observed a ghost
broadcast under whatever prefix they asked about.
The fix: if `stripPrefix` returns `null`, FIN the bidi cleanly without
writing any Announce body. The subscriber sees an empty announce
stream and moves on.
Regression test:
`MoqLiteSessionTest.publisher_skips_announce_when_announce_please_prefix_does_not_match`.
Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M4).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
The remaining 🟦 cosmetic items from the 2026-05-09 audit. Pre-fix the
connection's `closeErrorCode` was effectively unused — TLS handshake
failures bubbled up as generic `QuicCodecException` and observers had
to grep the reason string for the spec category. Now closeErrorCode
carries the RFC 9000 §20.1 transport code or the RFC 9001 §4.8
TLS-alert-mapped CRYPTO_ERROR.
Implementation:
* `TlsAlertException(alertCode, message)` carrier raised by the
TLS layer for the well-defined cases:
- HelloRetryRequest received (handshake_failure = 40)
- TLS 1.3 not negotiated (protocol_version = 70)
- Unsupported cipher (illegal_parameter = 47)
- ALPN mismatch (no_application_protocol = 120)
- Cert chain validation failure (bad_certificate = 42)
- CertificateVerify signature failure (decrypt_error = 51)
- Server Finished MAC mismatch (decrypt_error = 51)
- TLS KeyUpdate over QUIC (unexpected_message = 10) — RFC 9001
§6 mandates this specific code; fixes the misleading prior
message that said "rotation not implemented" (we DO implement
QUIC's own KEY_PHASE-bit rotation).
The QUIC parser maps `0x100 + alertCode` per RFC 9001 §4.8 and
stamps `closeErrorCode`.
* `QuicTransportError` constants object covering the RFC 9000 §20.1
table (NO_ERROR through NO_VIABLE_PATH including the previously-
flagged CRYPTO_BUFFER_EXCEEDED 0x0d, KEY_UPDATE_ERROR 0x0e,
AEAD_LIMIT_REACHED 0x0f).
* `markClosedExternally(reason, errorCode = NO_ERROR)` overload —
backward compatible; lets call sites pin the spec code on
`closeErrorCode` for qlog observability without changing the
wire-emit semantics (still no CONNECTION_CLOSE frame, same as
before — that's a separate, larger refactor).
* RFC 9000 §22 CRYPTO_BUFFER_EXCEEDED enforcement — pre-insert
per-level cap of 64 KiB on inbound CRYPTO data. Generous enough
for an RSA-4096 cert chain with intermediates; bounds the
worst-case heap a misbehaving peer can pin to 192 KiB across
all 3 encryption levels. Fires before the receive buffer
actually allocates.
* INVALID_TOKEN — N/A for client role (only servers validate Retry
tokens). KEY_UPDATE_ERROR — exposed as a constant; no current
failure path maps to it (PN regression on a key-update packet
is spec-allowed to handle silently per RFC 9001 §6.1, which we
do).
Tests: `ErrorCodeMappingTest` (6 cases) covers TlsAlertException
construction + offset, alert code bounds, markClosedExternally
preservation, CRYPTO_BUFFER_EXCEEDED close, sanity-check that small
CRYPTO frames don't trip the cap, and §20.1 numeric values.
`HelloRetryRequestTest` updated to expect TlsAlertException(40)
instead of generic QuicCodecException.
Plan updated to mark the §4.8 + §22 (CRYPTO_BUFFER_EXCEEDED) items
resolved; KEY_UPDATE_ERROR documented as TLS-side covered + QUIC-side
spec-allowed-silent; INVALID_TOKEN documented as N/A for client.
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
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
- Merge nested if statements in marmot stores' delete() and in
ZoomableContentDialog's MediaUrl* gating block (S1066).
- Replace `if (cond) false else x` with `!cond && x` in
ImageVideoDescription's effectiveStripMetadata (S1125).
- Remove unused `nip95description` local in FileServerSelectionRow.
All changes are body-internal with identical behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Peer's CONNECTION_CLOSE now transitions the connection through DRAINING
for 3 * PTO before flipping to CLOSED, instead of going straight to
CLOSED. While DRAINING:
- the writer emits no packets (drainOutbound returns null);
- the parser drops late inbound silently at the top of feedDatagramInner;
- close()-awaiters unblock immediately via closingDrainSignal so the
transition isn't gated on the 3*PTO grace.
After 3 * PTO the driver's send-loop timer fires
markClosedExternally("draining period elapsed") which transitions to
CLOSED. The §10.2.2 grace period gives the peer's last retransmits a
chance to converge before we discard state.
Implementation:
* `Status.DRAINING` enum value with kdoc tying it to §10.2.2.
* `QuicConnection.drainingDeadlineMs` + `enterDraining(reason, nowMs)`
(sets status, computes `now + max(3*pto, MIN_DRAINING_PERIOD_MS)`,
completes closingDrainSignal, fires qlog) + `isDrainingExpired`.
* Parser's CONNECTION_CLOSE handler routes through `enterDraining`
instead of `markClosedExternally`.
* Driver folds `drainingDeadlineMs` into its send-loop sleep
`withTimeoutOrNull` and transitions to CLOSED on expiry.
* Writer short-circuits drainOutbound for DRAINING (returns null —
spec MUST NOT send).
* Parser drops late inbound at the top of feedDatagramInner.
Updated `FrameRoutingTest.connection_close_from_peer_short_circuits_remaining_frames`
to expect DRAINING (was CLOSED). Other status=CLOSED assertions in the
test suite cover OUR-side closes (markClosedExternally on protocol
violations / TRANSPORT_PARAMETER_ERROR / FLOW_CONTROL_ERROR) which
still go directly to CLOSED.
Test: `DrainingStateTest` (7 cases) covers the peer-close → DRAINING
transition, late-inbound drop, deadline math, the
MIN_DRAINING_PERIOD_MS floor, retransmit no-op semantics, and the
fresh-connection invariant.
Plan updated to mark all 🟡 Medium items resolved. The 2026-05-09
audit's complete spec-compliance close-out: 2 of 2 High + 6 of 6
Medium fixed in seven commits.
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
A peer that has lost connection state (crash, restart, route change)
signals so by sending a "Stateless Reset" datagram — bytes shaped like
a short-header packet whose trailing 16 bytes equal a
`stateless_reset_token` previously communicated by the peer.
Pre-fix the tokens were stored (via NEW_CONNECTION_ID into the
[PathValidator] pool, and via the peer's `statelessResetToken`
transport parameter) but never matched against arriving datagrams —
a peer's stateless reset looked indistinguishable from noise and the
connection lingered until idle timeout, or worse, an attacker could
spam look-like-noise packets toward our integrity counter.
Implementation:
* `QuicConnection.isStatelessReset(datagram)` — short-header-form
+ size + trailing-16-bytes comparison. Matches against the peer's
advertised token AND every unused entry in `pathValidator`'s
pool. Constant-time per-token compare per §10.3.1 to avoid
leaking token bits via timing.
* `PathValidator.unusedTokenForSequence(seq)` — accessor for the
above to walk the pool.
* Parser pre-empts AEAD/HP parsing: `feedDatagramInner` checks every
short-header-form datagram before the loop. False-positive rate
of a real short-header packet (whose trailing 16 bytes are the
AEAD tag) matching a known token is 2^-128, negligible in
practice. Defense-in-depth: the AEAD-failure branch in
`feedShortHeaderPacket` retains a redundant check for
second-packet-of-coalesced edge cases.
* On match, `markClosedExternally("stateless reset received from
peer")` transitions silently to CLOSED — no CONNECTION_CLOSE
emission per §10.3.1.
Limitation: tokens for an actively-used DCID we migrated to via
`PathValidator.tryStartValidation` are lost when the entry leaves
the unused pool. Acceptable for the audio-rooms scope (no migration);
documented in the kdoc.
Test: `StatelessResetDetectionTest` (5 cases) covers token-match
silent-close, unknown-trailer no-close, pool-stored token match,
long-header form rejected, too-short datagram rejected.
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
Per-key AEAD usage limits per RFC 9001 §6.6 / §B.1:
* Confidentiality limit (encrypt count per send key):
AES-128-GCM = 2^23, ChaCha20-Poly1305 = 2^62. Reaching this means
AEAD security is no longer assured; the endpoint MUST initiate a
key update or close.
* Integrity limit (forged-packet count per receive key):
AES-128-GCM = 2^52, ChaCha20-Poly1305 = 2^36. Reaching this means
an attacker has been grinding for AEAD key recovery; MUST close
with AEAD_LIMIT_REACHED.
Pre-fix neither limit was tracked. Long-running AES-128-GCM sessions
(~2hrs at 1000pkt/s) could roll past the confidentiality limit; an
attacker spamming forged packets had no failure ceiling.
Implementation:
* `Aead.confidentialityLimit` / `Aead.integrityLimit` properties
surface the spec values per cipher; AES-128-GCM and
ChaCha20-Poly1305 (commonMain singletons + jvmAndroid JCA classes)
return the §B.1 numbers.
* `QuicConnection.aeadEncryptCount` / `aeadDecryptFailureCount` —
per-key counters, reset on every 1-RTT key rotation
(`commitKeyUpdate` and `initiateKeyUpdate`).
* Writer increments encrypt count after each application-level
build. At half the limit it soft-triggers `initiateKeyUpdate`
(latched via `aeadKeyUpdateRequested` so the in-flight rotation
isn't re-issued); at the limit it closes with AEAD_LIMIT_REACHED
if rotation hasn't completed.
* Parser increments decrypt-failure count on every 1-RTT AEAD
auth-tag failure; closes when the count hits the integrity limit.
Initial / Handshake levels are out of scope — their keys' lifetime is
too short to approach the limit.
Test: `AeadInvocationLimitTest` (6 cases) covers spec-value
verification, counter increment on application send, counter reset on
rotation, confidentiality-limit close, integrity-limit close (via a
real ciphertext-tampered datagram from the in-process pipe).
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
Once the peer has sent RESET_STREAM the receive side enters the "Reset
Recvd" terminal state per §3.2; any subsequent STREAM frame on that id
is a peer protocol violation and MUST close the connection with
STREAM_STATE_ERROR.
Pre-fix the parser silently absorbed the bytes — a peer that violated
the spec would leave us with a phantom mid-reset stream the peer
believed was already dead, with reset/byte bookkeeping diverging.
Implementation: a per-stream `peerResetReceived: Boolean` flag set in
the RESET_STREAM handler and checked at the top of the STREAM handler.
The flag is independent of our local-side `resetState` (which tracks
OUR RESET emission).
Test: `StreamAfterResetTest` (4 cases) covers STREAM-after-reset
closure, FIN-bearing STREAM-after-reset, the legal FIN-then-RESET
shape (no false-positive), and per-stream isolation (reset on stream 3
doesn't poison stream 7).
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
Pre-fix the receiver enforced ONLY per-stream `receiveLimit`. The
aggregate cap (`initial_max_data` / latest MAX_DATA) was advertised and
respected on the SEND side but never checked on the RECEIVE side — a
peer that opened many streams and pushed each up to its per-stream cap
could collectively exceed `initial_max_data` without us closing.
Implementation:
* `QuicStream.receiveHighestOffset`: per-stream high-water mark of
"largest stream offset received" — the spec quantity that gets
summed across streams for the connection-level check.
* `QuicConnection.connectionInboundOffsetSum`: running sum across
all streams of `receiveHighestOffset`. Updated in the parser at
STREAM and RESET_STREAM frame ingest time.
* Parser closes with FLOW_CONTROL_ERROR whenever the running sum
exceeds `advertisedMaxData`.
* RESET_STREAM final-size accounting: per §4.5 the finalSize counts
toward connection-level flow control even though no STREAM frame
delivered those bytes — a peer that resets a stream at a finalSize
larger than it had previously sent gets the extra bytes added to
the running sum at RESET arrival.
Different from the writer's MAX_DATA threshold logic (which uses the
cheaper contiguous-end approximation): the receiver-side enforcement
needs the strict spec quantity to close before bookkeeping diverges.
Test: `ConnectionLevelFlowControlTest` (5 cases) covers below-cap pass,
over-cap close, retransmit-doesn't-double-count, RESET_STREAM
finalSize counts toward limit, fresh-handshake invariants.
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
Five spec-compliance fixes from the 2026-05-09 audit, all in the same
"hostile-peer hardening" tier.
* RFC 9000 §18.2 — `applyPeerTransportParameters` now closes the
connection with TRANSPORT_PARAMETER_ERROR when the peer advertises
`max_udp_payload_size < 1200`, `ack_delay_exponent > 20`, or
`active_connection_id_limit < 2`. Pre-fix the values were decoded
but never bounds-checked.
* RFC 9000 §13.2.5 — the parser's ACK-delay decoding now uses the
PEER's advertised `ack_delay_exponent` (with the §18.2 default of 3
as the pre-handshake fallback) instead of our own config value.
Defensive coercion to 0..20 is preserved so a bypass of the bounds
check above can't desync the RTT estimator.
* RFC 9114 §7.2.4.1 — `Http3Settings.decodeBody` now rejects the
HTTP/2-reserved SETTINGS ids 0x02, 0x03, 0x04, 0x05 with
H3_SETTINGS_ERROR. Pre-fix they fell through to the generic
1<<32 cap and were accepted.
* RFC 9221 §3 — the writer now drops outbound DATAGRAM frames when
the peer didn't advertise `max_datagram_frame_size` (or advertised
0), or when the encoded frame (type byte + length varint + payload)
would exceed the peer's advertised value. Pre-fix the writer
emitted DATAGRAM regardless and let spec-conformant peers close us
with PROTOCOL_VIOLATION.
* Added `TransportParameterDefaults` for the §18.2 defaults
(ack_delay_exponent=3, max_ack_delay=25ms, active_connection_id_limit=2)
so callers don't hard-code the magic numbers.
Tests: `TransportParameterBoundsTest` (8 cases) drives a real handshake
through the in-process pipe and asserts CLOSED on each violation +
CONNECTED at the boundary; `Http3ReservedSettingsTest` (5 cases) covers
the four reserved ids and the adjacent legal ids. Plan tier dropped
from 🟡 Medium to resolved for all five items.
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
The two High-severity gaps from the 2026-05-09 four-RFC compliance audit.
Fixed-bit (RFC 9000 §17.2 long-header, §17.3 short-header): the spec
says fixed-bit (0x40) MUST be 1 in every v1 packet; receivers MUST
discard packets where it's 0. Pre-fix the parsers checked only the
form-bit (0x80) and accepted any fixed-bit value — a peer or off-path
attacker could route packets through AEAD that aren't valid v1 packets.
Both `parseAndDecrypt` paths and the `peekKeyPhase` shortcut now
silently drop fixed-bit=0. The bit isn't header-protected (HP only
XORs the low 5 bits), so the check runs on the raw wire byte before
HP unmask + AEAD. Test: `FixedBitValidationTest` (3 cases).
Idle timeout (RFC 9000 §10.1): `max_idle_timeout` was decoded into
config and advertised via the ClientHello transport-params extension,
but never enforced — a black-holed connection lived indefinitely.
This commit:
* adds `QuicConnection.lastActivityMs`, bumped on (a) successful
inbound packet decrypt and (b) outbound ack-eliciting send per
§10.1.1
* adds `effectiveIdleTimeoutMs()` returning the min of local and
peer advertisements (skipping any side that advertised 0), with
the §10.1 `3 * PTO` floor applied
* folds the idle deadline into the driver's send-loop
`withTimeoutOrNull` so an idle connection wakes exactly at expiry
* silently transitions to CLOSED via `markClosedExternally` per
§10.2.1 ("the connection enters the closing state silently —
discarding the connection state without sending a CONNECTION_CLOSE")
Test: `IdleTimeoutTest` (8 cases) covers the min-of-two computation,
0-means-disabled handling, the 3*PTO floor, deadline math, and
postpone-on-activity. Plan updated to mark both items resolved.
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
Plan had drifted ~2 weeks behind shipping work. Rewrote to reflect the
features that landed since 2026-04-26 (path validation, 0-RTT, key
update, ECN, session resumption, lock-split refactor, DoS hardening)
and added a fresh RFC compliance gaps section catalogued from a
four-agent audit (RFC 9000, 9001, 9002, 9221+9114+9204).
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
Round 2 of the blocking-code audit follow-ups. Both changes remove
commonMain synchronized blocks and replace polling with event-driven
suspension; :quic:jvmTest stays green on JVM and :quic:compileAndroidMain
passes for Android.
QuicStream.resetStream / stopSending
- Replace synchronized(this) double-checked init with
AtomicReference<ResetState?>.compareAndSet(null, …) (and same for
stopSendingState). The pattern is "first call wins"; CAS expresses
that directly without a lock.
- Backing fields converted from `internal var … = null` to
`internal val … = AtomicReference(null)`. The two readers in
QuicConnectionWriter switch to .load(); QuicConnectionWriter gains a
file-level @OptIn(ExperimentalAtomicApi::class).
- The @Volatile resetEmitPending / stopSendingEmitPending writes happen
only on the winning CAS path, so the writer reading the flag still
sees the populated state via volatile happens-before.
QuicConnectionDriver.close polling loop
- Replace `while (status == CLOSING) delay(1)` polling with a
CompletableDeferred<Unit> on QuicConnection (closingDrainSignal).
The deferred is completed at both transitions to CLOSED:
(1) drainOutbound after building the CONNECTION_CLOSE datagram, and
(2) markClosedExternally on its synchronized first-call-wins path.
- close() now awaits the deferred under the existing
CLOSE_FLUSH_TIMEOUT_MILLIS bound — same upper-bound semantics, no
1 ms wakeups during teardown. complete(Unit) is idempotent, so the
two transition sites racing each other is safe.
https://claude.ai/code/session_01CXTjnuHKCNXDmpfyKxgG3V
Audit of blocking and synchronized code in the QUIC module surfaced four
hot-path wins, all verified against :quic:jvmTest.
- PlatformCrypto: header-protection AES-ECB now uses a per-thread cached
Cipher. Previously every inbound and outbound packet paid for
Cipher.getInstance("AES/ECB/NoPadding") provider lookup. ThreadLocal is
safe because the call is stateless — every invocation re-init's with the
caller-supplied key.
- JdkCertificateValidator: gate the SAN-side InetAddress.getByName behind
looksLikeIpLiteral so a malformed cert with a hostname in a type 7 SAN
cannot trigger DNS resolution on the TLS validation path.
- QuicConnectionDriver.close: replace synchronized(this) double-checked
init with AtomicReference<Job?> + compareAndSet on a CoroutineStart.LAZY
job. Lock-free, removes a synchronized from commonMain, preserves the
original "first caller wins, second awaits same Job" contract.
- SendBuffer: mark _nextOffset, nextSendOffset, _finPending, _finSent,
_finAcked @Volatile and drop synchronized from their single-field
getters (nextOffset, sentOffset, finPending, finSent, finAcked). The
compound-formula readableBytes getter still synchronizes.
https://claude.ai/code/session_01CXTjnuHKCNXDmpfyKxgG3V
Move the feedDatagram KDoc below the MAX_QUIC_OFFSET const so it
attaches to the function declaration, and merge the duplicate KDoc
blocks above TlsResumptionState into a single block.
Round 13 — three architectural follow-ups + a closeout note.
* JdkCertificateValidator: ship a hand-picked Public Suffix List
subset covering the high-volume multi-label effective-TLDs
(multi-tenant ccTLDs and major hosting platforms). Pre-fix the
dot-count heuristic accepted `*.co.uk`, `*.s3.amazonaws.com`,
`*.github.io`, etc. — wildcards spanning these would impersonate
every co-tenant. The new MULTI_LABEL_PUBLIC_SUFFIXES set adds a
layer above the dot-count check; combined with the WebPKI / CT
ecosystem already requiring CAs to consult the full PSL when
issuing, this closes the practical attack surfaces. Full
~9000-entry PSL data shipping is still deferred (data-shipping
ask, doc'd); a domain not in the subset that's also a multi-label
ETLD remains a gap.
* JcaChaCha20Poly1305Aead: new JCA-backed implementation mirroring
JcaAesGcmAead's shape (cached Cipher + SecretKeySpec, range
overloads via Cipher.doFinal(input, off, len, output, outOff),
recent-nonce history for legitimate IV reuse on the
Initial-padding rebuild path). bestChaCha20Poly1305Aead(key)
expect/actual factory tries the JCA path first (Java 11+ /
Android API 28+) and falls back to the pure-Kotlin
ChaCha20Poly1305Aead singleton if the algorithm isn't available
(older Android, headless GraalVM native-image without the
standard providers). PacketProtectionBuilder routes the
ChaCha20-Poly1305 cipher suite through the factory instead of
the singleton. On supporting platforms this gives the same
outbound-allocation savings as round 8's AES-GCM range overload.
* QuicConnectionWriter: stop emitting fake ECN counts on ACK
frames. Pre-fix every 1-RTT ACK carried `AckEcnCounts(0, 0, 0)`
— claiming to track ECN while actually never reading inbound TOS
bits. RFC 9000 §13.4.2: "An endpoint that uses ECN MUST report
accurate ECN counts." Hardcoded zeros could be flagged as a
PROTOCOL_VIOLATION by strict peers cross-validating against
outbound packet counts; aioquic / picoquic / quic-go tolerate
it but other stacks may not. With ecnCounts = null we honestly
advertise "this endpoint isn't reporting ECN", peer skips its
own ECN-driven congestion logic for our direction. We still
mark outbound ECT(0) (other peers' tracking benefits from the
path-quality signal); RFC 9000 §13.4 allows the asymmetry.
* MutableSharedFlow migration for QuicStream.incoming declined as
obviated. The audit's suggestion was a workaround for the
cancel-coupling specifically (collector cancel → channel cancel
→ INTERNAL_ERROR) — round 11's `flow { for (c in
incomingChannel) emit(c) }` wrapper solved that. Switching to
MutableSharedFlow would change the semantics from "each byte to
exactly one consumer" (correct for stream bytes) to fan-out
(every emission to all collectors), which is wrong for QUIC
stream data.
All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 40s.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
Round 12 — six smaller follow-ups across visibility, perf, and secret-handling.
* QuicStream.receiveDirtyForFlowControl gains @Volatile. The parser's
read-loop writes the flag and the writer's send-loop reads it
WITHOUT holding the same lock; without volatile the writer could
miss the parser's update for an unbounded time on JVM (the field is
hot in a drain loop where the JIT might cache it), suppressing
MAX_STREAM_DATA emissions until something else triggered a fresh
load.
* SendBuffer.readableBytes runs in O(1) instead of O(R) by maintaining
a cached `retransmitTotalBytes` counter. Updated in lockstep with
every retransmit deque mutation: addLast in [requeueAllInflight] +
the two paths in [removeOverlap] (RETRANSMIT zero-length + main
range), and add/removeFirst in [takeChunk]. Pre-flight "anything to
send?" check on the writer's hot path was previously walking the
deque per-stream per-drain.
* SendBuffer.requeueAllInflight coalesces adjacent ranges on insert.
Pre-fix the PTO probe path appended each in-flight range as a
separate retransmit entry, so on the next drain takeChunk emitted
one tiny STREAM frame per original-packet boundary. With
coalescing, contiguous bytes get replayed as one chunk + one AEAD
seal. FIN-bearing ranges stay separate (merging across a FIN
changes the implicit final-size invariant).
* TlsResumptionState dropped `data class`. The auto-generated
equals/hashCode used reference equality on its ByteArray fields
(PSK / ticket / peerTransportParameters), so two byte-identical
states compared unequal — almost never useful and a footgun for
caller-side caches. The auto-toString would dump PSK contents into
any log. Replaced with hand-written equals/hashCode using
contentEquals on the byte fields and a redacted toString that
reveals only sizes.
* QuicConnectionParser RESET_STREAM handler bounds finalSize at
[0, 2^62-1] per RFC 9000 §16 (the QUIC offset ceiling). Pre-fix
we accepted any varint, including values that could overflow
downstream Long math.
* PathChallenge/PathResponse IAE leak: re-traced and verified
non-issue. The decoder calls `r.readBytes(8)`, which either throws
QuicCodecException (short read) or returns exactly 8 bytes — the
constructor's `require(data.size == 8)` is unreachable from the
decode path. The audit was over-cautious; no code change.
All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 39s.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
Round 11 — six follow-ups across perf, correctness, and API hygiene.
* QuicStream.incoming: replace `consumeAsFlow()` with `flow { for (c in
incomingChannel) emit(c) }`. Pre-fix the consume-style flow
cancelled the underlying Channel when the collector terminated,
which coupled "application stopped reading" with "parser
INTERNAL_ERRORs the connection on next delivery". The new emit-only
flow leaves the channel open across collector cancellation, so
applications can stop reading temporarily and resume later
(sequential collects, not concurrent — that's still a race on the
channel iterator).
* RFC 9001 §6.1 client-initiated key update: the existing
[QuicConnection.initiateKeyUpdate] no longer requires the caller
to enforce spec invariants. Returns false if the handshake isn't
yet complete (§6.5: MUST NOT initiate before HANDSHAKE_DONE) or if
a previous rotation is still in flight (§6.4: MUST NOT initiate a
subsequent update until the previous one is confirmed). The parser
clears the [keyUpdateInProgress] flag on the first inbound packet
that AEAD-decrypts under the post-rotation live keys — the
confirmation signal that the peer has rolled forward.
* Aead.sealInto: new range + in-place seal that writes ciphertext+tag
DIRECTLY into a caller-supplied output buffer at a given offset.
Default impl falls back to sealRange + copy; JcaAesGcmAead overrides
to use Cipher.doFinal(input, inOff, inLen, output, outOff).
LongHeaderPacket.build / ShortHeaderPacket.build now pre-allocate
the final packet buffer in a single shot and have the AEAD write
ciphertext+tag in-place. Pre-fix every outbound packet allocated
4 ByteArrays (headerBytes, paddedPlaintext, ciphertext, concat
buffer); now ~2 (the final packet + the AEAD provider's internal
scratch).
* QuicConnectionWriter.drainOutbound: skip the
`active.sortedByDescending { priority }` allocation when EVERY
active stream shares the same priority — not just the
default-zero case. A homogeneous priority-7 workload now keeps
insertion order at no cost.
* QuicConnection.scratchAppFrames / scratchAppTokens: per-connection
reusable lists for buildApplicationPacket. Cleared at function
entry, re-used across drains under streamsLock's single-writer
guarantee. The tokens list is `.toList()`-snapshotted into the
SentPacket record before reuse, so retransmit dispatch is
unaffected. NOT applied to collectHandshakeLevelFrames because
its returned [HandshakeLevelContents] is held across two
buildLongHeaderFromFrames calls (natural-size + padded rebuild)
in drainOutbound.
* Removed dead `parts = mutableListOf<ByteArray>()` declaration at
the top of drainOutbound — never referenced; the actual datagram
assembly uses inline `listOfNotNull(...)` instead.
All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 43s.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
Round 10 — supersedes the typed-exception approach from round 9.
Round 9 introduced [PskRejectedException] as a "punt to the application
layer" hack, with a comment claiming the in-place fallback was too
subtle to land safely. After tracing the actual derivation path the
fix turns out to be one line.
The key observation: the on-wire ClientHello (with `pre_shared_key`
extension and binder bytes) goes into BOTH client and server
transcript hashes regardless of accept / reject (RFC 8446 §4.2.11).
The transcript hash itself doesn't need any rebuild. The ONLY thing
that differs between accept and reject is how [earlySecret] is
derived:
* accepted: HKDF-Extract(0, PSK)
* rejected: HKDF-Extract(0, 0) ← same as no-resumption path
So when the server returns ServerHello without `pre_shared_key`,
we simply call `keySchedule.deriveEarly()` to overwrite the
PSK-derived [earlySecret] with the zero-keyed value. [deriveHandshake]
runs immediately after with the new earlySecret + ECDHE shared
secret, and the handshake proceeds along the regular non-resumption
path (which is well-tested by every non-resumption test in the
suite).
* `pskAccepted = false` so the
WAITING_CERTIFICATE_OR_FINISHED state correctly demands
Certificate + CertificateVerify (a Finished without those would
still be rejected as unauthenticated).
* Any 0-RTT packets the application emitted under the
PSK-derived [clientEarlyTrafficSecret] are lost — server can't
decrypt them and EncryptedExtensions arrives without the
early_data extension, so [earlyDataAccepted] = false. The
application layer is responsible for replaying any 0-RTT-bound
payload over 1-RTT. The handshake itself proceeds cleanly.
* [PskRejectedException] (added in round 9) deleted as dead code.
[QuicCodecException] reverts to a `final` class.
All 269 :quic:jvmTest tests pass. The fallback re-uses the
deriveEarly() codepath that every non-resumption test exercises,
so test coverage is implicit in the existing suite.
https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM