e71a2b26ef5c0ee224bcbe4064aabf944f7d92fd
12175 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e71a2b26ef |
docs(audio-rooms): coding plans for Tier 1-4, one file per tier
The earlier integration audit identified the gaps; this is the
how-to-build-them. Split across four files plus an index so each
review / commit stays small:
- 2026-04-26-tier-plans-index.md — top-level pointer + sequence
dependencies + what's deliberately out of scope.
- 2026-04-26-tier1-coding-plan.md — listener counter, presence
aggregation, augmented kind-10312 tags (publishing/onstage),
live chat (kind 1311), reactions (kind 7 / 9735), edit + close
+ scheduled rooms, role parsing + promote/demote, hand-raise
queue, kick (kind 4312). Concrete file-level wiring for each
step + suggested commit order (six independent PRs).
- 2026-04-26-tier2-coding-plan.md — participant grid,
per-avatar context menu (follow / mute / zap / promote / kick),
zap entry points (room + speaker), share-via-naddr.
- 2026-04-26-tier3-coding-plan.md — room theming PARSER ONLY
(graceful fallback for themed rooms; full theming behind a
later phase), background-audio + wake-lock audit checklist.
- 2026-04-26-tier4-coding-plan.md — moq-auth token re-mint on
long sessions and moq-lite Connection.Reload-equivalent
reconnect with backoff. Step 1 is subsumed by Step 2 once the
reconnect path is in.
No code changes — pure docs. Each plan names exact file paths,
new types, reused helpers, strings, tests, and call-out risks so
an implementer (or follow-up agent) can pick up Step N without
re-deriving the surrounding context.
|
||
|
|
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)
|
||
|
|
7e67c4655f |
fix(audio-rooms): share Docker harness across all interop test classes
Each interop test class was running its own NostrNestsHarness.start() in @BeforeClass and harnessOrNull?.close() in @AfterClass — meaning the Docker stack tore down + spun back up between every class. That sequence was both slow (~30 s Cargo build for moq-relay each time) and unreliable: leftover network state from the prior `down -v` was racing the next `up -d`, leaving moq-auth either unreachable (SocketException) or producing truncated 401 responses (EOFException on body.string()) for tests that ran after the first. Symptoms before this fix (first class wins; everything else fails): - NostrNestsAuthEndpointsInteropTest ✅ - NostrNestsAuthInteropTest → SocketException ❌ - NostrNestsAuthFailureInteropTest → EOFException ❌ - NostrNestsRoundTripInteropTest → "Failed to reach" ❌ - NostrNestsMultiPeerInteropTest → "Failed to reach" ❌ Fix: NostrNestsHarness.shared() returns a process-singleton. First caller does the docker compose up + port-probe; every subsequent caller reuses the same containers. Teardown is registered once via Runtime.addShutdownHook so the stack lives for the JVM's lifetime and dies cleanly at test process exit. All five test classes now call shared() in @BeforeClass; the @AfterClass blocks no longer call close() on the singleton (clearing the local reference is enough — the shutdown hook handles the real teardown). The original start() entry point is preserved for callers that want a per-call harness. |
||
|
|
8b5af5d496 |
docs(audio-rooms): refresh against shipped state + nostrnests gap audit
Existing plan docs were written before the moq-lite swap, the
create-space + kind-10112 work, and the harness / submodule findings.
This refresh aligns them with what's actually live on the branch and
captures the work still ahead.
- 2026-04-26-audio-rooms-completion.md — flipped to a STATUS-FIRST
layout: implementation table for every protocol/transport/UI
surface, "pending" table for the remaining items (reconnect,
level meters, Desktop / iOS, Nests parity), pointers section
refreshed.
- 2026-04-26-moq-lite-gap.md — marked DONE with the commit range
that landed it (
|
||
|
|
015b0d7dac |
fix(audio-rooms): switch NestsServersEvent to kind 10112 (nostrnests claim)
nostrnests's reference README under "Nostr Integration" already declares: kind:10112 — User-published audio server lists We initially picked 10062 (mirroring BlossomServersEvent's 10063) without spotting that prior claim. Switching to 10112 so a single replaceable event surfaces in both Amethyst and nostrnests's web UI without collision. No migration cost — no users have published kind 10062 yet. |
||
|
|
13e84f276d |
fix(audio-rooms): clone kixelated/moq + run generate-certs in harness
The previous --recurse-submodules fix turned out to be moot: nostrnests's docker-compose-moq.yml references `./moq` as a build context, but the moq directory is NOT in the nostrnests repo and is NOT a submodule — each developer is expected to clone kixelated/moq into ./moq themselves before running compose. Confirmed against the upstream README + repo listing (nostrnests/nests has moq-auth/, nests-relay/, NestsUI-v2/ but no moq/ directory). Two new harness steps before `docker compose up -d`: - ensureMoqSource — clone https://github.com/kixelated/moq.git into <nests-cache>/moq on first run; fetch + checkout DEFAULT_MOQ_REVISION on every run so the build is reproducible. Override the pin via -DnestsInteropMoqRev=<sha-or-branch>. - ensureDevCerts — run dev-config/generate-certs.sh to produce the self-signed TLS chain moq-relay mounts read-only at /certs. The script is idempotent (bails when fullchain.pem exists) so we always invoke it; a chmod +x defensively handles Windows / odd CI checkouts. Reverted the spurious --recurse-submodules + submodule update path since the repo doesn't have submodules at all — the original `git clone` was correct, just incomplete. |
||
|
|
64859546b3 |
fix(audio-rooms): clone nostrnests with submodules so docker compose finds moq/
The harness was running plain `git clone`, but docker-compose-moq.yml references `./moq` (kixelated/moq-rs) and `./moq-auth` as build contexts via git submodules. Without --recurse-submodules the directories don't exist and `docker compose up -d` fails with: unable to prepare context: path '<cache>/nests/moq' not found Fix: clone with --recurse-submodules on first run, and sync submodules after every fetch+checkout so the working tree matches whatever revision pin the checked-out commit declares. |
||
|
|
364b2cd926 |
feat(audio-rooms): start space FAB + Nests servers settings (kind 10062)
Two adjacent additions so users can both create and host their own
audio rooms from inside Amethyst:
Create-space flow
- CreateAudioRoomSheet — modal bottom sheet on AudioRoomsScreen
surfaced by a new "Start space" FAB. Fields: room name, summary,
MoQ service URL, MoQ relay endpoint, optional cover image.
- CreateAudioRoomViewModel — builds + signs MeetingSpaceEvent
(kind 30312, status=OPEN, tagging the user as `host`),
broadcasts via account.signAndComputeBroadcast, then returns
launch info so the sheet can fire AudioRoomActivity straight
into the freshly-published room.
- Defaults pull the first saved Nests server (below) when present;
fall back to https://moq.nostrnests.com.
Nests servers settings (proposed kind 10062)
- NestsServersEvent — replaceable kind-10062 event listing the
user's preferred audio-room MoQ servers. Wire shape mirrors
BlossomServersEvent (one `server` tag per base URL); registered
in EventFactory; consumed by LocalCache via consumeBaseReplaceable.
- NestsServerListState — per-account observation state, mirror of
BlossomServerListState, exposed on Account.nestsServers.
- sendNestsServersList on Account; included in
accountSettingsEvents() so an outbox change republishes it.
- Filter additions: BasicAccountInfo + AccountInfoAndLists now
request kind 10062 from relays.
- NestsServersViewModel + NestsServersScreen — Settings UI to
add / remove / reset to recommended servers (currently just
nostrnests.com). Wired into AllSettingsScreen as "Audio-room
servers"; routed via Route.EditNestsServers.
- kind_nests_servers label for RelayInformationScreen.
Default suggestion list lives in DEFAULT_NESTS_SERVERS at the top
of NestsServersScreen — add new community-run moq-rs deployments
there as they come online.
|
||
|
|
76b772ab41 |
test(audio-rooms): forward -DnestsInterop to test workers
Without this, `-DnestsInterop=true` on the Gradle command line was only set on the Gradle JVM, not on the test executor JVM that NostrNestsHarness.isEnabled() reads via System.getProperty. Result: every interop test silently skipped no matter how the run was invoked. Also forwards `nestsInteropRev` (used by the harness to pin the nostrnests revision in the cache). Verified with `./gradlew :nestsClient:jvmTest --tests NostrNestsAuthInteropTest -DnestsInterop=true` that the harness now actually runs (the run only fails inside the test because the sandbox blocks outbound traffic to GHCR / Docker Hub — both registries return 503 at the auth-token endpoint, so we can't pull strfry / build moq images here. On a network-enabled host the harness will bring up the stack normally). |
||
|
|
bc43168032 |
chore(audio-rooms): post-moq-lite cleanup + KDoc + plan refresh
Tidy items now that the moq-lite swap is complete on both sides:
- Drop the no-op `supportedMoqVersions: List<Long>` parameter from
`connectNestsListener` / `connectNestsSpeaker`. moq-lite negotiates
via ALPN, no caller passed a value, and the project rule forbids
no-op back-compat shims.
- `NostrNestsRoundTripInteropTest` KDoc + comments now describe the
moq-lite framing path (one Subscribe bidi for `audio/data`, group
uni streams with `DataType=0` + GroupHeader + size-prefixed frames)
instead of the stale IETF "OBJECT_DATAGRAMs / SETUP" framing.
- `DefaultNestsListener` / `DefaultNestsSpeaker` KDoc now flags them
as IETF MoQ-transport reference impls — production uses
`MoqLiteNests*`. They stay around for the IETF unit-test suite.
- `audio-rooms-completion.md` Phase M5 / M6 / M7 marked **DONE** —
the plan was written before the moq-lite gap was discovered, so
it described the work as IETF-MoQ-publisher additions; the
moq-lite path lands the same outcome via a different protocol.
|
||
|
|
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.
|
||
|
|
5914e9e9fc |
docs(audio-rooms): moq-lite listener landed — refresh status callouts
Phase 5d wrapped, so the doc set now reflects "listener path done,
speaker pending":
- nestsClient/plans/2026-04-26-moq-lite-gap.md — new "Implementation
status" section maps phases 5a → 5d to commits, calls out the
speaker side as blocked on a small `WebTransportSession.acceptBidi`
extension (since publisher.rs:40 uses Stream::accept), and points
at the existing :quic primitive (QuicConnection.awaitIncomingPeerStream)
that the bridge can lean on.
- nestsClient/plans/2026-04-26-audio-rooms-completion.md — Phase M1
is no longer "on hold for moq-lite"; manual nostrnests.com
validation should now work end-to-end on the listener path.
- .claude/CLAUDE.md — :nestsClient now hosts both IETF MoQ-transport
and moq-lite Lite-03; production listener path uses moq-lite.
|
||
|
|
41f4dcd9ac |
feat(audio-rooms): connectNestsListener uses moq-lite (phase 5d)
Production wiring switch — `connectNestsListener` now opens a
moq-lite (Lite-03) session against the WebTransport peer instead of
running the IETF MoQ-transport SETUP handshake. The downstream
NestsListener interface is unchanged; consumer code in commons /
amethyst keeps working.
Listener flow (per the audio-rooms NIP draft + nostrnests JS reference):
- subscribeSpeaker(pubkey) → MoqLiteSession.subscribe(broadcast =
pubkey, track = "audio/data")
- frames map to MoqObject so AudioRoomPlayer / AudioRoomViewModel
keep working unchanged. Per-frame `objectId` is synthesised as a
monotonic counter (moq-lite has no per-frame ID); `groupId` =
moq-lite group sequence; `trackAlias` = subscribe id.
NestsListenerState.Connected.negotiatedMoqVersion now reports
MOQ_LITE_03_VERSION (a synthetic constant carrying the ALPN suffix
in the low bytes) since moq-lite has no in-band SETUP message.
NestsConnectTest:
- Drop the SETUP-faking peer side — moq-lite has no SETUP, so
connectNestsListener returns Connected immediately after the WT
handshake.
- Assert MOQ_LITE_03_VERSION on Connected.
Speaker path (`connectNestsSpeaker`) is untouched — it still uses
the IETF DefaultNestsSpeaker. Speaker-side moq-lite needs
`acceptBidiStream` on the WebTransportSession interface so the relay
can open Announce/Subscribe bidis to the publisher; tracked in
plans/2026-04-26-moq-lite-gap.md phase-5c-speaker.
Verified `:commons:compileKotlinJvm` + `:amethyst:compilePlayDebugKotlin`
both still compile against the swap.
|
||
|
|
4e136cacf4 |
feat(audio-rooms): MoqLiteSession listener-side (phase 5c-listener)
Listener-side moq-lite (Lite-03) session that wraps a
WebTransportSession and exposes:
- announce(prefix) — opens an Announce bidi with
ControlType=Announce + AnnouncePlease(prefix), surfaces a flow
of relay Announce updates (Active/Ended)
- subscribe(broadcast, track, ...) — opens a Subscribe bidi with
ControlType=Subscribe + body, reads the size-prefixed response,
returns a MoqLiteSubscribeHandle whose frames flow yields each
frame the publisher pushes
- close() — cancels pumps, FINs every active subscribe bidi
(moq-lite UNSUBSCRIBE is "FIN the bidi"), closes the transport
Group demux: a single inbound-uni-stream pump per session reads the
DataType byte (Group=0) + size-prefixed group header, then routes
each subsequent (size, payload) frame to the matching
subscriptionsBySubscribeId entry's frame channel.
Speaker side stops at the announce/subscribe interface — moq-lite
publisher flows depend on accept_bi() / server-initiated bidi
streams (see clarifying agent summary in plans/2026-04-26-moq-lite-gap.md).
That's a follow-up; the WebTransportSession interface needs an
acceptBidiStream() method first.
FakeWebTransport.openPeerUniStream() — new helper so tests can
inject group data from the peer side without going through the
session's outbound uni stream API.
Tests in MoqLiteSessionTest — runBlocking-based (not runTest) so
real-time channel coordination works without virtual-time
artifacts:
- subscribe_writes_request_and_returns_handle_on_ok
- subscribe_throws_on_drop_response
- announce_streams_relay_updates
- groups_are_demuxed_by_subscribeId (no cross-talk)
- unsubscribe_FINs_the_subscribe_bidi
|
||
|
|
fb47a4cf75 |
feat(audio-rooms): moq-lite codec primitives + message types (phase 5a/5b)
First two phases of the moq-lite (Lite-03) implementation per
nestsClient/plans/2026-04-26-moq-lite-gap.md. No production wiring
yet — the production helpers still call the IETF MoqSession.
What landed:
- MoqLitePath — mandatory wire-boundary normalisation (strip
leading/trailing/duplicate `/`), join, path-component-aware
stripPrefix. Mirrors rs/moq-lite/src/path.rs semantics.
- MoqLiteAlpn / MoqLiteControlType / MoqLiteDataType /
MoqLiteAnnounceStatus / MoqLiteSubscribeResponseType — wire
enums for ALPN ("moq-lite-03"), per-bidi ControlType varints,
Group uni-stream type byte, announce status, subscribe
response type.
- Message data classes — AnnouncePlease, Announce, Subscribe,
SubscribeOk, SubscribeDrop, GroupHeader, Probe.
- MoqLiteCodec — encode/decode for each message, with the
size-prefix envelope baked in. Handles the off-by-one
`0 = None, n = Some(n−1)` trick for startGroup/endGroup,
coerces booleans to 0/1, validates priority fits in u8,
rejects unknown status/response bytes. Path normalisation
applied at every encode + decode boundary.
- MoqLitePathTest, MoqLiteCodecTest — round-trip tests for
every codec entry point + negative paths (oversized priority,
invalid ordered byte, unknown status, trailing garbage).
All MoqWriter / MoqReader / MoqCodecException primitives reused
from the IETF MoQ codec — same varint and length-prefix shapes.
|
||
|
|
7f48e52541 |
docs(audio-rooms): full moq-lite wire spec + IETF gap call-outs
Background research turned up the complete moq-lite (Lite-03) wire
format from kixelated/moq-rs and @moq/lite v0.1.7. Folded the spec
into nestsClient/plans/2026-04-26-moq-lite-gap.md as a phase-5
implementation plan:
- ALPN ("moq-lite-03"); no SETUP/control message in Lite-03 (the WT
handshake IS the handshake)
- Per-request bidi streams keyed by ControlType varint (Announce=1,
Subscribe=2, Fetch=3, Probe=4)
- AnnouncePlease(prefix) / Announce(status, suffix, hops) shape
- Subscribe with priority (raw u8), ordered, maxLatency (ms),
startGroup/endGroup (off-by-one None-encoded), reply Ok/Drop
- Group = uni stream with (DataType=0, subscribeId, sequence)
header followed by varint-length frames until QUIC FIN
- No datagrams; no per-frame envelope beyond size
- Mandatory path normalisation; FIN-as-unsubscribe; RESET_STREAM
for errors
Phase-5a..e implementation plan included (~1 week scope).
Doc + KDoc updates so the IETF MoQ-transport code is correctly
labelled and the moq-lite gap is discoverable from every entry point:
- .claude/CLAUDE.md project description and architecture diagram
- nestsClient/plans/2026-04-26-audio-rooms-completion.md status block
- MoqSession.kt, MoqMessage.kt, MoqObject.kt, MoqCodec.kt KDoc — flag
these as "IETF draft-ietf-moq-transport-17, NOT moq-lite", with
pointers to the gap doc
- NestsConnect.kt — note that step 3 of the listener handshake
(SETUP) does NOT match nostrnests's relay framing
|
||
|
|
1887bd1fa7 |
test/refactor(audio-rooms): nostrnests wire-shape fixes + interop expansion (phase 4)
Wire-shape corrections discovered while scoping the interop test suite
against the real moq-rs relay:
1. WebTransport CONNECT path is now /<moqNamespace> (matches the
relay's claims.root prefix check). Previously hardcoded "/anon".
2. JWT travels in the ?jwt=<token> query parameter, not the
Authorization header — moq-rs only reads the query param. The
bearer-token path on QuicWebTransportFactory is now unused for
nests; left in place for non-nests WebTransport servers.
3. Harness `moqEndpoint` is the relay base URL only; the connect
helpers append /<namespace>?jwt=<token> themselves.
Interop test additions (all -DnestsInterop=true gated, default-skipped):
- NostrNestsAuthFailureInteropTest — locks in the moq-auth sidecar's
rejection paths (missing/wrong-scheme Authorization, NIP-98 signed
for the wrong URL, malformed namespace per the strict regex,
publish=true grant for any caller — sidecar does NOT gate by NIP-53
hostlist).
- NostrNestsAuthEndpointsInteropTest — /health, /.well-known/jwks.json
shape (must contain ES256/P-256), 404 on unknown route.
- NostrNestsMultiPeerInteropTest — multi-listener fan-out, multi-
speaker isolation, subscribe-before-announce. Code is wired through
production connectNestsSpeaker / connectNestsListener; will pass
once the moq-lite gap (below) is resolved.
Major finding documented in nestsClient/plans/2026-04-26-moq-lite-gap.md:
nostrnests's stack uses moq-lite (kixelated's variant), NOT IETF
draft-ietf-moq-transport which `:nestsClient` currently implements. The
two are wire-incompatible — single-string broadcast/track names vs. byte
tuples, different ANNOUNCE/SUBSCRIBE framing. The wire-shape fixes here
make the WebTransport CONNECT itself succeed, but the post-CONNECT MoQ
framing layer still needs a moq-lite codec before round-trip / multi-peer
tests can pass against real nests. Pursued as a separate phase.
|
||
|
|
0ac8c0f791 |
test(audio-rooms): production round-trip via real MoQ relay (phase 3/3)
Drives connectNestsSpeaker + connectNestsListener end-to-end against the
real nostrnests Docker stack. Speaker announces a track, listener
subscribes by pubkey, speaker pushes deterministic frames through
AudioRoomBroadcaster → MoQ → relay → listener.objects flow, and the
test asserts payload integrity + monotonic object ids.
Validates the wire shapes the Phase-2 refactor committed to:
- QuicWebTransportFactory + PermissiveCertificateValidator can
handshake against the relay's self-signed dev cert
- JWT minting + WebTransport CONNECT + MoQ SETUP all succeed
- The single-segment TrackNamespace `nests/<kind>:<host>:<room>`
matches the relay's `root` JWT claim
Single-keypair design sidesteps host-vs-audience auth policy so the
test stays focused on transport + protocol; a future dual-keypair
test can layer permissions on top.
Skipped by default — set -DnestsInterop=true to enable.
|
||
|
|
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.
|
||
|
|
3283d302fa |
test(audio-rooms): nostrnests interop harness + /auth ping (phase 1/3)
Brings up the nostrnests reference server (https://github.com/nostrnests/nests) locally via Docker Compose so we can drive `:nestsClient`'s production code against the real MoQ relay + NIP-98 auth sidecar. Mirrors the `:quic` `InteropRunner` pattern (aioquic Docker, opt-in via `-DinteropHost=…`): - Set `-DnestsInterop=true` to enable; default `:nestsClient:jvmTest` runs skip via JUnit `Assume.assumeTrue` (shown as <skipped>, not <failure>). - Repo cloned + cached at `~/.cache/amethyst-nests-interop/nests/`, pinned to the `DEFAULT_REVISION` (currently `main`; override via `-DnestsInteropRev=<sha>` to lock in for reproducibility). - `docker compose -f docker-compose-moq.yml up -d` brings up moq-relay (host 4443 TCP+UDP), moq-auth (host 8090), strfry (7777). Port-probes 4443 + 8090 with a 90 s timeout. - `close()` runs `docker compose down -v --remove-orphans`. Tests use `@BeforeClass`/`@AfterClass` to amortise the ~30-60 s spin-up across all cases in one class. Phase-1 ping test (NostrNestsAuthInteropTest): - Generates an ephemeral KeyPair / NostrSignerInternal via Quartz. - POSTs `<authBase>/auth` with `{"namespace":"nests/30312:<pubkey>:<roomId>", "publish":true}`, NIP-98 Authorization header signed for that exact (url, method, payload) tuple. - Asserts 200 + a `"token":"…"` JWT in the response body. Doesn't yet route through `OkHttpNestsClient` because the production client's wire shape (GET `<base>/<roomId>` returning `{endpoint, token, codec, sample_rate}`) does not match nostrnests' actual API (POST `<base>/auth` with `{namespace, publish}` body, returning just `{token}` — endpoint comes from the NIP-53 event's `endpoint` tag instead of the HTTP response). Phase 2 of this audit refactors production to match; this test documents the divergence on the wire so the refactor has a clear target. Verified: harness compiles clean; default `:nestsClient:jvmTest` shows the test as <skipped> (not <failure>) when `nestsInterop` property is unset. Files: - nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt - nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsAuthInteropTest.kt |
||
|
|
2a932dc974 |
fix(audio-rooms): clean up deferred audit items (VM + Android + MoQ comment)
Lands the audit follow-ups that didn't require external input. Only the wire-format draft pinning (MoQ #1, #8) remains deferred — that's gated on the M4 manual interop pass, and the same-room-PIP-re-entry corner case (Android #5) — Android has no programmatic PIP-exit API. ViewModel: - VM #10: serialize disconnect→connect via a tracked `pendingCloseJob`. teardown() records the listener.close() launch (when not finalCleanup); the next launchConnect() awaits it before opening a fresh transport. Eliminates the brief two-QUIC-session overlap that some MoQ relays reject by deduping on client pubkey. - VM #4: extracted shared connect-launch body into `launchConnect(triggerRetryOnFailure)` so connect() / connectInternal() share one implementation. Removed the near-duplicate viewModelScope.launch block. - VM #8b: setMicMuted no longer silently swallows handle failures. BroadcastUiState.Broadcasting gains a `muteError: String?` field that the UI can surface as an inline message; cleared on the next successful toggle. The broadcast itself stays running with its previous mute state — only the mute toggle failed. - VM #6: documented the dispatcher-confinement contract in the class kdoc. Audit was theoretical — every map mutation already runs on viewModelScope (Dispatchers.Main.immediate on Android, same dispatcher the MoQ flow's onEach callback uses because the player launch lives in viewModelScope). Future cross-thread callers must marshal explicitly. Android: - Android #2: AudioRoomActivity.toggleMuteSignal type tightened from `MutableSharedFlow<Unit>` to `SharedFlow<Unit>` so external code can't tryEmit into it. Internal emit uses the private `_toggleMuteSignal`. - Android #10: presence debounce-publisher (the LaunchedEffect keyed on micMutedTag) now skips entirely when micMutedTag is null. Stops the duplicate first-frame publish where heartbeat fires immediately AND debounce-publisher fires 500 ms later, both with muted=null. Once the user goes live the debounce-publisher kicks in for state changes. MoQ session: - MoQ #11: send() rollback comment rewritten to say "monotonic; gaps acceptable per spec, this just minimises them on full-fanout failures" instead of the misleading "strictly contiguous" claim. Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest (80 tests), :amethyst:compilePlayDebugKotlin all green. Still deferred: - MoQ #1, #8: wire-format draft pinning (draft-17 vs draft-11). Needs M4 interop input from `nostrnests.com` to know what the relay actually speaks. - Android #5: same-room re-entry from MainActivity while in PIP doesn't auto-exit PIP. Android has no programmatic PIP-exit API; user must tap the expand button. Corner case. - Test coverage gaps (round-1 VM #10, round-2 VM #13): retry-counter + broadcast state + setMicMuted-no-handle + server-Closed cleanup + double-connect-while-Failed. Each is a small dedicated test using the existing connector-seam pattern; landing as a separate test-only commit. |
||
|
|
ed793e8eb3 |
fix(audio-rooms): round-2 audit — pump self-join (CRITICAL) + 5 other findings
Round 2 audit (3 parallel agents reviewing every change since the previous follow-up commit) caught one CRITICAL regression and several HIGH/MED items. Most round-1 fixes verified clean. CRITICAL fix (audit round-2 MoQ #1): - Pump exception handlers added in the previous commit call `close()` from inside the failing pump's own coroutine. `close()` now does `controlPumpJob?.join()` to drain in-flight writes — but the Job we try to join is the very Job we're inside, so `join()` suspends forever (lambda can't finish until close returns; close can't return until lambda finishes). `runCatching` doesn't help — `join()` doesn't throw, it suspends. This deadlocks the entire session whenever a pump fails. Fix: skip the join when the current coroutine IS the job we're joining. `currentCoroutineContext()[Job]` identifies the caller; we compare and bypass. HIGH fixes: - MoQ #2 (regression): concurrent unannounce() + post-OK AnnounceError handler could both write UNANNOUNCE on the wire (some relays disconnect on UNANNOUNCE for an unknown namespace). Fix: `AnnounceHandleImpl.unannounceWritten: Boolean` flag, set under stateMutex by whichever writer goes first; the other path skips. - VM #3 (new): `connect()` overwrote `listener` if invoked from a Failed-with-stale-listener state, leaking the previous MoQ session. Fix: call `teardown(targetState=Idle, finalCleanup=false)` before launching the new attempt when listener or stateObserverJob is still alive. - VM #7 (new): `openSubscription` allocated decoder + player via the factories, then attached them to the slot. If the VM scope was cancelled between `decoderFactory()` and `slot.attach(...)`, the native MediaCodec / AudioTrack leaked because nothing was tracking them yet. Fix: nest a try/catch that releases both on any throw (including `CancellationException`) before re-throwing. MED fixes: - MoQ #7 (new): `capture.start()` could throw before `job` was assigned, leaving the broadcaster in a half-started state where future `start()` calls would re-pass the guards and double-start the mic. Fix: try/catch around capture.start; on throw, set `stopped = true` + run capture.stop and propagate. - MoQ #8 (new): `stopped` was read across threads (setMuted from caller, stop from anywhere) without a `@Volatile` barrier. Visibility hazard. Fix: `@Volatile private var stopped`. - Android #12 (new): after the user granted RECORD_AUDIO via the Settings deep-link, `permissionDenied` stayed `true` because the launcher callback never fired — the warning + Open-settings button remained visible until the user tapped Talk again. Fix: derive `showDenialWarning` from `permissionDenied AND ContextCompat.checkSelfPermission(...) != GRANTED`. Re-checks every recomposition (including post-Settings return). Round-1 fixes verified clean by this audit: - pending-deferred completeExceptionally on close - SubscribeDoneStatus codes (UNSUBSCRIBED=0x00, TRACK_ENDED=0x03) - suspend `stop()` conversions on broadcaster + player - gate release before NestsSpeaker teardown chain - unannounce() ordering on thrown wire-write - SharedFlow PIP signal (rapid double-tap behavior is correct) - RECEIVER_NOT_EXPORTED gate (constant 4 doesn't collide; round-1 collision claim was incorrect) - onUserLeaveHint guards (PIP from lobby, no PIP support) - foreground service `startForeground`-always-first contract (`Result.onFailure` is `inline`, the `return` IS a non-local return from `onStartCommand` — verified) - 4-hour wake-lock cap - AudioRoomBridge.clear() in AccountViewModel.onCleared Still deferred: - VM #6 (round-1 carryover): unsynchronized speakingExpiryJobs map. Cross-thread mutation under contention. Needs ConcurrentHashMap or Dispatchers.Main.immediate marshalling. - VM #10 (round-2 new): brief two-QUIC-session overlap during rapid disconnect→connect. - Android #5 (round-2 new): same-room re-entry from MainActivity while in PIP doesn't auto-exit PIP. - MoQ #1, #8 (round-1 carryover): wire-format draft pinning. Still blocked on M4 manual interop input. Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest (80 tests), :amethyst:compilePlayDebugKotlin all green. |
||
|
|
0b1ec52f79 |
fix(audio-rooms): audit follow-up — MoQ HIGH/MED + VM concurrency + Android polish
Round 2 of the audit-driven cleanup. Lands every HIGH and most MED findings from the protocol / ViewModel / Android lifecycle audits that weren't fixed in the previous commit. The wire-format draft pinning (audit MoQ #1, #8) is intentionally deferred until the M4 manual interop pass against `nostrnests.com` reveals which draft revision the relay is actually speaking. MoQ session fixes: - #5 UNANNOUNCE wire-write now happens BEFORE removing announces[ns], so an inbound SUBSCRIBE during the teardown window sees the namespace as withdrawn (sessionClosed=true → SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST)) instead of "namespace never existed". - #4 dispatchControlMessage(AnnounceError) now distinguishes pre-OK and post-OK errors. Post-OK is a session-level kick: mark the handle closed, send UNANNOUNCE, then drop. Pre-OK still rolls back the optimistic announces[] insert as before. - #6 close() now joins the cancelled control + datagram pumps before calling controlStream.finish(), so an in-flight SUBSCRIBE_OK / SUBSCRIBE_DONE / SUBSCRIBE_ERROR write can complete its writeMutex.withLock { ... } critical section. Previously cancellation could truncate a frame mid-flight and we'd send FIN over a corrupted stream. - #9 pumps wrapped in try/catch that calls close(...) on unexpected exceptions, so a transport-died-mid-session no longer leaves the session thinking it's healthy with new subscribe/announce calls hanging on a dead peer. - #10 TrackPublisher.send rolls back nextObjectId when every datagram fan-out fails (transport down). The audio-rooms NIP wants strictly contiguous object ids per group; a gap from a fully-failed send would trip strict subscribers. - #13 DefaultNestsSpeaker.close drops `gate` before calling activeHandle.close() / session.close(). The teardown chain runs cancelAndJoin on the broadcaster + sends SUBSCRIBE_DONE per attached subscriber + joins MoQ pumps; holding the gate through all of that blocked any other concurrent API call on this speaker. Resource lifecycle (audit MoQ #11/#12): - AudioRoomBroadcaster.stop() now `cancelAndJoin`s the loop before releasing the encoder + closing the publisher. The loop's last encoder.encode/publisher.send no longer races encoder.release()/publisher.close() — both produced use-after-release on native MediaCodec on Android, the latter sent orphan OBJECT_DATAGRAMs to subscribers we'd just told SUBSCRIBE_DONE. - AudioRoomPlayer.stop() promoted to `suspend` + cancelAndJoin for the same reason: decoder.release() ran while the decode loop was still inside MediaCodec.decode(...), undefined behaviour. Updated VM call sites (closeSubscription, teardown) to route both player.stop() and handle.unsubscribe() through one launched coroutine via the new `detach(): Pair<AudioRoomPlayer?, SubscribeHandle?>` shape. ViewModel fixes: - #4 auto-retry uses a single `retryPending: Boolean` flag instead of `Job.isActive`. Two scheduleAutoRetry calls could previously both pass `Job.isActive == false` (the launched body had just started) and stack a second retry on top of one already running. - #7 setMicMuted updates the UI INSIDE the launched coroutine, after the suspending broadcastHandle.setMuted() returns. Previously the indicator could claim "muted" while audio was still on the wire if the handle's setMuted suspended on a gate. - #8 connect() cancels the previous stateObserverJob before kicking off the new attempt, so a delayed Failed/Closed emission from the old listener can no longer clobber the fresh Connecting UI. - #9 disconnect() clears requestedSpeakers, so a fresh connect() to a different room (or the same room after a long pause) doesn't reuse a stale speaker snapshot. - #12 updateSpeakers filters out the user's own pubkey: subscribing to your own forwarded audio would echo through the local playback device whenever the broadcast track loops back from the relay. Android lifecycle / PIP / service: - #6 PIP aspect ratio flipped from 9:16 (portrait sliver) to 16:9 (landscape) so the row of avatars under the title actually fits. - #7 process-death recovery: when AudioRoomBridge is empty (previous process's AccountViewModel is gone), redirect to MainActivity before finish() so the user lands somewhere meaningful instead of a black-flash. - #8 AudioRoomForegroundService.onStartCommand always calls startForeground first, on every invocation including ACTION_STOP. startForegroundService's 5-second contract requires it; previously the STOP path skipped it. startForeground itself wrapped in runCatching so a foreground-not-allowed exception bails cleanly rather than leaking the wake-lock. - #10 wake-lock timeout reduced 12 h → 4 h. Stuck connections that fail to detect a network drop no longer hold the device awake for half a day. - #11 presence-event spam fix: split the publish loop into a heartbeat keyed only on (address, handRaised) and a separate debounced state-change publisher keyed on micMutedTag. Every mute toggle previously triggered a full sign + publish + relay round trip; now we coalesce within a 500 ms window. - #12 final "leaving" presence routed through GlobalScope.launch instead of rememberCoroutineScope (which is cancelled on dispose, so the leave event almost never reached the relay). - #14 RECORD_AUDIO denial recovery: when the user has tapped "Don't ask again", the launcher silently returns false. New "Open settings" button deep-links to the app's settings page so the user can re-grant the permission and try again. - #15 setPictureInPictureParams now updates outside PIP too, so the next entry shows the correct mute-state icon without an extra flip. - #16 onNewIntent override: a second Join tap for a different room finishes the current Activity and starts a fresh one with the new extras, instead of singleTask silently keeping the old room running. Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest (80 tests), :amethyst:compilePlayDebugKotlin all green. Audit findings still deferred (all documented inline / in this commit): - MoQ #1, #8: wire-format draft pinning (draft-17 vs draft-11). Needs the M4 manual interop pass to confirm what nests is actually speaking. - VM #6: confined map mutation under a single dispatcher. Current setup (Dispatchers.Main via setMain in tests, viewModelScope in prod) is functionally fine; full belt-and-suspenders confining is a separate concurrency review. - VM #10: test coverage gaps for retry + speaker reconcile cycle + setMicMuted no-handle case + server-initiated Closed leaves stale state. Each is a dedicated test. - Android #18: startListening / promoteToMicrophone race. Mitigation (always declaring microphone foreground type) requires unconditional RECORD_AUDIO grant which listener-only users won't have. |
||
|
|
f0b27654ba |
test(audio-rooms) + fix: round-trip test + audit pass
Adds an end-to-end MoQ round-trip test and lands the highest-severity
findings from a 3-agent audit (protocol / ViewModel / Android lifecycle)
of the M5–M7 + Activity work.
Round-trip test (`:nestsClient` MoqRoundTripTest):
- Two MoqSession.client() instances (publisher + subscriber) talk
through a hand-rolled in-test relay coroutine that mirrors a real
MoQ relay's wire behavior (forwards CLIENT_SETUP / SERVER_SETUP /
ANNOUNCE / SUBSCRIBE / SUBSCRIBE_OK / OBJECT_DATAGRAM).
- 100-Opus-frame test exercises the full publisher → wire →
subscriber path, asserting payload bytes + monotonic group/object
ids round-trip correctly. Catches any drift between our publisher
and our own subscriber's wire format.
- Second test verifies SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST) flows
back as MoqProtocolException when the publisher hasn't openTrack'd.
MoQ protocol fixes (CRITICAL audit findings):
- SubscribeDoneStatus constants were inverted: had UNSUBSCRIBED=0x01
(peer reads as INTERNAL_ERROR) and TRACK_ENDED=0x00 (peer reads as
UNSUBSCRIBED). Swapped to draft-stable values: UNSUBSCRIBED=0x00,
TRACK_ENDED=0x03.
- Pending CompletableDeferreds for in-flight SUBSCRIBE / ANNOUNCE on
session close were `cancel()`-ed, which propagates as
CancellationException — caller's entire scope cancels instead of
catching a domain MoqProtocolException. Switched all sites to
`completeExceptionally(MoqProtocolException("session closed"))`
including unsubscribe-while-pending-OK.
ViewModel fixes:
- openSubscription race: re-check `activeSubscriptions[pubkey] === slot
&& !closed` AFTER the suspending `subscribeSpeaker` returns; if the
user removed the speaker mid-flight, fire-and-forget UNSUBSCRIBE
rather than attaching a leaked SubscribeHandle + AudioRoomPlayer to
a discarded slot.
- Server-initiated `Closed` listener state now triggers
`teardown(targetState=Closed)` and resets the auto-retry counter,
so a transport-died-mid-handshake doesn't leave stale subscriptions
in the VM map until the Activity finishes.
- Cleanup-scope split: `disconnect()` (user-driven) routes the close
through `viewModelScope` (still alive); `onCleared()` routes it
through a process-lived `cleanupScope` (default GlobalScope, tests
pass backgroundScope) so MoQ control frames (UNSUBSCRIBE,
UNANNOUNCE, SUBSCRIBE_DONE) actually land before the QUIC
transport drops, instead of being eaten by the cancelled
viewModelScope.
Android lifecycle fixes:
- AudioRoomBridge.clear() wired into AccountViewModel.onCleared next
to the existing CallSessionBridge.clear() — no more cross-account
AccountViewModel leak after logout/switch.
- registerReceiver flag now gated on Build.VERSION.SDK_INT
TIRAMISU+ — RECEIVER_NOT_EXPORTED on pre-33 devices collides with
RECEIVER_VISIBLE_TO_INSTANT_APPS. PendingIntents stay
package-scoped via setPackage(packageName).
- onUserLeaveHint no longer enters PIP unconditionally: gated on
ui.connection == Connected AND PackageManager
FEATURE_PICTURE_IN_PICTURE present, so PIP-from-lobby /
PIP-on-incompatible-device doesn't leave a frozen full-screen
card in Recents.
- Replaced the process-wide singleton AudioRoomPipActions toggle
Boolean with a per-Activity MutableSharedFlow<Unit> so a stale
emission from a torn-down Activity can't leak into a new one.
ViewModel test was extended with `cleanupScope = backgroundScope`
plumbing so the post-disconnect `listener.close()` assertion remains
observable in the test scheduler.
Verified: spotlessApply clean; :commons:jvmTest (9 tests),
:nestsClient:jvmTest (80 tests including 2 new round-trip), and
:amethyst:compilePlayDebugKotlin all green.
Audit findings deferred to follow-up commits (none ship-blocking):
- Wire layout pinning vs draft-17 vs draft-11 (currently emits a
draft-11-style OBJECT_DATAGRAM; we advertise draft-17). Resolve
via the M4 manual interop pass against nostrnests.
- UNANNOUNCE racing inbound SUBSCRIBE; control-pump cancel half-write
(audit MoQ #5, #6) — small ordering tweaks.
- Two retry coroutines stacking under fast Failed bursts (audit VM #4).
- AudioRoomForegroundService startForeground always-first contract
(audit Android #8).
|
||
|
|
3816e471b7 |
refactor(audio-rooms): dedicated AudioRoomActivity with PIP — replaces in-channel AudioRoomStage
Moves the entire audio-room session into its own Activity with Picture-in-Picture support. The chat-room screen (ChannelView) now shows a thin lobby card (`AudioRoomJoinCard`) with a Join button; tapping it launches `AudioRoomActivity`, which owns the MoQ session, the broadcaster, presence (kind 10312), hand-raise, and the foreground service for screen-locked playback. Why: the previous design tied the audio session to the chat-room screen's Composable lifecycle, which (a) made "navigate away keeps audio playing" require a process-level workaround, (b) tied hand-raise + presence to a UI screen even when the user wasn't audio- connected, (c) had no clear home for PIP. A dedicated Activity mirrors the existing CallActivity pattern: clear lifecycle, Activity finish() = leave room, PIP = keep speakers visible while doing other things. Files split into focused units (the original single-file design hit generation limits): - `AudioRoomActivity.kt` (~200 lines): Activity class, PIP entry on onUserLeaveHint, PIP RemoteAction wiring (mute / leave) with a BroadcastReceiver that signals a shared toggle into the composable. Manifest registers it with `supportsPictureInPicture=true`, `launchMode=singleTask`, `resizeableActivity=true`. - `AudioRoomActivityContent.kt`: top-level composable. Loads the addressable note, constructs the VM via factory, auto-connects on entry, runs the presence loop (with mic-mute reflected in the kind 10312 event), drives the foreground service, flips between full-screen and PIP layouts. - `AudioRoomFullScreen.kt`: full-screen layout — title, summary, on-stage / audience rows with active-speaker rings, ConnectionRow, TalkRow (RECORD_AUDIO permission flow inside), hand-raise, Leave button. - `AudioRoomPipScreen.kt`: compact PIP layout — up to 4 on-stage avatars with speaking ring + room title. - `AudioRoomCommon.kt`: shared StagePeopleRow + connectingLabel. - `AudioRoomJoinCard.kt`: lobby card in ChannelView. Title + summary + Join button. Hidden when the event has no `service` tag. - `AudioRoomBridge.kt`: process-level singleton that hands the active AccountViewModel to the standalone Activity (mirrors CallSessionBridge). - `AudioRoomViewModelFactory.kt`: extracted from the deleted AudioRoomStage; binds the Android audio actuals (OkHttp, QUIC, MediaCodec, AudioTrack, AudioRecord). Deleted: `AudioRoomStage.kt` (replaced by the lobby card + the Activity). ChannelView wiring changed from `AudioRoomStage(channel, …)` to `AudioRoomJoinCard(channel, …)` — one-line swap. Strings: 2 new (`audio_room_join`, `audio_room_leave`). Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest :amethyst:compilePlayDebugKotlin` all green. |
||
|
|
0a45d1094f |
feat(audio-rooms): M8 polish + M3/M9 foreground service
M8a — presence event reflects mic-mute state:
- AudioRoomStage's `publishPresence` now passes the broadcaster's
current mic state into the kind 10312 `muted` tag: `null` when not
broadcasting (no mic to be muted on), explicit `true` / `false` while
the speaker path is `Broadcasting`. Other clients can now render a
mute indicator on our avatar.
M8b — auto-reconnect with capped exponential backoff:
- AudioRoomViewModel detects `NestsListenerState.Failed` and schedules a
retry after 1s, 2s, 4s, ..., capped at 16s. Up to 3 attempts before
the UI stays in Failed for a manual retry.
- User-initiated `connect()` and `disconnect()` reset the retry counter
so manual recovery starts fresh.
M8c — iOS:
- Skipped: neither `:nestsClient` nor `:quic` declares an iOS target
yet, so there's no iosMain source set to populate. When iOS lands,
the audio capture/playback + transport actuals will need iOS impls
(and the speaker UI will need an iOS shell).
M3 + M9 — foreground service:
- New `AudioRoomForegroundService` (foregroundServiceType
`mediaPlayback|microphone`) anchors the process so audio keeps
playing with the screen off. Holds a partial wake-lock + media-style
notification; the notification's "Stop" action stops the service.
- Lifecycle wired in AudioRoomStage via:
* LaunchedEffect(isConnected, isBroadcasting) on the listener +
broadcast UI state — promotes to mediaPlayback+microphone type
when broadcasting starts (Android 14+ split foreground-type
permission requirement), falls back to mediaPlayback when only
listening, stops entirely when listener drops.
* DisposableEffect(Unit) for screen-exit cleanup.
- The service does NOT own the MoQ session / decoder / player — those
remain in the VM. Screen-off works; "navigate away keeps audio" would
require moving the audio stack into the service, which is a bigger
refactor outside the audio-rooms completion plan's scope.
- Strings: 6 new `audio_room_notification_*` keys.
- Manifest: declares the service with `mediaPlayback|microphone`
foreground type. RECORD_AUDIO + FOREGROUND_SERVICE_MICROPHONE were
already declared.
Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:quic:jvmTest :amethyst:compilePlayDebugKotlin` all green.
|
||
|
|
1c6d939d28 |
feat(audio-rooms): speaker UI — Talk button + mic-mute + RECORD_AUDIO permission
Wires the M5–M7 publisher path into the existing audio-room screen so hosts and speakers can broadcast their own audio. ViewModel (commons AudioRoomViewModel): - New optional constructor params `captureFactory` / `encoderFactory`. When both are non-null, `canBroadcast = true`; otherwise the speaker UI is hidden (desktop, listener-only). - `startBroadcast(speakerPubkeyHex)` — kicks off `connectNestsSpeaker(...)` + `NestsSpeaker.startBroadcasting()`, surfaces a `BroadcastUiState.Connecting` → `Broadcasting(isMuted)` transition. - `setMicMuted` / `stopBroadcast` — mic-side counterparts to the listener's `setMuted` / `disconnect`. - `BroadcastUiState` sealed hierarchy added to `AudioRoomUiState`. - `disconnect` and `onCleared` tear the broadcast down before tearing the listener down so SUBSCRIBE_DONE / UNANNOUNCE go out cleanly. - `NestsSpeakerConnector` test seam mirrors `NestsListenerConnector`. Screen (AudioRoomStage): - AudioRoomViewModelFactory now wires the Android speaker actuals (`AudioRecordCapture` + `MediaCodecOpusEncoder`). - New `AudioTalkRow` composable: Talk button gated on `RECORD_AUDIO` (granted via `ActivityResultContracts.RequestPermission`), Live indicator (red AssistChip) + mic-mute toggle while broadcasting, Stop talking button. Hidden unless the user is in the room's `p` tags as host or speaker AND `viewModel.canBroadcast`. - Permission denial surfaces an inline error message rather than reprompting; user can re-tap Talk to try again. - Strings: 8 new `audio_room_*` keys (talk / stop_talking / mic_mute / mic_unmute / broadcast_connecting / broadcasting / broadcast_failed / record_permission_required). `AndroidManifest.xml` already declares `RECORD_AUDIO` (and the foreground-microphone permission for M9), so no manifest change here. Verified: `:commons:jvmTest` + `:nestsClient:jvmTest` (78 tests, all green) + `:amethyst:compilePlayDebugKotlin` clean. |
||
|
|
0afde79afd |
feat(audio-rooms): M6 + M7 — AudioRoomBroadcaster + NestsSpeaker API
M6 (AudioRoomBroadcaster): Inverse of AudioRoomPlayer. Pulls PCM frames
from an AudioCapture, runs them through an OpusEncoder, and pushes the
resulting Opus packets into a MoqSession.TrackPublisher as
OBJECT_DATAGRAMs. setMuted keeps the capture + encoder running so unmute
is sample-accurate; encode failures are reported via onError but don't
tear the loop down.
M7 (NestsSpeaker): Mirror of NestsListener for the host / speaker path.
- `NestsSpeaker.startBroadcasting()` — sends ANNOUNCE for the room's
namespace (`["nests", roomId]`), opens a TrackPublisher named after
this user's pubkey hex, wires an AudioRoomBroadcaster onto it.
- `BroadcastHandle.setMuted` / `close()` — speaker-side equivalents of
the listener's mute / disconnect controls.
- `NestsSpeakerState` sealed hierarchy (Idle / Connecting{step} /
Connected / Broadcasting{isMuted} / Failed / Closed).
- `connectNestsSpeaker` orchestration mirrors `connectNestsListener`
end-to-end (HTTP → WebTransport → MoQ setup), then returns a
`DefaultNestsSpeaker` ready for `startBroadcasting`.
Tests:
- AudioRoomBroadcasterTest (5 tests): pcm-flow ordering, mute behavior,
encoder warmup skip, encode-error tolerance, idempotent stop.
- NestsSpeakerTest (2 tests): start/mute/close state transitions,
double-start rejection.
Bug caught + fixed during M7 testing: `DefaultNestsSpeaker.close()`
held its `gate` mutex while calling `handle.close()` which then called
back through `parent.broadcastClosed()` → self-deadlock. Fixed by
making `broadcastClosed` lockless (StateFlow updates are atomic; the
activeHandle compare-and-set is benign under the gate's exclusion of
concurrent startBroadcasting calls).
Verified: `:nestsClient:jvmTest` (78 tests, all green) +
`./gradlew spotlessApply` clean.
|
||
|
|
5c32996f93 |
feat(audio-rooms): M5 MoQ publisher path — ANNOUNCE + inbound SUBSCRIBE + OBJECT emit
Lifts MoqSession from listener-only to bidirectional. A session can now
ANNOUNCE a track namespace, register one TrackPublisher per track name
under it, accept inbound SUBSCRIBEs from the peer, and emit
OBJECT_DATAGRAMs that fan out to every attached subscriber. This is what
the speaker / host path needs in nests — phases M6/M7 layer audio
capture + a NestsSpeaker API on top.
Public API additions:
- `MoqSession.announce(namespace, parameters)` — sends ANNOUNCE, awaits
ANNOUNCE_OK, returns an `AnnounceHandle`.
- `AnnounceHandle.openTrack(name)` — registers a `TrackPublisher` for a
track under the announced namespace. Idempotent insert is rejected.
- `TrackPublisher.send(payload)` — encodes one MoQ OBJECT_DATAGRAM and
emits it to every currently-attached subscriber. Group id is fixed at
zero per the audio-rooms NIP draft; object ids are monotonic.
- `TrackPublisher.close()` — sends SUBSCRIBE_DONE to attached
subscribers, removes the track from its parent announce.
- `AnnounceHandle.unannounce()` — sends UNANNOUNCE on the wire, closes
every registered publisher.
- `ErrorCode.TRACK_DOES_NOT_EXIST` and `SubscribeDoneStatus.{TRACK_ENDED,
UNSUBSCRIBED}` constants for the SUBSCRIBE_ERROR / SUBSCRIBE_DONE
values we emit.
Internal additions:
- `pendingAnnounces` keyed by namespace, mirroring the existing
`pendingSubscribes` pattern.
- `announces` map tracking live publishers per namespace.
- `inboundSubscribers` + `publisherSubscribers` so the control-pump can
fan an inbound SUBSCRIBE to the right TrackPublisher and so
TrackPublisher.send can snapshot subscribers without per-OBJECT
locking.
- Control pump now routes ANNOUNCE_OK / ANNOUNCE_ERROR (publisher-side
ack) and inbound SUBSCRIBE / UNSUBSCRIBE (we're being asked to send
OBJECTs, or stop). Unknown-track SUBSCRIBE replies with
SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST) so the peer doesn't hang.
- `close()` propagates session death into every AnnounceHandleImpl /
TrackPublisherImpl so any in-flight `send` short-circuits cleanly.
All shared-state mutation is funneled through the existing `stateMutex`
(no `synchronized` — that's JVM-only and would break commonMain). The
session-wide `writeMutex` continues to serialize control-stream writes.
Tests (new in MoqSessionTest):
- announce → ANNOUNCE_OK happy path; UNANNOUNCE wire frame on close.
- ANNOUNCE_ERROR surfaces as MoqProtocolException.
- End-to-end: announce + openTrack + peer SUBSCRIBE → SUBSCRIBE_OK +
three OBJECT_DATAGRAMs round-trip with intact group/object ids.
- send() returns false when no subscribers are attached (no buffering).
- Inbound SUBSCRIBE for unknown track under an announced namespace
replies SUBSCRIBE_ERROR.
Verified: `:nestsClient:jvmTest` (12 tests, 0 failures) + `:quic:jvmTest`
(green) + `./gradlew spotlessApply` clean.
|
||
|
|
46f693800d |
feat(audio-rooms): M2 multi-speaker subscribe + per-speaker speaking indicator
Listener side now subscribes to every host AND speaker on the stage (was just hosts) and exposes a `speakingNow: ImmutableSet<String>` derived from MoQ object arrival. Each on-stage avatar gets a primary-color ring while its track is delivering audio (debounced 250 ms — ~12 Opus frames — so packet jitter doesn't make the ring flicker). ViewModel: - `AudioRoomViewModel.uiState` gains `speakingNow`. - New `onSpeakerActivity(pubkey)` is invoked once per received MoQ object via a `Flow.onEach` tap on `SubscribeHandle.objects` (before the AudioRoomPlayer consumes it). Each invocation (re)arms a per-speaker expiry coroutine that clears the entry after `SPEAKING_TIMEOUT_MS`. - speakingNow is cleared whenever the underlying subscription closes (speaker removed from room) or the listener tears down (disconnect / onCleared). Screen: - AudioRoomViewModel is now hoisted up to AudioRoomStageContent so the speaker rows can read speakingNow alongside the connection UI. - StagePeopleRow wraps each ClickableUserPicture in a 2 dp circular border when the participant is in `speakingNow`. - updateSpeakers now receives `(hosts + speakers).pubKeys` so dynamic speaker promotion via NIP-53 event updates flows through to the reconcile loop already in M1. Test: - Adds `speakingNowClearsOnTeardown` to AudioRoomViewModelTest. The per-frame activity path is exercised end-to-end by the existing AudioRoomPlayerTest in :nestsClient (which uses the in-memory pipe). Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest :amethyst:compilePlayDebugKotlin` green. |
||
|
|
f9aa762d9b |
feat(audio-rooms): M1 listener-only wire-up — Connect button + Connected chip + mute
Wires `connectNestsListener` into the existing NIP-53 audio-room "stage" so
a user can tap Connect, see the live connection state, and hear hosts
speaking. Per the audio-rooms completion plan
(`nestsClient/plans/2026-04-26-audio-rooms-completion.md`) phase M1.
UI (amethyst):
- AudioRoomStage gets a state-chip / Connect / Disconnect / Mute row
above the existing hand-raise. State chip walks ResolvingRoom →
OpeningTransport → MoQ-handshake → Connected; Failed surfaces the
reason inline with a Retry button. The 30312 `service` tag drives
visibility — rooms hosted on non-nests servers show "Audio not
available" and the rest of the stage UI continues to work.
ViewModel (commons):
- New `AudioRoomViewModel` in commons/.../viewmodels/ owns one
`NestsListener` and one `AudioRoomPlayer` per active speaker, exposes
a single `StateFlow<AudioRoomUiState>`, and reconciles subscriptions
whenever the screen pushes a new speaker set via `updateSpeakers`.
Audio-pipeline construction is injected via `decoderFactory` /
`playerFactory` so a future desktop port can reuse the orchestration
once Compose Desktop has WebTransport. Unit-tested with a fake
`NestsListenerConnector` driving state transitions directly.
nestsClient:
- `AudioPlayer.setMuted(Boolean)` joins the interface; `AudioTrackPlayer`
routes it through `AudioTrack.setVolume(0f/1f)`. Mute keeps the
decode + network pipeline running so unmute is sample-accurate.
Build:
- `:commons` commonMain now `implementation(project(":nestsClient"))` so
the shared VM can see `NestsListener` / `AudioRoomPlayer` / interfaces.
Concrete OkHttp / Quic / MediaCodec / AudioTrack actuals stay in
`:nestsClient`'s platform source sets and are bound by an
Android-side `AudioRoomViewModelFactory` co-located with the screen.
Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:quic:jvmTest :amethyst:compilePlayDebugKotlin` all green.
|
||
|
|
c24e676004 |
Merge pull request #2577 from vitorpamplona/claude/quic-audio-rooms-v5ihC
feat(quic+nestsClient): pure-Kotlin QUIC v1 + HTTP/3 + WebTransport + MoQ listener stack |
||
|
|
4338e5e6c4 |
docs(quic+nestsClient): post-implementation status + audio-rooms completion plan
Two new module-local plan docs (per CLAUDE.md's "plans live in the owning
module" rule) and a sweep of stale inline phase references.
quic/plans/2026-04-26-quic-stack-status.md:
Post-mortem of the original docs/plans/2026-04-22 plan. Documents
what shipped vs what was estimated, the actual package layout (~8.5k
LoC, 39 test files, 5 audit rounds), the crypto delegation surface
(Quartz only — no BouncyCastle, no JNI), interop verification status
(aioquic + picoquic; nests not yet), and known deferred items
(STREAM retransmit, Initial-key discard, etc.).
nestsClient/plans/2026-04-26-audio-rooms-completion.md:
Punch list to ship audio rooms end-to-end:
M1 Listener wire-up in Amethyst UI
M2 Multi-speaker audience UX
M3 Foreground service for backgrounded playback
M4 Manual interop pass against nostrnests.com
M5 MoQ publisher path (ANNOUNCE / TrackPublisher)
M6 Capture → encode → publish pipeline
M7 NestsSpeaker API
M8 App polish (reconnect, leave cleanup)
M9 Foreground service for speakers
~6 weeks for full audio rooms; ~2 weeks for listener-only MVP.
Inline doc cleanup:
* Removed "Phase 3a/3c-1/3c-2/3c-3" / "Phase B/C/D-K/L" references
from active code; replaced with "today" or pointers to the
completion plan
* Removed "Kwik-based stub" references; QuicWebTransportFactory and
surrounding docs now describe :quic as the production path
* TlsClient header reflects non-null certificateValidator + the
JdkCertificateValidator / PermissiveCertificateValidator split
* SendBuffer header documents the best-effort no-retransmit mode
explicitly (was hidden behind a "Phase L will fix this" note)
* MoqMessage / MoqObject / MoqSession reflect listener-side as
shipped + publisher-side as Phase M5
CLAUDE.md:
* Module list now includes :quic and :nestsClient (was 5 modules,
now 7)
* Architecture diagram + sharing philosophy explain what each new
module owns
No production behaviour changes; doc + comment-only edits. Tests green.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
7f05fd6e2a |
fix(quic): round-5 audit fixes — concurrency, ack-eliciting flags, scope leaks
Two parallel audit agents inspected the round-4 commits for regressions and
concurrency hazards. Major findings:
ackEliciting regression (HIGH from core-regression report):
Round-4's ACK gating optimization (only emit ACKs when something
ack-eliciting was received) didn't update the parser's per-frame
handling. MaxDataFrame, MaxStreamDataFrame, MaxStreamsFrame,
NewConnectionIdFrame, HandshakeDoneFrame, ResetStreamFrame, StopSendingFrame,
NewTokenFrame all need ackEliciting=true per RFC 9000 §13.2.1. Pre-fix a
packet carrying only one of these would record the PN but never trigger
an ACK, causing the peer to PTO-retransmit forever.
HandshakeDoneFrame conditional (HIGH):
Pre-fix the dispatcher unconditionally set status=CONNECTED; if
applyPeerTransportParameters had just called markClosedExternally
(e.g. CID-validation failure), a later HANDSHAKE_DONE in the same
payload would resurrect the connection. Now only sets CONNECTED when
status is HANDSHAKING.
WT scope leak (CRITICAL from concurrency report):
QuicWebTransportSessionState.close() never cancelled the scope holding
the demux pump and capsule reader coroutines; both kept running past
close, retaining QuicStream / chunk channels indefinitely. Memory
growth on long sessions that opened/closed many WT sessions.
WtPeerStreamDemux.route() collector leak (CRITICAL):
The route function launches a coroutine to drain stream.incoming into
chunkChannel (UNLIMITED). Four early-return paths (truncated stream
type, mismatched WT signal, foreign session id) returned without
closing chunkChannel — collector kept running, channel grew unbounded.
Now wrapped in coroutineScope{} so the collector is joined on every
exit. Also explicitly cancels collector on the catch path.
RESET_STREAM stream-id ownership (HIGH):
Pre-fix the dispatcher closed the local read side on whatever stream
the peer named. RFC 9000 §3.5: the peer can only RESET_STREAM streams
where it owns a send side. A peer RESETting a CLIENT_UNI is
STREAM_STATE_ERROR (we own the only side). Now closes the connection
in that case.
looksLikeIpLiteral tightening (HIGH):
Pre-fix accepted "1.2.3.4.5", "1.2", "1." as IP literals — Java's
InetAddress.getByName resolves all of those via DNS, defeating
audit-4 #4's SNI-leak fix. Now strict: 4 dot-separated octets each
in 0..255, or contains a colon (IPv6).
Signal channels closed on teardown (MEDIUM):
closeAllSignals() helper closes peerStreamSignal +
incomingDatagramSignal alongside closedSignal; pre-fix only closedSignal
was closed and racing parser frames could still trySend into
never-consumed channels. Centralised the call so close() and
markClosedExternally both invoke it.
Driver.close() idempotency (HIGH):
A second concurrent close() (common: session close + read-loop death
racing) used to launch a parallel teardown that called scope.cancel()
while the first's joinAll was mid-flight. Now memoizes the launched
Job behind a synchronized block.
Driver.close() flush detection (MEDIUM):
Pre-fix spun on `pendingDatagrams.isEmpty()` to detect
CONNECTION_CLOSE flush, but the writer's CLOSING branch bypasses
pendingDatagrams entirely. Now spins on `connection.status ==
CLOSING`, which transitions to CLOSED only after drainOutbound builds
the close packet.
@Volatile on peerMaxStreams* (MEDIUM):
peerMaxStreamsBidi/Uni snapshots are documented lock-free; without
@Volatile, JLS allows long-tearing on 32-bit JVMs and the JIT may
cache stale values.
CertificateFactory parse inside try (MEDIUM):
Malformed cert chain bytes used to throw raw CertificateException
through the read loop. Now wrapped, so parse failure becomes a clean
CONNECTION_CLOSE.
GOAWAY id-regression observability (MEDIUM):
Pre-fix the QuicCodecException thrown on increasing GOAWAY id was
silently swallowed by route()'s catch. Now also surfaces via
peerGoawayProtocolError so the application/QUIC layer can act.
appendFlowControlUpdates uses streamsListLocked (perf):
The round-4 perf #10 fix introduced streamsListLocked (no
entries.toList per drain) but appendFlowControlUpdates still iterated
the Map. Now also uses the index-friendly view.
peerCloseDeferred completion on session close (MEDIUM):
awaitPeerClose() used to hang forever if the local side called close()
before any peer-initiated WT_CLOSE_SESSION arrived. Now cancelled with
CancellationException on local close.
Tests:
AckElicitingFramesTest pins the ackEliciting contract on every round-5
fix plus the ResetStream-on-CLIENT_UNI rejection.
JdkCertificateValidatorIpLiteralTest pins the tightened pattern via
reflection.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
920b36cdd6 |
perf(quic): round-4 perf-audit fixes — ACK gating, flow-control dirty-set, streams list view
Four perf wins from the round-4 audit, all in the steady-state hot path (audio rooms run at ~50 datagrams/sec/participant). Perf #1 — ACK frame gating (saves a frame + bandwidth on every drain): AckTracker.buildAckFrame now returns null when no new ack-eliciting packet has arrived since the last build. Pre-fix the writer emitted a redundant ACK frame on every outbound packet (~50/sec each direction) even when the only inbound traffic since last drain was ACK-only. RFC 9000 §13.2 only requires ACKs in response to ack-eliciting packets within max_ack_delay; gating on ackElicitingPending satisfies that without delay-timer machinery. Perf #11 — AckTracker.purgeBelow short-circuit: Common case: peer ACKs a high PN, we already pruned below it. Pre-fix triggered a full ListIterator walk anyway. Now bails out when the tail's start is already above the threshold. Perf #9 — Flow-control dirty-set: QuicStream gains a receiveDirtyForFlowControl flag set by the parser when readContiguous advances the frontier. The writer's appendFlowControlUpdates now skips per-direction-window lookup + threshold comparison for streams whose flag is unset. Big win for multi-stream sessions (audio rooms with N×M streams per participant). Perf #10 — Streams list view: QuicConnection maintains a parallel insertion-ordered List<QuicStream> alongside the streams Map. Writer's round-robin scan reads the list directly instead of allocating `entries.toList()` per drain. No removal path exists today; the insert points (openBidi/UniStream, getOrCreatePeerStreamLocked) update both. AckTrackerGatingTest pins the new gating contract: first build returns a frame; second build without new reception returns null; subsequent ack-eliciting reception re-arms; non-ack-eliciting receptions alone don't. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |
||
|
|
21da61ad64 |
test(quic): comprehensive regression tests for round-4 fixes + coverage holes
Pins every behavioural change made in the round-4 audit-fix commit so a
future regression can't quietly resurrect any of the bugs.
FrameRoutingTest (new):
* RESET_STREAM / STOP_SENDING / NEW_TOKEN round-trip and don't kill
the connection on arrival
* Peer attempting CLIENT_BIDI / CLIENT_UNI stream IDs closes the
connection (STREAM_STATE_ERROR)
* MaxDataFrame raises sendConnectionFlowCredit; lower values ignored
* CONNECTION_CLOSE returns immediately — frames after it are not
dispatched (would otherwise create phantom streams on a closed
connection)
* HANDSHAKE_DONE at APPLICATION level is legal (ensures the level-
validation guard didn't over-fire)
* incomingDatagrams queue caps at MAX_INCOMING_DATAGRAM_QUEUE; oldest
entries dropped on overflow
ReceiveBufferFinTest (new): the audit-4 #4 silent-truncation fix
* isFullyRead() stays false when FIN arrives before a gap fills
* isFullyRead() flips true only after contiguous-end reaches finOffset
* Zero-length FIN frame at exact end marks stream complete
* finOffset is pinned at first observation (RFC 9000 §4.5)
QuicConnectionWriterTest (new): drainOutbound paths previously untested
* CLOSING-status drain produces a CONNECTION_CLOSE packet
* appendFlowControlUpdates raises stream.receiveLimit after consumer
drains > half window
* Writer enforces sendConnectionFlowCredit cap (audit-4 #9 — never
exceeds the peer's initial_max_data even with more bytes queued)
JcaAesGcmAeadTest (new, jvmTest): JVM-platform AEAD round-trip
* seal → open round-trip
* Different nonces produce different ciphertexts
* Rebuild path (same nonce twice) uses fallback cipher and still opens
* Corrupted ciphertext / wrong AAD → null
* key/nonce/tag length constants
ChaCha20Poly1305AeadTest (new): the seal side that prior tests skipped
* seal → open round-trip
* Bad tag / wrong AAD → null
* Wrong-size key/nonce throws IllegalArgumentException
WtPeerStreamDemuxTest: GOAWAY id-regression branch (audit-4 #5)
* Increasing GOAWAY id is rejected; previously recorded id stays put
CapsuleReaderTest: new strictness assertions
* WT_CLOSE_SESSION body < 4 bytes throws QuicCodecException
* Reason > 8192 bytes throws QuicCodecException
Notes:
* QuicConnectionDriver direct unit tests would require turning UdpSocket
from `expect class` into an interface; deferred — driver paths are
exercised end-to-end by InteropRunner and indirectly via every pipe-
based test.
* Decrypting client-emitted packets in tests requires server-side keys
(server's RX = client's TX with different cached cipher state in JCA);
writer tests assert side-effects on connection state instead.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
222a4e7d42 |
fix(quic): round-4 tier-1 + tier-2 audit fixes
Critical interop blockers + security/correctness gaps surfaced by the
parallel round-4 audit. All fixes have inline comments referencing the
audit finding number.
Frame layer:
* Decode RESET_STREAM (0x04), STOP_SENDING (0x05), NEW_TOKEN (0x07).
Pre-fix these fell through to the `unknown frame type` branch and
threw QuicCodecException through the read loop, killing the
connection. aioquic and picoquic emit RESET_STREAM regularly.
* Wrap decodeFrames in try/catch in dispatchFrames; on a decode
error, transition to CLOSED gracefully via markClosedExternally
instead of letting the exception escape the read loop.
Connection layer:
* Reject peer-attempted CLIENT_BIDI / CLIENT_UNI stream IDs that don't
map to a stream we opened (RFC 9000 §19.8 STREAM_STATE_ERROR).
* MaxDataFrame now actually updates sendConnectionFlowCredit (was a
no-op pre-fix; sustained sends silently stalled).
* Writer enforces sendConnectionFlowCredit and tracks
sendConnectionFlowConsumed so cumulative bytes stay under the
peer's initial_max_data cap.
* SERVER_BIDI peer-opened streams inherit sendCredit from
peer.initialMaxStreamDataBidiLocal (was 0L; reply path was wedged
until MAX_STREAM_DATA arrived).
* applyPeerTransportParameters validates initial_source_connection_id
and original_destination_connection_id (RFC 9000 §7.3 MUST checks);
mismatch closes with TRANSPORT_PARAMETER_ERROR.
* Cap incomingDatagrams queue at 256 (audio rooms ~50/sec; 5-second
burst). On overflow, drop oldest — fresh frames matter more for
live media. Pre-fix RFC 9221 datagrams were unbounded.
Stream layer:
* QuicStream.deliverIncoming now returns Boolean; parser closes the
connection with INTERNAL_ERROR on saturation rather than silently
dropping bytes (peer believes the bytes were delivered, application
sees a hole).
* ReceiveBuffer tracks finOffset and exposes isFullyRead(); parser
only closes the incoming channel after the contiguous read frontier
reaches the FIN offset (pre-fix closing on FIN-frame arrival
truncated streams that had gaps).
TLS hardening:
* certificateValidator is non-null. Tests pass an explicit
PermissiveCertificateValidator; null was a silent-MITM hazard.
* Drop SIG_RSA_PKCS1_SHA256 from accepted CertificateVerify
schemes (forbidden by RFC 8446 §4.2.3 in CertificateVerify).
* Hard-fail the PSK-Finished path: we never offer a pre_shared_key
extension, so a server skipping Certificate/CertificateVerify is
either misbehaving or a partial-MITM stripping cert proof.
* Validate ALPN: reject any ALPN the server selected that we didn't
offer (was previously accepted silently).
* Add APPLICATION-level inboundBuffer so post-handshake CRYPTO
(NewSessionTicket, KeyUpdate detection) reaches the
SENT_CLIENT_FINISHED handler.
* State.FAILED is now actually assigned on any handler throw;
pushHandshakeBytes refuses further bytes when in FAILED.
* IP-literal precheck before InetAddress.getByName so cert
validation doesn't trigger DNS A/AAAA lookups for hostnames
(audit-4 #4: leaked SNI/hostname over plaintext DNS).
WT layer:
* GOAWAY id-regression check (RFC 9114 §5.2: MUST NOT increase).
A server sending an increasing id raises QuicCodecException.
* WT_CLOSE_SESSION decoder rejects bodies < 4 bytes (mandatory
error-code field) and reasons > 8192 bytes.
* Capsule reader catches Throwable but separately rethrows
CancellationException; on parse error, completes peerCloseDeferred
exceptionally so awaitPeerClose() doesn't hang forever.
HTTP/3 + QPACK:
* Http3Settings.decodeBody rejects duplicate ids (RFC 9114 §7.2.4.1
H3_SETTINGS_ERROR).
* QpackInteger.decode bounds-checks shift before extending value;
defence-in-depth Long-overflow check on accumulated value.
* QpackDecoder static-table accesses go through a bounds-checking
helper that throws typed QuicCodecException; literal lengths are
range-checked before allocation.
Test infra:
* InMemoryQuicPipe accepts an injectable serverScid and constructs
its tlsServer with TPs that include the required CIDs.
* InProcessTlsServer emits stub Certificate + CertificateVerify
so the real (non-PSK) handshake path is exercised.
* Updated all test callers to use PermissiveCertificateValidator.
* Updated CapsuleReaderTest with negative-path assertions for the
new strictness.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
0023c73aeb |
test(quic): regression tests for receive-limit, incoming channel cap, coalesced-packet skip
Three pending audit-2/3 regression tests, plus the InMemoryQuicPipe helpers needed to drive them. ReceiveLimitEnforcementTest — peer overshooting per-stream receive limit must transition the connection to CLOSED via markClosedExternally. Mirror test verifies the boundary value (frameEnd == receiveLimit) does NOT close. QuicStreamIncomingChannelTest — the per-stream incoming channel is bounded at 64 chunks; trySend on saturation must not block (would deadlock the parser on the connection lock). Empty chunks are filtered. closeIncoming terminates the collector. CoalescedPacketSkipTest — RFC 9000 §12.2 / RFC 9001 §5.5: feedDatagram must walk across coalesced packets, must skip a packet that fails AEAD verification using peekHeader.totalLength (not break the loop), and must exit cleanly when a trailing header is truncated. Pipe additions: buildServerApplicationDatagram + coalesceDatagrams give tests the primitives to drive arbitrary server → client app-level frames. InMemoryQuicPipe also takes an optional tlsServer so tests can advertise non-default transport parameters. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |
||
|
|
62a42cb36d |
fix(quic): audit-3 follow-ups + regression coverage
Cipher caching: cache JCA Cipher + SecretKeySpec per direction in a new
JcaAesGcmAead so steady-state seal/open avoids Cipher.getInstance("AES/GCM/
NoPadding") per packet (audit-1, audit-3 hot path). Initial-padding rebuild
edge case (re-encrypting the same PN with the same nonce) falls back to a
fresh cipher because JCA tracks (key, iv) pairs and rejects legitimate reuse.
Channel-based wakeups: replace the WT peer-stream poller's delay(5)
busy-loop with awaitIncomingPeerStream/awaitIncomingDatagram suspending
on conflated wakeup channels fired by the parser. Connection close also
closes a closedSignal so any awaiter unblocks promptly with null.
Driver close ordering: close() now joins the read + send loops with a
bounded timeout instead of yield()+cancel()-racing them. Catches the case
where scope.cancel() fired mid-socket.send, occasionally producing partial
datagrams or skipping CONNECTION_CLOSE entirely.
WT graceful close: spawn a CapsuleReader-driven coroutine on the CONNECT
bidi that decodes WT_CLOSE_SESSION and surfaces it via peerCloseSession +
awaitPeerClose. Previously the encoder existed but no decoder consumed
incoming capsules, so peer-initiated graceful close was silent.
GOAWAY: WtPeerStreamDemux decodes the GOAWAY varint body into
peerGoawayStreamId instead of `is Goaway -> Unit`-dropping it.
TLS transcript hash: incremental SHA-256 backed by JCA MessageDigest,
snapshotted via clone() — replaces the O(n²) "concatenate-everything-and-
re-hash on every snapshot" implementation. TLS 1.3 takes ≥3 snapshots per
handshake.
MAX_STREAMS routing: parser now bumps peerMaxStreamsBidi/Uni on inbound
MAX_STREAMS frames; openBidiStream/openUniStream throw QuicStreamLimitException
when the cap is reached instead of silently overrunning it. Initial cap
sourced from peer transport parameters.
Regression tests:
* CapsuleReaderTest – round-trip, split-chunk, partial, unknown types
* TlsTranscriptHashTest – snapshot determinism, no consume-on-snapshot
* PeerStreamLimitTest – TP-driven cap, MAX_STREAMS frame round-trip
* WtPeerStreamDemuxTest – CONTROL stream GOAWAY decode
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
c4cf92429a |
Merge pull request #2549 from vitorpamplona/claude/improve-desktop-design-iOokB
Add native OS theming and improve desktop UI layout |
||
|
|
2f9a0a0e03 |
fix(quic): live interop against aioquic + round-3 audit fixes
🎉 First successful live-interop handshake against a real reference impl. Drove our pure-Kotlin QUIC client at the aucslab/aioquic-http3-server Docker container; HANDSHAKE COMPLETE with negotiated ALPN=h3 and full transport parameter exchange: max_data=1048576, max_streams_bidi=128, max_streams_uni=128, idle_timeout=60000ms, max_datagram=65536 Every layer worked end-to-end over real UDP: packet codec, header protection, AES-128-GCM AEAD, TLS 1.3 with X25519, CertificateVerify, transport parameters, ALPN negotiation. The PermissiveCertificateValidator + InteropRunner main + Gradle :quic:interop task make this reproducible in one command. Round-3 audit fixes (8 critical bugs caught by 4 parallel reviewers): 1. feedDatagram coalesced-packet skip (RFC 9001 §5.5): `?: break` discarded all subsequent coalesced packets if any one of them failed to decrypt. Now uses peekHeader to advance over a failed packet, only breaking on a totally-unparseable header. 2. Receive-side flow-control enforcement: Parser was inserting STREAM frames without checking against the limit we advertised. A misbehaving peer could blow past initialMaxStreamDataX with no error; now triggers markClosedExternally with FLOW_CONTROL diagnostic. 3. Bounded incomingChannel (audit-2 finding finally addressed): QuicStream.incomingChannel was Channel.UNLIMITED — slow consumer + fast peer = unbounded heap growth. Now Channel(64), bounded by the per-stream receive limit. Combined with #2, memory growth is capped. 4. RetryPacket CID length validation: parse() didn't bounds-check dcidLen/scidLen against the RFC 9000 §17.2 1..20 range. Hostile Retry with cidLen=255 → readBytes throws QuicCodecException to the caller (instead of returning null for silent drop). Added explicit `!in 0..20 → return null`. 5. HelloRetryRequest detection: No HRR check — we treated it as a regular ServerHello, derived an ECDHE on garbage, then failed AEAD downstream with a confusing error. Now checks ServerHello.random against the SHA-256("HelloRetryRequest") magic value and throws cleanly. 6. CancellationException handling in WT factory: Catch-all wrapped CancellationException as HandshakeFailed, breaking structured concurrency. Now rethrows ce after closing the driver. 7. InteropRunner scope leak on timeout: parentScope was never cancelled — orphaned coroutines kept the IO dispatcher alive after main exited. Now scope.cancel() runs unconditionally; small delay gives the driver-launched teardown a moment to flush before exit. 8. RFC 9220 §3.1 SETTINGS-before-CONNECT documented as known limitation: The strict requirement to wait for SETTINGS_ENABLE_WEBTRANSPORT=1 before sending CONNECT requires more refactoring (the demux capturing peerSettings lives inside QuicWebTransportSessionState, which we don't build until after the request bidi opens). Tolerant servers (aioquic, quic-go) accept early CONNECT; documented for future strict-RFC fix. All :quic:jvmTest + :nestsClient:jvmTest pass. Live aioquic interop verified post-fix: handshake completes, transport parameters round-trip cleanly. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |
||
|
|
d2a10c9e4a |
feat(quic): live interop harness — picoquic Docker + InteropRunner main
Setup for driving the pure-Kotlin QUIC client against real reference
servers, kicking off the live-interop validation that closes the loop on
the audit cycles.
PermissiveCertificateValidator (commonMain test-only):
Accepts any cert chain and any signature. For local dev servers (picoquic,
quic-go, nests-rs in Docker) where the system trust store would reject the
self-signed cert. Documented as test-only — must never be wired into
production code.
InteropRunner.main (jvmTest):
Standalone runnable that opens a UDP socket, builds a QuicConnection with
PermissiveCertificateValidator, drives the handshake under a configurable
timeout, and reports CONNECTED / HandshakeFailed / Timeout / UdpFailed
with peer transport parameters on success. Exit code 0 on connect, 1 on
any failure mode — wirable into CI.
`quic/scripts/run-picoquic.sh`:
One-line Docker harness that runs Christian Huitema's picoquic reference
server on UDP 4433. Picoquic is the IETF QUIC WG's reference impl and
the most permissive target for a fresh client (clear qlog traces, accepts
a wide range of transport params).
`./gradlew :quic:interop` Gradle task:
Wraps the runner so live-interop is one command:
./gradlew :quic:interop -PinteropHost=127.0.0.1 -PinteropPort=4433
Validated against a non-running server: returns Timeout cleanly with
exit 1 (the failure-path proves the harness wires correctly).
Workflow documented in `quic/scripts/README.md` covering picoquic,
quic-go, aioquic, quiche, and the eventual graduation path to the
quic-interop-runner Docker matrix at https://interop.seemann.io.
Next step: actually run picoquic in Docker and chase whatever real-world
incompatibilities surface. Each will be a focused fix commit.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
bd192fc649 |
fix(quic): PTO timer + IDN/IPv6 hostname + Huffman lookup table + amortized compaction
Closes the remaining items from the round-2 audit.
Driver send-loop PTO timer:
Loop suspends on `withTimeoutOrNull(ptoMillis) { sendWakeup.receive() }`
instead of unconditional receive. PTO doubles on each consecutive timeout
up to 60s; resets to 1s on any wakeup.
Without this, a single lost ClientHello with no inbound traffic to drive
wakeups left the connection wedged forever. The PTO wake now gives the
writer a chance to re-emit on retransmission.
JdkCertificateValidator hostname matching:
- IDN.toASCII normalization on both the cert SAN and the input host so
a SAN of `xn--bcher-kva.de` matches an input of `bücher.de`.
- IPv6 literals compared via InetAddress.getByName().hostAddress so
`::1` and `0:0:0:0:0:0:0:1` compare equal.
- Wildcard public-suffix-label rejection: requires the suffix to contain
at least 2 dots, so `*.com` is rejected even if a misbehaving CA
issued such a cert.
QpackHuffman.decode O(N×256) → O(N×L) where L is the number of distinct
code lengths (≤ 26):
Previously the inner loop scanned all 256 symbols per output byte. Now
it walks length buckets in ascending order and does a HashMap lookup at
each. For ASCII text the hot path matches at length 5-8.
Http3FrameReader.push + CapsuleReader.push amortized compaction:
Was O(N) memcpy on every push → O(N²) over the lifetime of a long-lived
stream. Now compacts only when consumed prefix is at least half the
buffer, giving amortized O(1) per byte.
All :quic:jvmTest + :nestsClient:jvmTest pass.
End of round-2 audit fixes. The branch now passes:
- Every RFC 9001 Appendix A test vector bit-for-bit
- The full in-memory client+server pipe handshake
- Hostile-input fuzzing of decodeFrames
- Coalesced-packets ACK regression
- Multi-cipher-suite (AES-GCM + ChaCha20-Poly1305) handshake
- Negative-path TLS rejection (session_id_echo, version, group, missing
extensions)
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
8f5280c9c3 |
fix(quic): WebTransport peer-stream demux + flow-control + perf cleanup
Round-2 audit found that without prefix-stripping on peer-initiated streams,
the server's HTTP/3 control stream would deliver its SETTINGS frame to the
application, breaking MoQ framing. Plus several flow-control + perf items
the audit flagged.
WtPeerStreamDemux:
Spawned per WT session in QuicWebTransportSessionState.init. Routes peer
streams by their leading varint(s):
- HTTP/3 CONTROL stream (0x00) → drains internally, captures peer
SETTINGS frame for inspection (peerSettings property).
- QPACK encoder/decoder streams (0x02/0x03) → drained to /dev/null
(we run with QPACK_MAX_TABLE_CAPACITY=0).
- WebTransport unidirectional (0x54) + matching quarter session id →
surfaces to app as a StrippedWtStream with the prefix stripped.
- WebTransport bidi signal (0x41) + matching quarter session id → same.
- Anything else → dropped per RFC 9114 §9.
pollIncomingPeerStream is now @Deprecated; the recommended path is the
incomingStrippedStreams flow on the session state. The nestsClient adapter
now consumes the stripped flow and emits StrippedWtReadStreamAdapter
through the existing WebTransportSession.incomingUniStreams API.
WT_CLOSE_SESSION decoder + CapsuleReader:
Added stateful capsule reader that yields parsed WtCloseSession
(errorCode + reason) from the CONNECT bidi. Wires into the future close
notification path; not yet bound to status flip but the parser is in
place.
Flow-control fixes from the audit:
- Per-direction receive window in getOrCreatePeerStreamLocked. SERVER_UNI
streams now use initialMaxStreamDataUni instead of bidi-remote.
- appendFlowControlUpdates picks per-direction window matching the
stream's StreamId.kindOf.
- MAX_DATA tracking via QuicConnection.advertisedMaxData high-water mark.
Previously the writer emitted a fresh MAX_DATA on every outbound
packet once the threshold was crossed (spam). Now: only when the new
value strictly exceeds advertisedMaxData.
Stream iteration round-robin:
Writer was iterating streamsLocked() in insertion order, starving streams
created later under MTU pressure. Added streamRoundRobinStart counter on
QuicConnection that advances after each drain.
SendBuffer alias defense:
takeChunk's fast path used to return the head ByteArray reference directly
when consuming the whole chunk — caller-owned byte arrays could be mutated
in-place. Now always copies, since downstream encoders pass the bytes to
AEAD.seal which assumes immutability.
UdpSocket cleanup:
- readBuf shrunk from 65 KiB to 2 KiB. QUIC datagrams cap at MTU; the old
65 KiB allocation was per-connection waste with no benefit.
- Removed pointless synchronized(readBuf) — only the read loop touches it.
All :quic:jvmTest + :nestsClient:jvmTest pass.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
94a3d32a6d |
fix(quic): lifecycle hangs + TLS hardening from second-pass audit
Six fixes from the round-2 audit, all of which would either hang the
client on failure modes or weaken security against a misbehaving server.
QuicConnection / awaitHandshake hang fixes:
- QuicConnectionClosedException + markClosedExternally(reason).
- close() now also calls signalHandshakeFailed if !handshakeComplete.
- QuicConnectionParser inbound CONNECTION_CLOSE → markClosedExternally
(was: just flip status, leaving awaiters hanging forever).
- QuicConnectionDriver.readLoop has a finally-block that calls
markClosedExternally + wakeup so when the socket closes mid-handshake
or the server closes uncleanly, awaitHandshake() throws instead of
suspending forever.
QuicConnectionDriver.close() no longer cancels its own caller scope:
Previously close() was suspend, called connection.close (acquires lock)
→ wakeup → scope.cancel → socket.close. If close() is invoked from
inside the driver scope (e.g. by the WT factory's exception cleanup
path), scope.cancel cancels the very coroutine running close, so
socket.close may never run. close() is now non-suspend and dispatches
the teardown onto parentScope.launch so the cancel never reaches its
caller.
QuicWebTransportFactory.connect:
- readResponseStatus wrapped in withTimeoutOrNull(connectTimeoutMillis,
default 10s). Dead network → HandshakeFailed instead of forever-hang.
- Whole post-handshake setup (open control + request streams + read
response) now in try/catch that calls driver.close() on any unexpected
exception. Previously a thrown SocketException between awaitHandshake()
and the explicit non-2xx branch leaked the driver + UDP socket.
JdkCertificateValidator.validateChain authType:
Was hardcoded "ECDHE_ECDSA". Now derived from the leaf cert's public-key
algorithm: "RSA" → ECDHE_RSA, "EC"/"EdDSA" → ECDHE_ECDSA. Some Android
trust managers (RootTrustManager, NetworkSecurityConfig) gate
algorithm-specific pinning rules on this string and may reject mismatched
combos.
TlsExtension.decodeList bounds:
Inner reads were unbounded against the extension-list end. A malicious
server could claim totalLen=4 but encode an extension whose data length
declared 1000, reading past the supposed extension-list end into
trailing handshake bytes. Now: validates totalLen ≤ r.limit upfront and
asserts r.position ≤ end after each inner decode.
TlsClient KeyUpdate close-on-receipt:
Previously HS_KEY_UPDATE was silently dropped. RFC 9001 §6: if the peer
rotates keys and we keep using the old ones, AEAD opens silently fail
→ connection wedges. Until we implement rotation, KeyUpdate must throw
so the QUIC layer closes cleanly with a fatal error instead of
desynchronizing.
All :quic:jvmTest + :nestsClient:jvmTest pass.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
9d0a7ead7a |
fix(quic): packet codec hostile-input validation + RFC-correct Initial padding
Round-2 audit found I'd only fixed varint-truncation in Frame.kt, not in
LongHeaderPacket. Plus the datagram padding was wire-illegal AND broke
our own short-header receive path.
LongHeaderPacket.parseAndDecrypt + peekHeader:
- Both tokenLen and length now validated against r.remaining BEFORE
.toInt() truncation. Hostile peer sending length=2^62-1 → return null
instead of OOM/crash.
- dcidLen and scidLen validated 1..20 per RFC 9000 §17.2.
- peekHeader correctly handles Retry packets (no token-length, no
length, no packet-number fields per RFC 9000 §17.2.5) — returns
PeekedHeader with totalLength = bytes.size - offset instead of
reading nonexistent length field as garbage.
Buffer.ensure: doubling loop spins forever if buf.size == 0. coerce
starting newSize to ≥ 8 so the loop always terminates.
QuicConnectionWriter Initial padding (RFC 9000 §14.1):
Previously: built packets, computed datagram size, appended trailing
zero bytes after the last packet to reach 1200. That's wire-illegal
(RFC says PADDING frames inside the encryption envelope) AND breaks
our own short-header receiver because ShortHeaderPacket.parseAndDecrypt
uses bytes.size as packetEnd → AEAD authentication includes the trailing
zeros and fails.
Now: two-pass build. Phase 1 collects ACK+CRYPTO frames per level into
local lists (single takeChunk per level), builds natural-size packets.
If Initial is present and total < 1200, rewinds the Initial PN and
rebuilds with PADDING bytes appended to the encrypted payload. PADDING
is one 0x00 byte per frame so concatenating N zero bytes inside the
payload is N valid PADDING frames — the receiver collapses them to
nothing during decode.
PacketNumberSpaceState.rewindOutboundForRebuild() supports the rebuild.
InMemoryQuicPipe handshake test still passes — verifies the receiver
correctly accepts our new PADDING-frame Initials.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
02b03e143b |
test(quic): in-memory QUIC pipe (quiche-style) for full-stack handshake
Adds InMemoryQuicPipe — a quiche-Pipe-style harness that runs a real QuicConnection through the full handshake without touching the network. The "server" side wraps InProcessTlsServer in QUIC packet protection (Initial + Handshake long-header packets) and routes CRYPTO bytes between the layers. Direct port of the pattern from quiche/src/test_utils.rs (`Pipe`). InMemoryQuicPipeTest.client_connection_reaches_connected_via_in_memory_pipe verifies the full client receive path: - ClientHello at Initial level → server decrypts, drives TLS, replies - Server Initial packet (ServerHello) → client decrypts, derives handshake keys - Server Handshake packets (EE + Finished) → client verifies, derives 1-RTT keys - Client Finished at Handshake level → server verifies - Client status flips to CONNECTED, both directions of 1-RTT keys installed This is the test category three of the four mature QUIC implementations surveyed have or rely on: - quiche's `Pipe` is the gold standard (we ported it here) - quic-interop-runner is the network-level equivalent (Docker matrix) - kwik notably does NOT have one — uses Mockito + reflection instead It catches the largest class of bugs: wrong layer-to-layer wiring (e.g. TLS layer derives keys but QUIC layer doesn't install them, the hardcoded-cipher-suite C1 bug, AckTracker PN bug C2 across coalesced packets). Future tests can build on it: stream send/receive, datagram round-trip, flow-control stall, retransmission once we add it. Pipe currently supports AES-128-GCM only; ChaCha20 path validation is covered by TlsRoundTripTest at the TLS layer for now. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |
||
|
|
cd68502355 |
test(quic): adversarial + parametrized + negative-path tests from review
Builds the test categories the audit identified as missing. Patterns informed
by surveys of Cloudflare quiche (the `Pipe` style + flow-control assertions),
kwik (server-side hostile-peer matrix), and quic-interop-runner (scenario
checklist).
FrameFuzzerTest — 8 tests
- 2000 random byte sequences fed through decodeFrames; the contract is
"succeed or throw QuicCodecException, never crash." Catches the C5 class
(oversized varints) plus general DoS resilience.
- Crafted hostile vectors: STREAM with length=2^62-1, CRYPTO 1 GiB,
ACK with 1B range count, NCID with cidLen=255, CONNECTION_CLOSE with
1 GiB reason, DATAGRAM 1 GiB, valid frame followed by unknown type.
AckTrackerCoalescedTest — 3 tests
- Two coalesced packets in one datagram both end up in the ACK frame.
Direct regression test for the C2 bug where the parser fed
`state.pnSpace.largestReceived` instead of the actual decrypted PN.
- Gapped PNs produce two ranges with the correct gap encoding (RFC 9000
§19.3.1 `previous_smallest - current_largest - 2`).
- Out-of-order arrival of contiguous PNs still merges into one range.
FlowControlEnforcementTest — 6 tests
- SendBuffer respects maxBytes (the writer's `sendCredit - sentOffset`
enforcement point).
- maxBytes=0 with pending data returns null (sender stalls cleanly).
- Multi-take across chunked-queue boundaries preserves byte order
(regression coverage for the new O(1) chunked enqueue replacing the
old O(N²) copyOf path).
- FIN handling: piggyback on final data chunk vs. zero-length post-data.
TlsSecurityPropertiesTest — 5 tests
- ServerHello with non-empty session_id_echo rejected (RFC 8446 §4.1.3
downgrade signal).
- Pre-TLS-1.3 legacy_version rejected.
- Server picking unsupported group (secp256r1) rejected — we advertise
X25519 only.
- Missing supported_versions / missing key_share extensions rejected.
TlsRoundTripTest — multi-cipher parametrization
- InProcessTlsServer takes a `preferredCiphers` list.
- New test forces ChaCha20-Poly1305-SHA256 selection and asserts the
full handshake completes with that suite, with the
onApplicationKeysReady callback reporting the actual negotiated
cipher (not the previously-hardcoded AES). This is direct regression
coverage for the C1 bug.
Total: 22 new tests + multi-cipher parametrization. All :quic:jvmTest +
:nestsClient:jvmTest pass.
Notable gap acknowledged from surveys: an in-memory `Pipe`-style
client+server harness (quiche pattern). Requires a server-side
QuicConnection implementation, which is ~1 day of work; deferred until we
have a concrete need beyond what the in-process TLS server already covers.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
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
|
||
|
|
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
|