Listeners stuck on a spinning speaker avatar with no audio (against
both Amethyst-hosted and nostrnests.com-hosted Nests). The trace
captured on a working speaker phone showed every group keyed to
subId=1 / track='catalog.json' even though the broadcaster was
pumping Opus frames:
13:21:27.570 inbound SUBSCRIBE id=1 track='catalog.json'
13:21:27.574 inbound SUBSCRIBE id=0 track='audio/data'
13:21:27.609 openGroup seq=0 keyedOnSubId=1 track='catalog.json'
13:21:27.610 broadcaster: send accepted (subscriber attached)
... every subsequent group keyed=1, every audio frame on the
catalog stream
Root cause: `PublisherStateImpl.openNextGroupLocked` keyed each uni
stream off `inboundSubs.first()` with no track filter. The kdoc
asserted "Inbound subscription set is expected to be small (1 in
nests's listener-per-room model)" — that was true when listeners
only opened the audio sub. Once the listener side started opening a
catalog sub alongside (commits ~Apr 2026), whichever SUBSCRIBE
arrived first (typically the catalog one, by ~4 ms in this trace)
became the routing target for all audio frames. The relay forwarded
those frames to the listener with subscribeId=catalog, the listener
routed them to its catalog handler, RoomSpeakerCatalog.parseOrNull
returned null (it's not JSON), and the audio subscription's frames
channel never received anything — so onSpeakerActivity never fired
and the spinner stayed forever.
Fix: per moq-lite Lite-03, a publisher is responsible for one
(broadcast, track) tuple. Thread `track` through `MoqLiteSession.publish`
and have `PublisherStateImpl.registerInboundSubscription` reject
inbound SUBSCRIBEs whose track doesn't match — those still receive
SUBSCRIBE_OK from the bidi handler (best-effort per Lite-03's
optional-content semantics for catalog) but don't influence the
audio routing. `MoqLiteNestsSpeaker.startBroadcasting` passes
`track = MoqLiteNestsListener.AUDIO_TRACK` (= "audio/data").
Test callsites (MoqLiteSessionTest, NostrnestsProdAudioTransmissionTest,
NostrNestsSustainedSendOutcomesInteropTest) updated to pass the new
required parameter; all use track="audio/data" matching what they
exercise.
The diagnostic logs from the prior commits stay in — they're how we
caught this and they'll catch the next one. They can be stripped in
a follow-up once the fix is confirmed in the field.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
The previous round of fc-* lines exclusively reported the speaker's QUIC
connection state. That hid the real story for the residual stream loss:
each Opus frame the relay forwards arrives as a fresh peer-initiated
uni stream on the LISTENER side, so the listener's peerInitiatedUni
counter and advertisedMaxStreamsUni evolution are what matter — the
speaker's view is stable at peerInitiatedUni=1 for the whole run
(only the WT control stream is relay-initiated on that side).
Changes:
- MoqLiteSession.transport changed from `private` to `internal val`
so test code can downcast.
- MoqLiteNestsListener exposes an `internal val transport` getter
that returns the underlying WebTransportSession via the session's
new internal accessor.
- SendTraceScenario.run takes a new optional
`listenerFlowControlSnapshots: List<suspend () -> QuicFlowControlSnapshot>`.
When supplied, it emits `fc-listener[idx]-pre / -post-pump /
-post-grace` lines in the same shape as the speaker side.
- Refactored the three checkpoint emit sites into a shared
`logFcSnapshot(scope, label, snap, includeRcvBuf)` helper so the
speaker and listener lines print identical column layouts.
- withProdSpeakerAndListeners + withHarnessSpeakerAndListeners cast
each listener to MoqLiteNestsListener (the only impl in the tree)
and downcast its WebTransportSession to QuicWebTransportSession,
yielding a per-listener snapshot supplier list to the scenario
block.
What the next sweep tells us: for each cliffed scenario, fc-listener[0]
should now show whether the listener is the bottleneck (e.g.
peerInitiatedUni stuck below the relay's intent, or udpDatagrams not
matching the relay's send count) or whether the relay just stopped
forwarding (peerInitiatedUni climbs but matches the partial received
count).
All :quic and :nestsClient JVM tests pass.
Three targeted changes against the residual sustained-load stream loss
that survived the MAX_STREAMS_UNI fix (long broadcasts and bursty
scenarios still cliffed mid-pump). Each addresses one of the round-1
hypotheses; the next prod sweep should tell us how much each
contributes.
quic/UdpSocket:
- Bump SO_RCVBUF to 4 MiB at bind time. The kernel default (~200 KB
on Linux/macOS, similar on Android) holds barely 130 MTU-sized
datagrams; a multi-second moq-lite broadcast that the relay fans
out to several subscribers transiently overflows this. Anything
queued past `rmem` is silently dropped by the kernel and never
reaches our QUIC stack.
- Add lifetime diagnostic counters (receivedDatagramCount,
receivedByteCount, receiveBufferSizeBytes) on the expect surface
so commonMain code can read them via the new udpStatsSupplier
hook on QuicConnection without each platform having to surface its
own native bookkeeping.
- QuicConnectionDriver wires the supplier on start; the connection's
flowControlSnapshot bundles the stats into a new
QuicFlowControlSnapshot.udp field.
QuicConnection.flowControlSnapshot:
- Now also surfaces advertisedMaxStreamsUni / advertisedMaxStreamsBidi
and peerInitiatedUniCount / peerInitiatedBidiCount so a sweep can
see the listener-side stream-cap evolution alongside speaker-side
counters that were already there.
SendTraceScenario:
- verbosePerFrame default flipped from true to false. The per-frame
`tx i=…` and `rx[idx] gid=…` logs go through InteropDebug ->
JUnit's stdout capture; at 50 frames/sec the capture thread
serialises the receive coroutine and starves the QUIC read loop,
biasing the recorded received count downward on long runs. Tests
that need the per-frame timeline opt in explicitly.
- Replace the receive-side CopyOnWriteArrayList sink (O(N) per add,
~1.1M element copies cumulative for a 1500-frame run) with
Collections.synchronizedList(ArrayList(capacityHint)) — O(1)
amortised. Snapshot under the same lock at end of run so the
JDK's iterator-locking contract is satisfied.
- fc-pre / fc-post-pump / fc-post-grace lines now include
`udpDatagrams=… udpBytes=… udpRcvBuf=…` plus
`advertisedMaxStreamsUni=… peerInitiatedUni=…` so the next sweep's
diagnostic dump shows directly whether the kernel actually
delivered datagrams the relay sent.
Plan doc: nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md
documents round-2 changes and lists the remaining open hypotheses
(relay-side per-subscriber queue policy, receive-side
MAX_STREAM_DATA threshold).
All :quic + :nestsClient JVM tests pass.
Adds a read-only diagnostic surface that lets a test (or any caller)
read the peer's transport parameters, the live connection-level send
credit / consumed counters, the current peer-granted MAX_STREAMS_*
values, and the total bytes sitting in stream send buffers but not
yet handed to STREAM frames.
Goal: pin which budget runs out at the production "stream cliff"
described in nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md.
The plan flagged three candidates — connection-level MAX_DATA, per-
stream MAX_STREAM_DATA, or the relay's MAX_STREAMS_UNI extension
policy. The snapshot makes it possible to attribute the stall by
reading the diff between the pre-pump, post-pump, and post-grace
snapshots from the test's stdout.
QuicConnection:
- flowControlSnapshot(): suspend, lock-protected, returns
QuicFlowControlSnapshot (new data class) — peer TPs + live
accounting + sum of enqueued-not-sent bytes across streams.
QuicWebTransportSession (jvmAndroid adapter):
- quicFlowControlSnapshot() passthrough so the test can downcast
its WebTransportSession and read the underlying connection's
state without poking through the common transport interface.
SendTraceScenario:
- Optional flowControlSnapshot lambda parameter; when supplied,
logs three checkpoints — fc-pre, fc-post-pump, fc-post-grace —
each on a single line with the full snapshot.
NostrnestsProdAudioTransmissionTest + NostrNestsSustainedSendOutcomesInteropTest:
- withProdSpeakerAndListeners / withHarnessSpeakerAndListeners now
yield a snapshot lambda to the scenario block. Every sweep test
automatically dumps fc-* lines to the JUnit XML system-out.
FlowControlSnapshotTest:
- Pre-handshake: peer TP fields are null, counters zero.
- Post-handshake: every TP field reflects what the in-process TLS
server advertised; sendConnectionFlowCredit equals
initial_max_data; consumed = 0.
- Post-allocate-and-enqueue: nextLocalUni/BidiIndex advance,
totalEnqueuedNotSentBytes sums the buffered chunks, and
streamsWithPendingBytes counts only streams with > 0 pending.
Reading the production sweep output after this commit:
- fc-pre dumps what the relay grants on handshake (initial_max_data,
initial_max_stream_data_uni, initial_max_streams_uni).
- fc-post-pump shows whether sendConnectionFlowConsumed has
plateaued at the cap (peer didn't extend MAX_DATA) or whether
bytes are stuck in stream send buffers.
- The diff between fc-post-pump and fc-post-grace tells us
whether the relay's eventual MAX_DATA / MAX_STREAMS update did
or didn't arrive during the 30-60 s grace window.
Production sweep against nostrnests.com cliffed at exactly 99 frames received
across every multi-frame scenario (frames200/400, 30s, 120s, etc.) when the
broadcaster opened one QUIC uni stream per Opus frame. The investigation in
nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md walks through
why the obvious "raise the stream cap" hypothesis didn't pan out (publisher.send
was never returning false past the cap, the local QuicConnection was already
seeing the relay extend MAX_STREAMS_UNI dynamically).
The same sweep showed sweep_frames_per_group_5 / _20 / _all delivering 100/100
frames cleanly. The cliff scales with stream-creation rate, not byte volume or
total stream count — so packing N frames per group (one uni stream per N frames
instead of per frame) bypasses whatever the underlying limit is.
NestMoqLiteBroadcaster:
- Add framesPerGroup constructor param, default 5 (≈ 100 ms of audio per
moq-lite group). Stream-creation rate drops from 50/sec to 10/sec, well
below the production cliff threshold.
- Counter resets after each endGroup; trailing partial group flushes its
FIN on capture EOF so the listener doesn't sit on a half-open stream.
MoqLiteNestsSpeaker + connectNestsSpeaker:
- Plumb framesPerGroup through to the broadcaster. Default flows from
NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP so production callers
get the mitigation without code changes; tests can override to 1 to
keep their groupId-per-frame assertions.
Tests:
- All harness interop tests (RoundTrip, LateJoin, MultiPeer, Mute,
Unsubscribe, SpeakerClose, both Reconnecting variants) plus the prod
NostrnestsProdAudioTransmissionTest now pass framesPerGroup = 1
explicitly to preserve their per-frame group/object id assertions.
- All :quic and :nestsClient JVM tests pass.
Trade-off: a brand-new subscriber that joins mid-broadcast picks up at the
next group boundary. With 5 frames/group the late-join initial gap is up
to 100 ms (perceptually inaudible for live audio). The previous wire shape
gave at most 20 ms.
Investigation plan for the underlying QUIC cliff lives in
nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md. The
attempted-then-reverted suspend/STREAMS_BLOCKED commit is summarised
under "Attempt 1" in that doc.
The previous run pinned the production failure to "82/100 frames at 20 ms cadence
two-users, all 18 trailing groups lost; publisher.send returned true for every
frame". To narrow the cause the next run needs to vary one dimension at a time
and dump rich per-frame data, so we can attribute the loss to:
- QUIC stream-credit (RTT-bound) vs absolute count cap
- Per-stream-creation cost vs per-byte cost
- Listener-side buffer overflow vs relay-side drop
- Steady-state vs initial-burst behaviour
- Shared end-of-stream FIN flush race vs prod-only loss
SendTraceScenario.kt: shared per-frame instrumented pump driver. Records
publisher.send Boolean per frame, send latency, endGroup exceptions,
arrivals per subscriber with wall-clock timing, missing-objectId run
lengths, send-latency p50/p99/max + count of >1ms sends. Verbose logging
(InteropDebug.checkpoint) emits one line per tx and one per rx so the
JUnit XML system-out doubles as a timing trace.
Sweep methods (mirrored prod-mode and harness-mode):
- baseline 100×20ms (reproduce known cliff)
- cadence sweep: 5 / 10 / 40 / 80 / 200 ms (RTT-dependent?)
- burst (no cadence) 100 frames (max inflight)
- frame-count sweep: 50 / 200 / 400
- payload sweep: 1 KB / 4 KB / 16 KB
- frames-per-group: 5 / 20 / 100 (uni-stream vs bytes)
- late subscribe at frame 25, frame 50 (from-latest semantics)
- mid-pump 5 s pause after frame 50
- 2-, 3-subscriber fan-out
- 50 ms slow-consumer listener (per-subscription buffer overflow)
- long-run 30 s and 120 s (steady-state)
Each test method is a one-line Scenario literal; setup helpers
withProdSpeakerAndListeners / withHarnessSpeakerAndListeners build
publisher + N listeners, run the scenario, and tear down in order.
Smoke-tested against the local kixelated/moq reference relay: every
scenario except framesPerGroup=100 reproduces the same trailing-frame
artifact (received=99/100, missing=[99]) the original test surfaced.
framesPerGroup=100 receives every frame, confirming the local issue is
specifically per-uni-stream FIN flushing — independent of the production
82/100 cliff.
Mirror NostrnestsProdAudioTransmissionTest.sustained_per_frame_send_outcomes_two_users
but drive the local Docker-backed kixelated/moq reference relay via
NostrNestsHarness. Lets us reproduce the production "82/100" cliff
without nostrnests.com so we can attribute the loss to client code
(`:nestsClient` / `:quic`) vs. the production deployment.
Same per-frame instrumentation: bypass NestMoqLiteBroadcaster, call
publisher.send() / publisher.endGroup() directly, record the boolean
return per frame plus any endGroup exception, dump a per-frame
send/recv table on failure.
Test 4 showed a hard 82/100 cliff at 20 ms cadence two-users, all 18
trailing groups lost permanently. Root-cause-narrowing requires knowing
whether MoqLitePublisherHandle.send returned false (frame dropped at the
moq-lite layer due to inboundSubs.isEmpty() / publisherClosed) or true
(frame queued by moq-lite and lost downstream — uni-stream write error
swallowed by runCatching, QUIC flow control wedged, or relay drop).
The production NestMoqLiteBroadcaster wraps everything in
runCatching {…}.onFailure {…} and ignores the boolean, so frame loss is
structurally invisible to the app. This new test bypasses the broadcaster
and calls publisher.send / publisher.endGroup directly while mirroring
the auth + transport + session setup that connectNestsSpeaker performs.
Records sendOutcomes[i] and endGroupErrors[i] per frame; on failure
prints a per-frame send/recv table so we can see exactly when send
flips false (or whether all 100 sends report true and the loss is
strictly downstream of moq-lite).
Previously test 4 used .take(N).toList() inside withTimeoutOrNull, so a
timeout discarded every received frame and the only failure signal was
"only got partial audio". When the production run flagged frame loss,
we couldn't tell whether the stream stalled mid-flight, never started
fanning out, or dropped scattered groups.
Drain into an external CopyOnWriteArrayList via .onEach { } so the
partial contents survive the timeout, then on failure surface:
- received N/100 with first/last arrival timestamps
- missingGroups summary as run-length ranges (e.g. [3-7,42,90-99])
- first/last 20 arrivals so the front and tail of the sequence are
visible without flooding stdout
The gap pattern alone narrows the diagnosis: front-loaded losses point
to subscribe-settle being too short, tail-loaded losses point to a
broadcaster shutdown race, scattered drops point to per-subscription
buffer overflow / datagram loss.
Four progressively-narrower JVM tests that drive the production
connectNestsSpeaker / connectNestsListener / OkHttpNestsClient /
QuicWebTransportFactory / NestMoqLiteBroadcaster code paths against
the real moq.nostrnests.com + moq-auth.nostrnests.com infrastructure
(no Docker harness, no Nostr events) so we can isolate why audio is
not flowing between two real users:
1. auth_minting_works_for_publish_and_listen_paths
2. same_user_speaker_and_listener_round_trip
3. two_users_speaker_publishes_listener_subscribes
4. sustained_real_time_cadence_two_users (~2s @ 20ms cadence)
Skipped by default; opt in with -DnestsProd=true and (optionally)
override URLs via -DnestsProdEndpoint=... / -DnestsProdAuth=...
Lets a clustered burst of mint calls (typical interop test scenario,
also any moq-auth deployment with stricter rate limits) outlast the
60 s rate-limit window instead of cascading into a hard failure.
- Retry up to 7 times on HTTP 429. Worst-case total wait at 1 s
initial + 16 s cap is 1+2+4+8+16+16+16 = 63 s — just past the
reference moq-auth 60 s/IP window.
- Respect the `Retry-After` header (delta-seconds OR HTTP-date)
when present; fall back to capped exponential backoff otherwise.
- Re-sign the NIP-98 auth event on every attempt. The reference
validator accepts a 60 s validity window; without re-signing,
any retry that waits past that window fails 401 "Event too old".
- Suspending `delay` so coroutine cancellation tears the loop down
cleanly.
`./gradlew :nestsClient:jvmTest -DnestsInterop=true -DnestsInteropExternal=true`
now goes green in a single invocation: 157 tests across 33 classes,
0 failures. Previously the same command cascaded 11 failures once
the moq-auth 20/min/IP bucket filled.
Unit tests: 7 new cases covering Retry-After parsing (seconds,
HTTP-date, garbage, missing), exponential cap, the 429-then-200
retry loop, max-retries exhaustion, and that non-429 4xx is NOT
retried. Backed by a tiny ServerSocket-based HTTP fake so no new
test deps.
To make a SubscribeHandle survive a publisher session swap on the same
relay, three things had to change together:
1. Broadcaster emits one Opus frame per moq-lite group
(`publisher.send` + `publisher.endGroup`). moq-lite's "from-latest"
subscribe semantics deliver a new subscriber the NEXT group's
frames; without per-frame rotation a subscriber that attaches
mid-broadcast waits forever for the (single, never-ending) group
to end.
2. MoqLiteSession opens its announce-watch bidi synchronously before
the first subscribe, dispatches subscribe + announce frames over a
single long-running collector (varint type code hoisted outside
`collect`), and FINs the publisher's currentGroup when an inbound
subscribe bidi closes — so the next send opens a fresh group keyed
off the live subscriber instead of the recycled one.
3. ReconnectingNestsListener no longer breaks on terminal=Closed in
the orchestrator; the user-driven stop path goes through
`orchestrator.cancel()`, so any other Closed (peer-driven
transport close, half-broken session, publisher recycle) is a
reconnect trigger.
Round-trip interop test updated to assert `groupId == idx` (one group
per frame) rather than `groupId == 0`. All three reconnecting-listener
interop scenarios now pass against the real moq-rs relay:
happy-path, session-swap, and listener-survives-publisher-recycle.
Two layered fixes for the gap captured in
nestsClient/plans/2026-04-28-listener-survives-publisher-recycle.md.
Session layer (MoqLiteSession.kt):
- Lazy single shared announce-watch pump per session, opened on
first subscribe. moq-lite Lite-03 has no explicit "publisher
gone" message on the subscribe bidi (the relay keeps that
bidi open across publisher cycles in case a fresh publisher
takes over the suffix), so the announce stream's Ended event
is the only reliable signal.
- On Announce(Ended) for a broadcast suffix, close the
matching ListenerSubscription's frames Channel and remove it
from the map. The wrapper-level frames.consumeAsFlow() flow
ends naturally — same shape as a user-driven
handle.unsubscribe() — so the wrapper pump's collect-
completion path drives the re-issue.
Wrapper layer (ReconnectingNestsListener.kt):
- Inner re-subscribe `while (currentCoroutineContext().isActive)`
loop in reissuingSubscribe. When the underlying frames flow
completes (publisher cycled, signalled by the session layer
above), re-issue subscribe against the same listener with a
100 ms backoff. moq-lite supports subscribe-before-announce so
a re-subscribe issued during the gap attaches cleanly when
the next publisher comes up under the same suffix.
Verified against the real moq-rs relay (host build, external
mode): the new
NostrNestsReconnectingListenerInteropTest.subscribe_handle_survives_publisher_recycle
test passes — single SubscribeHandle keeps emitting frames across
multiple speaker JWT-refresh cycles. Speaker reconnect tests
still pass too.
In production, this closes the audio dropout that would have
fired every 9 minutes per speaker JWT refresh on long Nest calls
(see the plan doc for the original diagnosis).
https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
The first cut of the JWT-refresh interop test held a single listener-
side SubscribeHandle open across the speaker's session recycle and
asserted both pre- and post-recycle frames arrived on it. That fails
because moq-lite's relay doesn't auto-route a vanilla SubscribeHandle
to a fresh publisher session under the same suffix — the
listener-survival-across-publisher-recycle is a separate concern,
NOT a speaker-reconnect bug. Verified against a real moq-rs relay
(host-built, external mode, --auth-key-dir + per-kid JWK file).
Reworked to validate just what the speaker reconnect should
guarantee:
- Phase 1: pre-refresh frames round-trip on the first session.
- Phase 2: orchestrator recycles (openCount → 2+, wrapper state
reaches Broadcasting on the new session).
- Phase 3: post-refresh frames round-trip on a FRESH listener
subscription against the new session.
- Wrapper invariant: outward state never surfaced
Reconnecting / Failed during the recycle.
Both speaker interop tests pass against the real relay.
The "listener subscription survives publisher recycle" gap is a
real production concern for >9-min stage time but lives outside
this PR — it would need either listener-side announce-watching or
a coupled refresh-on-publisher-cycle hook in the listener wrapper.
https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
Two ship-readiness items.
1. Status: CLOSED handler. The NestActivityBody never observed kind-30312
status flips, so when a host ended the room every other listener and
speaker stayed connected to the relay until they manually backed out
or the JWT expired — silent room with stale "you're in" UI. New
LeaveOnRoomClosed composable in NestRoomLifecycle.kt watches
event.isLive() and calls onLeave() when the live event flips to
CLOSED (or hits the 8 h auto-close cutoff in MeetingSpaceEvent.
checkStatus). Same teardown path as kick — VM.onCleared() releases
the listener + speaker when the activity finishes.
2. Speaker-reconnect interop test against the real nostrnests stack,
mirroring NostrNestsReconnectingListenerInteropTest. Two cases:
- Happy path: wrapper drives a single real session, frames round-trip.
- Forced JWT refresh (4 s window): orchestrator recycles the
underlying speaker mid-stream; frames pre- AND post-recycle must
all land on the same listener-side SubscribeHandle. Validates the
production 540 s ↔ 600 s JWT-TTL relationship against the real
moq-rs relay. Gated by -DnestsInterop=true.
https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
Two new opt-in interop cases drive the production
connectReconnectingNestsListener against the real nostrnests
relay (set -DnestsInterop=true to run):
* reconnecting_wrapper_round_trips_frames_via_real_relay —
happy-path validation that the new MutableSharedFlow-backed
pump doesn't drop frames against real moq-lite framing.
* reconnecting_wrapper_keeps_handle_alive_across_session_swap —
custom connector opens two real sessions; we close the first
mid-stream, observe the orchestrator's automatic reconnect,
and confirm that frames published after the swap arrive on
the SAME consumer-facing SubscribeHandle. Real-wire version
of the in-process test added in the previous commit.
Speaker pushes 4 frames, then calls broadcast.close() + speaker.close().
Listener should receive the 4 pushed frames — and currently DOES NOT
auto-terminate its objects flow when the speaker disconnects, because
the relay's `subscribed complete` signal isn't propagated to a
Channel.close() on our listener side. UI code is expected to drive
its own teardown.
The test pins that current behaviour: it asserts the flow stays open
within a 5s window after speaker.close(). If a future change wires
the relay's done-signal through to a clean flow completion (the more
user-friendly behaviour), this test will fail loudly and ask you to
flip the assertion + rename the test —
`speaker_close_terminates_listener_flow_cleanly`.
Documents a known gap rather than asserting an aspirational contract,
so we don't paper over the missing path with a "happy when listener
unilaterally closes" test.
One speaker, two listeners A and B. Push 3 frames (both receive), A
unsubscribes, push 3 more frames, B's stream completes with all 6.
If A's unsubscribe accidentally tore down the speaker's broadcast or
B's subscribe (e.g. an over-eager session-wide cleanup in a future
refactor), B would time out waiting for the second batch — and the
test names that exact failure mode in the message.
Pin moq-lite-03's "from latest" subscribe semantics: a listener that
joins after the speaker has been broadcasting for a while sees only
new frames, not the pre-subscribe history.
Phase 1: speaker pushes 5 early frames (bytes 0..4) with no listener
attached. The relay does not buffer these in moq-lite-03.
Phase 2: late listener subscribes.
Phase 3: speaker pushes 5 more frames (bytes 100..104). The listener
takes the first 5 frames it sees and we assert ALL of them carry
bytes >= 100 — any contamination from phase 1 fails loudly with
the offending bytes named.
This pins the no-replay guarantee against a future regression to
"buffer-and-replay" behaviour, which would change recovery latency
characteristics for users joining mid-stream.
Verifies the broadcaster's mute path through a real moq-relay:
- push two unmuted frames -> listener receives them
- setMuted(true), push two muted frames -> listener never sees them
- setMuted(false), push two more unmuted frames -> listener resumes
Asserts the listener's flow contains exactly [0, 1, 2, 3] in order
(the muted 50, 51 frames must be absent, not silently filled with
zeros). This pins `if (muted) continue` in
AudioRoomMoqLiteBroadcaster against an accidental "send a silent
placeholder while muted" regression.
Also factors the previously-private DriverCapture / StubEncoder
helpers into a shared InteropFrameDriver.kt so the new test can
reuse them without copy-pasting per file.
The previous test
`listener_subscribed_before_announce_receives_late_frames` validated a
contract from older moq-rs versions: the relay would HOLD a SUBSCRIBE
issued before any publisher announced, then resolve it once a publisher
arrived. moq-lite-03 dropped that behaviour — the relay now rejects
immediately with `subscribed error err=not found` and FINs the bidi
without writing a SubscribeDrop body, which our session reader surfaces
as `MoqProtocolException: subscribe stream FIN before reply`.
Rename the test to
`subscribe_before_announce_fails_with_not_found` and flip the
assertion to pin the new contract. Acceptance message check is
permissive: matches the current "FIN before reply" form AND a
hypothetical future "not found" SubscribeDrop, so a relay change to
explicitly Drop wouldn't silently regress this test.
The class-level coverage doc loses the obsolete
"subscribe-before-announce holds" claim and gains a note on why
the test now flips.
Verified end-to-end against moq-relay 0.10.25 + moq-auth running
bare-metal (-DnestsInteropExternal=true): all five interop test
classes (Auth, AuthFailure, AuthEndpoints, RoundTrip, MultiPeer)
now pass — 13/13 cases green.
Without `wt-available-protocols`, moq-relay (`web-transport-quinn`) falls
back to the legacy in-band SETUP exchange (moq-lite-02) instead of
selecting the moq-lite-03 sub-protocol from the ALPN-style negotiation
header. Then the relay tries to decode our first post-CONNECT bytes as a
SETUP_CLIENT message, hits an unknown control type, and closes the QUIC
connection with `connection closed err=invalid value` — surfaced
client-side as a stuck SUBSCRIBE that ends with
`subscribe stream FIN before reply for id=0` (the bidi gets FIN'd because
the whole connection is being torn down).
Pass `wt-available-protocols: "moq-lite-03"` on the Extended CONNECT
request, encoded as an RFC 8941 Structured Field List of strings (the
header format mandated by draft-ietf-webtrans-http3-14 §3.3). With this,
moq-relay logs `negotiated version=moq-lite-03 transport="quic"` and the
SUBSCRIBE makes it into the relay's actual moq-lite session pump.
Mechanism: web-transport-proto's `ConnectRequest::encode` reads
`self.protocols` and writes them as a comma-separated list of bare
strings under `wt-available-protocols`. The server side (web-transport-
quinn) reads the same header into `request.protocols`, and moq-native's
`QuinnRequest::ok()` picks the first match against its supported ALPN
list (`moq-lite-04`, `moq-lite-03`, `moq-00`, `moqt-15`, etc.). On
match, version selection happens via the WT sub-protocol response and
the in-band SETUP is skipped — which is what moq-lite-03 expects.
Default the factory list to `["moq-lite-03"]`. Callers that want a
different version (or to disable sub-protocol negotiation entirely
to talk to a SETUP-based server) override the constructor parameter.
Bare-metal harness: NostrNestsHarness.startExternal() now skips the
TCP probe of the moq-relay port. moq-relay binds UDP only; the Docker
forwarder happens to also open TCP, but a directly-launched binary
doesn't, so the previous `Socket(host, 4443)` probe failed with
ConnectException. The QUIC handshake from the test surfaces a real
transport problem if any.
Adds a "bring your own stack" path to NostrNestsHarness so the interop
tests can run without a Docker daemon. With `-DnestsInteropExternal=true`:
- Skip the `docker compose up` that builds + boots
moq-auth + moq-relay
- Port-probe + /health-check the same 8090 / 4443 endpoints the
Docker path uses
- close() is a no-op — the caller owns the lifecycle
Useful in two situations:
1. Sandboxes / restricted CI without a Docker daemon (just run
`cargo install moq-relay` + `node moq-auth/dist/index.js`
yourself, then run gradle with the flag)
2. Fast iteration — the Docker path takes ~30 s to compile
moq-relay on first run; with this, you keep both processes
alive across many test invocations
Forward `nestsInteropExternal` (and `nestsInteropDebug`,
`nestsInteropMoqRev`) through the Test task so the property reaches
test workers; without that, the gate stays off in the worker JVM.
Also: `assertSpeakerReached` / `assertListenerReached` now log a "✘"
checkpoint with the rich state description before calling fail().
JUnit captures the assertion message in a separate section, but the
"standard output" tab is what most people read first when scanning
for a cause — so the chained-cause string now lands in both places.
When an interop test fails today the report shows a generic
AssertionError pointing at a `runBlocking {` line — useless for
narrowing down which sub-step (mintToken, WT connect, ANNOUNCE,
SUBSCRIBE_OK, frame round-trip) actually exploded.
This adds a small InteropDebug helper that:
- prints a labelled "▶ start" / "✔ ok" / "✘ fail — Class: msg ⟵ Cause: msg"
trail per step (output gated on `-DnestsInterop=true` so the
default test run stays silent)
- walks chained `cause` so the surface message reveals the real
network / protocol error instead of the wrapping string
- pretty-prints NestsSpeakerState / NestsListenerState (Failed in
particular unwraps `cause` for the report)
Each test body in NostrNestsRoundTripInteropTest +
NostrNestsMultiPeerInteropTest now wraps connect / startBroadcasting /
subscribeSpeaker / await-frames in `InteropDebug.stepSuspending(...)`
so a failure points at the exact sub-step rather than the test method.
Harness diagnostics: when `start()` fails, capture
`docker compose ps` + recent logs for moq-auth / moq-relay / strfry
into the IllegalStateException message before tearing the stack down.
Without this, the test report only shows "exited with code 1" —
operators have to re-run by hand to find out which container
crashed.
Output is hidden by default; only surfaces when
`-DnestsInterop=true` (or the explicit `-DnestsInteropDebug=true`)
is set.
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)
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.
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.
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.
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.
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.
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.
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.
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
Replace the Kwik stub in :nestsClient with a pure-Kotlin QUIC + WebTransport
adapter built on top of :quic.
- QuicWebTransportSessionState (in :quic) bundles the QuicConnection +
QuicConnectionDriver + the CONNECT bidi stream id and exposes
open-bidi-stream / open-uni-stream / send-datagram / poll-incoming-* /
close primitives. Stream-type prefix bytes (0x41 / 0x54 + quarter session id)
are pushed onto each new stream automatically. close() emits a
WT_CLOSE_SESSION capsule before tearing down the QUIC connection.
- QuicWebTransportFactory (in :nestsClient/jvmAndroid, replacing
KwikWebTransportFactory) drives the full open sequence:
1. UDP connect + QuicConnection.start
2. wait until handshake completes (Status.CONNECTED)
3. open H3 control uni-stream with stream-type 0x00 + the
SETTINGS frame (ENABLE_CONNECT_PROTOCOL=1, H3_DATAGRAM=1,
ENABLE_WEBTRANSPORT=1)
4. open the Extended CONNECT bidi: HEADERS frame carrying
:method=CONNECT, :protocol=webtransport, :scheme=https,
:authority, :path, optional Authorization: Bearer
5. wrap the connection + driver + connect stream id in a
WebTransportSession adapter that the existing nestsClient MoQ +
audio pipeline already targets.
- The KwikWebTransportFactory stub + its test are removed; nothing else in
:amethyst, :commons, or the audio pipeline changes — the moment connect()
returns a session, the rest of PR #2494's stack runs end-to-end.
- spotless / ktlint compliance: file rename to match the QuicWebTransportSession
class name, comment-style cleanup in TlsConstants and LongHeaderPacket.
Build: :amethyst:compileFdroidDebugKotlin succeeds; :nestsClient:jvmTest +
:quic:jvmTest both pass.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Phase 3b-1 of the Clubhouse/nests integration. Lands the
[WebTransportSession] abstraction the MoQ layer (Phase 3c) will code
against, so MoQ framing + tests can develop in parallel with the real
Kwik-based transport integration (deferred to Phase 3b-2).
commonMain:
- `WebTransportSession` — bidi/uni stream access, datagrams, close.
- `WebTransportBidiStream` / `WebTransportReadStream` /
`WebTransportWriteStream` — minimal read/write surface.
- `WebTransportFactory.connect(authority, path, bearerToken)` for
opening sessions.
- `WebTransportException(kind, ...)` with four canonical failure modes
(HandshakeFailed, ConnectRejected, PeerClosed, NotImplemented) so UI
code doesn't need to know about library-specific exceptions.
- `FakeWebTransport.pair()` — in-memory, fully-wired client/server
pair for unit-testing MoQ framing without touching a real QUIC stack.
jvmAndroid:
- `KwikWebTransportFactory` stub that throws
`WebTransportException(NotImplemented)` at `connect()`. Doc spells
out the handshake sequence (QUIC dial → H3 SETTINGS →
`:method=CONNECT :protocol=webtransport` Extended CONNECT → 2xx) so
Phase 3b-2 can drop the real implementation in without touching
callers.
Tests:
- `FakeWebTransportTest` — datagram round-trip both directions,
bidi-stream write visible on peer side, close() flips isOpen.
- `WebTransportExceptionTest` — kind + message + cause preserved.
- `KwikWebTransportFactoryTest` — `connect()` currently fails with the
NotImplemented sentinel, so Phase 3b-2 can replace this test when
the real handshake lands.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Phase 3a of the Clubhouse/nests integration. Adds a new KMP module
`nestsClient` (Android + JVM targets) that owns the HTTP control plane
for talking to a nests audio-room backend. No transport/audio yet —
that arrives in 3b with WebTransport + MoQ.
Surface:
- `NestsAuth.header(signer, url, method)` signs a kind 27235 event via
Quartz's existing HTTPAuthorizationEvent and returns a ready-to-use
`Authorization: Nostr <base64>` header value.
- `NestsRoomInfo` data class + tolerant JSON parser (ignores unknown
fields so newer nests server revisions don't break older clients).
- `NestsClient.resolveRoom(serviceBase, roomId, signer)` calls
`<serviceBase>/<roomId>` with the signed NIP-98 header and returns
the MoQ endpoint + token the audio layer will need.
- `OkHttpNestsClient` (jvmAndroid) is the default implementation
shared between Android and desktop JVM.
Tests:
- `NestsRoomInfoTest` (commonTest) covers full/minimal payloads,
unknown-field tolerance, missing-endpoint rejection, and URL
construction edge cases.
- `NestsAuthTest` (jvmTest) signs a real 27235 event, decodes the
base64 back through Quartz's JacksonMapper, and asserts the
signature verifies and the url+method tags bind correctly.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D