697a7492437dce8eee49e58714c521f32b7a7895
62 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
edd6eb5c10 |
fix(quic): pad short plaintext payloads for HP sample (RFC 9001 §5.4.2)
ShortHeaderPacket.build / LongHeaderPacket.build crashed with `IllegalArgumentException: packet too short for HP sample` whenever the plaintext payload was small enough that pnLen + payload < 4 — most visibly on the 1-RTT path when buildApplicationPacket emitted a single 1-byte PING (PTO probe with no ACKs queued and no streams to drain) and the packet number still fit in 1 byte. The crash tore down the writer loop, which surfaced upstream as moq-lite "subscribe stream FIN before reply" because in-flight bidi streams got FIN'd by the peer. RFC 9001 §5.4.2 mandates the sender pad the plaintext so the encrypted output (plaintext + 16-byte AEAD tag) has at least 20 bytes after the packet-number offset for the 16-byte HP sample. The fix pads the plaintext with trailing 0x00 bytes — those decode as PADDING frames per RFC 9000 §19.1 and decodeFrames already absorbs them. For long-header packets the padded size feeds back into the Length varint. |
||
|
|
fba0a5c952 |
feat(quic): bestEffort streams + park CC plan indefinitely
After drafting the congestion-control plan we concluded the audio-rooms
workload doesn't actually need CC — speakers push ~8 KB/sec, which
never fills any modern link's capacity. The one real concern that
surfaced — STREAM retransmit wasting bandwidth on stale Opus frames
on lossy uplinks — is much cheaper to fix directly than to bound via
a 14-test CC subsystem.
SendBuffer gains a `bestEffort: Boolean = false` constructor flag.
When true, markLost drops the lost ranges instead of moving them to
the retransmit queue and lets the underlying byte storage compact as
if the bytes had been ACK'd. The FIN flag (if covered) also stays
sent — best-effort skips FIN re-emission too. The peer may end up
with a truncated stream; moq-lite's per-stream timeouts handle that.
Plumbed through QuicStream → QuicConnection.openUniStream(bestEffort)
→ QuicWebTransportSessionState.openUniStream(bestEffort) →
WebTransportSession.openUniStream(bestEffort). Default is false
everywhere, so reliable streams (HTTP/3 control, moq-lite SUBSCRIBE
bidi, etc.) keep RFC 9000 §3.5 semantics.
MoqLiteSession.openGroupStream now passes `bestEffort = true` —
group streams carry a single Opus packet, are real-time, and don't
benefit from retransmit.
Internal cleanup: `removeOverlap`'s `ackedNotLost: Boolean` parameter
became `OverlapAction { ACK, RETRANSMIT, DROP }` so the third best-
effort disposition has a name. Same code paths, same tests, just
clearer at the call site.
CC plan (quic/plans/2026-05-05-congestion-control.md) is updated to
"parked indefinitely" with a note that this commit is the lighter-
weight alternative that addresses the only practical concern. The
plan is preserved as a reference if a future workload justifies CC.
New tests: SendBufferBestEffortTest (6 cases — reliable baseline,
best-effort drops, FIN drop in best-effort mode, partial overlap,
idempotent stale loss, ACK path still works).
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
08bb3e14e1 |
docs(quic): plan congestion control (NewReno per RFC 9002 §7)
Open plan, not started. Conservative scope: - NewReno reference algorithm (RFC 9002 §7). - Bytes-in-flight tracking + send-side gating. - Persistent-congestion handling. - ~14 unit + 2 integration tests, ~3-5 days work. Out of scope (deferred follow-ups): pacing, CUBIC, BBR, ECN. The Why section is honest that this is "good citizen" work, not "fix a bug" work — the audio cliff was a stream-id / forwarding issue, not a rate-control issue, and the retransmit subsystem we just shipped is what production actually needed. Plan stays open as the natural next item but should not be prioritised over field validation of the retransmit work. Reference: neqo's cc/classic_cc.rs. https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ |
||
|
|
2053f50f35 |
fix(quic): discard Initial/Handshake keys per RFC 9001 §4.9
Pre-fix `:quic` held Initial AND Handshake encryption-level state indefinitely once derived. AEAD cipher state, per-level CRYPTO buffers, and the per-level sent-packet map all stayed alive for the lifetime of the connection — a real memory leak for long sessions (audio rooms run for hours). LevelState.discardKeys() (idempotent): - Nulls sendProtection / receiveProtection (frees AEAD state). - Replaces cryptoSend / cryptoReceive with empty instances. - Replaces ackTracker with an empty instance. - Clears sentPackets and resets largestAckedPn / largestAckedSentTimeMs. - Latches keysDiscarded = true. Hook locations: - Initial discard (RFC 9001 §4.9.1, client side): in QuicConnectionWriter.drainOutbound, after a Handshake-level packet is built into the outbound datagram. The next drainOutbound MUST NOT touch the Initial level; any retransmitted Initial from the peer is silently dropped (receiveProtection == null), which is correct per the same RFC since the server has also moved up encryption levels by then. - Handshake discard (RFC 9001 §4.9.2 + §4.1.2, client side): in QuicConnectionParser, on receipt of a HANDSHAKE_DONE frame. Once a level's protection is null, parser-side decrypt at that level returns null silently (existing receiveProtection == null check) and writer-side build skips it (existing sendProtection == null check), so no further code paths needed updating. New test: KeyDiscardTest (4 cases — Initial keys discarded after first Handshake packet, Handshake keys still live until HANDSHAKE_DONE, Handshake keys discarded on HANDSHAKE_DONE, discardKeys is idempotent). Listed in the audit-summary deferred-work as item 3 (`No Initial / Handshake key discard`). https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ |
||
|
|
e3a3ffd1d9 |
fix(quic): correct AckTracker.purgeBelow via ACK-of-ACK dispatch
Pre-fix, QuicConnectionParser purged the inbound AckTracker on every inbound AckFrame using `frame.largestAcknowledged - frame.firstAckRange` — but that value lives in OUR outbound PN space, while the tracker holds inbound PNs we received from the peer. The two PN spaces are unrelated; the bug mostly hid because they grow at similar rates, but caused range-list bloat over long sessions where traffic is asymmetric (e.g. listener receives ~50 audio frames/sec while sending back ~1 ACK/sec). The correct semantics: purge only when the peer has confirmed receipt of OUR outbound ACK frame. Now driven by the ACK-of-ACK dispatch. - RecoveryToken.Ack changed from data object to data class carrying (level, largestAcked) — the encryption level and the largest inbound PN our outbound ACK frame covered. - QuicConnectionWriter populates these fields from the AckFrame at emit time. - QuicConnection.onTokensAcked dispatches RecoveryToken.Ack to levelState(level).ackTracker.purgeBelow(largestAcked + 1). - The wrong purge in QuicConnectionParser is removed (replaced with a comment pointing at the new dispatch path). Listed in the audit-summary deferred-work as item 6 (`AckTracker.purgeBelow threshold semantics`). New test: AckTrackerPurgeOnAckOfAckTest (4 cases — purge on ack-of-ack, level routing, partial purge keeps higher PNs, out-of-order ACKs are safe). https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ |
||
|
|
70af1953dc |
docs(quic): record retransmit subsystem implementation status
Update the two affected plan docs now that RFC 9002 retransmit is shipped on this branch. quic/plans/2026-05-04-control-frame-retransmit.md: - Mark plan as shipped 2026-05-05; list the 15 commits that landed it (plan + 9 steps + 5 follow-ups + perf + audit). - Document what changed vs the original scope: the deferred follow-ups (STREAM data, CRYPTO, RESET_STREAM/STOP_SENDING/NEW_CONNECTION_ID) all shipped on top of the receive-flow-control core. - Note the binary-search SendBuffer perf optimisation and the audit-driven first-call-wins fix. - Update the cap-workaround status: initialMaxStreamsUni is back to 10_000 (not the 1_000_000 mentioned in the original Why). quic/plans/2026-04-26-quic-stack-status.md: - Phase F downgraded from "partial" to "done (no CC)" — loss detection, RTT estimator, PTO, and per-frame retransmit shipped. - Removed "no STREAM retransmit" / "SendBuffer doesn't retain bytes until ACK" from the deliberately-don't-do list (now do). - Added congestion-control as the new deliberately-don't-do entry. - Crossed out the corresponding deferred-work items; added congestion control as deferred item #8. - Listed the new recovery test files in the test inventory. - Linked to the retransmit plan + implementation log. https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ |
||
|
|
086a9c75dc |
fix(quic): RESET_STREAM/STOP_SENDING first-call-wins + threading contract
Audit follow-up from the prior two commits.
1. resetStream() / stopSending() now no-op on the second call. RFC 9000
§3.5 pins finalSize at first emission; replaying retransmits with a
larger value (because the app enqueued more bytes between two
resetStream calls) would trigger FINAL_SIZE_ERROR on the peer. The
"idempotent: a second call overwrites with the newer error code"
claim was simply wrong. Two new tests lock the contract:
resetStream_secondCallIsNoOp_finalSizeFrozen and
stopSending_secondCallIsNoOp.
2. resetEmitPending / resetAcked / stopSendingEmitPending /
stopSendingAcked are now @Volatile. The public emit APIs are
callable from any coroutine while the writer / loss / ACK
dispatchers read the same fields under QuicConnection.lock; volatile
gives the cross-thread happens-before, and the first-call-wins gate
above eliminates the only multi-writer race (two app threads racing
the writer's clear-after-emit).
3. SendBuffer's class-level KDoc still claimed range arithmetic was
O(N) "swap to TreeMap if profiling flags it" — stale after the
binary-search refactor in
|
||
|
|
303caa8cf1 |
perf(quic): binary-search SendBuffer overlap + insert (O(log N))
removeOverlap was O(N) on the in-flight list — every ACK or loss notification scanned the whole deque. Replaced with a binary-search firstOverlapIndex helper plus a forward early-exit walk and a backward bulk-removal pass. addToInFlight likewise binary-searches for the middle-insert position instead of linear-scanning. The list is sorted by offset and non-overlapping by construction, so firstOverlapIndex finds the first entry whose end-offset > target in O(log N), and the walk terminates as soon as r.offset >= rangeEnd. Workload today is small (<100 entries per stream), but audio rooms with many active streams compound the per-ACK cost. https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ |
||
|
|
996ab39940 |
feat(quic): emit RESET_STREAM / STOP_SENDING + per-stream retransmit dispatch
Application code can now call QuicStream.resetStream(errorCode) and stopSending(errorCode); the next writer drain emits the matching frame with a RecoveryToken. Loss dispatch re-flags the per-stream emit-pending bit; ACK dispatch latches resetAcked / stopSendingAcked so stale loss notifications can't re-emit. NEW_CONNECTION_ID retransmit drains QuicConnection.pendingNewConnectionId on next writer pass (no public emit API since :quic doesn't rotate connection IDs, but the wiring is in place for a future emit path). Five tests in ResetStopSendingEmitTest mirror neqo's send-stream reset coverage: emit-and-token, retransmit-on-loss, ack-then-stale-loss-drop, stop-sending emission, and NEW_CONNECTION_ID drain. https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ |
||
|
|
0c847b4f69 |
feat(quic): wire CRYPTO retransmit per encryption level
Closes commits D + E of the deferred-follow-ups pass. With
SendBuffer's retain-until-ACK semantics in commit B and the
Crypto / ResetStream / StopSending / NewConnectionId tokens added
in commit A, the writer now records a Crypto token per CRYPTO
frame at each encryption level (Initial, Handshake) so RFC 9002
retransmit recovers lost handshake bytes.
# Writer side
QuicConnectionWriter.collectHandshakeLevelFrames returns a new
HandshakeLevelContents(frames, tokens) pair instead of a bare
frame list. For each emitted CryptoFrame it appends a matching
RecoveryToken.Crypto(level, offset, length).
QuicConnectionWriter.buildLongHeaderFromFrames now also takes the
parallel tokens list, and after encryption records a SentPacket
in the matching LevelState.sentPackets map. Initial-level rebuilds
with padding (RFC 9000 §14.1) call pnSpace.rewindOutboundForRebuild
to reuse the same PN — the second build's map insert overwrites
the prior entry, so retention reflects the final padded packet.
drainOutbound's two callsites updated to pass tokens through.
# ACK / loss dispatch
Already wired in commit A. QuicConnection.onTokensAcked routes
Crypto tokens to LevelState.cryptoSend.markAcked at the matching
level, releasing buffer memory as the contiguous low end is ACK'd.
QuicConnection.onTokensLost routes them to markLost, re-queueing
the bytes for retransmit at the same level.
# RESET_STREAM / STOP_SENDING / NEW_CONNECTION_ID
Same dispatcher-only completion. The pendingResetStream /
pendingStopSending / pendingNewConnectionId maps on QuicConnection
are populated by the loss dispatcher when those token types are
seen. :quic doesn't currently emit any of those frames (no
application code triggers stream reset, connection-ID rotation
isn't wired), so the writer never drains the maps yet —
scaffolding for future emit support. The exhaustive when in
onTokensLost / onTokensAcked is now complete: any future addition
of a new RecoveryToken variant trips the compile-time exhaustive
check, mirroring the test in RecoveryTokenTest.
# Tests added (3, all pass)
CryptoRetransmitTest:
- handshakePacket_carriesCryptoToken_inSentPacket: ClientHello
emission produces an Initial-level SentPacket with a Crypto
token at offset 0 with the expected level.
- cryptoData_lostAndRetransmittedAtSameLevel: simulate loss via
direct dispatch, observe re-queue in cryptoSend, verify next
drain produces a fresh Initial packet replaying the same
offset (RFC 9000 §13.3 idempotent).
- cryptoAck_releasesBufferAtSameLevel: ACK via onTokensAcked,
cryptoSend's readableBytes drops to 0.
# Net result
Lost handshake bytes (ClientHello, EncryptedExtensions, Certificate,
Finished, NewSessionTicket) are now recovered automatically. The
prior 1-second-fixed-PTO placeholder in QuicConnectionDriver
(commit step 7 of the prior plan) becomes meaningfully more useful
— PTO now wakes the writer to retransmit ACTUAL data, not just
emit empty PINGs.
Full :quic test suite + nestsClient moq-lite + amethyst Android
compile all pass.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
f623e886c3 |
feat(quic): wire STREAM data retransmit — token emission + ACK/loss dispatch
Closes commit C of the deferred-follow-ups pass. With the
SendBuffer rewrite in commit B (retain-until-ACK with markAcked /
markLost), the connection now wires STREAM frames into the same
RFC 9002 retransmit path that already handles flow-control
extensions.
# Writer side
QuicConnectionWriter.buildApplicationPacket records a
RecoveryToken.Stream(streamId, offset, length, fin) for every
STREAM frame it emits. The token captures the on-wire byte range
plus the FIN bit so retransmit can reproduce the same StreamFrame
on next drain.
# ACK side
New QuicConnection.onTokensAcked() mirrors onTokensLost. The
parser's AckFrame handler iterates the drained packets and routes
each to onTokensAcked, which:
- For Stream tokens: calls SendBuffer.markAcked(offset, length).
The buffer removes the range from in-flight; if the contiguous
low end is now fully ACK'd, flushedFloor advances and storage
shifts forward.
- For Crypto tokens: same shape, applied to the per-level
cryptoSend buffer (commit E will exercise this path for
handshake reliability — Crypto retransmit is wired now but the
writer's CRYPTO emission path doesn't yet record Crypto tokens;
that's commit E).
- For control-frame and Ack tokens: ACK-no-op. The frame already
did its job by reaching the peer; no per-buffer state to
release.
# Loss side
onTokensLost (commit A) already routes Stream tokens to
SendBuffer.markLost. With commit B's real implementation (was a
no-op stub), this now actually re-queues the byte range for
retransmit. The next writer drain pulls from the retransmit queue
before any fresh sends, with the original offset preserved (RFC
9000 §13.3 idempotent retransmit).
# Tests added (3, all pass)
- streamFrame_carriesStreamToken_inSentPacket: writer emits a
Stream token whose fields match the StreamFrame on the wire
- streamData_lostAndRetransmittedOnNextDrain: simulate loss via
direct dispatch, observe re-emit at the same offset in a fresh
SentPacket (different PN)
- streamData_ackedReleasesBuffer: ACK via onTokensAcked,
enqueue more bytes, observe the next send picks up at the
post-ACK offset (proves bytes were released and floor advanced)
Full :quic test suite, nestsClient moq-lite tests, amethyst Android
compile all pass.
Net result: lost STREAM data (e.g. nestsClient bidi control-stream
bytes — moq-lite Subscribe/Announce control messages travel on
QUIC bidi streams) is now recovered automatically. Audio rooms
benefit indirectly: the relay's announce/subscribe path is more
resilient to packet loss.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
03cfb3188f |
feat(quic): rewrite SendBuffer for retain-until-ACK with markAcked/markLost
Foundation for STREAM and CRYPTO retransmit (commits C, D of the
deferred-follow-ups pass). Replaces the prior best-effort mode
where takeChunk released bytes immediately.
# Three logical regions
The buffer covers `[flushedFloor, nextOffset)`. Each byte is in one
of three states:
- In-flight: sent but not yet ACK'd. Sorted-by-offset list.
- Needs retransmit: declared lost; re-sent before any fresh bytes.
FIFO queue.
- Unsent: `[nextSendOffset, nextOffset)`.
# New API
- markAcked(offset, length): removes the range from in-flight,
advances flushedFloor through any contiguous low-end ACKs and
shifts the underlying byte storage forward. Length=0 means
FIN-only ACK; latches finAcked = true.
- markLost(offset, length, fin): removes from in-flight, appends
to retransmit queue. If fin, clears finSent so the FIN gets
re-emitted. Idempotent: stale loss notifications below
flushedFloor are absorbed.
takeChunk priority order:
1. retransmit queue (preserves original offset; same byte data)
2. fresh unsent bytes from [nextSendOffset, nextOffset)
3. FIN-only zero-byte chunk if finPending and everything drained
# Range arithmetic
removeOverlap walks in-flight, computes the three-piece split for
each overlapping range (leftKept, covered, rightKept) and either
drops the covered portion (ACK) or pushes it onto retransmit
(loss). FIN belongs to the rightmost piece, so split at the end of
the original range correctly preserves it.
# Compaction
advanceFlushedFloorIfPossible bumps the floor whenever the lowest
in-flight / retransmit / unsent offset is above it, then
ByteArray.copyInto shifts the data window forward. Memory bounded
by `nextOffset - flushedFloor` rather than growing unboundedly.
# FIN handling
Treated as a virtual byte at offset = nextOffset. finPending arms
takeChunk to attach FIN to the final data chunk; finSent latches
true on emission; markLost(fin=true) clears it for re-emission;
finAcked latches true when the FIN-bearing range is ACK'd.
# Tests added (14, all pass)
- takeChunk_releasesNothingUntilAcked
- markAcked_full_releasesBytes_andDoesNotResend
- markLost_movesBytesToRetransmitQueue_takeChunkReplaysSameOffset
- markLost_partialRange_splitsInFlight
- markAcked_partialRange_splitsInFlight
- retransmitDrainsBeforeFreshBytes (priority order)
- maxBytesSplits_acrossRetransmitAndFresh
- fin_carriedOnFinalDataChunk_andRetransmittedOnLoss
- fin_only_emittedAfterDataDrained
- fin_only_lostAndRetransmits
- markAcked_advancesFlushedFloor_releasesMemory
- markAcked_outOfOrder_preventsFloorAdvance
- markLost_belowFlushedFloor_isNoop (defensive)
- finAcked_latchesTrueOnce
- readableBytes_reflectsRetransmitPlusFresh
- multipleSendsWithinSingleEnqueue_acksIndependently
Existing :quic tests (handshake, flow control, frame routing) +
nestsClient moq-lite + amethyst Android compile all pass.
SendBufferConcurrencyTest unchanged — the synchronized-on-this
discipline carries over from the prior implementation.
Wires nothing yet — commits C/D will route Stream/Crypto tokens
through markLost. The dispatcher in QuicConnection.onTokensLost
already calls markLost (added in commit A as a no-op stub); now
those calls actually do work.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
7f6d9085a4 |
feat(quic): extend RecoveryToken — Stream, Crypto, ResetStream, StopSending, NewConnectionId
Token-shape commit (commit A of the deferred-follow-ups pass that
extends RFC 9002 retransmit beyond the receive-side flow-control
extensions originally shipped at commit
|
||
|
|
c43c95184e |
feat(quic): steps 7, 8, 9 of RFC 9002 retransmit — PTO + integration test + revert workaround
Step 7: Probe Timeout (RFC 9002 §6.2).
- QuicLossDetection.ptoBaseMs(maxAckDelayMs) computes
`smoothed_rtt + max(4 * rttvar, 1ms) + max_ack_delay`.
- QuicConnection gains pendingPing: Boolean and
consecutivePtoCount: Int. The driver sets pendingPing = true
when the PTO timer fires; the writer drains it as a PingFrame
(smallest ack-eliciting frame). The peer ACKs the PING; that
ACK feeds loss detection (steps 5–6) and triggers retransmit.
- QuicConnectionDriver.sendLoop replaces the prior fixed 1-second
placeholder with RFC 9002 PTO timing, doubling backoff per
consecutivePtoCount per §6.2.2.
- Parser resets consecutivePtoCount on any new ack-eliciting ACK.
Step 8: end-to-end integration test (RetransmitIntegrationTest, 2
tests). Drives the full chain (writer → SentPacket → loss detection
→ dispatch → re-emit) by simulating loss directly on
QuicConnection state, since the in-process pipe doesn't model loss:
- maxStreamsUni_lostByPacketThreshold_isRetransmitted: emit a
MAX_STREAMS_UNI, simulate ACK at PN+4 (above
PACKET_THRESHOLD), verify retransmit lands in a NEW packet
with a fresh PN.
- lossDispatch_handlesSupersedeAcrossMultipleEmits: emit two
successive MAX_STREAMS_UNI bumps (caps 6 and 10), declare the
OLDER one lost. Supersede check drops the stale lost token —
no retransmit because the newer cap covers it.
Step 9: revert the cap workaround.
initialMaxStreamsUni: 1_000_000 → 10_000 (moq-rs's own default).
The 1M value was a workaround for the moq-rs cliff that fired
when our :quic emitted its first MAX_STREAMS_UNI extension. With
retransmit now durable, a single dropped extension is recovered
automatically — no need for the high-cap dodge. Lowering back
exercises the rolling-extension path (and its retransmit) in
production, which is what we want to validate the new code.
Tests added (4):
- PtoTest x4: RFC 9002 §6.2.1 duration math
(initial / with-ack-delay / after-rtt-sample / variance-floor)
- RetransmitIntegrationTest x2: end-to-end retransmit cycle and
supersede semantics
Full :quic test suite (~80 tests) + nestsClient moq-lite tests +
amethyst Android compile all green.
Closes the 9-step plan started at commit
|
||
|
|
15a6bfcc84 |
feat(quic): step 6 of RFC 9002 retransmit — dispatch lost tokens to pending*
QuicConnection.onTokensLost(tokens) closes the loop between loss
detection (step 5) and writer drain (step 4). For each lost token:
- Ack: ignored (RFC 9000 §13.2.1: ACK frames not retransmittable)
- MaxStreamsUni: pendingMaxStreamsUni := token.maxStreams
iff token.maxStreams == advertisedMaxStreamsUni
- MaxStreamsBidi / MaxData: same shape, against their advertised cap
- MaxStreamData: pendingMaxStreamData[streamId] := maxData iff
stream exists AND token.maxData == stream.receiveLimit
The supersede check (`lost == advertised`) mirrors neqo's
`fc.rs::frame_lost` line 322. If a higher extension has gone out
since, the older lost frame is irrelevant — the newer value
covers the receiver's grant. Without the check we'd resurrect
stale extensions and waste wire bandwidth re-emitting values the
peer already has.
Wired into the parser's AckFrame handler immediately after
detectAndRemoveLost: walk each lost packet's tokens and call
onTokensLost. Caller already holds the connection lock.
Tests added (8, all pass):
- ackToken_doesNotPopulateAnyPending
- lostMaxStreamsUni_matchingAdvertised_setsPending
- lostMaxStreamsUni_supersededByHigherEmit_isDropped (the
supersede-check invariant from neqo's fc.rs:322)
- lostMaxStreamsBidi_matchingAdvertised_setsPending
- lostMaxData_matchingAdvertised_setsPending
- lostMaxData_supersededIsDropped
- lostMaxStreamData_unknownStream_dropped (defensive)
- multipleLostTokens_dispatchAll (one packet's worth of mixed
tokens dispatched in one call)
- lostTokensFromMultiplePackets_unionInPending (sequential
dispatch across multiple lost packets — older stale, newer
valid; the valid one survives)
Mirror of neqo's `streams.rs::lost` dispatch + the
`no_max_allowed_frame_after_old_loss` and
`set_max_active_equal_does_not_set_frame_pending` tests from
`fc.rs` deferred from step 4 per the plan.
Full :quic test suite + nestsClient moq-lite tests pass.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
1df6441639 |
feat(quic): step 5 of RFC 9002 retransmit — loss detection + RTT estimator
QuicLossDetection encapsulates the RFC 9002 §5–§6 algorithms:
- RTT estimation (§5): smoothedRtt, rttVar, latestRtt, minRtt
with first-sample bootstrap; ack-delay clamped against minRtt
so a peer reporting a large delay can't push the estimate
below its observed floor (§5.3 anti-exploit clamp).
- Loss-delay (§6.1.2): max(latestRtt, smoothedRtt) * 9/8,
clamped to GRANULARITY_MS (1 ms).
- detectAndRemoveLost (§6.1): walks the in-flight set,
removes entries that are either:
- more than PACKET_THRESHOLD (3) PNs below largestAckedPn,
OR
- sent more than lossDelay ago.
Returns the lost packets so step 6 can dispatch their tokens.
Wired into QuicConnectionParser's AckFrame handler:
1. Snapshot largest-acked send time BEFORE drain
2. Drain ACK'd packets (step 3)
3. If largestAckedPn advanced AND any drained packet was
ack-eliciting, update RTT (RFC 9002 §5.2 sample conditions)
4. detectAndRemoveLost on the surviving set; lost list dropped
for now — step 6 wires the dispatch
LevelState gains:
- largestAckedPn: Long? (high-water mark for packet-threshold)
- largestAckedSentTimeMs: Long? (RTT sample input)
QuicConnection gains:
- lossDetection: QuicLossDetection (single instance, RTT is
per-path; we model a single path)
Tests added (11, all pass):
- firstRttSample_setsAllRttFieldsAtomically
- secondRttSample_movesSmoothedRttTowardSample (math: 7/8 + 1/8)
- ackDelay_clampedAgainstMinRtt (anti-exploit clamp)
- negativeRttSample_isIgnored (clock skew defense)
- lossDelay_floor (initial 333*9/8 = 374)
- packetThresholdLost_removesPacketsBelowThreshold (PNs 0..6
when largestAcked=10, threshold=3 → 7..9 survive)
- timeThresholdLost_removesPacketsSentTooLongAgo (sentAt + delay
< now → lost; recent → kept)
- packetEqualToLargestAcked_notLost (edge case)
- emptyMap_returnsEmpty
- lostPackets_carryOriginalTokens (drain preserves token list)
- packetThresholdAndTimeThresholdMatch_singleRemoval (no
double-iteration)
Mirrors the subset of neqo's `recovery/mod.rs` tests in scope per
the plan: remove_acked, time_loss_detection_gap,
time_loss_detection_timeout, big_gap_loss,
duplicate_ack_does_not_update_largest_acked_sent_time. PTO tests
land in step 7.
Full :quic test suite passes.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
29282634e5 |
feat(quic): step 4 of RFC 9002 retransmit — pending* fields + writer drain
Adds the writer's drain side of the retransmit path. The QuicConnection
gains four `pending*` fields:
- pendingMaxStreamsUni: Long?
- pendingMaxStreamsBidi: Long?
- pendingMaxData: Long?
- pendingMaxStreamData: MutableMap<Long, Long> (keyed by stream id)
Each non-null entry signals "the last extension we sent at this value
was lost; re-emit it". appendFlowControlUpdates now drains all four
ahead of the rolling-extension threshold check, emitting a fresh
frame + RecoveryToken for each pending entry and clearing it.
Step 4 only wires the consumer side; the setter side (loss
dispatcher) is step 6. Tests populate `pending*` directly to exercise
the drain in isolation.
Tests added (8, all pass):
- pendingMaxStreamsUni / Bidi / MaxData / MaxStreamData each
individually drain to a SentPacket carrying the matching token
- multiplePending: all four pending types drain into one packet
(writer drains them sequentially, no fan-out)
- noPending: drain produces no extension tokens
- pendingDrainBeforeThresholdCheck_supersedeOrderObservable:
writer drains the pending value as-is — supersede check is the
setter's responsibility (step 6), not the drain's
- pendingClearedAcrossDrains: a cleared pending stays cleared on
subsequent drains
Mirrors neqo's `fc.rs` retransmit tests in the plan
(`need_max_allowed_frame_after_loss`, `lost_after_increase`,
`multiple_retries_after_frame_pending_is_set`,
`new_retired_before_loss`). The supersede-check tests
(`no_max_allowed_frame_after_old_loss`,
`set_max_active_equal_does_not_set_frame_pending`) belong to step 6
and land there.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
0ced269b27 |
feat(quic): step 3 of RFC 9002 retransmit — drain SentPacket on ACK
QuicConnectionParser's AckFrame handler now drains
state.sentPackets of every entry whose packet number is covered by
the ACK's ranges. The drained SentPackets are returned but
discarded for now; step 5 will route them to loss detection / RTT.
New helper file `connection/recovery/AckedPackets.kt`:
- forEachAckedPacketNumber(ack, block): inline iterator over an
AckFrame's ranges in RFC 9000 §19.3.1 order. Walks first range
[largestAcked - firstAckRange, largestAcked] then each
additional range with `nextLargest = previousSmallest - gap - 2`,
`nextSmallest = nextLargest - ackRangeLength`. Defensive clamp
at PN 0 against malformed peer ACKs.
- drainAckedSentPackets(sentPackets, ack): walks via
forEachAckedPacketNumber and removes each matching entry from
the map. Returns the drained list.
Wired into QuicConnectionParser.kt:165 alongside the existing
ackTracker.purgeBelow call.
Tests added (9, all pass):
- simpleRange / multipleRanges / singlePacketAck: range walking
for typical ACK shapes
- ackForUnsentPn_isNoOp / emptyMap_returnsEmptyDrain: defensive
paths
- ackBoundary_pn0Inclusive: PN 0 is correctly included, no
underflow
- forEachAckedPacketNumber_iteratesDescending /
forEachAckedPacketNumber_acrossMultipleRanges_descending:
iterator semantics
- returnedDrain_preservesTokens: drained SentPacket retains its
full tokens list — step 5+ will dispatch these to RTT / loss
Mirror of neqo's `recovery/mod.rs::remove_acked` (one of the 20
recovery tests we owe per
`quic/plans/2026-05-04-control-frame-retransmit.md`). The remaining
loss-detection tests land in step 5.
Full :quic test suite + nestsClient moq-lite tests pass.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
ea15a9afa1 |
feat(quic): step 2 of RFC 9002 retransmit — record SentPacket per outbound
Plumbs sent-packet retention into the writer. From now on every
Application packet emission stores a SentPacket in
`LevelState.sentPackets`, keyed by packet number, carrying:
- the packet number (from `pnSpace.allocateOutbound()`)
- the writer's `nowMillis` send time
- whether the packet is ack-eliciting (RFC 9000 §13.2.1)
- the encrypted on-wire size (or 0 if encrypt threw)
- a list of RecoveryTokens — one per retransmittable frame in the
packet (Ack token for ACK frames; MaxStreamsUni / MaxStreamsBidi
/ MaxData / MaxStreamData for the corresponding flow-control
extensions)
`appendFlowControlUpdates` now takes a parallel `tokens: MutableList`
and writes lock-step with `frames`. The writer's existing semantics
are unchanged — same frames go on the wire, same advertised-cap
bookkeeping. Step 2 only adds the retention; nothing reads
`sentPackets` yet (steps 3–6 do that).
Order of operations on packet emission:
1. Allocate packet number
2. runCatching the encrypt step
3. Record SentPacket regardless of encrypt outcome (sizeBytes=0 if
it threw — the bookkeeping survives so loss detection can later
declare the gap lost on the time threshold)
4. Re-throw the encrypt exception so the driver loop sees the same
error it did before this change
Tests added (3, all pass):
- writer_records_sent_packet_with_max_streams_uni_token: cross
half-window, drain, observe a SentPacket whose tokens contain
MaxStreamsUni with maxStreams matching advertisedMaxStreamsUni
- ack_only_outbound_records_sent_packet_with_ack_token_and_not_ack_eliciting:
pending ACK only, observe a SentPacket with single Ack token and
ackEliciting=false
- successive_drains_record_distinct_packet_numbers: two drains
record disjoint PN sets
Full :quic test suite passes (no regressions). nestsClient moq-lite
tests pass.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
9e6fa3d3f0 |
feat(quic): step 1 of RFC 9002 retransmit — RecoveryToken + SentPacket types
First step of `quic/plans/2026-05-04-control-frame-retransmit.md`.
Pure type definitions, no behavior change yet — sets up the data
shape the next steps will populate from the writer (step 2) and
drain from ACK / loss-detection paths (steps 3–6).
RecoveryToken: sealed class mirroring neqo's
neqo-transport/src/recovery/token.rs:21 StreamRecoveryToken.
- Ack (singleton object): tracked but never retransmitted, so the
sent-packet map invariant ("every retained entry has at least
one token") holds for ACK-only packets too
- MaxStreamsUni / MaxStreamsBidi: receive-side stream-id cap
extension (RFC 9000 §19.11) — the frame whose loss tripped
the moq-rs cliff
- MaxData: connection-level data cap extension (§19.9)
- MaxStreamData: per-stream data cap extension (§19.10)
SentPacket: data class mirroring neqo's
neqo-transport/src/recovery/sent.rs::Packet. Held in a per-pn-space
map on QuicConnection (step 2 wires this up). Carries packet number,
send time, ack-eliciting flag, on-wire size, and the token list to
dispatch on loss.
Tests: 6 token tests (data-class equality, sealed-hierarchy
exhaustiveness, Ack singleton-ness) + 6 SentPacket tests (equality
across fields, copy semantics, ACK-only-with-Ack-token convention,
multi-token packet shape). All pass; full :quic test suite still
passes — types are purely additive.
Out of scope as documented in the plan: STREAM data retransmit,
CRYPTO retransmit, RESET_STREAM/STOP_SENDING/etc, congestion control,
0-RTT. Those are separate follow-ups.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
c24630513f |
docs(quic): plan control-frame retransmit subsystem mirroring neqo
The shipping fix raises initialMaxStreamsUni to 1M to dodge the
moq-rs cliff that fired when our :quic emitted its first
MAX_STREAMS_UNI extension. That sidesteps the symptom but leaves
every ack-eliciting control frame one wire-loss away from a silent
stall (MAX_DATA, MAX_STREAM_DATA, RESET_STREAM, etc.). Browser
QUIC stacks (Firefox neqo and Chrome quiche, both verified by
reading source) implement RFC 9002 §6 loss detection + per-frame
retransmit; :quic does not.
Plan documents:
- the architecture, mirroring neqo's typed-token shape (chosen
over quiche's monotonic-control-frame-id deque on code-fit
grounds — better match for :quic's existing sealed-class
Frame / MoqLiteControl idioms)
- file-by-file implementation order in 9 steps that each compile
and test independently
- inventory of ~50 tests to port from neqo (9 from fc.rs, ~20
from recovery/mod.rs, ~10 connection-level, plus codec round
trips). Each row links to the upstream neqo test by file:line
and the equivalent Kotlin test name we'll add
- explicit out-of-scope list (STREAM data retransmit, CRYPTO
retransmit, congestion control, 0-RTT, multipath) — separate
follow-ups
- effort estimate (4–7 days) and acceptance criteria
References upstream code at /tmp/quic-refs/neqo (mozilla/neqo) and
/tmp/quic-refs/quiche (google/quiche), sparse-cloned for source
verification.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
c3d6cadffb |
fix(quic): raise stream-id cap to 1M to support multi-hour Nests; strip diagnostic logs
Two changes once the cliff is confirmed sidestepped:
1. Raise initialMaxStreamsUni from 10 000 → 1 000 000.
At framesPerGroup=5 with 20 ms Opus frames the relay opens ~10
uni streams/sec to a listener. The half-window threshold check
(`count + initialMaxStreamsUni/2 >= advertisedMaxStreamsUni`)
now trips at count=500 000 ≈ 13.9 hours of continuous audio.
For any realistic Nest the rolling MAX_STREAMS_UNI extension
path — which is what tripped the moq-rs cliff — is dormant.
Memory cost: QuicConnection.streams grows for the connection's
lifetime (no removal in current model), so 2 hours costs ~72k
stream entries. Per-stream overhead is small enough that this
is tolerable for an audio-room workload; bounded growth is a
known follow-up.
2. Strip the high-frequency diagnostic logs that were added during
investigation. Production keeps:
- SUBSCRIBE_DROP (rare error)
- MAX_STREAMS_UNI / MAX_STREAMS_BIDI emit (should never fire
in normal operation now; if it does, we want to know)
- pumpUniStreams / pumpInboundBidis ended (pump death)
- announce / subscribe bidi.incoming() exception path
- ReconnectingHandle.opener throw + retry
Stripped:
- per-stream "transport delivered uni stream #N"
- per-group "uni grpHdr id=N seq=M"
- per-group "openGroup seq=N keyedOnSubId=…"
- per-25-stream peerInitiatedUniCount milestone
- per-chunk "subscribe id=N: bidi chunk #M"
- per-update RoomAnnouncement
- 5-second QuicWebTransportSession flow-control snapshot ticker
- "VM.openSubscription ->/<- subscribeSpeaker"
- "VM.onSpeakerActivity FIRST frame"
- "broadcaster: send accepted (subscriber attached)"
- "broadcaster: publisher.send returned false (no inbound subscriber)"
- "first inbound subscriber attached"
- "ignoring inbound SUBSCRIBE id=N track=catalog.json"
- "publish suffix=…"
- "transport delivered inbound bidi #N"
- "inbound AnnouncePlease prefix=…"
- "inbound SUBSCRIBE id=… track=…"
- "inbound SUBSCRIBE FIN'd: removing id=…"
The diagnostic logs can be re-added behind a debug flag later if
we need to chase a different regression.
Confirmed durable for at least 15 s of continuous audio against
nostrnests.com production with the prior 10 000-cap fix; bumping to
1 000 000 expands the headroom to multi-hour broadcasts without any
new code path firing.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
f32131d073 |
fix(quic): raise initialMaxStreamsUni from 100 → 10000 to dodge MAX_STREAMS_UNI cliff
Production trace pinned the audio-cliff to the exact moment our
writer emits its first MAX_STREAMS_UNI extension (frame 0x13).
With the default cap of 100 and the half-window threshold check at
count=50, the listener emits MAX_STREAMS_UNI(150) at count=50, then:
16:47:18.092 MAX_STREAMS_UNI emit oldCap=100 → newCap=150
16:47:18.210 transport delivered uni stream #50 ← LAST stream
16:47:20.204 udpRecvDatagrams=431
16:47:25.209 udpRecvDatagrams=443 (+12)
16:47:30.213 udpRecvDatagrams=443 ← FROZEN forever
`udpRecvDatagrams` (kernel-level UDP receive counter) freezes:
the relay's UDP packets stop reaching the OS. Our QUIC state still
believes the connection is alive (sendCredit=2^62-1, pendingBytes=0,
peerInitMaxStreamsUni=10000) but `peerInitiatedUniCount` is stuck at
51 forever. The relay, meanwhile, FINs both inbound SUBSCRIBE bidis
on the publisher side at +10s, telling the publisher "this listener
went away" — split-brain.
The cliff is repeatable but not at a fixed stream count: prior runs
saw cliffs at 124 (where the *second* bump at count=100 would have
fired) and 61 (where some intermediate condition tripped). The
common thread is the rolling-extension path, not the absolute count.
Without a packet capture from the relay we can't pin whether our
MAX_STREAMS_UNI frame is malformed, mis-sequenced, ill-timed against
moq-rs's flow-control state machine, or something subtler. But moq-rs
itself advertises `max_concurrent_uni_streams = 10000` for exactly
the audio-rooms workload — every Opus group is a fresh peer-initiated
uni stream — and matching that takes the listener to ~5 000 groups
(at framesPerGroup=5, Opus 20ms, that's ~8 minutes of audio) before
the half-window threshold trips at all. For any realistic Nest
duration we never need to extend.
The extension code path stays in QuicConnectionWriter.kt:395 — it's
correct per RFC 9000 §19.11, the bug is in moq-rs (or its Quinn
config) and we're working around it for now.
Side effect: we also bump the bidi cap implicitly — but that's
controlled by initialMaxStreamsBidi (still 100), unaffected by this
change. Bidi streams are control-stream-only in moq-lite (Subscribe
+ Announce), well below 100.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
283e7760bd |
debug(quic): log MAX_STREAMS_UNI emission and per-25-stream count milestones
Listener cliff at uni stream #124 with the publisher continuing to push for ~55 s past that point — the prior MAX_STREAMS_UNI extension fix is either firing too late or not firing at all on the live device. The trace can't tell us which because the bump itself is silent. - QuicConnectionWriter: log every MAX_STREAMS_UNI / MAX_STREAMS_BIDI emission with old→new cap and the current peerInitiated count, so we can see whether the writer is bumping the cap at all and how far behind reception it falls. - QuicConnection: log peerInitiatedUniCount every 25 streams along with the currently advertised cap and headroom. Cheap and lets us correlate "stream count growth" against "cap bumps" without printing per-stream noise. Tag is `NestQuic` so listeners can scope `adb logcat -s NestRx:D NestTx:D NestQuic:D` to capture the full picture. https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ |
||
|
|
46af65591a |
chase residual stream loss: bump SO_RCVBUF to 4 MiB + diagnostic UDP counters + cheaper test collector
Three targeted changes against the residual sustained-load stream loss
that survived the MAX_STREAMS_UNI fix (long broadcasts and bursty
scenarios still cliffed mid-pump). Each addresses one of the round-1
hypotheses; the next prod sweep should tell us how much each
contributes.
quic/UdpSocket:
- Bump SO_RCVBUF to 4 MiB at bind time. The kernel default (~200 KB
on Linux/macOS, similar on Android) holds barely 130 MTU-sized
datagrams; a multi-second moq-lite broadcast that the relay fans
out to several subscribers transiently overflows this. Anything
queued past `rmem` is silently dropped by the kernel and never
reaches our QUIC stack.
- Add lifetime diagnostic counters (receivedDatagramCount,
receivedByteCount, receiveBufferSizeBytes) on the expect surface
so commonMain code can read them via the new udpStatsSupplier
hook on QuicConnection without each platform having to surface its
own native bookkeeping.
- QuicConnectionDriver wires the supplier on start; the connection's
flowControlSnapshot bundles the stats into a new
QuicFlowControlSnapshot.udp field.
QuicConnection.flowControlSnapshot:
- Now also surfaces advertisedMaxStreamsUni / advertisedMaxStreamsBidi
and peerInitiatedUniCount / peerInitiatedBidiCount so a sweep can
see the listener-side stream-cap evolution alongside speaker-side
counters that were already there.
SendTraceScenario:
- verbosePerFrame default flipped from true to false. The per-frame
`tx i=…` and `rx[idx] gid=…` logs go through InteropDebug ->
JUnit's stdout capture; at 50 frames/sec the capture thread
serialises the receive coroutine and starves the QUIC read loop,
biasing the recorded received count downward on long runs. Tests
that need the per-frame timeline opt in explicitly.
- Replace the receive-side CopyOnWriteArrayList sink (O(N) per add,
~1.1M element copies cumulative for a 1500-frame run) with
Collections.synchronizedList(ArrayList(capacityHint)) — O(1)
amortised. Snapshot under the same lock at end of run so the
JDK's iterator-locking contract is satisfied.
- fc-pre / fc-post-pump / fc-post-grace lines now include
`udpDatagrams=… udpBytes=… udpRcvBuf=…` plus
`advertisedMaxStreamsUni=… peerInitiatedUni=…` so the next sweep's
diagnostic dump shows directly whether the kernel actually
delivered datagrams the relay sent.
Plan doc: nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md
documents round-2 changes and lists the remaining open hypotheses
(relay-side per-subscriber queue policy, receive-side
MAX_STREAM_DATA threshold).
All :quic + :nestsClient JVM tests pass.
|
||
|
|
d391ae1db9 |
quic: emit MAX_STREAMS_* to extend peer's stream-id cap (fixes prod cliff at frame ~99)
flowControlSnapshot dump from sweep_frames_200 against nostrnests.com proved
the speaker side was perfectly healthy (~5 EB conn-credit unused, 10 000-stream
peer cap unused, 0 bytes stuck in send buffers, all 200 streams opened) yet
the listener cliffed at frame 99. The constraint was on the listener's
receive side: our :quic was advertising initial_max_streams_uni = 100 at
handshake and never extending it, so the relay could only open 100 uni
streams to us *for the lifetime of the connection*. Each Opus frame the
relay forwards is a fresh peer-initiated uni stream, so any broadcast
longer than ~100 frames silently truncated at the audience.
QuicConnection:
- peerInitiatedUniCount / peerInitiatedBidiCount counters (incremented
in getOrCreatePeerStreamLocked).
- advertisedMaxStreamsUni / advertisedMaxStreamsBidi tracking, starting
at config.initialMaxStreams* and raised by the writer.
QuicConnectionWriter.appendFlowControlUpdates:
- When peerInitiatedUniCount + cfg.initialMaxStreamsUni / 2 >=
advertisedMaxStreamsUni, emit MaxStreamsFrame(bidi=false, newCap)
where newCap = peerInitiatedUniCount + cfg.initialMaxStreamsUni.
Same pattern the existing MaxDataFrame / MaxStreamDataFrame
extension uses; same half-window threshold so we don't spam the
peer.
- Symmetric branch for bidi.
PeerStreamCreditExtensionTest:
- Writer DOES emit MAX_STREAMS_UNI when peer's lifetime uni-stream count
crosses the half-window threshold; advertisedMaxStreamsUni updates.
- Writer DOES NOT emit MAX_STREAMS_UNI below the threshold;
advertisedMaxStreamsUni stays at the initial cap.
Updated nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md
with the fc-snapshot dump that proved the cause and the fix that
addresses it. The framesPerGroup=5 mitigation in NestMoqLiteBroadcaster
can be reverted to 1 once the prod sweep confirms the cliff is gone.
All :quic and :nestsClient JVM tests pass.
|
||
|
|
a0e5e04964 |
quic: expose flow-control snapshot for prod cliff investigation
Adds a read-only diagnostic surface that lets a test (or any caller)
read the peer's transport parameters, the live connection-level send
credit / consumed counters, the current peer-granted MAX_STREAMS_*
values, and the total bytes sitting in stream send buffers but not
yet handed to STREAM frames.
Goal: pin which budget runs out at the production "stream cliff"
described in nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md.
The plan flagged three candidates — connection-level MAX_DATA, per-
stream MAX_STREAM_DATA, or the relay's MAX_STREAMS_UNI extension
policy. The snapshot makes it possible to attribute the stall by
reading the diff between the pre-pump, post-pump, and post-grace
snapshots from the test's stdout.
QuicConnection:
- flowControlSnapshot(): suspend, lock-protected, returns
QuicFlowControlSnapshot (new data class) — peer TPs + live
accounting + sum of enqueued-not-sent bytes across streams.
QuicWebTransportSession (jvmAndroid adapter):
- quicFlowControlSnapshot() passthrough so the test can downcast
its WebTransportSession and read the underlying connection's
state without poking through the common transport interface.
SendTraceScenario:
- Optional flowControlSnapshot lambda parameter; when supplied,
logs three checkpoints — fc-pre, fc-post-pump, fc-post-grace —
each on a single line with the full snapshot.
NostrnestsProdAudioTransmissionTest + NostrNestsSustainedSendOutcomesInteropTest:
- withProdSpeakerAndListeners / withHarnessSpeakerAndListeners now
yield a snapshot lambda to the scenario block. Every sweep test
automatically dumps fc-* lines to the JUnit XML system-out.
FlowControlSnapshotTest:
- Pre-handshake: peer TP fields are null, counters zero.
- Post-handshake: every TP field reflects what the in-process TLS
server advertised; sendConnectionFlowCredit equals
initial_max_data; consumed = 0.
- Post-allocate-and-enqueue: nextLocalUni/BidiIndex advance,
totalEnqueuedNotSentBytes sums the buffered chunks, and
streamsWithPendingBytes counts only streams with > 0 pending.
Reading the production sweep output after this commit:
- fc-pre dumps what the relay grants on handshake (initial_max_data,
initial_max_stream_data_uni, initial_max_streams_uni).
- fc-post-pump shows whether sendConnectionFlowConsumed has
plateaued at the cap (peer didn't extend MAX_DATA) or whether
bytes are stuck in stream send buffers.
- The diff between fc-post-pump and fc-post-grace tells us
whether the relay's eventual MAX_DATA / MAX_STREAMS update did
or didn't arrive during the 30-60 s grace window.
|
||
|
|
96a585a67e |
Revert "quic: suspend openUni/BidiStream on peer-cap exhaustion + emit STREAMS_BLOCKED"
This reverts commit
|
||
|
|
f0705e3ab1 |
quic: suspend openUni/BidiStream on peer-cap exhaustion + emit STREAMS_BLOCKED
Production sweep showed every audio scenario opening >100 client-initiated
uni streams cliffed at received=99/N (one-line summary
[sweep-30s] sub[0] received=99/1500 missing=[99-1499]).
Same shape across every cadence, payload, and frame-count sweep variant —
the relay's initial_max_streams_uni=100 was being silently exhausted, after
which openUniStream threw QuicStreamLimitException, which the production
NestMoqLiteBroadcaster swallowed via its outer runCatching, dropping every
subsequent frame on the floor.
Fix:
- QuicConnection.openBidiStream / openUniStream now SUSPEND when the
peer-granted cap is reached, instead of throwing. They re-acquire
the connection lock on each retry, so the parser's MAX_STREAMS update
is observed atomically. Closing the connection wakes blocked openers
with QuicConnectionClosedException so they don't hang.
- QuicConnection.streamCapNotifier — single CompletableDeferred swapped
after each fire so all blocked openers wake at once rather than
serialising through Channel.receive.
- QuicConnectionParser fires the notifier whenever an inbound
MAX_STREAMS frame raises peerMaxStreams{Bidi,Uni}.
- QuicConnectionWriter emits a STREAMS_BLOCKED frame (RFC 9000 §19.14)
when an opener registers itself blocked, draining the slot once
written so we send at most one STREAMS_BLOCKED per cap value.
Frame.kt gains a real StreamsBlockedFrame class — previously the
inbound bytes were just consumed and discarded.
- QuicConnectionDriver.start wires connection.sendWakeupHook so an
internal opener-blocked event nudges the send loop without callers
needing a driver reference.
PeerStreamLimitTest rewritten:
- "throws QuicStreamLimitException" → "suspends with withTimeoutOrNull"
- Added: MAX_STREAMS_UNI frame wakes a suspended opener
- Added: openUniStream queues the STREAMS_BLOCKED slot
- Added: closing the connection unblocks waiters with the closed
exception
- Added: StreamsBlockedFrame round-trips through encode/decode
All :quic and :nestsClient JVM tests pass.
|
||
|
|
a6605e2792 |
fix(quic): use hostname-aware trust manager on Android
Android's RootTrustManager throws when an app has Network Security Config domain-specific entries and the 2-arg checkServerTrusted overload is used, crashing Nest room joins on relays covered by such config. Discover the 3-arg checkServerTrusted(chain, authType, hostname) overload via reflection and invoke it with the SNI host; fall back to the standard 2-arg form on plain JVM where that overload doesn't exist. |
||
|
|
f63e3b1c67 | Merge branch 'main' of https://github.com/vitorpamplona/amethyst | ||
|
|
df98235d31 | Minor adjustments to remove warnings | ||
|
|
a84fbd2e57 |
test(quic): add concurrent producer/consumer regression for SendBuffer
The previous SendBuffer suite (FlowControlEnforcementTest) is entirely
single-threaded — every test calls enqueue and takeChunk sequentially
on the same coroutine, so the race that crashed the audio path in
production (NoSuchElementException from chunks.first() under
concurrent enqueue + takeChunk) stayed invisible. The whole :quic
commonTest tree had no concurrent test at all.
Three new tests run real-thread races on Dispatchers.Default:
- concurrent_enqueue_and_takeChunk_does_not_throw drives multiple
producer coroutines + a consumer coroutine and asserts the buffer
drains cleanly with no exception.
- concurrent_takeChunk_callers_never_double_drain_a_chunk fans out
multiple consumers against a pre-populated buffer; asserts the
sum of bytes handed out equals the bytes enqueued (i.e. no chunk
is double-counted by overlapping head-peel paths).
- concurrent_finish_with_inflight_enqueue_emits_correct_fin races
finish() against in-flight writes and asserts the FIN comes
AFTER every enqueued byte.
Tests pass against the synchronised SendBuffer; running them against
the pre-fix unsynchronised version corrupts state badly enough that
the consumer wedges (an explicit "this is what the bug looked like"
demonstration). With internal synchronisation in place the suite
finishes in <0.2 s.
Documents the concurrent-access contract so a future "let's drop the
sync, it's hot" refactor immediately fails CI.
|
||
|
|
2d45c6ff4a |
fix(quic): make SendBuffer thread-safe to stop torn-state crash
QuicConnectionDriver.sendLoop holds the connection mutex while it calls
SendBuffer.takeChunk via QuicConnectionWriter.drainOutbound, but
WtPeerStreamDemux's per-stream `send` callback calls
SendBuffer.enqueue from arbitrary application coroutines without the
connection lock. The two paths concurrently mutate
(chunks, pendingBytes, headOffset, finPending, finSent, sentEnd,
nextOffset). Under load this surfaced as
java.util.NoSuchElementException: ArrayDeque is empty.
at kotlin.collections.ArrayDeque.first(ArrayDeque.kt:102)
at com.vitorpamplona.quic.stream.SendBuffer.takeChunk(SendBuffer.kt:85)
at com.vitorpamplona.quic.connection.QuicConnectionWriterKt.buildApplicationPacket(...)
at com.vitorpamplona.quic.connection.QuicConnectionDriver.sendLoop(...)
The writer saw `pendingBytes > 0` (incremented by an in-flight
enqueue on another thread) before the matching `chunks.addLast`
became visible, fell into the head-peel branch, and tripped on
chunks.first().
Wrap every read and write of SendBuffer state in `synchronized(this)`,
including the cheap `readableBytes` / `sentOffset` / `finPending` /
`finSent` getters used by the writer's pre-flight checks (so they
can't read torn state either). The lock is uncontended in the common
case and short-held in the rare race; we already use synchronized
blocks elsewhere in commonMain (QuicConnectionDriver.kt).
|
||
|
|
71cf99dc22 |
feat(audio-rooms): moq-lite speaker side end-to-end (phase 5c-speaker)
Production speaker path now runs on moq-lite, so connectNestsSpeaker
exchanges real moq-lite framing with the nostrnests reference relay.
Transport layer:
- WebTransportSession.incomingBidiStreams() — peer-initiated bidi
flow. moq-lite publishers receive Announce + Subscribe bidis from
the relay (rs/moq-lite/src/lite/publisher.rs:40 uses
Stream::accept(session)), so the abstraction grew the
accept-bidi-from-peer surface.
- WebTransportSession.openUniStream() — locally-opened uni stream
for group push (rs/moq-lite/src/lite/publisher.rs:338 uses
session.open_uni()).
- :quic WtPeerStreamDemux StrippedWtStream now carries optional
send/finish closures. The demux takes the QuicConnectionDriver
so wakeups fire after each app-level write on a peer-initiated
bidi.
- FakeWebTransport now exposes incomingBidiStreams + openUniStream
directly; the openPeerUniStream test helper went away (production
flow covers it).
Session layer:
- MoqLiteSession.publish(suffix) — claims a broadcast suffix and
lazily launches a relay→us bidi pump. ControlType=Announce reads
AnnouncePlease, replies Active(suffix). ControlType=Subscribe reads
body, replies SubscribeOk, registers the inbound subscription.
- MoqLitePublisherHandle — startGroup / send / endGroup / close
semantics. send opens a uni stream per group with DataType=0 +
GroupHeader and pushes varint(size)+payload frames. close emits
Announce(Ended) on every active announce bidi, FINs the uni.
Application layer:
- AudioRoomMoqLiteBroadcaster — sibling of AudioRoomBroadcaster but
drives MoqLitePublisherHandle (keeps IETF broadcaster intact for
its unit tests).
- MoqLiteNestsSpeaker — NestsSpeaker adapter, mirror of
MoqLiteNestsListener on the publish side.
- connectNestsSpeaker now opens a MoqLiteSession (no SETUP) and
returns MoqLiteNestsSpeaker.
Tests:
- 4 new MoqLiteSessionTest cases:
publisher_replies_to_announcePlease_with_active_announce,
publisher_acks_subscribe_and_pushes_group_data_on_uni_stream,
publisher_send_returns_false_when_no_inbound_subscriber,
publisher_close_emits_ended_announce.
Verified :commons:compileKotlinJvm + :amethyst:compilePlayDebugKotlin
both still compile against the swap.
Docs (plans + CLAUDE.md) refreshed to reflect speaker-side landing.
|
||
|
|
4338e5e6c4 |
docs(quic+nestsClient): post-implementation status + audio-rooms completion plan
Two new module-local plan docs (per CLAUDE.md's "plans live in the owning
module" rule) and a sweep of stale inline phase references.
quic/plans/2026-04-26-quic-stack-status.md:
Post-mortem of the original docs/plans/2026-04-22 plan. Documents
what shipped vs what was estimated, the actual package layout (~8.5k
LoC, 39 test files, 5 audit rounds), the crypto delegation surface
(Quartz only — no BouncyCastle, no JNI), interop verification status
(aioquic + picoquic; nests not yet), and known deferred items
(STREAM retransmit, Initial-key discard, etc.).
nestsClient/plans/2026-04-26-audio-rooms-completion.md:
Punch list to ship audio rooms end-to-end:
M1 Listener wire-up in Amethyst UI
M2 Multi-speaker audience UX
M3 Foreground service for backgrounded playback
M4 Manual interop pass against nostrnests.com
M5 MoQ publisher path (ANNOUNCE / TrackPublisher)
M6 Capture → encode → publish pipeline
M7 NestsSpeaker API
M8 App polish (reconnect, leave cleanup)
M9 Foreground service for speakers
~6 weeks for full audio rooms; ~2 weeks for listener-only MVP.
Inline doc cleanup:
* Removed "Phase 3a/3c-1/3c-2/3c-3" / "Phase B/C/D-K/L" references
from active code; replaced with "today" or pointers to the
completion plan
* Removed "Kwik-based stub" references; QuicWebTransportFactory and
surrounding docs now describe :quic as the production path
* TlsClient header reflects non-null certificateValidator + the
JdkCertificateValidator / PermissiveCertificateValidator split
* SendBuffer header documents the best-effort no-retransmit mode
explicitly (was hidden behind a "Phase L will fix this" note)
* MoqMessage / MoqObject / MoqSession reflect listener-side as
shipped + publisher-side as Phase M5
CLAUDE.md:
* Module list now includes :quic and :nestsClient (was 5 modules,
now 7)
* Architecture diagram + sharing philosophy explain what each new
module owns
No production behaviour changes; doc + comment-only edits. Tests green.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
7f05fd6e2a |
fix(quic): round-5 audit fixes — concurrency, ack-eliciting flags, scope leaks
Two parallel audit agents inspected the round-4 commits for regressions and
concurrency hazards. Major findings:
ackEliciting regression (HIGH from core-regression report):
Round-4's ACK gating optimization (only emit ACKs when something
ack-eliciting was received) didn't update the parser's per-frame
handling. MaxDataFrame, MaxStreamDataFrame, MaxStreamsFrame,
NewConnectionIdFrame, HandshakeDoneFrame, ResetStreamFrame, StopSendingFrame,
NewTokenFrame all need ackEliciting=true per RFC 9000 §13.2.1. Pre-fix a
packet carrying only one of these would record the PN but never trigger
an ACK, causing the peer to PTO-retransmit forever.
HandshakeDoneFrame conditional (HIGH):
Pre-fix the dispatcher unconditionally set status=CONNECTED; if
applyPeerTransportParameters had just called markClosedExternally
(e.g. CID-validation failure), a later HANDSHAKE_DONE in the same
payload would resurrect the connection. Now only sets CONNECTED when
status is HANDSHAKING.
WT scope leak (CRITICAL from concurrency report):
QuicWebTransportSessionState.close() never cancelled the scope holding
the demux pump and capsule reader coroutines; both kept running past
close, retaining QuicStream / chunk channels indefinitely. Memory
growth on long sessions that opened/closed many WT sessions.
WtPeerStreamDemux.route() collector leak (CRITICAL):
The route function launches a coroutine to drain stream.incoming into
chunkChannel (UNLIMITED). Four early-return paths (truncated stream
type, mismatched WT signal, foreign session id) returned without
closing chunkChannel — collector kept running, channel grew unbounded.
Now wrapped in coroutineScope{} so the collector is joined on every
exit. Also explicitly cancels collector on the catch path.
RESET_STREAM stream-id ownership (HIGH):
Pre-fix the dispatcher closed the local read side on whatever stream
the peer named. RFC 9000 §3.5: the peer can only RESET_STREAM streams
where it owns a send side. A peer RESETting a CLIENT_UNI is
STREAM_STATE_ERROR (we own the only side). Now closes the connection
in that case.
looksLikeIpLiteral tightening (HIGH):
Pre-fix accepted "1.2.3.4.5", "1.2", "1." as IP literals — Java's
InetAddress.getByName resolves all of those via DNS, defeating
audit-4 #4's SNI-leak fix. Now strict: 4 dot-separated octets each
in 0..255, or contains a colon (IPv6).
Signal channels closed on teardown (MEDIUM):
closeAllSignals() helper closes peerStreamSignal +
incomingDatagramSignal alongside closedSignal; pre-fix only closedSignal
was closed and racing parser frames could still trySend into
never-consumed channels. Centralised the call so close() and
markClosedExternally both invoke it.
Driver.close() idempotency (HIGH):
A second concurrent close() (common: session close + read-loop death
racing) used to launch a parallel teardown that called scope.cancel()
while the first's joinAll was mid-flight. Now memoizes the launched
Job behind a synchronized block.
Driver.close() flush detection (MEDIUM):
Pre-fix spun on `pendingDatagrams.isEmpty()` to detect
CONNECTION_CLOSE flush, but the writer's CLOSING branch bypasses
pendingDatagrams entirely. Now spins on `connection.status ==
CLOSING`, which transitions to CLOSED only after drainOutbound builds
the close packet.
@Volatile on peerMaxStreams* (MEDIUM):
peerMaxStreamsBidi/Uni snapshots are documented lock-free; without
@Volatile, JLS allows long-tearing on 32-bit JVMs and the JIT may
cache stale values.
CertificateFactory parse inside try (MEDIUM):
Malformed cert chain bytes used to throw raw CertificateException
through the read loop. Now wrapped, so parse failure becomes a clean
CONNECTION_CLOSE.
GOAWAY id-regression observability (MEDIUM):
Pre-fix the QuicCodecException thrown on increasing GOAWAY id was
silently swallowed by route()'s catch. Now also surfaces via
peerGoawayProtocolError so the application/QUIC layer can act.
appendFlowControlUpdates uses streamsListLocked (perf):
The round-4 perf #10 fix introduced streamsListLocked (no
entries.toList per drain) but appendFlowControlUpdates still iterated
the Map. Now also uses the index-friendly view.
peerCloseDeferred completion on session close (MEDIUM):
awaitPeerClose() used to hang forever if the local side called close()
before any peer-initiated WT_CLOSE_SESSION arrived. Now cancelled with
CancellationException on local close.
Tests:
AckElicitingFramesTest pins the ackEliciting contract on every round-5
fix plus the ResetStream-on-CLIENT_UNI rejection.
JdkCertificateValidatorIpLiteralTest pins the tightened pattern via
reflection.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
920b36cdd6 |
perf(quic): round-4 perf-audit fixes — ACK gating, flow-control dirty-set, streams list view
Four perf wins from the round-4 audit, all in the steady-state hot path (audio rooms run at ~50 datagrams/sec/participant). Perf #1 — ACK frame gating (saves a frame + bandwidth on every drain): AckTracker.buildAckFrame now returns null when no new ack-eliciting packet has arrived since the last build. Pre-fix the writer emitted a redundant ACK frame on every outbound packet (~50/sec each direction) even when the only inbound traffic since last drain was ACK-only. RFC 9000 §13.2 only requires ACKs in response to ack-eliciting packets within max_ack_delay; gating on ackElicitingPending satisfies that without delay-timer machinery. Perf #11 — AckTracker.purgeBelow short-circuit: Common case: peer ACKs a high PN, we already pruned below it. Pre-fix triggered a full ListIterator walk anyway. Now bails out when the tail's start is already above the threshold. Perf #9 — Flow-control dirty-set: QuicStream gains a receiveDirtyForFlowControl flag set by the parser when readContiguous advances the frontier. The writer's appendFlowControlUpdates now skips per-direction-window lookup + threshold comparison for streams whose flag is unset. Big win for multi-stream sessions (audio rooms with N×M streams per participant). Perf #10 — Streams list view: QuicConnection maintains a parallel insertion-ordered List<QuicStream> alongside the streams Map. Writer's round-robin scan reads the list directly instead of allocating `entries.toList()` per drain. No removal path exists today; the insert points (openBidi/UniStream, getOrCreatePeerStreamLocked) update both. AckTrackerGatingTest pins the new gating contract: first build returns a frame; second build without new reception returns null; subsequent ack-eliciting reception re-arms; non-ack-eliciting receptions alone don't. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |
||
|
|
21da61ad64 |
test(quic): comprehensive regression tests for round-4 fixes + coverage holes
Pins every behavioural change made in the round-4 audit-fix commit so a
future regression can't quietly resurrect any of the bugs.
FrameRoutingTest (new):
* RESET_STREAM / STOP_SENDING / NEW_TOKEN round-trip and don't kill
the connection on arrival
* Peer attempting CLIENT_BIDI / CLIENT_UNI stream IDs closes the
connection (STREAM_STATE_ERROR)
* MaxDataFrame raises sendConnectionFlowCredit; lower values ignored
* CONNECTION_CLOSE returns immediately — frames after it are not
dispatched (would otherwise create phantom streams on a closed
connection)
* HANDSHAKE_DONE at APPLICATION level is legal (ensures the level-
validation guard didn't over-fire)
* incomingDatagrams queue caps at MAX_INCOMING_DATAGRAM_QUEUE; oldest
entries dropped on overflow
ReceiveBufferFinTest (new): the audit-4 #4 silent-truncation fix
* isFullyRead() stays false when FIN arrives before a gap fills
* isFullyRead() flips true only after contiguous-end reaches finOffset
* Zero-length FIN frame at exact end marks stream complete
* finOffset is pinned at first observation (RFC 9000 §4.5)
QuicConnectionWriterTest (new): drainOutbound paths previously untested
* CLOSING-status drain produces a CONNECTION_CLOSE packet
* appendFlowControlUpdates raises stream.receiveLimit after consumer
drains > half window
* Writer enforces sendConnectionFlowCredit cap (audit-4 #9 — never
exceeds the peer's initial_max_data even with more bytes queued)
JcaAesGcmAeadTest (new, jvmTest): JVM-platform AEAD round-trip
* seal → open round-trip
* Different nonces produce different ciphertexts
* Rebuild path (same nonce twice) uses fallback cipher and still opens
* Corrupted ciphertext / wrong AAD → null
* key/nonce/tag length constants
ChaCha20Poly1305AeadTest (new): the seal side that prior tests skipped
* seal → open round-trip
* Bad tag / wrong AAD → null
* Wrong-size key/nonce throws IllegalArgumentException
WtPeerStreamDemuxTest: GOAWAY id-regression branch (audit-4 #5)
* Increasing GOAWAY id is rejected; previously recorded id stays put
CapsuleReaderTest: new strictness assertions
* WT_CLOSE_SESSION body < 4 bytes throws QuicCodecException
* Reason > 8192 bytes throws QuicCodecException
Notes:
* QuicConnectionDriver direct unit tests would require turning UdpSocket
from `expect class` into an interface; deferred — driver paths are
exercised end-to-end by InteropRunner and indirectly via every pipe-
based test.
* Decrypting client-emitted packets in tests requires server-side keys
(server's RX = client's TX with different cached cipher state in JCA);
writer tests assert side-effects on connection state instead.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
222a4e7d42 |
fix(quic): round-4 tier-1 + tier-2 audit fixes
Critical interop blockers + security/correctness gaps surfaced by the
parallel round-4 audit. All fixes have inline comments referencing the
audit finding number.
Frame layer:
* Decode RESET_STREAM (0x04), STOP_SENDING (0x05), NEW_TOKEN (0x07).
Pre-fix these fell through to the `unknown frame type` branch and
threw QuicCodecException through the read loop, killing the
connection. aioquic and picoquic emit RESET_STREAM regularly.
* Wrap decodeFrames in try/catch in dispatchFrames; on a decode
error, transition to CLOSED gracefully via markClosedExternally
instead of letting the exception escape the read loop.
Connection layer:
* Reject peer-attempted CLIENT_BIDI / CLIENT_UNI stream IDs that don't
map to a stream we opened (RFC 9000 §19.8 STREAM_STATE_ERROR).
* MaxDataFrame now actually updates sendConnectionFlowCredit (was a
no-op pre-fix; sustained sends silently stalled).
* Writer enforces sendConnectionFlowCredit and tracks
sendConnectionFlowConsumed so cumulative bytes stay under the
peer's initial_max_data cap.
* SERVER_BIDI peer-opened streams inherit sendCredit from
peer.initialMaxStreamDataBidiLocal (was 0L; reply path was wedged
until MAX_STREAM_DATA arrived).
* applyPeerTransportParameters validates initial_source_connection_id
and original_destination_connection_id (RFC 9000 §7.3 MUST checks);
mismatch closes with TRANSPORT_PARAMETER_ERROR.
* Cap incomingDatagrams queue at 256 (audio rooms ~50/sec; 5-second
burst). On overflow, drop oldest — fresh frames matter more for
live media. Pre-fix RFC 9221 datagrams were unbounded.
Stream layer:
* QuicStream.deliverIncoming now returns Boolean; parser closes the
connection with INTERNAL_ERROR on saturation rather than silently
dropping bytes (peer believes the bytes were delivered, application
sees a hole).
* ReceiveBuffer tracks finOffset and exposes isFullyRead(); parser
only closes the incoming channel after the contiguous read frontier
reaches the FIN offset (pre-fix closing on FIN-frame arrival
truncated streams that had gaps).
TLS hardening:
* certificateValidator is non-null. Tests pass an explicit
PermissiveCertificateValidator; null was a silent-MITM hazard.
* Drop SIG_RSA_PKCS1_SHA256 from accepted CertificateVerify
schemes (forbidden by RFC 8446 §4.2.3 in CertificateVerify).
* Hard-fail the PSK-Finished path: we never offer a pre_shared_key
extension, so a server skipping Certificate/CertificateVerify is
either misbehaving or a partial-MITM stripping cert proof.
* Validate ALPN: reject any ALPN the server selected that we didn't
offer (was previously accepted silently).
* Add APPLICATION-level inboundBuffer so post-handshake CRYPTO
(NewSessionTicket, KeyUpdate detection) reaches the
SENT_CLIENT_FINISHED handler.
* State.FAILED is now actually assigned on any handler throw;
pushHandshakeBytes refuses further bytes when in FAILED.
* IP-literal precheck before InetAddress.getByName so cert
validation doesn't trigger DNS A/AAAA lookups for hostnames
(audit-4 #4: leaked SNI/hostname over plaintext DNS).
WT layer:
* GOAWAY id-regression check (RFC 9114 §5.2: MUST NOT increase).
A server sending an increasing id raises QuicCodecException.
* WT_CLOSE_SESSION decoder rejects bodies < 4 bytes (mandatory
error-code field) and reasons > 8192 bytes.
* Capsule reader catches Throwable but separately rethrows
CancellationException; on parse error, completes peerCloseDeferred
exceptionally so awaitPeerClose() doesn't hang forever.
HTTP/3 + QPACK:
* Http3Settings.decodeBody rejects duplicate ids (RFC 9114 §7.2.4.1
H3_SETTINGS_ERROR).
* QpackInteger.decode bounds-checks shift before extending value;
defence-in-depth Long-overflow check on accumulated value.
* QpackDecoder static-table accesses go through a bounds-checking
helper that throws typed QuicCodecException; literal lengths are
range-checked before allocation.
Test infra:
* InMemoryQuicPipe accepts an injectable serverScid and constructs
its tlsServer with TPs that include the required CIDs.
* InProcessTlsServer emits stub Certificate + CertificateVerify
so the real (non-PSK) handshake path is exercised.
* Updated all test callers to use PermissiveCertificateValidator.
* Updated CapsuleReaderTest with negative-path assertions for the
new strictness.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
0023c73aeb |
test(quic): regression tests for receive-limit, incoming channel cap, coalesced-packet skip
Three pending audit-2/3 regression tests, plus the InMemoryQuicPipe helpers needed to drive them. ReceiveLimitEnforcementTest — peer overshooting per-stream receive limit must transition the connection to CLOSED via markClosedExternally. Mirror test verifies the boundary value (frameEnd == receiveLimit) does NOT close. QuicStreamIncomingChannelTest — the per-stream incoming channel is bounded at 64 chunks; trySend on saturation must not block (would deadlock the parser on the connection lock). Empty chunks are filtered. closeIncoming terminates the collector. CoalescedPacketSkipTest — RFC 9000 §12.2 / RFC 9001 §5.5: feedDatagram must walk across coalesced packets, must skip a packet that fails AEAD verification using peekHeader.totalLength (not break the loop), and must exit cleanly when a trailing header is truncated. Pipe additions: buildServerApplicationDatagram + coalesceDatagrams give tests the primitives to drive arbitrary server → client app-level frames. InMemoryQuicPipe also takes an optional tlsServer so tests can advertise non-default transport parameters. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |
||
|
|
62a42cb36d |
fix(quic): audit-3 follow-ups + regression coverage
Cipher caching: cache JCA Cipher + SecretKeySpec per direction in a new
JcaAesGcmAead so steady-state seal/open avoids Cipher.getInstance("AES/GCM/
NoPadding") per packet (audit-1, audit-3 hot path). Initial-padding rebuild
edge case (re-encrypting the same PN with the same nonce) falls back to a
fresh cipher because JCA tracks (key, iv) pairs and rejects legitimate reuse.
Channel-based wakeups: replace the WT peer-stream poller's delay(5)
busy-loop with awaitIncomingPeerStream/awaitIncomingDatagram suspending
on conflated wakeup channels fired by the parser. Connection close also
closes a closedSignal so any awaiter unblocks promptly with null.
Driver close ordering: close() now joins the read + send loops with a
bounded timeout instead of yield()+cancel()-racing them. Catches the case
where scope.cancel() fired mid-socket.send, occasionally producing partial
datagrams or skipping CONNECTION_CLOSE entirely.
WT graceful close: spawn a CapsuleReader-driven coroutine on the CONNECT
bidi that decodes WT_CLOSE_SESSION and surfaces it via peerCloseSession +
awaitPeerClose. Previously the encoder existed but no decoder consumed
incoming capsules, so peer-initiated graceful close was silent.
GOAWAY: WtPeerStreamDemux decodes the GOAWAY varint body into
peerGoawayStreamId instead of `is Goaway -> Unit`-dropping it.
TLS transcript hash: incremental SHA-256 backed by JCA MessageDigest,
snapshotted via clone() — replaces the O(n²) "concatenate-everything-and-
re-hash on every snapshot" implementation. TLS 1.3 takes ≥3 snapshots per
handshake.
MAX_STREAMS routing: parser now bumps peerMaxStreamsBidi/Uni on inbound
MAX_STREAMS frames; openBidiStream/openUniStream throw QuicStreamLimitException
when the cap is reached instead of silently overrunning it. Initial cap
sourced from peer transport parameters.
Regression tests:
* CapsuleReaderTest – round-trip, split-chunk, partial, unknown types
* TlsTranscriptHashTest – snapshot determinism, no consume-on-snapshot
* PeerStreamLimitTest – TP-driven cap, MAX_STREAMS frame round-trip
* WtPeerStreamDemuxTest – CONTROL stream GOAWAY decode
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
2f9a0a0e03 |
fix(quic): live interop against aioquic + round-3 audit fixes
🎉 First successful live-interop handshake against a real reference impl. Drove our pure-Kotlin QUIC client at the aucslab/aioquic-http3-server Docker container; HANDSHAKE COMPLETE with negotiated ALPN=h3 and full transport parameter exchange: max_data=1048576, max_streams_bidi=128, max_streams_uni=128, idle_timeout=60000ms, max_datagram=65536 Every layer worked end-to-end over real UDP: packet codec, header protection, AES-128-GCM AEAD, TLS 1.3 with X25519, CertificateVerify, transport parameters, ALPN negotiation. The PermissiveCertificateValidator + InteropRunner main + Gradle :quic:interop task make this reproducible in one command. Round-3 audit fixes (8 critical bugs caught by 4 parallel reviewers): 1. feedDatagram coalesced-packet skip (RFC 9001 §5.5): `?: break` discarded all subsequent coalesced packets if any one of them failed to decrypt. Now uses peekHeader to advance over a failed packet, only breaking on a totally-unparseable header. 2. Receive-side flow-control enforcement: Parser was inserting STREAM frames without checking against the limit we advertised. A misbehaving peer could blow past initialMaxStreamDataX with no error; now triggers markClosedExternally with FLOW_CONTROL diagnostic. 3. Bounded incomingChannel (audit-2 finding finally addressed): QuicStream.incomingChannel was Channel.UNLIMITED — slow consumer + fast peer = unbounded heap growth. Now Channel(64), bounded by the per-stream receive limit. Combined with #2, memory growth is capped. 4. RetryPacket CID length validation: parse() didn't bounds-check dcidLen/scidLen against the RFC 9000 §17.2 1..20 range. Hostile Retry with cidLen=255 → readBytes throws QuicCodecException to the caller (instead of returning null for silent drop). Added explicit `!in 0..20 → return null`. 5. HelloRetryRequest detection: No HRR check — we treated it as a regular ServerHello, derived an ECDHE on garbage, then failed AEAD downstream with a confusing error. Now checks ServerHello.random against the SHA-256("HelloRetryRequest") magic value and throws cleanly. 6. CancellationException handling in WT factory: Catch-all wrapped CancellationException as HandshakeFailed, breaking structured concurrency. Now rethrows ce after closing the driver. 7. InteropRunner scope leak on timeout: parentScope was never cancelled — orphaned coroutines kept the IO dispatcher alive after main exited. Now scope.cancel() runs unconditionally; small delay gives the driver-launched teardown a moment to flush before exit. 8. RFC 9220 §3.1 SETTINGS-before-CONNECT documented as known limitation: The strict requirement to wait for SETTINGS_ENABLE_WEBTRANSPORT=1 before sending CONNECT requires more refactoring (the demux capturing peerSettings lives inside QuicWebTransportSessionState, which we don't build until after the request bidi opens). Tolerant servers (aioquic, quic-go) accept early CONNECT; documented for future strict-RFC fix. All :quic:jvmTest + :nestsClient:jvmTest pass. Live aioquic interop verified post-fix: handshake completes, transport parameters round-trip cleanly. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |
||
|
|
d2a10c9e4a |
feat(quic): live interop harness — picoquic Docker + InteropRunner main
Setup for driving the pure-Kotlin QUIC client against real reference
servers, kicking off the live-interop validation that closes the loop on
the audit cycles.
PermissiveCertificateValidator (commonMain test-only):
Accepts any cert chain and any signature. For local dev servers (picoquic,
quic-go, nests-rs in Docker) where the system trust store would reject the
self-signed cert. Documented as test-only — must never be wired into
production code.
InteropRunner.main (jvmTest):
Standalone runnable that opens a UDP socket, builds a QuicConnection with
PermissiveCertificateValidator, drives the handshake under a configurable
timeout, and reports CONNECTED / HandshakeFailed / Timeout / UdpFailed
with peer transport parameters on success. Exit code 0 on connect, 1 on
any failure mode — wirable into CI.
`quic/scripts/run-picoquic.sh`:
One-line Docker harness that runs Christian Huitema's picoquic reference
server on UDP 4433. Picoquic is the IETF QUIC WG's reference impl and
the most permissive target for a fresh client (clear qlog traces, accepts
a wide range of transport params).
`./gradlew :quic:interop` Gradle task:
Wraps the runner so live-interop is one command:
./gradlew :quic:interop -PinteropHost=127.0.0.1 -PinteropPort=4433
Validated against a non-running server: returns Timeout cleanly with
exit 1 (the failure-path proves the harness wires correctly).
Workflow documented in `quic/scripts/README.md` covering picoquic,
quic-go, aioquic, quiche, and the eventual graduation path to the
quic-interop-runner Docker matrix at https://interop.seemann.io.
Next step: actually run picoquic in Docker and chase whatever real-world
incompatibilities surface. Each will be a focused fix commit.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
bd192fc649 |
fix(quic): PTO timer + IDN/IPv6 hostname + Huffman lookup table + amortized compaction
Closes the remaining items from the round-2 audit.
Driver send-loop PTO timer:
Loop suspends on `withTimeoutOrNull(ptoMillis) { sendWakeup.receive() }`
instead of unconditional receive. PTO doubles on each consecutive timeout
up to 60s; resets to 1s on any wakeup.
Without this, a single lost ClientHello with no inbound traffic to drive
wakeups left the connection wedged forever. The PTO wake now gives the
writer a chance to re-emit on retransmission.
JdkCertificateValidator hostname matching:
- IDN.toASCII normalization on both the cert SAN and the input host so
a SAN of `xn--bcher-kva.de` matches an input of `bücher.de`.
- IPv6 literals compared via InetAddress.getByName().hostAddress so
`::1` and `0:0:0:0:0:0:0:1` compare equal.
- Wildcard public-suffix-label rejection: requires the suffix to contain
at least 2 dots, so `*.com` is rejected even if a misbehaving CA
issued such a cert.
QpackHuffman.decode O(N×256) → O(N×L) where L is the number of distinct
code lengths (≤ 26):
Previously the inner loop scanned all 256 symbols per output byte. Now
it walks length buckets in ascending order and does a HashMap lookup at
each. For ASCII text the hot path matches at length 5-8.
Http3FrameReader.push + CapsuleReader.push amortized compaction:
Was O(N) memcpy on every push → O(N²) over the lifetime of a long-lived
stream. Now compacts only when consumed prefix is at least half the
buffer, giving amortized O(1) per byte.
All :quic:jvmTest + :nestsClient:jvmTest pass.
End of round-2 audit fixes. The branch now passes:
- Every RFC 9001 Appendix A test vector bit-for-bit
- The full in-memory client+server pipe handshake
- Hostile-input fuzzing of decodeFrames
- Coalesced-packets ACK regression
- Multi-cipher-suite (AES-GCM + ChaCha20-Poly1305) handshake
- Negative-path TLS rejection (session_id_echo, version, group, missing
extensions)
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
8f5280c9c3 |
fix(quic): WebTransport peer-stream demux + flow-control + perf cleanup
Round-2 audit found that without prefix-stripping on peer-initiated streams,
the server's HTTP/3 control stream would deliver its SETTINGS frame to the
application, breaking MoQ framing. Plus several flow-control + perf items
the audit flagged.
WtPeerStreamDemux:
Spawned per WT session in QuicWebTransportSessionState.init. Routes peer
streams by their leading varint(s):
- HTTP/3 CONTROL stream (0x00) → drains internally, captures peer
SETTINGS frame for inspection (peerSettings property).
- QPACK encoder/decoder streams (0x02/0x03) → drained to /dev/null
(we run with QPACK_MAX_TABLE_CAPACITY=0).
- WebTransport unidirectional (0x54) + matching quarter session id →
surfaces to app as a StrippedWtStream with the prefix stripped.
- WebTransport bidi signal (0x41) + matching quarter session id → same.
- Anything else → dropped per RFC 9114 §9.
pollIncomingPeerStream is now @Deprecated; the recommended path is the
incomingStrippedStreams flow on the session state. The nestsClient adapter
now consumes the stripped flow and emits StrippedWtReadStreamAdapter
through the existing WebTransportSession.incomingUniStreams API.
WT_CLOSE_SESSION decoder + CapsuleReader:
Added stateful capsule reader that yields parsed WtCloseSession
(errorCode + reason) from the CONNECT bidi. Wires into the future close
notification path; not yet bound to status flip but the parser is in
place.
Flow-control fixes from the audit:
- Per-direction receive window in getOrCreatePeerStreamLocked. SERVER_UNI
streams now use initialMaxStreamDataUni instead of bidi-remote.
- appendFlowControlUpdates picks per-direction window matching the
stream's StreamId.kindOf.
- MAX_DATA tracking via QuicConnection.advertisedMaxData high-water mark.
Previously the writer emitted a fresh MAX_DATA on every outbound
packet once the threshold was crossed (spam). Now: only when the new
value strictly exceeds advertisedMaxData.
Stream iteration round-robin:
Writer was iterating streamsLocked() in insertion order, starving streams
created later under MTU pressure. Added streamRoundRobinStart counter on
QuicConnection that advances after each drain.
SendBuffer alias defense:
takeChunk's fast path used to return the head ByteArray reference directly
when consuming the whole chunk — caller-owned byte arrays could be mutated
in-place. Now always copies, since downstream encoders pass the bytes to
AEAD.seal which assumes immutability.
UdpSocket cleanup:
- readBuf shrunk from 65 KiB to 2 KiB. QUIC datagrams cap at MTU; the old
65 KiB allocation was per-connection waste with no benefit.
- Removed pointless synchronized(readBuf) — only the read loop touches it.
All :quic:jvmTest + :nestsClient:jvmTest pass.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
94a3d32a6d |
fix(quic): lifecycle hangs + TLS hardening from second-pass audit
Six fixes from the round-2 audit, all of which would either hang the
client on failure modes or weaken security against a misbehaving server.
QuicConnection / awaitHandshake hang fixes:
- QuicConnectionClosedException + markClosedExternally(reason).
- close() now also calls signalHandshakeFailed if !handshakeComplete.
- QuicConnectionParser inbound CONNECTION_CLOSE → markClosedExternally
(was: just flip status, leaving awaiters hanging forever).
- QuicConnectionDriver.readLoop has a finally-block that calls
markClosedExternally + wakeup so when the socket closes mid-handshake
or the server closes uncleanly, awaitHandshake() throws instead of
suspending forever.
QuicConnectionDriver.close() no longer cancels its own caller scope:
Previously close() was suspend, called connection.close (acquires lock)
→ wakeup → scope.cancel → socket.close. If close() is invoked from
inside the driver scope (e.g. by the WT factory's exception cleanup
path), scope.cancel cancels the very coroutine running close, so
socket.close may never run. close() is now non-suspend and dispatches
the teardown onto parentScope.launch so the cancel never reaches its
caller.
QuicWebTransportFactory.connect:
- readResponseStatus wrapped in withTimeoutOrNull(connectTimeoutMillis,
default 10s). Dead network → HandshakeFailed instead of forever-hang.
- Whole post-handshake setup (open control + request streams + read
response) now in try/catch that calls driver.close() on any unexpected
exception. Previously a thrown SocketException between awaitHandshake()
and the explicit non-2xx branch leaked the driver + UDP socket.
JdkCertificateValidator.validateChain authType:
Was hardcoded "ECDHE_ECDSA". Now derived from the leaf cert's public-key
algorithm: "RSA" → ECDHE_RSA, "EC"/"EdDSA" → ECDHE_ECDSA. Some Android
trust managers (RootTrustManager, NetworkSecurityConfig) gate
algorithm-specific pinning rules on this string and may reject mismatched
combos.
TlsExtension.decodeList bounds:
Inner reads were unbounded against the extension-list end. A malicious
server could claim totalLen=4 but encode an extension whose data length
declared 1000, reading past the supposed extension-list end into
trailing handshake bytes. Now: validates totalLen ≤ r.limit upfront and
asserts r.position ≤ end after each inner decode.
TlsClient KeyUpdate close-on-receipt:
Previously HS_KEY_UPDATE was silently dropped. RFC 9001 §6: if the peer
rotates keys and we keep using the old ones, AEAD opens silently fail
→ connection wedges. Until we implement rotation, KeyUpdate must throw
so the QUIC layer closes cleanly with a fatal error instead of
desynchronizing.
All :quic:jvmTest + :nestsClient:jvmTest pass.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
9d0a7ead7a |
fix(quic): packet codec hostile-input validation + RFC-correct Initial padding
Round-2 audit found I'd only fixed varint-truncation in Frame.kt, not in
LongHeaderPacket. Plus the datagram padding was wire-illegal AND broke
our own short-header receive path.
LongHeaderPacket.parseAndDecrypt + peekHeader:
- Both tokenLen and length now validated against r.remaining BEFORE
.toInt() truncation. Hostile peer sending length=2^62-1 → return null
instead of OOM/crash.
- dcidLen and scidLen validated 1..20 per RFC 9000 §17.2.
- peekHeader correctly handles Retry packets (no token-length, no
length, no packet-number fields per RFC 9000 §17.2.5) — returns
PeekedHeader with totalLength = bytes.size - offset instead of
reading nonexistent length field as garbage.
Buffer.ensure: doubling loop spins forever if buf.size == 0. coerce
starting newSize to ≥ 8 so the loop always terminates.
QuicConnectionWriter Initial padding (RFC 9000 §14.1):
Previously: built packets, computed datagram size, appended trailing
zero bytes after the last packet to reach 1200. That's wire-illegal
(RFC says PADDING frames inside the encryption envelope) AND breaks
our own short-header receiver because ShortHeaderPacket.parseAndDecrypt
uses bytes.size as packetEnd → AEAD authentication includes the trailing
zeros and fails.
Now: two-pass build. Phase 1 collects ACK+CRYPTO frames per level into
local lists (single takeChunk per level), builds natural-size packets.
If Initial is present and total < 1200, rewinds the Initial PN and
rebuilds with PADDING bytes appended to the encrypted payload. PADDING
is one 0x00 byte per frame so concatenating N zero bytes inside the
payload is N valid PADDING frames — the receiver collapses them to
nothing during decode.
PacketNumberSpaceState.rewindOutboundForRebuild() supports the rebuild.
InMemoryQuicPipe handshake test still passes — verifies the receiver
correctly accepts our new PADDING-frame Initials.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
02b03e143b |
test(quic): in-memory QUIC pipe (quiche-style) for full-stack handshake
Adds InMemoryQuicPipe — a quiche-Pipe-style harness that runs a real QuicConnection through the full handshake without touching the network. The "server" side wraps InProcessTlsServer in QUIC packet protection (Initial + Handshake long-header packets) and routes CRYPTO bytes between the layers. Direct port of the pattern from quiche/src/test_utils.rs (`Pipe`). InMemoryQuicPipeTest.client_connection_reaches_connected_via_in_memory_pipe verifies the full client receive path: - ClientHello at Initial level → server decrypts, drives TLS, replies - Server Initial packet (ServerHello) → client decrypts, derives handshake keys - Server Handshake packets (EE + Finished) → client verifies, derives 1-RTT keys - Client Finished at Handshake level → server verifies - Client status flips to CONNECTED, both directions of 1-RTT keys installed This is the test category three of the four mature QUIC implementations surveyed have or rely on: - quiche's `Pipe` is the gold standard (we ported it here) - quic-interop-runner is the network-level equivalent (Docker matrix) - kwik notably does NOT have one — uses Mockito + reflection instead It catches the largest class of bugs: wrong layer-to-layer wiring (e.g. TLS layer derives keys but QUIC layer doesn't install them, the hardcoded-cipher-suite C1 bug, AckTracker PN bug C2 across coalesced packets). Future tests can build on it: stream send/receive, datagram round-trip, flow-control stall, retransmission once we add it. Pipe currently supports AES-128-GCM only; ChaCha20 path validation is covered by TlsRoundTripTest at the TLS layer for now. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |
||
|
|
cd68502355 |
test(quic): adversarial + parametrized + negative-path tests from review
Builds the test categories the audit identified as missing. Patterns informed
by surveys of Cloudflare quiche (the `Pipe` style + flow-control assertions),
kwik (server-side hostile-peer matrix), and quic-interop-runner (scenario
checklist).
FrameFuzzerTest — 8 tests
- 2000 random byte sequences fed through decodeFrames; the contract is
"succeed or throw QuicCodecException, never crash." Catches the C5 class
(oversized varints) plus general DoS resilience.
- Crafted hostile vectors: STREAM with length=2^62-1, CRYPTO 1 GiB,
ACK with 1B range count, NCID with cidLen=255, CONNECTION_CLOSE with
1 GiB reason, DATAGRAM 1 GiB, valid frame followed by unknown type.
AckTrackerCoalescedTest — 3 tests
- Two coalesced packets in one datagram both end up in the ACK frame.
Direct regression test for the C2 bug where the parser fed
`state.pnSpace.largestReceived` instead of the actual decrypted PN.
- Gapped PNs produce two ranges with the correct gap encoding (RFC 9000
§19.3.1 `previous_smallest - current_largest - 2`).
- Out-of-order arrival of contiguous PNs still merges into one range.
FlowControlEnforcementTest — 6 tests
- SendBuffer respects maxBytes (the writer's `sendCredit - sentOffset`
enforcement point).
- maxBytes=0 with pending data returns null (sender stalls cleanly).
- Multi-take across chunked-queue boundaries preserves byte order
(regression coverage for the new O(1) chunked enqueue replacing the
old O(N²) copyOf path).
- FIN handling: piggyback on final data chunk vs. zero-length post-data.
TlsSecurityPropertiesTest — 5 tests
- ServerHello with non-empty session_id_echo rejected (RFC 8446 §4.1.3
downgrade signal).
- Pre-TLS-1.3 legacy_version rejected.
- Server picking unsupported group (secp256r1) rejected — we advertise
X25519 only.
- Missing supported_versions / missing key_share extensions rejected.
TlsRoundTripTest — multi-cipher parametrization
- InProcessTlsServer takes a `preferredCiphers` list.
- New test forces ChaCha20-Poly1305-SHA256 selection and asserts the
full handshake completes with that suite, with the
onApplicationKeysReady callback reporting the actual negotiated
cipher (not the previously-hardcoded AES). This is direct regression
coverage for the C1 bug.
Total: 22 new tests + multi-cipher parametrization. All :quic:jvmTest +
:nestsClient:jvmTest pass.
Notable gap acknowledged from surveys: an in-memory `Pipe`-style
client+server harness (quiche pattern). Requires a server-side
QuicConnection implementation, which is ~1 day of work; deferred until we
have a concrete need beyond what the in-process TLS server already covers.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|