Commit Graph

64 Commits

Author SHA1 Message Date
Claude 72295915de fix(quic): tier-local round-robin so priority survives every drain
The previous priority-then-round-robin shape applied the rotating
start-index globally over the sorted list, so the cross-tier order
flipped on alternate drains: drain N had high-priority first, drain
N+1 advanced streamRoundRobinStart and had low-priority first. The
priority hint was silently defeated under any sustained traffic.

Replace it with strict priority across tiers + round-robin only
within each same-priority tier. Higher tiers always drain ahead of
lower ones; same-priority peers continue to take turns via the
existing rotating start. Default-priority callers see no behaviour
change (single tier, identical rotation semantics).

Tighten the test: drain twice and assert the higher-priority stream
emits first on BOTH drains — the regression case that the single-
drain version of the test missed. Add a regression guard for the
same-priority round-robin so a future refactor can't silently
serialise on the first stream.

https://claude.ai/code/session_01KWdr4RjVvyYZfEuPVaQfUa
2026-05-06 20:34:39 +00:00
Claude f1034b1f53 feat(quic): priority-aware stream scheduling for moq-lite groups
Bias the QUIC connection writer's drain loop toward higher-priority
streams so moq-lite group streams with newer (higher) sequence numbers
drain ahead of older ones under congestion. Implements the T11.3
follow-up flagged in nestsClient/plans/2026-05-06-stream-priority-
followup.md (now removed).

QuicStream gets a `@Volatile var priority: Int = 0`. The writer's
streamsView iteration is replaced by a stable sortedByDescending pass
so same-priority streams keep their existing rotating-start round
robin while higher-priority tiers always drain first.

WebTransportWriteStream gains a `setPriority(Int)` hook; the QUIC-
backed adapter forwards to the underlying QuicStream, while the
in-memory test fakes treat it as a no-op. MoqLiteSession.openGroupStream
calls `uni.setPriority(sequence)` (saturating to Int.MAX_VALUE) to
mirror moq-rs's `Publisher::serve_group`.

Tests: a new InMemoryQuicPipe.decryptClientApplicationFrames helper
walks past coalesced long-header packets to surface 1-RTT frames,
which lets QuicConnectionWriterTest assert that the higher-priority
stream's StreamFrame lands first inside a single drained packet.

https://claude.ai/code/session_01KWdr4RjVvyYZfEuPVaQfUa
2026-05-06 20:25:01 +00:00
Claude 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.
2026-05-05 13:37:41 +00:00
Claude 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
2026-05-05 02:06:08 +00:00
Claude 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
2026-05-05 01:46:00 +00:00
Claude 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
2026-05-05 01:41:28 +00:00
Claude 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
2026-05-05 01:30:02 +00:00
Claude 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
2026-05-05 01:15:29 +00:00
Claude 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 303caa8. Updated to reflect the actual
   O(log N + k) cost.

4. The bulk-removal comment in removeOverlap overstated the win
   ("O(k) per call, single shift of trailing entries"). ArrayDeque
   removeAt(i) shifts on every call, so the actual cost is
   O(k * (size - end + k)). Toned down — it's still cheap because
   k is 1-2 in steady state.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-05 01:04:32 +00:00
Claude 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
2026-05-05 00:23:18 +00:00
Claude 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
2026-05-05 00:23:07 +00:00
Claude 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
2026-05-05 00:10:57 +00:00
Claude 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
2026-05-04 23:24:26 +00:00
Claude 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
2026-05-04 23:20:39 +00:00
Claude 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 9e6fa3d).

New variants on RecoveryToken sealed class:
  - Stream(streamId, offset, length, fin): STREAM data range we
    sent. On loss the dispatcher will re-queue the range on the
    stream's SendBuffer (commits C, D wire that).
  - Crypto(level, offset, length): CRYPTO bytes at a specific
    encryption level. RFC 9000 §17.2.5 + §13.3: handshake bytes
    are reliable; lost CRYPTO retransmits at the same encryption
    level. Commit E wires the per-level dispatch.
  - ResetStream(streamId, errorCode, finalSize),
    StopSending(streamId, errorCode),
    NewConnectionId(seq, retirePriorTo, cid, statelessResetToken):
    reliable per RFC 9000 §13.3, scaffolding-only since :quic
    doesn't currently emit any of these. The pendingResetStream /
    pendingStopSending / pendingNewConnectionId maps on
    QuicConnection are populated by the dispatcher but not yet
    drained by any writer code path.

QuicConnection.onTokensLost extended with the new variants:
  - Stream/Crypto: route to the matching SendBuffer.markLost
    (currently a no-op — see SendBuffer.markLost kdoc; commit C
    replaces the stub with the retain-until-ACK rewrite).
  - ResetStream/StopSending/NewConnectionId: write to the
    pending* maps; future emit code drains them.

NewConnectionId data-class needs explicit equals/hashCode because
its ByteArray fields would otherwise compare by identity (Kotlin
data-class auto-equals limitation). Tested.

Tests added (4):
  - whenDispatch_isExhaustive extended with all 10 variants;
    catches at compile time if a new variant is added without
    updating the dispatcher
  - newConnectionId_arrayEqualityIsByContent
  - stream_equalityByValue
  - crypto_equalityByValue

SendBuffer gains placeholder markLost(offset, length, fin) and
markAcked(offset, length) methods — both no-ops today. Their
signatures are stable so the dispatcher wiring lands once now and
doesn't need re-touching when commit C makes them functional.

Full :quic test suite passes.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 23:14:42 +00:00
Claude 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 9e6fa3d (step 1).

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 23:02:01 +00:00
Claude 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
2026-05-04 22:55:34 +00:00
Claude 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
2026-05-04 22:52:49 +00:00
Claude 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
2026-05-04 22:48:40 +00:00
Claude 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
2026-05-04 22:45:10 +00:00
Claude 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
2026-05-04 22:42:15 +00:00
Claude 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
2026-05-04 22:31:07 +00:00
Claude 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
2026-05-04 22:25:34 +00:00
Claude 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
2026-05-04 21:55:49 +00:00
Claude 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
2026-05-04 20:53:58 +00:00
Claude 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
2026-05-04 17:48:03 +00:00
Claude 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.
2026-05-01 15:07:09 +00:00
Claude 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.
2026-05-01 14:21:52 +00:00
Claude 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.
2026-05-01 14:14:06 +00:00
Claude 96a585a67e Revert "quic: suspend openUni/BidiStream on peer-cap exhaustion + emit STREAMS_BLOCKED"
This reverts commit f0705e3ab1.
2026-05-01 13:59:38 +00:00
Claude 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.
2026-05-01 12:52:22 +00:00
Claude 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.
2026-04-30 16:10:00 +00:00
Vitor Pamplona f63e3b1c67 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-04-29 17:28:38 -04:00
Vitor Pamplona df98235d31 Minor adjustments to remove warnings 2026-04-29 17:16:14 -04:00
Claude 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.
2026-04-29 21:08:03 +00:00
Claude 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).
2026-04-29 20:56:33 +00:00
Claude 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.
2026-04-26 18:01:00 +00:00
Claude 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
2026-04-26 01:38:48 +00:00
Claude 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
2026-04-26 01:06:52 +00:00
Claude 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
2026-04-26 00:49:20 +00:00
Claude 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
2026-04-26 00:38:55 +00:00
Claude 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
2026-04-26 00:31:21 +00:00
Claude 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
2026-04-26 00:07:50 +00:00
Claude 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
2026-04-25 23:48:44 +00:00
Claude 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
2026-04-25 23:21:37 +00:00
Claude 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
2026-04-25 23:08:18 +00:00
Claude 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
2026-04-25 22:52:19 +00:00
Claude 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
2026-04-25 22:49:32 +00:00
Claude 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
2026-04-25 22:42:10 +00:00
Claude 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
2026-04-25 22:38:04 +00:00