Commit Graph

17 Commits

Author SHA1 Message Date
Claude a0e5e04964 quic: expose flow-control snapshot for prod cliff investigation
Adds a read-only diagnostic surface that lets a test (or any caller)
read the peer's transport parameters, the live connection-level send
credit / consumed counters, the current peer-granted MAX_STREAMS_*
values, and the total bytes sitting in stream send buffers but not
yet handed to STREAM frames.

Goal: pin which budget runs out at the production "stream cliff"
described in nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md.
The plan flagged three candidates — connection-level MAX_DATA, per-
stream MAX_STREAM_DATA, or the relay's MAX_STREAMS_UNI extension
policy. The snapshot makes it possible to attribute the stall by
reading the diff between the pre-pump, post-pump, and post-grace
snapshots from the test's stdout.

QuicConnection:
  - flowControlSnapshot(): suspend, lock-protected, returns
    QuicFlowControlSnapshot (new data class) — peer TPs + live
    accounting + sum of enqueued-not-sent bytes across streams.

QuicWebTransportSession (jvmAndroid adapter):
  - quicFlowControlSnapshot() passthrough so the test can downcast
    its WebTransportSession and read the underlying connection's
    state without poking through the common transport interface.

SendTraceScenario:
  - Optional flowControlSnapshot lambda parameter; when supplied,
    logs three checkpoints — fc-pre, fc-post-pump, fc-post-grace —
    each on a single line with the full snapshot.

NostrnestsProdAudioTransmissionTest + NostrNestsSustainedSendOutcomesInteropTest:
  - withProdSpeakerAndListeners / withHarnessSpeakerAndListeners now
    yield a snapshot lambda to the scenario block. Every sweep test
    automatically dumps fc-* lines to the JUnit XML system-out.

FlowControlSnapshotTest:
  - Pre-handshake: peer TP fields are null, counters zero.
  - Post-handshake: every TP field reflects what the in-process TLS
    server advertised; sendConnectionFlowCredit equals
    initial_max_data; consumed = 0.
  - Post-allocate-and-enqueue: nextLocalUni/BidiIndex advance,
    totalEnqueuedNotSentBytes sums the buffered chunks, and
    streamsWithPendingBytes counts only streams with > 0 pending.

Reading the production sweep output after this commit:
  - fc-pre dumps what the relay grants on handshake (initial_max_data,
    initial_max_stream_data_uni, initial_max_streams_uni).
  - fc-post-pump shows whether sendConnectionFlowConsumed has
    plateaued at the cap (peer didn't extend MAX_DATA) or whether
    bytes are stuck in stream send buffers.
  - The diff between fc-post-pump and fc-post-grace tells us
    whether the relay's eventual MAX_DATA / MAX_STREAMS update did
    or didn't arrive during the 30-60 s grace window.
2026-05-01 14:14:06 +00:00
Vitor Pamplona 17054fc35a Links the Tor okhttpclient with nests 2026-04-28 14:45:14 -04:00
Claude cee9d8a430 feat(nests): retry 429 with backoff + re-sign NIP-98 on each retry
Lets a clustered burst of mint calls (typical interop test scenario,
also any moq-auth deployment with stricter rate limits) outlast the
60 s rate-limit window instead of cascading into a hard failure.

- Retry up to 7 times on HTTP 429. Worst-case total wait at 1 s
  initial + 16 s cap is 1+2+4+8+16+16+16 = 63 s — just past the
  reference moq-auth 60 s/IP window.
- Respect the `Retry-After` header (delta-seconds OR HTTP-date)
  when present; fall back to capped exponential backoff otherwise.
- Re-sign the NIP-98 auth event on every attempt. The reference
  validator accepts a 60 s validity window; without re-signing,
  any retry that waits past that window fails 401 "Event too old".
- Suspending `delay` so coroutine cancellation tears the loop down
  cleanly.

`./gradlew :nestsClient:jvmTest -DnestsInterop=true -DnestsInteropExternal=true`
now goes green in a single invocation: 157 tests across 33 classes,
0 failures. Previously the same command cascaded 11 failures once
the moq-auth 20/min/IP bucket filled.

Unit tests: 7 new cases covering Retry-After parsing (seconds,
HTTP-date, garbage, missing), exponential cap, the 429-then-200
retry loop, max-retries exhaustion, and that non-429 4xx is NOT
retried. Backed by a tiny ServerSocket-based HTTP fake so no new
test deps.
2026-04-28 17:34:22 +00:00
Claude da1c4d3968 fix(audio-rooms): advertise moq-lite-03 in WT CONNECT sub-protocols
Without `wt-available-protocols`, moq-relay (`web-transport-quinn`) falls
back to the legacy in-band SETUP exchange (moq-lite-02) instead of
selecting the moq-lite-03 sub-protocol from the ALPN-style negotiation
header. Then the relay tries to decode our first post-CONNECT bytes as a
SETUP_CLIENT message, hits an unknown control type, and closes the QUIC
connection with `connection closed err=invalid value` — surfaced
client-side as a stuck SUBSCRIBE that ends with
`subscribe stream FIN before reply for id=0` (the bidi gets FIN'd because
the whole connection is being torn down).

Pass `wt-available-protocols: "moq-lite-03"` on the Extended CONNECT
request, encoded as an RFC 8941 Structured Field List of strings (the
header format mandated by draft-ietf-webtrans-http3-14 §3.3). With this,
moq-relay logs `negotiated version=moq-lite-03 transport="quic"` and the
SUBSCRIBE makes it into the relay's actual moq-lite session pump.

Mechanism: web-transport-proto's `ConnectRequest::encode` reads
`self.protocols` and writes them as a comma-separated list of bare
strings under `wt-available-protocols`. The server side (web-transport-
quinn) reads the same header into `request.protocols`, and moq-native's
`QuinnRequest::ok()` picks the first match against its supported ALPN
list (`moq-lite-04`, `moq-lite-03`, `moq-00`, `moqt-15`, etc.). On
match, version selection happens via the WT sub-protocol response and
the in-band SETUP is skipped — which is what moq-lite-03 expects.

Default the factory list to `["moq-lite-03"]`. Callers that want a
different version (or to disable sub-protocol negotiation entirely
to talk to a SETUP-based server) override the constructor parameter.

Bare-metal harness: NostrNestsHarness.startExternal() now skips the
TCP probe of the moq-relay port. moq-relay binds UDP only; the Docker
forwarder happens to also open TCP, but a directly-launched binary
doesn't, so the previous `Socket(host, 4443)` probe failed with
ConnectException. The QUIC handshake from the test surfaces a real
transport problem if any.
2026-04-26 20:40:19 +00:00
Claude 74d5e77a83 fix(audio-rooms): retry mintToken on transport hiccup + harness /health warmup
The remaining interop failures all root in the same window: a stale
keep-alive pool entry from one test class is reused on the FIRST POST
of the next test class, the connection RSTs as the request body
writes, and OkHttp's built-in retryOnConnectionFailure won't retry a
POST after any byte of the body has gone out. Same situation hits a
phone client whose Wi-Fi hands off mid-mint.

Two fixes, both production-shaped:

  - OkHttpNestsClient.mintToken now wraps execute() in
    executeWithTransportRetry(): one retry on SocketException /
    EOFException / generic IOException. Request builders are
    immutable, so the second pass opens a fresh connection cleanly.
    HTTP error status codes (4xx / 5xx) and malformed responses are
    NOT retried — they go to the caller as before.

  - NostrNestsHarness now polls GET /health until it returns 200
    after the port-probe succeeds. moq-auth's Node runtime opens its
    listen socket before the request handlers are wired, so a POST
    that arrives in that window can RST. Waiting for /health proves
    the request pipeline is live, eliminating the SocketException
    that hit the first test of every test class run after
    AuthEndpoints.

Symptoms fixed:
  - NostrNestsAuthFailureInteropTest.missing_authorization_header_is_rejected_401
    -> SocketException
  - NostrNestsRoundTripInteropTest.production_speaker_broadcasts_to_production_listener_via_real_relay
    -> "Failed to reach http://127.0.0.1:8090/auth"
  - NostrNestsMultiPeerInteropTest.* (3 tests, same root cause)
2026-04-26 19:46:38 +00:00
Claude 71cf99dc22 feat(audio-rooms): moq-lite speaker side end-to-end (phase 5c-speaker)
Production speaker path now runs on moq-lite, so connectNestsSpeaker
exchanges real moq-lite framing with the nostrnests reference relay.

Transport layer:
  - WebTransportSession.incomingBidiStreams() — peer-initiated bidi
    flow. moq-lite publishers receive Announce + Subscribe bidis from
    the relay (rs/moq-lite/src/lite/publisher.rs:40 uses
    Stream::accept(session)), so the abstraction grew the
    accept-bidi-from-peer surface.
  - WebTransportSession.openUniStream() — locally-opened uni stream
    for group push (rs/moq-lite/src/lite/publisher.rs:338 uses
    session.open_uni()).
  - :quic WtPeerStreamDemux StrippedWtStream now carries optional
    send/finish closures. The demux takes the QuicConnectionDriver
    so wakeups fire after each app-level write on a peer-initiated
    bidi.
  - FakeWebTransport now exposes incomingBidiStreams + openUniStream
    directly; the openPeerUniStream test helper went away (production
    flow covers it).

Session layer:
  - MoqLiteSession.publish(suffix) — claims a broadcast suffix and
    lazily launches a relay→us bidi pump. ControlType=Announce reads
    AnnouncePlease, replies Active(suffix). ControlType=Subscribe reads
    body, replies SubscribeOk, registers the inbound subscription.
  - MoqLitePublisherHandle — startGroup / send / endGroup / close
    semantics. send opens a uni stream per group with DataType=0 +
    GroupHeader and pushes varint(size)+payload frames. close emits
    Announce(Ended) on every active announce bidi, FINs the uni.

Application layer:
  - AudioRoomMoqLiteBroadcaster — sibling of AudioRoomBroadcaster but
    drives MoqLitePublisherHandle (keeps IETF broadcaster intact for
    its unit tests).
  - MoqLiteNestsSpeaker — NestsSpeaker adapter, mirror of
    MoqLiteNestsListener on the publish side.
  - connectNestsSpeaker now opens a MoqLiteSession (no SETUP) and
    returns MoqLiteNestsSpeaker.

Tests:
  - 4 new MoqLiteSessionTest cases:
    publisher_replies_to_announcePlease_with_active_announce,
    publisher_acks_subscribe_and_pushes_group_data_on_uni_stream,
    publisher_send_returns_false_when_no_inbound_subscriber,
    publisher_close_emits_ended_announce.

Verified :commons:compileKotlinJvm + :amethyst:compilePlayDebugKotlin
both still compile against the swap.

Docs (plans + CLAUDE.md) refreshed to reflect speaker-side landing.
2026-04-26 18:01:00 +00:00
Claude beec8204e5 refactor(audio-rooms): NestsClient API matches nostrnests reality (phase 2/3)
The Phase-1 interop harness exposed a substantial mismatch between our
production HTTP client and what the nostrnests reference server actually
exposes. This commit refactors `:nestsClient` and the wiring above it so
the production code path can talk to a real moq-auth + moq-relay.

| Aspect    | Before                                    | After (matches nostrnests/moq-auth/src/index.ts) |
|-----------|-------------------------------------------|--------------------------------------------------|
| Method    | GET                                       | POST                                             |
| URL       | `<base>/<roomId>`                         | `<base>/auth`                                    |
| Body      | none                                      | `{"namespace":"nests/<kind>:<host>:<roomId>","publish":bool}` |
| Response  | `{endpoint, token, codec, sample_rate}`   | `{token}` only                                   |
| Endpoint  | from response                             | from event's `endpoint` tag (passed via `NestsRoomConfig.endpoint`) |
| NIP-98    | bound to GET URL                          | bound to POST URL + body hash                    |

Type changes:
- New `NestsRoomConfig` data class bundling (authBaseUrl, endpoint,
  hostPubkey, roomId, kind). Built by the caller (UI / VM) from the
  NIP-53 kind 30312 event before invoking connectNests*.
- `NestsRoomConfig.moqNamespace()` produces the exact format
  moq-auth's NAMESPACE_REGEX expects: `nests/<kind>:<hex64>:<roomId>`.
- `NestsRoomInfo` deleted; replaced with a tiny `NestsTokenResponse(token)`
  matching the real response shape.
- `NestsClient.resolveRoom(serviceBase, roomId, signer): NestsRoomInfo`
  → `NestsClient.mintToken(room, publish, signer): String`. The
  publish flag drives the JWT claims (`get` for listeners, `put`
  for speakers).

Wire path:
- `OkHttpNestsClient` now POSTs `<authBase>/auth` with a JSON body
  and a NIP-98 Authorization header bound to (POST, url, body-hash).
- `connectNestsListener` / `connectNestsSpeaker` take `room:
  NestsRoomConfig` instead of split (serviceBase, roomId), pass
  `publish=false` / `publish=true` respectively, and use the room's
  `endpoint` (not a server-returned one) for the WebTransport
  connect. The minted JWT is the bearer token.
- `NestsListenerState.Connected` / `NestsSpeakerState.Connected` /
  `Broadcasting` carry the `room: NestsRoomConfig` instead of the old
  `roomInfo: NestsRoomInfo`.
- MoQ TrackNamespace for the room is now a single segment whose
  bytes are `room.moqNamespace()` — the simplest mapping to the
  relay's JWT claim check (`root: "<namespace>"`); Phase-3 round-trip
  test will confirm and adjust if the relay expects a multi-segment
  tuple.

Wiring above:
- `AudioRoomViewModel` constructor: replaces `(serviceBase, roomId)`
  with `(room: NestsRoomConfig)`. Connector seam interfaces
  (NestsListenerConnector, NestsSpeakerConnector) follow the same
  shape.
- `AudioRoomViewModelFactory` (Android) takes `room: NestsRoomConfig`.
- `AudioRoomActivity` adds `EXTRA_AUTH_BASE_URL`, `EXTRA_ENDPOINT`,
  `EXTRA_HOST_PUBKEY`, `EXTRA_KIND` Intent extras (was just service
  + roomId) and reconstructs `NestsRoomConfig` in onCreate. Drops
  `EXTRA_SERVICE_BASE`.
- `AudioRoomJoinCard` reads `event.endpoint()` + `event.pubKey` +
  `event.kind` in addition to `event.service()`; rooms missing any
  of those are silently un-joinable (the event author didn't host
  on a nests-compatible relay).
- `AudioRoomActivityContent` takes `room: NestsRoomConfig` in place
  of (serviceBase, roomId) and threads it down.

Phase-1 ping test rewired to use the production `OkHttpNestsClient`
end-to-end against the real `/auth`, asserting we get back a
3-segment JWT.

Existing in-process tests updated for the new types: NestsConnectTest,
NestsSpeakerTest, AudioRoomViewModelTest. NestsRoomInfoTest renamed to
NestsRoomConfigTest with new cases for the namespace formatter and the
auth-URL helper. All 80 in-process tests still green.

Phase 3 (next) will add the full round-trip interop test that runs
production `connectNestsListener` + `connectNestsSpeaker` through the
real moq-relay — that's where MoQ wire-format assumptions (draft
revision, OBJECT_DATAGRAM layout, namespace tuple shape) get verified
or get followup audit findings.
2026-04-26 14:42:26 +00:00
Claude 4338e5e6c4 docs(quic+nestsClient): post-implementation status + audio-rooms completion plan
Two new module-local plan docs (per CLAUDE.md's "plans live in the owning
module" rule) and a sweep of stale inline phase references.

quic/plans/2026-04-26-quic-stack-status.md:
  Post-mortem of the original docs/plans/2026-04-22 plan. Documents
  what shipped vs what was estimated, the actual package layout (~8.5k
  LoC, 39 test files, 5 audit rounds), the crypto delegation surface
  (Quartz only — no BouncyCastle, no JNI), interop verification status
  (aioquic + picoquic; nests not yet), and known deferred items
  (STREAM retransmit, Initial-key discard, etc.).

nestsClient/plans/2026-04-26-audio-rooms-completion.md:
  Punch list to ship audio rooms end-to-end:
    M1 Listener wire-up in Amethyst UI
    M2 Multi-speaker audience UX
    M3 Foreground service for backgrounded playback
    M4 Manual interop pass against nostrnests.com
    M5 MoQ publisher path (ANNOUNCE / TrackPublisher)
    M6 Capture → encode → publish pipeline
    M7 NestsSpeaker API
    M8 App polish (reconnect, leave cleanup)
    M9 Foreground service for speakers
  ~6 weeks for full audio rooms; ~2 weeks for listener-only MVP.

Inline doc cleanup:
  * Removed "Phase 3a/3c-1/3c-2/3c-3" / "Phase B/C/D-K/L" references
    from active code; replaced with "today" or pointers to the
    completion plan
  * Removed "Kwik-based stub" references; QuicWebTransportFactory and
    surrounding docs now describe :quic as the production path
  * TlsClient header reflects non-null certificateValidator + the
    JdkCertificateValidator / PermissiveCertificateValidator split
  * SendBuffer header documents the best-effort no-retransmit mode
    explicitly (was hidden behind a "Phase L will fix this" note)
  * MoqMessage / MoqObject / MoqSession reflect listener-side as
    shipped + publisher-side as Phase M5

CLAUDE.md:
  * Module list now includes :quic and :nestsClient (was 5 modules,
    now 7)
  * Architecture diagram + sharing philosophy explain what each new
    module owns

No production behaviour changes; doc + comment-only edits. Tests green.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-26 01:38:48 +00:00
Claude 2f9a0a0e03 fix(quic): live interop against aioquic + round-3 audit fixes
🎉 First successful live-interop handshake against a real reference impl.
Drove our pure-Kotlin QUIC client at the aucslab/aioquic-http3-server Docker
container; HANDSHAKE COMPLETE with negotiated ALPN=h3 and full transport
parameter exchange:

  max_data=1048576, max_streams_bidi=128, max_streams_uni=128,
  idle_timeout=60000ms, max_datagram=65536

Every layer worked end-to-end over real UDP: packet codec, header
protection, AES-128-GCM AEAD, TLS 1.3 with X25519, CertificateVerify,
transport parameters, ALPN negotiation. The PermissiveCertificateValidator
+ InteropRunner main + Gradle :quic:interop task make this reproducible
in one command.

Round-3 audit fixes (8 critical bugs caught by 4 parallel reviewers):

1. feedDatagram coalesced-packet skip (RFC 9001 §5.5):
   `?: break` discarded all subsequent coalesced packets if any one of
   them failed to decrypt. Now uses peekHeader to advance over a failed
   packet, only breaking on a totally-unparseable header.

2. Receive-side flow-control enforcement:
   Parser was inserting STREAM frames without checking against the limit
   we advertised. A misbehaving peer could blow past initialMaxStreamDataX
   with no error; now triggers markClosedExternally with FLOW_CONTROL
   diagnostic.

3. Bounded incomingChannel (audit-2 finding finally addressed):
   QuicStream.incomingChannel was Channel.UNLIMITED — slow consumer +
   fast peer = unbounded heap growth. Now Channel(64), bounded by the
   per-stream receive limit. Combined with #2, memory growth is capped.

4. RetryPacket CID length validation:
   parse() didn't bounds-check dcidLen/scidLen against the RFC 9000 §17.2
   1..20 range. Hostile Retry with cidLen=255 → readBytes throws
   QuicCodecException to the caller (instead of returning null for silent
   drop). Added explicit `!in 0..20 → return null`.

5. HelloRetryRequest detection:
   No HRR check — we treated it as a regular ServerHello, derived an
   ECDHE on garbage, then failed AEAD downstream with a confusing error.
   Now checks ServerHello.random against the SHA-256("HelloRetryRequest")
   magic value and throws cleanly.

6. CancellationException handling in WT factory:
   Catch-all wrapped CancellationException as HandshakeFailed, breaking
   structured concurrency. Now rethrows ce after closing the driver.

7. InteropRunner scope leak on timeout:
   parentScope was never cancelled — orphaned coroutines kept the IO
   dispatcher alive after main exited. Now scope.cancel() runs
   unconditionally; small delay gives the driver-launched teardown a
   moment to flush before exit.

8. RFC 9220 §3.1 SETTINGS-before-CONNECT documented as known limitation:
   The strict requirement to wait for SETTINGS_ENABLE_WEBTRANSPORT=1
   before sending CONNECT requires more refactoring (the demux capturing
   peerSettings lives inside QuicWebTransportSessionState, which we don't
   build until after the request bidi opens). Tolerant servers (aioquic,
   quic-go) accept early CONNECT; documented for future strict-RFC fix.

All :quic:jvmTest + :nestsClient:jvmTest pass. Live aioquic interop verified
post-fix: handshake completes, transport parameters round-trip cleanly.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 23:21:37 +00:00
Claude 8f5280c9c3 fix(quic): WebTransport peer-stream demux + flow-control + perf cleanup
Round-2 audit found that without prefix-stripping on peer-initiated streams,
the server's HTTP/3 control stream would deliver its SETTINGS frame to the
application, breaking MoQ framing. Plus several flow-control + perf items
the audit flagged.

WtPeerStreamDemux:
  Spawned per WT session in QuicWebTransportSessionState.init. Routes peer
  streams by their leading varint(s):
    - HTTP/3 CONTROL stream (0x00) → drains internally, captures peer
      SETTINGS frame for inspection (peerSettings property).
    - QPACK encoder/decoder streams (0x02/0x03) → drained to /dev/null
      (we run with QPACK_MAX_TABLE_CAPACITY=0).
    - WebTransport unidirectional (0x54) + matching quarter session id →
      surfaces to app as a StrippedWtStream with the prefix stripped.
    - WebTransport bidi signal (0x41) + matching quarter session id → same.
    - Anything else → dropped per RFC 9114 §9.

  pollIncomingPeerStream is now @Deprecated; the recommended path is the
  incomingStrippedStreams flow on the session state. The nestsClient adapter
  now consumes the stripped flow and emits StrippedWtReadStreamAdapter
  through the existing WebTransportSession.incomingUniStreams API.

WT_CLOSE_SESSION decoder + CapsuleReader:
  Added stateful capsule reader that yields parsed WtCloseSession
  (errorCode + reason) from the CONNECT bidi. Wires into the future close
  notification path; not yet bound to status flip but the parser is in
  place.

Flow-control fixes from the audit:
  - Per-direction receive window in getOrCreatePeerStreamLocked. SERVER_UNI
    streams now use initialMaxStreamDataUni instead of bidi-remote.
  - appendFlowControlUpdates picks per-direction window matching the
    stream's StreamId.kindOf.
  - MAX_DATA tracking via QuicConnection.advertisedMaxData high-water mark.
    Previously the writer emitted a fresh MAX_DATA on every outbound
    packet once the threshold was crossed (spam). Now: only when the new
    value strictly exceeds advertisedMaxData.

Stream iteration round-robin:
  Writer was iterating streamsLocked() in insertion order, starving streams
  created later under MTU pressure. Added streamRoundRobinStart counter on
  QuicConnection that advances after each drain.

SendBuffer alias defense:
  takeChunk's fast path used to return the head ByteArray reference directly
  when consuming the whole chunk — caller-owned byte arrays could be mutated
  in-place. Now always copies, since downstream encoders pass the bytes to
  AEAD.seal which assumes immutability.

UdpSocket cleanup:
  - readBuf shrunk from 65 KiB to 2 KiB. QUIC datagrams cap at MTU; the old
    65 KiB allocation was per-connection waste with no benefit.
  - Removed pointless synchronized(readBuf) — only the read loop touches it.

All :quic:jvmTest + :nestsClient:jvmTest pass.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 22:49:32 +00:00
Claude 94a3d32a6d fix(quic): lifecycle hangs + TLS hardening from second-pass audit
Six fixes from the round-2 audit, all of which would either hang the
client on failure modes or weaken security against a misbehaving server.

QuicConnection / awaitHandshake hang fixes:
  - QuicConnectionClosedException + markClosedExternally(reason).
  - close() now also calls signalHandshakeFailed if !handshakeComplete.
  - QuicConnectionParser inbound CONNECTION_CLOSE → markClosedExternally
    (was: just flip status, leaving awaiters hanging forever).
  - QuicConnectionDriver.readLoop has a finally-block that calls
    markClosedExternally + wakeup so when the socket closes mid-handshake
    or the server closes uncleanly, awaitHandshake() throws instead of
    suspending forever.

QuicConnectionDriver.close() no longer cancels its own caller scope:
  Previously close() was suspend, called connection.close (acquires lock)
  → wakeup → scope.cancel → socket.close. If close() is invoked from
  inside the driver scope (e.g. by the WT factory's exception cleanup
  path), scope.cancel cancels the very coroutine running close, so
  socket.close may never run. close() is now non-suspend and dispatches
  the teardown onto parentScope.launch so the cancel never reaches its
  caller.

QuicWebTransportFactory.connect:
  - readResponseStatus wrapped in withTimeoutOrNull(connectTimeoutMillis,
    default 10s). Dead network → HandshakeFailed instead of forever-hang.
  - Whole post-handshake setup (open control + request streams + read
    response) now in try/catch that calls driver.close() on any unexpected
    exception. Previously a thrown SocketException between awaitHandshake()
    and the explicit non-2xx branch leaked the driver + UDP socket.

JdkCertificateValidator.validateChain authType:
  Was hardcoded "ECDHE_ECDSA". Now derived from the leaf cert's public-key
  algorithm: "RSA" → ECDHE_RSA, "EC"/"EdDSA" → ECDHE_ECDSA. Some Android
  trust managers (RootTrustManager, NetworkSecurityConfig) gate
  algorithm-specific pinning rules on this string and may reject mismatched
  combos.

TlsExtension.decodeList bounds:
  Inner reads were unbounded against the extension-list end. A malicious
  server could claim totalLen=4 but encode an extension whose data length
  declared 1000, reading past the supposed extension-list end into
  trailing handshake bytes. Now: validates totalLen ≤ r.limit upfront and
  asserts r.position ≤ end after each inner decode.

TlsClient KeyUpdate close-on-receipt:
  Previously HS_KEY_UPDATE was silently dropped. RFC 9001 §6: if the peer
  rotates keys and we keep using the old ones, AEAD opens silently fail
  → connection wedges. Until we implement rotation, KeyUpdate must throw
  so the QUIC layer closes cleanly with a fatal error instead of
  desynchronizing.

All :quic:jvmTest + :nestsClient:jvmTest pass.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 22:42:10 +00:00
Claude 368b8dd432 fix(quic): C3+C4+C9 + Tier-2 robustness from review
C3+C4 — HTTP/3 frame reader + WebTransport response :status check
  New Http3FrameReader buffers stream bytes and yields complete frames
  (DATA, HEADERS, SETTINGS, GOAWAY, Unknown). QuicWebTransportFactory
  drains the request stream after sending the Extended CONNECT request,
  feeds bytes through the reader, decodes the first HEADERS frame via
  QPACK, and pulls `:status`. Non-2xx → ConnectRejected. Without this,
  any 401/404/500 yielded a "connected" session that silently dropped.

  Tests: 5 new H3FrameReader tests covering SETTINGS, HEADERS round-trip,
  cross-push reassembly, unknown-type passthrough, multi-frame in one push.

C9 — flow-control enforcement + receive-side crediting
  - Send: per-stream `sendCredit` is now consulted before each takeChunk;
    bytes beyond `sendCredit - sentOffset` are held back. SendBuffer
    exposes `sentOffset` for the writer.
  - Receive: appendFlowControlUpdates() emits MAX_STREAM_DATA when the
    receive cursor crosses half the advertised window, and MAX_DATA at
    the connection level. Without this, peer windows close and any
    sustained transfer wedges silently.

Tier-2 cleanups (six items in one batch):
  - AckTracker.purgeBelow(): drop ranges below peer's largest_acked when
    we receive an ACK frame. Range list no longer grows unboundedly on
    long connections.
  - ReceiveBuffer adjacency edge: pull in the prior chunk when its
    endOffset exactly equals the new chunk's start. Previously perfectly-
    sequential receives starting at offset > 0 left adjacent chunks
    unmerged, growing the chunk list and overcounting bufferedAhead.
  - RetryPacket integrity-tag verify: constant-time compare instead of
    contentEquals.
  - ServerHello legacy_session_id_echo MUST be empty per RFC 8446 §4.1.3
    (we send empty); reject non-empty as a downgrade signal.
  - TLS state machine handles post-handshake NewSessionTicket and
    KeyUpdate at Application level — silently drop instead of throwing
    "unexpected post-handshake type" and tearing down the connection.

Tier-3 perf: SendBuffer chunked queue
  Replaced the O(N) copyOf-on-every-enqueue with an ArrayDeque<ByteArray>
  + headOffset cursor. Enqueue is now O(1); takeChunk peels at most one
  head chunk. Memory is bounded by the sum of outstanding writes instead
  of (sum)². For sustained MoQ stream writes of small chunks this drops
  from O(N²) memcpy to O(N).

All :quic:jvmTest + :nestsClient:jvmTest pass — every RFC 9001 Appendix A
vector still verifies bit-for-bit.

Remaining items deferred:
  Tier-2: incremental transcript hash (low impact: 4-5 calls per handshake
          over <10 KB), TLS HelloRetryRequest detection (we never send
          incompatible ClientHello today, server won't HRR).
  Tier-3: cipher reuse, Huffman lookup tree, UdpSocket selector, packet
          codec triple-allocation. None block live interop; revisit if
          measured RTT or CPU surfaces them.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 22:04:39 +00:00
Claude e250b76272 fix(quic): six critical correctness + security bugs from review
Synthesizes findings from four parallel layer reviews. Each fix here would
have broken or weakened live interop:

C1 — TlsClient stored cipher suite (was hardcoded)
  TlsClient.currentCipherSuite() always returned AES-128-GCM-SHA256, even
  when the server picked TLS_CHACHA20_POLY1305_SHA256. The QUIC layer would
  then install AES-GCM AEAD + AES-ECB header protection over a ChaCha20-
  derived secret → silent 1-RTT decrypt failure. Now stores the negotiated
  cipher from ServerHello and returns it.

C2 — AckTracker records the actual packet PN, not the largest received
  dispatchFrames() in QuicConnectionParser was passing
  state.pnSpace.largestReceived to the ACK tracker. With two coalesced
  packets in one datagram, only the larger PN was ever tracked → server
  retransmits the smaller forever. Plumb the parsed packet's PN through
  dispatchFrames and feed it to receivedPacket(). Also always record (even
  for non-ack-eliciting packets) so the peer's loss recovery sees a
  contiguous picture.

C5 — bounds-check every readVarint().toInt() length in frame decode
  CRYPTO, STREAM (LEN), CONNECTION_CLOSE reason, DATAGRAM_LEN, and ACK
  range count all read a 62-bit varint, truncate to Int, and pass straight
  to readBytes / repeat. A hostile peer could send length=2^62-1 → crash or
  multi-GB allocation. Added boundedLength() + boundedRangeCount() helpers
  that reject if value < 0 or > remaining.

C6 — frame type dispatch uses readVarint, not readByte
  RFC 9000 §12.4 specifies frame types as varints. We were reading a single
  byte, so any extension frame type ≥ 0x40 (e.g. ACK_FREQUENCY 0xAF) would
  be mis-dispatched. All current types are < 64 so the 1-byte form matches
  the 1-byte varint, but the change is forward-compatible.

C7 — CertificateValidator required (no silent skip)
  Both QuicConnection and TlsClient previously had `validator: ... = null`
  defaults. A misconfigured caller would silently accept any server's
  certificate. Removed the defaults; null is now an explicit opt-in for
  in-process loopback tests. Added JdkCertificateValidator backed by the
  platform / JDK system trust store with proper SAN-based hostname check
  and signature verification for ECDSA / RSA-PSS / RSA-PKCS1 / Ed25519.
  QuicWebTransportFactory uses it by default.

C8 — thread-safety on connection state
  QuicConnection.streams, pendingDatagrams, nextLocalBidiIndex/UniIndex
  were mutated from the driver loops and from app coroutines without
  synchronization → ConcurrentModificationException waiting to happen.
  Moved the mutex onto QuicConnection itself; the driver wraps feed/drain
  with `connection.lock.withLock { ... }`, public mutators became suspend
  and acquire the same lock. Internal helpers used by feed/drain are
  marked `Locked` to make the precondition explicit.

  Also replaced the `delay(2)` send-loop polling with a CONFLATED
  `Channel<Unit>` wakeup — app writes (queueDatagram, openBidiStream,
  stream write via the WT adapter) call `driver.wakeup()`. Idle CPU
  drops to zero between packets.

  awaitHandshake() replaces the busy-poll over `conn.status` in
  QuicWebTransportFactory.connect — backed by a CompletableDeferred that
  the TLS listener completes on onHandshakeComplete() or fails on a torn
  down read loop.

Tests: full :quic:jvmTest and :nestsClient:jvmTest suites pass — every
RFC 9001 Appendix A vector still verifies bit-for-bit.

Remaining critical work (in progress, separate commits):
  C3+C4 — HTTP/3 frame reader + WebTransport response :status check
  C9    — flow-control enforcement + MAX_STREAM_DATA crediting

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:58:45 +00:00
Claude 46742636c7 feat(quic): Phase L — wire QuicWebTransportFactory into nestsClient
Replace the Kwik stub in :nestsClient with a pure-Kotlin QUIC + WebTransport
adapter built on top of :quic.

- QuicWebTransportSessionState (in :quic) bundles the QuicConnection +
  QuicConnectionDriver + the CONNECT bidi stream id and exposes
  open-bidi-stream / open-uni-stream / send-datagram / poll-incoming-* /
  close primitives. Stream-type prefix bytes (0x41 / 0x54 + quarter session id)
  are pushed onto each new stream automatically. close() emits a
  WT_CLOSE_SESSION capsule before tearing down the QUIC connection.
- QuicWebTransportFactory (in :nestsClient/jvmAndroid, replacing
  KwikWebTransportFactory) drives the full open sequence:
    1. UDP connect + QuicConnection.start
    2. wait until handshake completes (Status.CONNECTED)
    3. open H3 control uni-stream with stream-type 0x00 + the
       SETTINGS frame (ENABLE_CONNECT_PROTOCOL=1, H3_DATAGRAM=1,
       ENABLE_WEBTRANSPORT=1)
    4. open the Extended CONNECT bidi: HEADERS frame carrying
       :method=CONNECT, :protocol=webtransport, :scheme=https,
       :authority, :path, optional Authorization: Bearer
    5. wrap the connection + driver + connect stream id in a
       WebTransportSession adapter that the existing nestsClient MoQ +
       audio pipeline already targets.
- The KwikWebTransportFactory stub + its test are removed; nothing else in
  :amethyst, :commons, or the audio pipeline changes — the moment connect()
  returns a session, the rest of PR #2494's stack runs end-to-end.
- spotless / ktlint compliance: file rename to match the QuicWebTransportSession
  class name, comment-style cleanup in TlsConstants and LongHeaderPacket.

Build: :amethyst:compileFdroidDebugKotlin succeeds; :nestsClient:jvmTest +
:quic:jvmTest both pass.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-25 21:03:54 +00:00
Claude c07f7baa14 docs(nestsClient): Phase 3b-2 Kwik integration plan in stub
Phase 3b-2 attempt. The honest version: I do not have network access
to verify Kwik's current Maven coordinates or test against a live
nests server, so committing speculative QUIC + Extended CONNECT code
risks breaking the build for everyone else without giving us
audible-audio-on-device confidence in return.

What I did instead: expanded the docstring on KwikWebTransportFactory
into a full integration playbook that the next person picking this
up can execute directly. It covers:

- Maven coordinates to verify (`tech.kwik:kwik-core` + `tech.kwik:flupke`,
  with `net.luminis.quic:kwik` as the legacy fallback group).
- The exact `gradle/libs.versions.toml` + `nestsClient/build.gradle.kts`
  edits to drop in once coords are confirmed.
- Step-by-step handshake sequence with the right HTTP/3 setting IDs:
  * SETTINGS_ENABLE_CONNECT_PROTOCOL=1 (RFC 8441, 0x08)
  * SETTINGS_ENABLE_WEBTRANSPORT=1 (WT-H3 draft, 0x2b603742)
  * SETTINGS_H3_DATAGRAM=1 (RFC 9297, 0x33)
- Extended CONNECT pseudo-header set, including the legacy
  `sec-webtransport-http3-draft02 = 1` for older server compat.
- WebTransport stream-type prefix bytes (0x41 for client bidi, 0x54
  for client uni) + WT capsule type for graceful close (0x2843).
- Test strategy: validate against local nests-rs first, then the
  production `nostrnests.com`.
- Open questions (Flupke Extended CONNECT maturity, draft churn,
  dev-only self-signed-cert support, Android API surface).

Behavior unchanged: connect() still throws WebTransportException
with Kind.NotImplemented and a message pointing at this docstring.

When the real implementation lands, no upstream caller needs to
change — the AudioRoomConnectionViewModel from Phase 3d-3 will
auto-light up the connection chip from "Failed: NotImplemented" to
"Connected" and start producing audio through the Phase 3d-1
pipeline that's already wired.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 07:41:56 +00:00
Claude 4ead4ccd5c feat(nestsClient): WebTransport abstraction + fake + Kwik stub
Phase 3b-1 of the Clubhouse/nests integration. Lands the
[WebTransportSession] abstraction the MoQ layer (Phase 3c) will code
against, so MoQ framing + tests can develop in parallel with the real
Kwik-based transport integration (deferred to Phase 3b-2).

commonMain:
- `WebTransportSession` — bidi/uni stream access, datagrams, close.
- `WebTransportBidiStream` / `WebTransportReadStream` /
  `WebTransportWriteStream` — minimal read/write surface.
- `WebTransportFactory.connect(authority, path, bearerToken)` for
  opening sessions.
- `WebTransportException(kind, ...)` with four canonical failure modes
  (HandshakeFailed, ConnectRejected, PeerClosed, NotImplemented) so UI
  code doesn't need to know about library-specific exceptions.
- `FakeWebTransport.pair()` — in-memory, fully-wired client/server
  pair for unit-testing MoQ framing without touching a real QUIC stack.

jvmAndroid:
- `KwikWebTransportFactory` stub that throws
  `WebTransportException(NotImplemented)` at `connect()`. Doc spells
  out the handshake sequence (QUIC dial → H3 SETTINGS →
  `:method=CONNECT :protocol=webtransport` Extended CONNECT → 2xx) so
  Phase 3b-2 can drop the real implementation in without touching
  callers.

Tests:
- `FakeWebTransportTest` — datagram round-trip both directions,
  bidi-stream write visible on peer side, close() flips isOpen.
- `WebTransportExceptionTest` — kind + message + cause preserved.
- `KwikWebTransportFactoryTest` — `connect()` currently fails with the
  NotImplemented sentinel, so Phase 3b-2 can replace this test when
  the real handshake lands.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 03:10:28 +00:00
Claude 933b522273 feat(nestsClient): NIP-98 auth + nests room-info client
Phase 3a of the Clubhouse/nests integration. Adds a new KMP module
`nestsClient` (Android + JVM targets) that owns the HTTP control plane
for talking to a nests audio-room backend. No transport/audio yet —
that arrives in 3b with WebTransport + MoQ.

Surface:
- `NestsAuth.header(signer, url, method)` signs a kind 27235 event via
  Quartz's existing HTTPAuthorizationEvent and returns a ready-to-use
  `Authorization: Nostr <base64>` header value.
- `NestsRoomInfo` data class + tolerant JSON parser (ignores unknown
  fields so newer nests server revisions don't break older clients).
- `NestsClient.resolveRoom(serviceBase, roomId, signer)` calls
  `<serviceBase>/<roomId>` with the signed NIP-98 header and returns
  the MoQ endpoint + token the audio layer will need.
- `OkHttpNestsClient` (jvmAndroid) is the default implementation
  shared between Android and desktop JVM.

Tests:
- `NestsRoomInfoTest` (commonTest) covers full/minimal payloads,
  unknown-field tolerance, missing-endpoint rejection, and URL
  construction edge cases.
- `NestsAuthTest` (jvmTest) signs a real 27235 event, decodes the
  base64 back through Quartz's JacksonMapper, and asserts the
  signature verifies and the url+method tags bind correctly.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
2026-04-22 02:58:24 +00:00