Commit Graph

43 Commits

Author SHA1 Message Date
Claude a0a604b8e7 refactor(quic): kill dead levelLock + add bug-resistant batched-open API
Audit follow-up. Two cleanups consolidated into one commit since they
share the goal of "make the lock contract obvious from the API".

(1) Remove LevelState.levelLock entirely.

The lock-split refactor introduced a per-level Mutex with a docstring
claiming the writer/parser would acquire it around encode + sentPackets
record + ACK observation. Neither actually does. SendBuffer's internal
synchronized(this) is what serializes cryptoSend mutations against
takeChunk and markAcked. handlePtoFired's levelLock acquisition was
the only production usage and it serialized only against itself.

Removed:
  - LevelState.levelLock
  - handlePtoFired's withLock wrapper (now a non-suspend fun)
  - All docstring references to a third lock domain

The pre-existing sentPackets HashMap race (writer mutates under
streamsLock, parser reads without sync) is unchanged — out of scope
for this PR. Acquisition order is now `lifecycleLock → streamsLock`,
flat.

(2) Add openBidiStreamsBatch + openUniStreamsBatch — the bug-resistant
high-level API for the prepareRequests / moq audio-rooms patterns.

The previous shape required callers to manually do
`streamsLock.withLock { repeat(N) { openBidiStreamLocked() ... } }`.
That contract regressed twice on this very branch: once held the wrong
lock (lifecycleLock alias), once skipped the wrap entirely. Both shapes
silently emitted one STREAM per packet under multiplex load.

The new API encapsulates the lock + the per-item init lambda:

    conn.openBidiStreamsBatch(items) { stream, item ->
        stream.send.enqueue(encode(item))
        stream.send.finish()
        Handle(stream)
    }

Callers physically cannot hold the wrong lock. Migrated:
  - Http3GetClient.prepareRequests
  - HqInteropGetClient.prepareRequests

Also added `openUniStreamLocked` + `openUniStreamsBatch` symmetric to
the bidi versions. moq audio-rooms eventually wants to open many uni
streams in burst; the same one-stream-per-packet bug lurks if every
open serializes through its own lock acquisition.

`openBidiStreamLocked` / `openUniStreamLocked` remain public (with
their `check(streamsLock.isLocked)` guards) for the rare custom-batch
callers that need to mix bidi+uni opens under a single hold. Most
callers should use the *Batch variants going forward.

Test coverage extended:
  - openBidiStreamsBatch happy path
  - openUniStreamLocked throws without streamsLock
  - openUniStreamsBatch happy path

Six tests in BatchedOpenLockContractTest now pin the contract.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 03:31:30 +00:00
Claude 07dd572423 perf+test(quic): audit follow-ups for the multiplex lock fix
Audit of recent multiplex / PTO commits surfaced three concrete
improvements:

1. **Pre-encode requests outside streamsLock** in both prepareRequests
   impls. QPACK encoding (Http3GetClient) and string formatting
   (HqInteropGetClient) are non-trivial under multiplex load — 1999
   paths means we were holding streamsLock across all 32 chunks ×
   ~2KB of QPACK encoding per chunk = ~64 KB of CPU work serialised
   against the send loop. Now done outside the lock.

2. **Fix O(N²) byte merging in MultiplexingRoundTripTest.** Previous
   shape allocated a new array per STREAM frame; for real-size
   payloads this would dominate test runtime. Switched to a per-
   stream MutableList<ByteArray> joined once at the end.

3. **Pin coalescing end-to-end in MultiplexingRoundTripTest.**
   Pre-existing test verified per-stream content arrived but said
   nothing about the wire shape. Added totalDatagrams ≤ 12 assertion
   so the matrix's "one stream per datagram" failure mode would
   break the test instead of passing silently.

Audit also surfaced two non-actionable items, flagged for future
cleanup but not changed in this commit:

- LevelState.levelLock docstring claims writer/parser acquire it
  around sentPackets mutations; in practice neither does. The
  handlePtoFired call that takes levelLock currently serialises
  only against itself; SendBuffer's internal synchronized is what
  prevents the actual cryptoSend race. Kept the call (matches the
  documented design intent and future-proofs against the writer
  actually taking levelLock) but the docstring is stale.

- openBidiStreamLocked's check(streamsLock.isLocked) catches "no
  lock" and "wrong lock" callers but not "another coroutine holds
  streamsLock and I'm calling without holding it" — kotlinx Mutex
  doesn't expose owner-aware checks without an explicit owner arg
  we don't pass. Acceptable since the bug we just fixed and any
  future regression in the same shape are caught.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 03:25:55 +00:00
Claude 991b1a1da3 fix(quic-interop): prepareRequests must hold streamsLock, not lifecycleLock
aioquic interop multiplexing 2026-05-06 qlog post-mortem:
  - 2898 packets sent in 60s, each carrying ONE STREAM frame
  - server log: streams created/discarded strictly serially, ~30-40ms apart
  - 1421/2000 files completed before the runner's 60s timeout
  - shape ≈ 1 RTT per stream — wire was emitting one stream per datagram

Cause: Http3GetClient.prepareRequests + HqInteropGetClient.prepareRequests
both did `conn.lock.withLock { ... openBidiStreamLocked() }`. Post the
lock-split refactor `conn.lock` is the deprecated alias for lifecycleLock.
The writer's drainOutbound takes streamsLock — not lifecycleLock — so the
send loop interleaved between every two openBidiStreamLocked calls,
draining one stream's data per pass.

Fix:
  1. Both prepareRequests impls now use conn.streamsLock.withLock.
  2. openBidiStreamLocked now `check`s streamsLock.isLocked at entry
     so this can never silently regress again — calling it without the
     lock (or with the wrong lock) throws IllegalStateException with
     a message naming streamsLock as the lock to acquire.
  3. New BatchedOpenLockContractTest pins the contract:
     - calling openBidiStreamLocked WITHOUT any lock throws
     - calling openBidiStreamLocked while holding lifecycleLock throws
       (the exact regression shape)
     - calling openBidiStreamLocked while holding streamsLock works
       (happy path)

The runtime check is the regression-proof part: future callers physically
cannot hold the wrong lock without the test (and prod) blowing up at the
first call site.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 03:18:18 +00:00
Claude 226fa14882 chore(quic-interop): inspect — point at the actual runner paths
Runner persists:
  <case>/output.txt              — runner stdout/stderr (+ client logs)
  <case>/client/qlog/*.sqlog     — qlog under client/qlog/, not client/
  <case>/server/stderr.log       — server stderr (CONNECTION_CLOSE reason)

Plus targeted grep for the events we actually need to localize a
multiplexing failure:
  - last 5 packet_received / packet_sent (where did the flow stall)
  - connection_closed (peer error code + reason)
  - packet_dropped (sign of decrypt fails / unknown CID / etc.)
2026-05-07 03:09:19 +00:00
Claude c0131b8125 chore(quic-interop): inspect — list every file in case dir first
Layout varies by runner version; print the tree so we can see what
the runner actually wrote (qlog/pcap/log filenames, sizes) before
guessing at paths.
2026-05-07 03:08:23 +00:00
Claude b924df1632 fix(quic-interop): inspect script — runner layout is <pair>/<testcase> 2026-05-07 03:07:56 +00:00
Claude 01a342f592 chore(quic-interop): inspect-multiplexing.sh post-mortem helper
Pulls the diagnostics needed to localize a multiplexing failure:
  - tail client/log.txt (per-stream errors, final outcome)
  - tail server/log.txt (peer's CONNECTION_CLOSE reason)
  - tail client qlog (frame-level event sequence)
  - count downloaded files (how many of N actually finished)
  - qlog event-type histogram (e.g. "lots of packet_dropped",
    "no stream_state_updated past t=30s", etc.)

Resolves the most recent run-* dir under ../quic-interop-runner/logs
automatically — no need to copy-paste paths after every run.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 03:06:48 +00:00
Claude 57ba23519d fix(quic): batch openBidiStream under one lock hold for multiplexing
Diagnosis from yet another qlog round: streams/packet still ~1 even
with the prepareRequest/awaitResponse split. Root cause: openBidiStream
is suspend due to lock.withLock, and each call releases the lock between
iterations. The send loop is queued on the lock; it grabs it the moment
we release, drains the one stream of data we just enqueued, and the
next prepareRequest call has to re-acquire after the send loop releases.
Net: one stream per drain per packet, same useless coalescing as before.

Fix is structural:
  - QuicConnection.openBidiStreamLocked() — public, lock-not-acquired
    version of openBidiStream. Caller MUST hold conn.lock.
  - GetClient.prepareRequests(authority, paths) — batch API that
    holds conn.lock once, opens + enqueues all N streams in a single
    critical section, releases. Send loop can't interject; when it
    next drains it sees ALL N streams' data ready and packs them
    into coalesced packets.
  - Http3GetClient + HqInteropGetClient: implement prepareRequests
    using openBidiStreamLocked under conn.lock.withLock { ... }.
  - InteropClient's chunked-multiplex loop: uses prepareRequests
    (batch) instead of N x prepareRequest.

Single-stream paths still use prepareRequest / get(); behavior unchanged.

The fundamental architectural improvement (per-stream / per-level lock
split, or actor-model dispatch) is a follow-up; this commit gets us
the throughput we need from the existing single-mutex shape by holding
the lock for the full chunk's worth of work.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 02:06:58 +00:00
Claude bc19e90c17 fix(quic-interop): batch enqueue + single wakeup per multiplex chunk
Diagnosis from qlog timeline pattern: send packets clustered ~37ms
apart (sim RTT) but each cluster contained only ONE stream's data
(80-byte packets despite 1452-byte capacity). 1457 GETs in 58s, ~25
streams/sec.

Root cause: race between client.get()'s per-call driver.wakeup() and
the dispatcher scheduling the OTHER 63 coroutines. Sequence:
  1. c1 acquires conn lock, enqueues request, wakes send loop, releases
  2. Send loop wakes, queues for lock — other 63 coroutines haven't
     started yet (dispatcher hasn't picked them up)
  3. Send loop acquires lock alone, drains c1's data into one tiny
     packet, releases
  4. c2 finally starts, acquires, enqueues, wakes...
  → one stream per packet, no coalescing

Fix: split GetClient.get() into prepareRequest (open + enqueue + FIN,
synchronous, no wake) and awaitResponse (collect, async). Multiplex
chunk loop now:
  Phase 1: serial prepareRequest for every URL in the chunk (64 in
           sequence, each adding to send buffers)
  Phase 2: SINGLE driver.wakeup() — by now all 64 streams have data
           queued; send loop drains them all in coalesced packets
  Phase 3: parallel awaitResponse with per-stream timeout

Predicted throughput jump: 25 streams/sec → ~1000+/sec (sim RTT-bound
at ~30ms per round trip = 64 streams per RTT = 2100/sec ceiling).

Single-request paths (transfer / chacha20 / etc) keep using the
default GetClient.get() which still wraps prepare+await; no behavioral
change there.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 01:57:47 +00:00
Claude 4f52027ccb fix(quic-interop): wake send loop on every queued request — fixes throughput
Diagnosis (qlog): 1361 GETs in 58s = 23/sec, but only 2618 packets sent
in that window — 0.5 GETs per packet. We were sending nearly empty
packets one at a time, not coalescing requests.

Root cause: stream.send.enqueue + stream.send.finish() don't wake the
send loop. The send loop suspends on sendWakeup until either an
inbound packet arrives or the PTO timer fires (~1s). For our chunked
parallel multiplexing path:
  1. enqueue 64 GETs into 64 streams
  2. send loop is asleep (last drain happened on previous chunk)
  3. wait ~1s for PTO before any of them go on the wire
  4. server processes, replies, our read loop wakes the send loop
  5. send loop drains ACKs (no new requests yet)

Each chunk wasted ~1s of PTO wait. With ~21 chunks at 1s each plus
RTTs and server processing, throughput floored at ~23 streams/sec.

Fix: pass QuicConnectionDriver to Http3GetClient + HqInteropGetClient
constructors. After stream.send.finish(), call driver.wakeup() to
nudge the send loop. The first request of each chunk now leaves
within microseconds instead of waiting for PTO.

Predicted throughput: 600+ streams/sec (RTT-bound at 30ms per chunk
of 64 = 2100/sec ceiling; CPU and lock contention drop it to ~600).
1999 streams in 3-5s instead of timing out at 60s.

The architectural fix would be having stream.send.enqueue auto-wake
via a callback set during stream creation. That's the cleaner shape;
this commit takes the minimal path: explicit driver-passed wakeup
from interop client code where the throughput matters.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 01:44:37 +00:00
Claude f7f6fe0578 fix(quic-interop): chunked-parallel multiplexing for real throughput
Multiplexing matters — MoQ audio rooms run hundreds of concurrent streams
(one per Opus frame). Earlier diagnosis showed our endpoint was
hard-capped at ~23 streams/sec because spawning 1999 simultaneous
coroutines all racing :quic's single conn.lock cratered throughput.

Diagnosis from the qlog/output.txt: 1359 GETs processed in 58s. Lock
contention scales superlinearly with suspended coroutines:
  - every drainOutbound walks streamsList O(N)
  - every openBidiStream queues behind every other waiter
  - the dispatcher thrashes context-switching across 1999 channels

Fix: process the multiplexing testcase's URLs in chunks of 64. Each
chunk is fully parallel on the wire (what the runner's tshark
multiplexing check verifies — streams overlap in time WITHIN a chunk),
and conn.lock only ever has ~64 live waiters instead of ~1999.

Predicted throughput jump from 23 to ~600+ streams/sec. 1999 files in
~3 seconds instead of timing out at 60.

Per-stream timeout still wraps each get() — a single hung stream
surfaces as status=0 instead of stalling its chunk's await.

This is the right answer for the throughput angle. The conn-lock split
remains a follow-up for genuinely-stratospheric stream counts (10k+),
but 64-wide concurrency comfortably handles the runner's 1999 and
real MoQ audio-room load shapes (hundreds of concurrent streams).

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 01:37:36 +00:00
Claude 4e00c8db07 revert(quic-interop): drop explicit QPACK SETTINGS — made multiplexing worse
The QPACK_MAX_TABLE_CAPACITY=0 advertisement worsened the result
(1 file → 0 files written). Empirical: empty SETTINGS = spec defaults
(both 0) and aioquic was already sending literal-encoded responses
under that condition.

Real bug isn't QPACK — diagnostic showed 1359 GETs processed in 58s
(~23 streams/sec). Throughput is hard-bottlenecked by :quic's single
conn.lock serializing send loop + read loop + openBidiStream across
1999 waiting coroutines. Even at maximum throughput we'd only complete
~1400 of 1999 in 60s. The 1 file we managed previously was just the
first response landing before lock contention spiked.

Filed multiplexing as a known-throughput-limit follow-up. Possible
fixes (per-level lock split / Semaphore-bounded concurrency / QPACK
dynamic-table support) are all non-trivial and the testcase is a
stress test, not a real-world load shape.

For now: revert to empty SETTINGS, accept multiplexing as the lone
deferred testcase. Aioquic + picoquic stay at 6/7.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 01:33:23 +00:00
Claude 911c66f447 fix(quic-interop): explicitly advertise QPACK_MAX_TABLE_CAPACITY=0
aioquic multiplexing diagnosis: runner asked for 1999 files, aioquic
processed 1371 GET requests, our endpoint wrote ONLY 1 file to
/downloads. The connection stayed healthy throughout (qlog shows
STREAM frames flowing both ways at t=58s). Server response volume
indicates each request got a 200 response.

The bug: our QpackDecoder is literal-only (no dynamic table). When
aioquic primes its dynamic table after a few requests and switches
to dynamic-table references in subsequent response HEADERS, our
decoder silently mis-parses — status comes back 0, file not written.
Empty-SETTINGS gives aioquic the spec default (also 0) but it apparently
doesn't strictly enforce: it still emits dynamic-refs anyway.

Fix: explicitly advertise QPACK_MAX_TABLE_CAPACITY=0 +
QPACK_BLOCKED_STREAMS=0 in our SETTINGS. Forces aioquic onto the
literal-only QPACK path that our decoder handles correctly.

A proper fix would be to implement QPACK dynamic-table support in our
decoder + read the server's encoder stream; that's its own project.
For now this gets multiplexing past the QPACK barrier so we can see
whether anything else is broken downstream.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 01:28:00 +00:00
Claude 0fea36fa56 docs(quic-interop): plan reflects Phase 5 — overnight bug-hunt complete
Records the qlog-driven debugging session that fixed:
  - QlogWriter close-race breaking healthy connections
  - QlogWriter per-event flush stalling the connection lock
  - PTO probe missing CRYPTO retransmit (aioquic close)
  - Retry test PN-reuse violation (RFC 9001 §5.7)
  - Multiplexing 30s timeout
  - Hung-stream blocks the parallel-await chain

Still open:
  - v2 testcase (needs RFC 9369 implementation)
  - Multiplexing throughput on Mac+Rosetta
  - Server role (would unlock handshakeloss vs aioquic)

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 01:13:06 +00:00
Claude 32ccbd2b24 fix(quic-interop): per-stream timeout in parallel multiplexing path
The previous coroutineScope { urls.map { async { ... } }.map { it.await() } }
pattern was vulnerable to a single hung stream blocking the whole await
chain — even though the others completed, sequential .await() iteration
would hang on the slowest forever. With multiplexing's hundreds of
streams, the probability of at least one having an issue (lost FIN,
slow consumer hitting channel-saturation thresholds, etc.) is high.

Wrap each get() in withTimeoutOrNull(PER_STREAM_TIMEOUT_SEC). A timed-out
stream surfaces as GetResponse(status=0); the caller's status != 200
check counts it as a failure but doesn't block the loop.

Doesn't fix throughput — that needs separate work on per-stream
backpressure and parser fairness. Just makes the failure mode visible
(status=0 → "failed file") rather than hidden (whole test times out
because of one bad stream).

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 01:12:20 +00:00
Claude 9a74d1d5df fix(quic-interop): bump TRANSFER_TIMEOUT_SEC to 60s for multiplexing
aioquic multiplexing qlog showed:
  t=31424: still receiving STREAM frames
  t=31493: PTO timer expired
  t=31507: connection_closed (owner: local)

We were still actively transferring when our 30s timeout fired. The
multiplexing testcase generates many small files (3431 sim packets
captured for the run) and download throughput on Mac+Rosetta is
dominated by per-write filesystem overhead in the Docker volume mount.

60s gives enough headroom without making fast-completing tests slower.
Doesn't fix any real protocol issue — just lets the test budget match
the workload.

If multiplexing still fails after this, the next investigation is the
sequential `.map { it.await() }` pattern in runTransferTest: a single
hung stream blocks the await chain even though others completed.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 01:11:12 +00:00
Claude 99a1a91de4 fix(quic-interop): drop per-event QlogWriter flush — it stalls the connection
aioquic still 0/7 after the close-race fix because there's a deeper
issue. QlogWriter.writeLineLocked() called writer.flush() on every
event. On macOS Docker Desktop the filesystem is virtualized and
per-event flush is multi-millisecond. The qlog hooks fire from
QuicConnectionWriter.drainOutbound() which runs INSIDE
conn.lock.withLock { ... } on the send loop. Every flush stalls
that lock; the receive loop blocks indefinitely trying to acquire
it for feedDatagram().

Symptom matched: client.sqlog showed our ClientHello packet_sent
at t=400ms, then NOTHING for the rest of the test — no
packet_received (server's response never decoded), no PTO probe
(send loop stuck behind disk IO).

Fix: remove per-event flush. close() flushes once at the end of
the run, which gives us the trace for everything except a hard
JVM kill mid-test (acceptable trade). BufferedWriter still buffers
in-memory; we don't lose events, just don't sync them to disk
synchronously.

Should restore aioquic to 5/7. picoquic was already 5/7 because
its server-response timing apparently let our send loop drain
between flushes; aioquic's faster turnaround tripped the lock more
reliably.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 00:46:28 +00:00
Claude bd9d717dff fix(quic-interop): QlogWriter swallows post-close writes
aioquic full-matrix run came back 0/7 with stack traces:
  java.io.IOException: Stream closed
    at QlogWriter.writeLineLocked(QlogWriter.kt:289)
    at QlogWriter.onPacketSent(QlogWriter.kt:126)
    at QuicConnectionWriter.emitQlogSent(...)

The send loop runs concurrently with InteropClient's teardown sequence:
  1. driver.close() — launches CLOSING → CLOSED async
  2. qlogWriter?.close() — closes the file
  3. delay(50)
  4. scope.cancel() — kills coroutines

Between (2) and (4), the send loop is still alive and emits
packet_sent for the CONNECTION_CLOSE Initial. QlogWriter.writeLineLocked
threw IOException into the send loop, propagated up, took down the
connection — including connections that had completed transfer
successfully.

Fix: QlogWriter swallows post-close IOExceptions silently. An observer
must NOT break the connection it's observing — that's the whole NoOp
contract. Adds @Volatile closed flag, latches it both on explicit
close() and on the first IOException; subsequent emits short-circuit.

picoquic was 5/7 (predictable: M + S still open) so the regression was
specifically aioquic-timing-sensitive. With this fix aioquic should
return to 5/7 too.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 00:40:21 +00:00
Claude 2ec995b1b6 docs(quic-interop): plan reflects Phase 4 + retry-debug roadmap
Documents:
  - Three overnight agents merged (VN defense in :quic, qlog observer
    + JSON-NDJSON writer wired into the prod endpoint, peer-uni-stream
    drainer fixing the multiplexing tear-down).
  - The agent-A versionnegotiation testcase mismatch — runner doesn't
    have that name; v2 is the closest, tests QUIC v2 which we don't
    speak. VN code stays as defensive support.
  - Open issues for tomorrow: retry test failure (qlog now available
    for diagnosis), v2 (deferred), re-runs against all three peers
    with the new fixes.
  - Documented run-matrix.sh's concurrency limit.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 00:38:11 +00:00
Claude 5a582ec3f5 feat(quic-interop): wire qlog observer into the production endpoint
Agent B's QlogWriter previously lived only in :quic's jvmTest scope,
hooked into the standalone InteropRunner. Brings it into :quic-interop
proper:
  - QlogWriter.kt + QlogWriterTest.kt copied into :quic-interop with
    package com.vitorpamplona.quic.interop.runner.
  - Jackson dep added to :quic-interop's build.gradle.kts.
  - InteropClient reads $QLOGDIR (the runner sets it inside the
    container per docker-compose.yml). When set, opens a QlogWriter
    at <QLOGDIR>/client.sqlog, passes as QuicConnection.qlogObserver,
    closes on every exit path (handshake_failed / udp_failed /
    transfer_timeout / ok).
  - QUIC_INTEROP_DEBUG=1 now prints qlogdir alongside the other env
    fields.

Net effect: any future runner-driven test failure dumps a structured
qlog file the user can drag straight into qvis (qvis.quictools.info)
to see frame-by-frame what the client decided to do. The retry test
failure on aioquic + picoquic that we still need to debug now produces
a usable artifact.

NOTE: the QlogWriter copy in :quic's jvmTest stays in place as the
helper for the standalone InteropRunner main(). Slight duplication;
acceptable while :quic-interop is its own module.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 00:35:11 +00:00
Claude 7d5fcd710f fix(quic-interop): drop versionnegotiation; document concurrency limit
Two corrections from the user's just-completed run:

1. The runner does not have a 'versionnegotiation' testcase. Available
   list output: handshake, transfer, longrtt, chacha20, multiplexing,
   retry, resumption, zerortt, http3, blackhole, keyupdate, ecn,
   amplificationlimit, handshakeloss, transferloss, handshakecorruption,
   transfercorruption, ipv6, v2, rebind-port, rebind-addr,
   connectionmigration. (`v2` exists but tests QUIC v2 protocol
   support — we're v1-only, so it would correctly fail.)

   The :quic-side VN code from agent A stays as defensive support for
   any future server that throws a VN at us; just no testcase wires
   it up.

2. run-matrix.sh is NOT safe to run concurrently. The runner's
   docker-compose.yml hardcodes container_name: sim/server/client —
   Docker enforces those globally regardless of COMPOSE_PROJECT_NAME.
   Documented the limitation + the recommended sequential loop in
   the script's comments.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 00:33:37 +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 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 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 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 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 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