Commit Graph

105 Commits

Author SHA1 Message Date
Claude 77c08ed332 fix(quic): restore QuicVersion + ctor params lost in qlog merge
Same merge-from-main shape as the prior agent A integration: the qlog
agent's worktree didn't carry the version-negotiation work (QuicVersion
import, currentVersion / vnConsumed fields, applyVersionNegotiation),
the Retry work (extraSecretsListener / cipherSuites / applyRetry), or
the version-negotiation testcase wiring. Merge with -X theirs took the
qlog version of QuicConnection.kt + QuicConnectionWriter.kt + the
existing InteropRunner.kt wholesale, dropping those.

Restored:
  - QuicVersion import in QuicConnection.kt + QuicConnectionWriter.kt +
    the test-side InteropRunner.kt (also touched by agent B's qlog
    hooks).
  - extraSecretsListener / cipherSuites / initialVersion ctor params
    on QuicConnection (qlogObserver kept; new qlog work landed).

Net result: all three overnight agents (A versionnegotiation, B qlog
observer, C peer-uni-stream drainer) now coexist on the branch with no
references missing. Full :quic:jvmTest green; :quic-interop:test +
installDist green.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 00:27:15 +00:00
Claude 0107bbbac9 Merge branch 'worktree-agent-a5e247c7b4025a83c' into claude/research-quic-libraries-hH1Dc 2026-05-07 00:24:08 +00:00
Claude 98bce58832 feat(quic-interop): wire peer-uni-stream drainer + versionnegotiation
Two integrations from overnight agents:

1. agent C — peer-uni-stream drainer for the H3 multiplexing fix.
   Http3GetClient.init() now takes a CoroutineScope and calls
   conn.drainPeerInitiatedUniStreamsIntoBlackHole(scope) to consume +
   discard the server's three uni streams (control, qpack-encoder,
   qpack-decoder). RFC 9114 §6.2 mandates the client read these;
   pre-fix their bytes accumulated in 64-chunk per-stream channels
   until parser overflow tore down the connection with INTERNAL_ERROR
   ~4.5s into a multiplexing run.

2. agent A — versionnegotiation testcase wired into dispatch.
   Adds the testcase to the runTransferTest route and threads
   QuicVersion.FORCE_VERSION_NEGOTIATION as the initial version when
   testcase=='versionnegotiation'. The QuicConnection's RFC 9000 §6
   VN flow (applyVersionNegotiation) takes over from there, retrying
   with v1 once the server replies with its supported list.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 00:23:48 +00:00
Claude cfc305feb3 feat(quic): add qlog observer infrastructure for interop diagnostics
Hooks every QUIC protocol decision (packets sent / received / dropped,
TLS key updates, transport params, ALPN, loss detection, PTO, close)
into a [QlogObserver] interface. Production callers default to
[QlogObserver.NoOp] (zero allocation, single virtual call); the
:quic interop runner wires a [QlogWriter] writing JSON-NDJSON
(qlog 0.3 / JSON-SEQ format) to `<QLOGDIR>/client.sqlog`, consumable
by qvis (https://qvis.quictools.info/) and Wireshark. Goal: every
interop-runner test failure produces a qlog file the operator can
drop into qvis to see exactly what we did differently from the spec.

Hooked call sites:
  - QuicConnection.start                   → connection_started, parameters_set(local), version_information
  - QuicConnection.close                   → connection_closed(local)
  - QuicConnection.markClosedExternally    → connection_closed(remote)
  - QuicConnection.applyPeerTransportParameters → parameters_set(remote)
  - QuicConnection.tlsListener (handshake/app keys) → security:key_updated
  - QuicConnection.tlsListener (handshake done)     → alpn_information
  - QuicConnectionWriter.buildLongHeaderFromFrames  → packet_sent (initial / handshake)
  - QuicConnectionWriter.buildApplicationPacket     → packet_sent (1-rtt)
  - QuicConnectionWriter.buildBestLevelPacket       → packet_sent (close-path)
  - QuicConnectionParser.feedLongHeaderPacket       → packet_received / packet_dropped
  - QuicConnectionParser.feedShortHeaderPacket      → packet_received / packet_dropped
  - QuicConnectionParser AckFrame loss-detect       → recovery:packet_lost
  - QuicConnectionDriver.sendLoop PTO branch        → recovery:loss_timer_updated (pto)
2026-05-07 00:21:42 +00:00
Claude d0bc998cd2 Merge branch 'worktree-agent-a4e96f738ceb4bbd5' into claude/research-quic-libraries-hH1Dc 2026-05-07 00:21:38 +00:00
Claude fcfd811545 fix(quic): restore Retry handling lost in versionnegotiation merge
The agent A worktree was based on main, so its QuicConnection.kt
didn't carry the Retry handling from d03e17981. Merging with
-X theirs replaced the file wholesale, dropping applyRetry +
extraSecretsListener / cipherSuites constructor params + the
RetryPacket import in the parser.

Restored:
  - extraSecretsListener + cipherSuites ctor params (used in tlsListener
    + the TlsClient construction site).
  - applyRetry method, now using the version-negotiation-introduced
    LevelState.resetForVersionNegotiation helper (functionally
    equivalent to the prior restoreFromRetry it replaced).
  - RetryPacket import in QuicConnectionParser.

Also dedupes a duplicate `originalClientHello` field that the merge
left both copies of (one from agent 3's Retry work, one from agent A's
VN work). Single field now serves both reset paths.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 00:21:38 +00:00
Claude aff2ee182b fix(quic): add explicit peer-uni-stream drainer to avoid H3 multiplex tear-down
Variant (B) from the three-way fix menu in the multiplexing-interop
investigation: keep `:quic` strict about per-stream backpressure (the
audit-4 #3 "INTERNAL_ERROR: stream … consumer overflowed" tear-down
stays the contract for app-data overflow on bidi streams) but expose
an explicit, opt-in helper for peer-initiated UNI streams that the
application has decided it does not need to interpret.

Root cause confirmed in QuicConnectionParser.kt:290: when the server
opens its three RFC 9114 §6.2.1 peer-uni streams (CONTROL +
QPACK_ENCODER + QPACK_DECODER) and the H3 client does not consume
them, the parser routes their bytes into each stream's bounded
incomingChannel (capacity 64). Once the QPACK encoder issues
dynamic-table inserts beyond 64 chunks the next chunk overflows
trySend, sets QuicStream.overflowed, and the parser maps that to
markClosedExternally — the entire connection dies.

Notes on scope:
  - The `Http3GetClient` and `:quic-interop` runner mentioned in the
    investigation prompt do NOT exist on the `main` worktree this
    branch starts from. The fix here is therefore `:quic`-only: the
    public `awaitIncomingPeerStream` API was already sufficient for
    an integrator to write the accept loop themselves; this commit
    wraps the common case in `drainPeerInitiatedUniStreamsIntoBlackHole`
    and updates the doc on `awaitIncomingPeerStream` so the next
    integrator landing the H3 GET client doesn't hit the same trap.
  - Variant (C) — silent default drain in `:quic` itself — was
    deliberately rejected: defaults that swallow application bytes
    are the misconfiguration we want type-system-or-API-explicit.
    The new helper requires the caller to pass a CoroutineScope, so
    opt-in is unmistakable in any callsite.

Regression test coverage in PeerUniStreamDrainTest:
  - pre_fix_no_consumer_overflows_and_tears_down_connection — pushes
    65 chunks (capacity + 1) on a SERVER_UNI stream with no consumer;
    asserts the connection transitions to CLOSED. Pins the existing
    backpressure contract.
  - drainPeerInitiatedUniStreamsIntoBlackHole_keeps_connection_alive
    — same setup but with the new helper running on a side scope;
    pushes 256 chunks (4× capacity) and asserts the connection stays
    CONNECTED. With the helper sabotaged, this test fails at
    line 119 with status=CLOSED, confirming it actually exercises
    the fix.

Full quic test suite: 295 tests, 0 failures, 0 errors.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 00:19:40 +00:00
Claude 04e30465d9 Merge branch 'worktree-agent-a9d8336181fce16eb' into claude/research-quic-libraries-hH1Dc 2026-05-07 00:17:49 +00:00
Claude 350387f7e0 feat(quic): handle Version Negotiation packets per RFC 9000 §6
Adds the client-side VN flow needed for the interop runner's
`versionnegotiation` testcase:

- `QuicConnection` accepts an `initialVersion` constructor parameter
  (default `QuicVersion.V1`) and exposes a mutable `currentVersion`
  the writer stamps into outbound long-headers. `start()` now caches
  the ClientHello bytes for VN-driven re-emission.
- `applyVersionNegotiation(supportedVersions)` validates per §6.2
  (anti-downgrade: reject if list contains the offered version),
  picks v1 from the offered set, regenerates DCID, re-derives
  Initial keys against the new DCID, resets the Initial level via
  `LevelState.resetForVersionNegotiation`, re-enqueues the cached
  ClientHello, and latches `vnConsumed` so a second VN is dropped.
  Failure to find a mutually supported version closes the connection
  with `QuicVersionNegotiationException`.
- `QuicConnectionParser.feedDatagram` detects `version == 0` long
  headers BEFORE peekHeader (whose layout assumes v1) and dispatches
  to a new `feedVersionNegotiationPacket` that parses the §17.2.1
  shape and validates the echoed DCID.
- `QuicConnectionWriter` reads `conn.currentVersion` instead of the
  hardcoded `QuicVersion.V1`.
- `QuicVersion.FORCE_VERSION_NEGOTIATION = 0x1a2a3a4a` for the
  interop runner.
- `InteropRunner` honors `TESTCASE=versionnegotiation` (or
  `-DinteropTestcase=`) and offers the force-VN version.

Regression coverage in `VersionNegotiationTest`:

- happy path: VN switches `currentVersion` to v1, regenerates DCID,
  resets PN, and the next drain emits a v1 Initial on the wire.
- downgrade defense: VN listing the offered version is dropped.
- unsupported list: VN whose versions we can't speak fails the
  handshake and closes the connection.
- second VN: post-consumption VN is ignored.
- DCID mismatch: spoofed VN with wrong echoed DCID is dropped.
- backward compatibility: default `initialVersion` keeps v1 behavior
  for existing callers.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 00:17:13 +00:00
Claude 70355898b3 docs(quic-interop): record overnight agent roadmap + post-fix predictions
Three agents in flight in worktrees:
  A — versionnegotiation testcase + configurable initial-version writer
  B — qlog observer infrastructure (QlogObserver interface in :quic,
      JSON-NDJSON writer in :quic-interop, hooks at packet/key/recovery
      sites, reads $QLOGDIR the runner already sets)
  C — multiplexing channel-saturation fix (consume server's peer uni
      streams in Http3GetClient — RFC 9114 §6.2 says clients MUST
      process incoming control + QPACK streams)

Plan doc now also captures the latest test matrix (pre-acfe815e1
multi-ALPN-offer fix) and the predictions for the next run.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 00:13:04 +00:00
Claude acfe815e1f fix(quic-interop): offer both h3 + hq-interop ALPNs, dispatch by negotiated
The previous commit (e5bbf8509) switched ALPN per testcase based on
quic-interop-runner convention (h3 for http3/multiplexing, hq-interop
otherwise). That broke picoquic, which had been 4/4 green: picoquic-qns
strictly registers h3 ALPN and rejects hq-interop. Different servers
disagree on which ALPN they configure for the same testcase:
  - quic-go-qns    — strictly hq-interop for non-http3
  - aioquic-qns    — accepts either
  - picoquic-qns   — strictly h3 for all testcases

There's no per-testcase convention all peers honor. Right move is the
TLS-spec-supported one: offer BOTH h3 and hq-interop in the ClientHello,
let the server pick whichever matches its config, then dispatch the GET
client by `tls.negotiatedAlpn` after the handshake completes. http3 and
multiplexing still restrict to h3 only since they exercise H3 framing.

Brings picoquic back to fully green and should unblock quic-go for the
non-http3 testcases.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 00:10:19 +00:00
Claude 038eb18617 feat(quic-interop): enable retry + ipv6 testcases; document phase 3
Two more testcases now dispatched through runTransferTest:
  - retry  — exercises the RFC 9000 §17.2.5 + RFC 9001 §5.8 Retry
             handling that landed in d03e17981 / 671f9c705 (DCID swap,
             integrity-tag verify, key re-derivation, token threading).
  - ipv6   — same flow over an IPv6 socket. JDK's DatagramChannel.connect
             handles the v6 address resolution natively; if anything
             breaks it'll be an actual bug worth surfacing.

Plan doc updated to reflect Phase 3 landings (ALPN per testcase,
HqInteropGetClient, multi-stream FIN delivery fix) and the current
validation matrix across aioquic / picoquic / quic-go.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 00:06:25 +00:00
Claude e5bbf85096 fix(quic-interop): hq-interop ALPN + per-testcase ALPN switch
quic-go-qns interop revealed two coupled gaps in our endpoint:

1. ALPN per testcase. The runner convention (followed strictly by
   quic-go-qns, lazily by aioquic / picoquic) is:
     - testcase 'http3' / 'multiplexing'  → ALPN 'h3'   (full HTTP/3)
     - everything else                    → ALPN 'hq-interop'
                                           (HTTP/0.9 over QUIC)
   We had hardcoded 'h3'. quic-go closed every non-http3 connection
   with CRYPTO_ERROR 0x178 (TLS no_application_protocol).

2. We had no HQ-interop client. HQ is dead simple: open bidi, send
   `GET /path\r\n` raw, FIN, read body verbatim until server FINs.
   No framing, no QPACK, no control stream.

This commit adds:
  - GetClient interface + GetResponse data class extracted from
    Http3GetClient so the runner code dispatches uniformly.
  - HqInteropGetClient — the 30-line HQ-interop GET implementation.
  - Alpn enum (H3, HQ_INTEROP) wired through main() → runTransferTest
    → QuicConnection.alpnList. Picked from the testcase name per the
    convention above.

Validated locally: :quic-interop:test green; :quic-interop:installDist
clean. Will need a real run against quic-go to confirm the fix.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 23:49:16 +00:00
Claude 2da5d42d70 Merge branch 'worktree-agent-a5a40cf58838c96dd' into claude/research-quic-libraries-hH1Dc 2026-05-06 23:48:39 +00:00
Claude 39f9ae2aab fix(quic): deliver FIN to per-stream Channel under concurrent multi-stream load
When QuicConnection tears down (CONNECTION_CLOSE, read-loop death,
INTERNAL_ERROR from a saturated stream channel, etc.) the per-stream
incomingChannel objects were left open, so any application coroutine
suspended on `stream.incoming.collect { … }` hung forever waiting for
a FIN that would never come. The connection-wide signal channels
(closedSignal, peerStreamSignal, incomingDatagramSignal) all closed
cleanly, but the per-stream Flows did not — surfacing in the
quic-interop-runner `multiplexing` case as 677 collectors stuck after
the connection died mid-response, so zero of the 1999 expected files
landed.

Fix: closeAllSignals() now also calls closeIncoming() on every stream
in streamsList. Channel.close() is idempotent, and consumeAsFlow drains
already-buffered chunks before honouring the close, so any bytes the
parser had already pushed are still surfaced to the collector before
the Flow terminates.

Adds MultiStreamFinDeliveryTest covering: (a) FIN delivery to N parallel
client-bidi streams, (b) connection-teardown unblocks every per-stream
Flow, (c) buffered bytes survive a teardown without an explicit FIN.
2026-05-06 23:47:28 +00:00
Claude a009dfc425 chore(quic-interop): drop the smoke target — runner is the canonical path
make smoke was the bisector for "is the bug in :quic or in the runner
environment?" while we were debugging the initial close-path / PTO /
padding issues. Now that the runner reliably runs the matrix end-to-end
(handshake + chacha20 green vs aioquic), smoke's only job — running
picoquic outside the runner — is unneeded, and the picoquic image
keeps changing its entrypoint / required args in ways that make smoke
finicky to maintain.

Removes:
  - make smoke / smoke-down targets
  - SMOKE_NET / SMOKE_PICOQUIC / SMOKE_CLIENT vars
  - SMOKE_MODE handling in run_endpoint.sh (just always tolerates
    /setup.sh failure now — same effect, less ceremony)
  - Plan doc note about make smoke updated to reflect removal.

If we ever need a non-runner bisector again, it's two `docker run`
commands; not worth permanent maintenance.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 23:25:08 +00:00
Claude 689fcdae96 chore(quic-interop): trim runner output noise to one line per test
The runner aggregates container stderr verbatim, which gave us a
~50-line block of routing setup, NIC checksum offload toggles,
container lifecycle messages, and the long Command: WAITFORSERVER=...
line for every single testcase. Useful once for triage; pure noise
across a multi-test matrix run.

Two changes:
  - run-matrix.sh pipes the runner output through a grep -Ev filter
    that drops the boilerplate while keeping outcomes (Test: ... took /
    status, the summary table, server's Starting server, sim scenario
    + capture lines, and any Python tracebacks). VERBOSE=1 bypasses
    the filter for debugging.
  - InteropClient drops its own pre-test header dump unless
    QUIC_INTEROP_DEBUG=1 is set; the per-GET success line goes silent
    too — failures still print as before.

Net result: a passing test reduces from ~50 noise lines to ~5
meaningful lines (test name, time, status, summary table).

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 23:22:52 +00:00
Claude 04bbb4e3e9 fix(quic-interop): handshake/chacha20 testcases must transfer the file
Two coupled bugs in our endpoint that the runner just surfaced:

1. The runner mounts \$CLIENT_DOWNLOADS to /downloads as a Docker volume
   (per quic-interop-runner's docker-compose.yml `client.volumes`).
   There is no DOWNLOADS env var. Our endpoint was reading \$DOWNLOADS,
   getting null, and (for handshake/chacha20) skipping any download path.
   Hard-code /downloads.

2. The handshake / chacha20 / handshakeloss testcases don't just verify
   the handshake completes — they also require the requested file at
   /downloads/<basename>. The runner's _check_files validator
   reported "Missing files: ['intense-tremendous-firefighter']" while
   our client side reported `handshake ok`. Route handshake-flavor
   testcases through runTransferTest so the full H3 GET pipeline runs.

   `chacha20` keeps the cipher-suite override (ChaCha20-only ClientHello);
   `handshakeloss` reuses the same H3 flow against a lossy sim.

Also drops the now-dead runHandshakeTest + parseFirstTarget helpers.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 23:19:31 +00:00
Claude 2667e966d8 fix(quic-interop): prefer Python <3.14 for the runner venv
pyshark's pcap reader calls asyncio.get_event_loop_policy().get_event_loop(),
which raises RuntimeError on Python 3.14 (deprecated in 3.12, removed in
3.14). Symptom: the matrix run completes the actual interop test
successfully but the runner's pcap-validation step crashes before it can
emit the result, dropping a 100+ line traceback.

run-matrix.sh now scans for python3.13 → 3.12 → 3.11 → python3, picking
the first that's both installed and < 3.14, and creates the venv with it.
Existing venvs created with 3.14 keep working (only matters for fresh
clones); for an existing broken venv, rm -rf .venv and re-run.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 23:14:14 +00:00
Claude 72f2fb2339 fix(quic-interop): picoquicdemo binary lives at /picoquic/picoquicdemo
Confirmed via `docker run --entrypoint find privateoctopus/picoquic:latest
/ -name picoquicdemo` — the binary isn't on PATH inside the image.
Use the absolute path so smoke runs without depending on PATH defaults.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 23:12:55 +00:00
Claude f6ddfb6e21 fix(quic-interop): override picoquic entrypoint so smoke runs picoquicdemo
The privateoctopus/picoquic:latest image now wraps its CMD in the
runner's /run_endpoint.sh, which expects ROLE/TESTCASE/CERTS env vars
and exits 127 with "Unsupported test case:" if they're absent. So the
old `... privateoctopus/picoquic:latest picoquicdemo -p 4433` form
silently doesn't start picoquicdemo at all — picoquic exits, our
client times out trying to talk to a dead container.

Override the entrypoint so picoquicdemo runs directly with our args.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 23:10:23 +00:00
Claude 671f9c7050 Merge branch 'worktree-agent-ac6580a9453de5616' into claude/research-quic-libraries-hH1Dc 2026-05-06 23:07:21 +00:00
Claude fb35031b4e Merge branch 'worktree-agent-a4016e24b23c3e8ff' into claude/research-quic-libraries-hH1Dc 2026-05-06 23:07:13 +00:00
Claude 2f9e4241a6 Merge branch 'worktree-agent-a2d7586cf714834cf' into claude/research-quic-libraries-hH1Dc 2026-05-06 23:04:27 +00:00
Claude d03e179816 feat(quic): wire Retry packet handling (RFC 9000 §17.2.5 + RFC 9001 §5.8)
The Retry parser + integrity-tag verifier already existed in
RetryPacket.kt, but feedDatagram dropped Retry packets on the floor.
Hook them up:

- QuicConnectionParser.feedLongHeaderPacket detects RETRY type before
  the standard parse-and-decrypt path, parses via RetryPacket, and
  dispatches to QuicConnection.applyRetry.

- QuicConnection.start() now caches the ClientHello bytes (TLS only
  emits ClientHello once; we need to re-queue the same bytes on the
  fresh Initial keys after Retry). New applyRetry method:
  verifies the integrity tag, swaps DCID to Retry's SCID, re-derives
  Initial keys, resets the Initial PN space + sentPackets +
  cryptoSend, re-enqueues the cached ClientHello, stores the Retry
  token, and latches retryConsumed so a second Retry is dropped.

- LevelState.restoreFromRetry / PacketNumberSpaceState.resetForRetry
  give applyRetry an in-place reset (the level reference is a `val`,
  so we mirror discardKeys' field-reset pattern).

- QuicConnectionWriter.buildLongHeaderFromFrames threads
  conn.retryToken through the Initial header's Token field on every
  Initial we emit after Retry.

Per RFC 9001 §5.8, a Retry with a bad integrity tag is silently
dropped; per RFC 9000 §17.2.5.2, only one Retry is honored per
connection. Both invariants are tested.

New test: RetryHandlingTest covers the happy path (DCID swap, PN
reset, token threading, ClientHello replay, ≥1200-byte padding),
the bad-tag path, and the second-retry path.
2026-05-06 23:02:15 +00:00
Claude 68f49c028a fix(quic-interop): smoke target uses picoquic IP directly (macOS DNS)
Even with /setup.sh skipped, the base image
(martenseemann/quic-network-simulator-endpoint) ships a /etc/resolv.conf
primed for the runner's sim that breaks Docker bridge name resolution
inside the JVM on macOS. Bypass DNS entirely: docker inspect the
picoquic container's IP and pass it directly via REQUESTS.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 23:01:21 +00:00
Claude c9e036f728 fix(quic): PTO retransmits unacked CRYPTO at Initial/Handshake (RFC 9002 §6.2.4)
Pre-handshake PTO previously only set `pendingPing`, which collapsed to
either nothing (the bug 86b6c609a fixed in a sibling branch) or a bare
PING. Against aioquic in the quic-interop-runner ns-3 sim, the first
ClientHello can be dropped (server not ready at t≈0.5s); a follow-up
PING with the same DCID is silently ignored because the server has no
state for that DCID. We need to retransmit the actual ClientHello bytes
so the server sees a full connection attempt.

Implementation:
  - SendBuffer.requeueAllInflight(): walks the inflight list and moves
    every sent-but-not-ACK'd range to the retransmit queue, preserving
    offset and FIN. Mirrors markLost's per-range path but applies to all
    inflight ranges in one shot. Idempotent + best-effort safe.
  - QuicConnection.requeueAllInflightCrypto(level): thin wrapper that
    drives the per-level cryptoSend buffer's new method.
  - QuicConnectionDriver.sendLoop: PTO branch now calls the new helper
    at the highest active pre-application level (Handshake > Initial)
    when 1-RTT keys aren't installed. The next drain naturally emits a
    CRYPTO frame at the original offset (takeChunk drains the
    retransmit queue first per existing semantics).
  - QuicConnectionWriter.collectHandshakeLevelFrames: honors pendingPing
    pre-handshake at the highest active level — but skips the PING when
    a CRYPTO frame is already in the same level's frame list (the
    CRYPTO retransmit covers the ack-eliciting requirement). Bare PING
    still goes out when there's nothing to retransmit.

Old RecoveryToken.Crypto entries in sentPackets for the original PNs
remain harmless: when loss detection eventually declares them lost,
markLost re-runs against ranges that have already moved on, which is
itself idempotent (clamps to flushedFloor / no-op on already-queued).

Test: PtoCryptoRetransmitTest reproduces the wire scenario — first drain
emits ClientHello in a ≥1200-byte Initial datagram; simulated PTO
calls requeueAllInflightCrypto + sets pendingPing; second drain must
contain a CRYPTO frame at the same offset with the same payload bytes,
not a bare PING. Datagram size still ≥1200 (RFC 9000 §14.1).

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 23:01:08 +00:00
Claude 9c86eee5e2 fix(quic): pad PING-only Initial datagrams to strict 1200 bytes (RFC 9000 §14.1)
The padding-rebuild branch in QuicConnectionWriter.drainOutbound computed
`padBytes = 1200 - natural`, but the QUIC long-header Length field is a
varint (RFC 9000 §16). When the natural-size payload was small enough for
Length to fit in 1 byte (body ≤ 63 bytes), the rebuild's larger body
crossed the 64-byte threshold and Length grew to 2 bytes — adding 1 wire
byte that wasn't in `natural`. PING-only PTO probe Initials therefore went
out at exactly 1199 bytes, one short of the §14.1 floor.

Fix: rebuild iteratively. After the first rebuild, measure the actual
datagram size; if still < 1200, bump padBytes by the residual and rebuild
once more. PADDING bytes inside the AEAD envelope add 1:1 to the wire
size and the Length varint grows monotonically, so the loop terminates
in ≤ 2 iterations for any reachable payload.

Same fix is applied to buildClosingDatagram so close-only Initial probes
on the boundary aren't tripped by future varint-growth changes.

Tightens the existing PTO-probe regression test to assert ≥ 1200 (was
relaxed to ≥ 1199 in 86b6c609a) and adds a new boundary test that builds
a single-byte-payload Initial and checks 1200 ≤ size ≤ 1203 — strict
floor with a tight ceiling so over-correction would also fail.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 23:00:30 +00:00
Claude d8e17207b6 fix(quic-interop): skip /setup.sh in smoke mode (it breaks Docker-bridge DNS)
The base image's /setup.sh configures routing for the runner's ns-3 sim
*and* points the container's DNS resolver at the sim's nameserver. When
we source it inside `make smoke` (which uses a plain Docker bridge
network without the sim), DNS resolution for the bridge container names
breaks: the JVM client can't resolve `amethyst-quic-interop-smoke-picoquic`
and aborts before any QUIC traffic.

Adds a SMOKE_MODE=1 env var; run_endpoint.sh skips /setup.sh entirely
when it's set. The Makefile smoke target sets it. Inside the runner
(unset, default), /setup.sh runs as before.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 22:58:59 +00:00
Claude 86b6c609a6 fix(quic): emit PING at Initial/Handshake on PTO pre-handshake (RFC 9002 §6.2.4)
The aioquic interop run revealed bug #3 (after the close-padding and
close-frame-type fixes): when PTO fires before the handshake completes,
the driver sets `pendingPing = true` but the writer only consumed that
flag in the 1-RTT path. Pre-handshake the flag was silently discarded,
so the second drain produced no Initial datagram — the connection sat
mute through every subsequent PTO. Symptom on the wire: exactly one
Initial packet (the close at PN=1, after our internal handshake
timeout), zero retransmits across the full 10-second budget, no chance
for the peer to recover from a dropped first ClientHello.

Fix routes pendingPing through to whatever encryption level is the
highest currently active — preferring 1-RTT, falling through Handshake,
finally Initial. Adds a regression test that drains a fresh connection
with `pendingPing = true` and verifies an Initial-level padded probe
datagram comes out (vs. null pre-fix).

Test relaxes the size assertion to ≥ 1199 due to a separate pre-existing
off-by-one in the writer's padding deficit calculation when the natural
payload uses a 1-byte Length varint that grows to 2 after padding —
that's a follow-up; the regression we care about here is "no probe at
all," not the byte-precise padding edge.

Outstanding from this run:
  - Strict ≥ 1200 padding for tiny payloads (PING-only Initial = 1199)
  - PTO should retransmit unacked CRYPTO bytes, not just emit a PING
    (current PING gets ACK + relies on packet-number-threshold loss
    detection to trigger CRYPTO retransmit; works but suboptimal)

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 22:50:02 +00:00
Claude f0100b3ca0 Merge remote-tracking branch 'origin/main' into claude/research-quic-libraries-hH1Dc 2026-05-06 22:38:20 +00:00
Claude 59438d5283 fix(quic): RFC 9000 §10.2.3 + §14.1 in CONNECTION_CLOSE-only datagrams
The quic-interop-runner against aioquic surfaced two real bugs in the
writer's CLOSING-status branch, both visible on the wire as a single
~45-byte UDP datagram instead of a properly framed close.

§10.2.3 — at Initial / Handshake levels only CONNECTION_CLOSE (Transport,
0x1c) is allowed. The application-level form (0x1d) leaks app state
pre-handshake. The writer was unconditionally building a 0x1d frame and
shipping it inside an Initial packet; aioquic dropped it silently.

§14.1 — any client datagram containing an Initial MUST be ≥ 1200 bytes
in UDP-payload terms. The CLOSING branch bypassed the existing padding
logic, so close-only Initial datagrams went out at ~45 bytes and
servers correctly rejected them (also as malformed).

Fix replaces the CLOSING branch with a dedicated `buildClosingDatagram`
helper:
  - Application keys present → 0x1d, original error code + reason.
  - Pre-1-RTT (Handshake or Initial) → 0x1c with errorCode =
    APPLICATION_ERROR (0x0c), frameType=0, empty reason.
  - Initial level: build at natural size, rewind PN if < 1200, rebuild
    with PADDING-frame deficit inside the AEAD envelope.

Plus a regression test covering both: pre-handshake close datagram size
≥ 1200, and ConnectionCloseFrame round-trips 0x1c vs 0x1d for the right
constructor inputs.

NOT addressed yet: why our ClientHello at PN=0 doesn't appear in the
runner pcap (only PN=1 close does). With this fix the close packet is
now well-formed; the next runner run will tell us whether the missing
ClientHello is a sim/capture artifact or a separate writer bug.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 22:37:55 +00:00
Claude 88c2e68d47 fix(quic-interop): make smoke target work on Docker Desktop for Mac
Two coupled fixes that surface together when running the smoke target
outside the runner's privileged sim environment:

1. run_endpoint.sh now tolerates /setup.sh failures.

   The base image's /setup.sh manipulates routes for the runner's ns-3
   sim. Outside the runner (e.g., make smoke without --privileged), it
   fails with "netlink error: Operation not permitted" — and our
   set -euo pipefail bailed before launching the JVM. The setup is
   genuinely not needed for smoke; tolerate the failure and continue.

2. make smoke uses a private Docker bridge instead of --network host.

   --network host on Docker Desktop for Mac is *not* the Mac host's
   network — it's the LinuxKit VM's, and 127.0.0.1 isn't routed back
   to other Docker containers. A dedicated bridge with DNS-by-name
   works identically on Mac and Linux. Bonus: smoke-down target
   reliably tears down the env.

Inside the runner these changes are no-ops: the runner provides the
privileges /setup.sh needs, and uses its own compose network not
--network host.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 22:25:40 +00:00
Claude fba21ad5a6 fix(quic-interop): use a fresh per-invocation log subdir
run.py refuses to start if --log-dir already exists (interop.py:81-82
calls sys.exit). Previous script eagerly mkdir'd $LOG_DIR, which made
the second run always fail.

New layout: $LOG_DIR is the parent (created if missing), each
invocation writes to $LOG_DIR/run-YYYYmmdd-HHMMSS/. Preserves history
of past runs instead of overwriting; latest is `ls -t $LOG_DIR | head -1`.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 22:11:09 +00:00
Claude d1dadfb962 fix(quic-interop): runner renamed implementations.json → implementations_quic.json
Upstream quic-interop-runner split its config into implementations_quic.json
+ implementations_webtransport.json so QUIC and WebTransport endpoint
registrations don't collide. Schema is unchanged; just the filename.

Also updated the Makefile comment + plan doc for consistency.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 22:08:05 +00:00
Claude 86192312ca feat(quic-interop): add run-matrix.sh wrapper for the per-run loop
One-shot wrapper that clones quic-interop-runner alongside this repo if
missing, sets up its venv, merges our implementations.json snippet, builds
the endpoint image, then invokes run.py with passed-through args. Every
step is idempotent so repeated invocations just iterate.

Designed to script only the per-run loop, not first-time tooling install
(Docker Desktop, Homebrew, Wireshark prefs) — those are GUI / one-time
steps where a script either fails awkwardly or hides what the user is
consenting to.

Cross-platform install hints (apt-get on Linux, brew on macOS) on missing
prereqs. Daemon liveness check (docker info) catches the common macOS
"Docker Desktop installed but not running" trap. SKIP_BUILD=1 escape
hatch for tight image-unchanged inner loops.

Plan doc updated: run-matrix.sh is now the documented happy path; the
manual jq-merge sequence is kept as a fallback.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 22:06:06 +00:00
Claude 093a39a3ee feat(quic-interop): alias sim-driven testcases + document unsupported ones
The quic-interop-runner exposes several testcases that drive the same
client logic but vary the network conditions injected by the ns-3 sim.
These don't need new client code on our side — they exercise the
existing handshake / transfer paths under loss / corruption / high-RTT /
cross-traffic, which is exactly the bug-finding signal we want.

Aliases added:
  - transferloss, transfercorruption, longrtt, goodput, crosstraffic
    → transfer (H3 GET against varying sim configs)
  - handshakeloss → handshake

Plan doc now lists every standard testcase and either marks it landed,
aliased, or explicitly unsupported with a written reason — so anyone
returning to this knows what's left and why each gap exists. Unsupported
set covers: versionnegotiation (writer hard-codes V1), resumption /
zerortt (no session ticket / 0-RTT), keyupdate (no KEY_PHASE handling),
retry (parser exists but not wired to feed-loop), rebinding-* (no client
migration), amplificationlimit (server-side), blackhole (inverse test),
ipv6 (UdpSocket v6 path unverified).

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 21:57:52 +00:00
Claude 68d0cdf2a2 feat(quic-interop): add transfer / multiplexing / http3 testcases via H3 GET client
Adds a minimal Http3GetClient (in :quic-interop, NOT :quic — interop-test
surface, not a production HTTP/3 client) that opens the three required
client uni streams (control + QPACK encoder + QPACK decoder per RFC 9114
§6.2.1), sends an empty SETTINGS, and per request opens a bidi stream,
encodes the four pseudo-headers via the existing literal-only QpackEncoder,
FINs, and reassembles HEADERS+DATA frames from the response.

Wires three testcases:
  - transfer: GET each REQUESTS URL sequentially, write body to
    \$DOWNLOADS/<basename>. status != 200 fails.
  - http3: identical to transfer.
  - multiplexing: same fetches but issued in parallel via
    coroutineScope { async { … } } so request streams genuinely
    overlap on the wire (what tshark verifies).

Out-of-scope (deliberate): GOAWAY, PUSH_PROMISE, dynamic QPACK table,
trailers, priority — none are required by these testcases.

Unit-tests round-trip the request encoding through the existing
Http3FrameReader + QpackDecoder so the wire format is verified without
needing a real peer.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 21:42:56 +00:00
Claude 7c41aa6dde test(quic-interop): unit-test SslKeyLogger + ship runner snippet
Refactors SslKeyLogger to take the ClientHello random at flush() time
(removes the lookup-lambda + bindConnection ceremony) so the formatter
is testable without spinning up a real QuicConnection. Adds three
unit tests covering: the four NSS Key Log lines emitted in the right
order, flush idempotency, and lower-case hex encoding.

Also drops a checked-in implementations.json snippet
(quic-interop-runner-snippet.json) so a sibling clone of the runner
can be registered with one jq merge instead of hand-edited JSON.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 21:37:06 +00:00
Claude afe11ac651 feat(quic-interop): wire SSLKEYLOGFILE + add chacha20 testcase (Phase 1a)
Adds two debugging hooks to :quic so the interop endpoint can produce
artifacts that make the runner actually useful for finding bugs:

  - TlsClient.clientRandom: capture and expose the 32-byte ClientHello
    random so a SSLKEYLOG line can correlate to this connection.
  - QuicConnection.extraSecretsListener: optional chained secrets
    listener (default null, no-op for production callers). Fires
    alongside the connection's own key-installation listener at every
    encryption-level transition.
  - QuicConnection.cipherSuites: knob to override the offered TLS
    cipher suites in ClientHello.

InteropClient now:
  - Writes NSS Key Log Format lines to $SSLKEYLOGFILE when set, so
    Wireshark can decrypt the sim's pcap captures.
  - Implements the `chacha20` testcase by offering only
    TLS_CHACHA20_POLY1305_SHA256 — exercises the ChaCha20 AEAD path
    end-to-end against a peer.

Defers QLOGDIR (needs qlog observer infrastructure across packet/
frame/recovery layers — own design doc) and `versionnegotiation`
(needs the writer to accept a configurable initial QUIC version).

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 21:32:44 +00:00
Claude 1586c0cced feat(quic-interop): scaffold quic-interop-runner endpoint module
Adds :quic-interop, a JVM-only module at quic/interop/ that implements
the quic-interop-runner Docker contract so we can drive matrix tests
against aioquic / quiche / picoquic / msquic / ngtcp2 / etc. as a
bug-finding harness.

Phase 0 supports only the `handshake` testcase; everything else returns
127 so the runner skips rather than fails. SSLKEYLOGFILE / QLOGDIR
plumbing is deferred to Phase 1.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-06 21:08:02 +00:00
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