Commit Graph

94 Commits

Author SHA1 Message Date
Claude 2da1885975 Add diagnostic sweep matrix across cadence, frame count, payload, group packing, fan-out
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.
2026-05-01 02:42:41 +00:00
Claude a97f494735 Reproduce per-frame send-outcome trace against the local moq-relay
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.
2026-05-01 02:17:48 +00:00
Claude d143053796 Add per-frame send-outcome trace to isolate moq-lite vs downstream loss
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).
2026-05-01 02:13:27 +00:00
Claude 7e3d622399 Sustained-cadence test: dump partial arrivals + gap pattern
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.
2026-05-01 02:05:40 +00:00
Claude 9f1b32f40d Add prod nostrnests.com audio transmission diagnostic tests
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=...
2026-05-01 01:08:14 +00:00
Claude dd9a530305 fix(nests): publish handles list under AtomicInteger barrier in speaker test
ScriptedSpeaker mutated `handles` AFTER incrementing `_startCount`,
so a reader that polled startCount on a different thread (the test
runs on the runBlocking thread while the broadcast pump runs on
Dispatchers.Default) could see startCount==1 via the volatile
AtomicInteger read before the subsequent list write became visible,
then fail `assertEquals(1, first.handles.size)` with a stale 0.

Also `mutableListOf` itself is not thread-safe — concurrent reads
during a write are undefined.

Reorder so the list append happens BEFORE the increment (the
volatile write now publishes the list mutation under the same
happens-before edge tests already rely on for startCount), and
swap the backing store for CopyOnWriteArrayList so concurrent
reads of `handles[0]` are well-defined.

Observed as a flake on Ubuntu CI; passes locally.
2026-04-30 14:34:53 +00:00
Claude 19b534a9e2 fix(nestsClient): register inbound moq-lite subscription before sending Ok
The publisher's inbound-bidi handler wrote SubscribeOk to the bidi
before calling registerInboundSubscription. The peer's first
publisher.send() after observing Ok could race the registration on
dispatchers that resume the peer's continuation before the handler's
(notably Windows under Dispatchers.Default), causing send to observe
an empty inboundSubs and return false. Reordering makes the peer's
view of Ok a happens-after of the registration.

Fixes the Windows-only failure in
MoqLiteSessionTest.publisher_acks_subscribe_and_pushes_group_data_on_uni_stream.
2026-04-29 22:14:23 +00:00
Claude aadb347b84 feat(nests): adopt deployed nostrnests schema (streaming/auth/live + paired 10112)
The deployed nostrnests reference (NestsUI v2 + moq-auth + moq-rs)
emits a different on-the-wire schema than the previous EGG-01 / EGG-09
drafts and Quartz writers. Verified by reading the production
NestsUI bundle. The deployed schema is now canonical:

kind:30312 (room event)
  - relay URL → ["streaming", url]   (was ["endpoint", url])
  - auth URL  → ["auth",      url]   (was ["service",  url])
  - live status → ["status", "live"]   (was "open")
  - ended status → ["status", "ended"] (was "closed")
  - room name → ["title", name]        (was ["room", name])

kind:10112 (user MoQ-server list)
  - 3-element entries: ["server", relay, auth]
  - first-element name "relay" accepted as a synonym (legacy)
  - 2-element entries: derive auth host by replacing leading "moq."
    with "moq-auth.", or prepending "moq-auth." otherwise

Implementation
- ServiceUrlTag.TAG_NAME = "auth", LEGACY_TAG_NAME = "service"
- EndpointUrlTag.TAG_NAME = "streaming", LEGACY_TAG_NAME = "endpoint"
- StatusTag enum: PLANNED/LIVE/PRIVATE/ENDED with code "live"/"ended";
  legacy "open"/"closed" still parsed on read
- NestsServersEvent: emit/read 3-element [server, relay, auth] tags
  with the legacy-shape tolerances above; expose a NestsServer pair
- Account.nestsServers.flow now produces List<NestsServer> pairs
- NestsServersScreen: rewritten edit-field asks for both URLs,
  recommended row shows the nostrnests pair, list rows show both URLs
- NestsScreen first-time setup writes the nostrnests pair, not a
  single URL; gate the create FAB on both URLs being parseable
- CreateNestViewModel: drop the resolveServerPair hardcoded mapping —
  the saved pair is now authoritative; defaults stay correct for an
  empty kind-10112 list

Specs
- EGG-01 rewritten to canonical streaming/auth/live/ended with a
  legacy-spelling table; example uses the real nostrnests pair
- EGG-02 references "auth" tag throughout; error taxonomy says "ended"
- EGG-09 rewritten to 3-element server tag with derivation fallback
- New nestsClient/specs/nip-53-proposed.md — a single self-contained
  proposed update to upstream NIP-53 covering kind 30312 + kind 10112
  with the deployed schema and the JWT-mint flow

All existing unit tests adjusted; quartz:jvmTest, amethyst:testPlayDebugUnitTest,
and nestsClient:jvmTest pass.
2026-04-29 19:32:53 +00:00
Claude 099da6e7b0 test(nests): fix flaky ReconnectingNestsListenerTest on macOS
`subscribeSpeaker_survives_session_swap` and
`unsubscribe_before_session_swap_releases_handle` had two race
conditions exposed only on macOS scheduling:

1. ScriptedListener.subscribeCount is incremented inside
   opener(listener) — BEFORE the wrapper's pump reaches
   `handle.objects.collect { frames.emit(it) }`. Waiting on
   subscribeCount alone doesn't prove the pump is collecting
   from first.frames. An emit upstream before the pump's collect
   registers is silently dropped.

2. The wrapper's outer `frames` SharedFlow is replay=0. The
   `scope.async { take(2).toList() }` consumer might not have
   subscribed by the time the test thread races into the first
   emit, dropping the value.

Both are fixed by waiting for actual subscription registration:
- first/second.frames.subscriptionCount.first { it > 0 } — flips
  to 1 only after the pump's collect lands, which is strictly
  after liveHandleRef.set(handle).
- onSubscription + CompletableDeferred — fires after the
  consumer's collector is registered against the SharedFlow.
2026-04-29 03:16:23 +00:00
Vitor Pamplona 86b1b02211 Last test to fix 2026-04-28 20:23:13 -04:00
Vitor Pamplona de773d06f9 Fixes test cases 2026-04-28 17:54:29 -04:00
Vitor Pamplona 17054fc35a Links the Tor okhttpclient with nests 2026-04-28 14:45:14 -04:00
Vitor Pamplona 98e62b167b Merge pull request #2621 from vitorpamplona/claude/review-nostr-nests-compliance-hKBnS
feat(nests): proactive JWT refresh + reconnect for speaker path
2026-04-28 13:56:06 -04:00
Claude cee9d8a430 feat(nests): retry 429 with backoff + re-sign NIP-98 on each retry
Lets a clustered burst of mint calls (typical interop test scenario,
also any moq-auth deployment with stricter rate limits) outlast the
60 s rate-limit window instead of cascading into a hard failure.

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

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

Unit tests: 7 new cases covering Retry-After parsing (seconds,
HTTP-date, garbage, missing), exponential cap, the 429-then-200
retry loop, max-retries exhaustion, and that non-429 4xx is NOT
retried. Backed by a tiny ServerSocket-based HTTP fake so no new
test deps.
2026-04-28 17:34:22 +00:00
Claude 461147af81 docs(nests): update plan doc for round-2 listener survival fixes
Captures the group-rotation, orchestrator-break-on-Closed removal,
ensureAnnounceWatchStarted, handleInboundBidi refactor, and
removeInboundSubscription changes that landed in d8ab4fd9. All
three reconnecting-listener interop scenarios now pass.
2026-04-28 17:23:10 +00:00
Claude d8ab4fd99b fix(nests): rotate moq-lite groups + reconnect after publisher cycle
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.
2026-04-28 17:06:09 +00:00
Claude 5f71dc7c76 test(nests): fix nextSubscribeBidi helper to terminate after match
The takeWhile/collect pattern only re-checks its predicate when the
NEXT upstream value emits, so once nextSubscribeBidi found its
target Subscribe bidi, the helper sat blocked waiting for a
follow-up bidi that may never come — turning groups_are_demuxed_by_
subscribeId into a 5-minute hang on tests that open exactly two
subscribes (the announce-watch bidi happened to land between, but
relying on it to nudge the flow forward is fragile).

Switched to transformWhile { … emit(…); false }.firstOrNull() so
the upstream collection terminates synchronously after the first
match. Warm-daemon test time drops from multi-minute timeout back
to the expected ~10 ms range.

https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
2026-04-28 16:22:01 +00:00
Claude cd9279d23b test(nests): skip housekeeping bidi in groups_are_demuxed_by_subscribeId
The session-layer fix in 851045c6 lazy-launches a single shared
announce-watch bidi on first subscribe (publisher-disconnect
detection). The unit test groups_are_demuxed_by_subscribeId
assumed each session.subscribe() opened exactly one peer-side bidi
and grabbed them by raw `peerOpenedBidiStreams().first()`, which
races with the announce-watch bidi.

New helper nextSubscribeBidi(serverSide) iterates accepted bidis,
peeks the control-byte varint, and skips any that aren't Subscribe.
The race window (announce-watch bidi between subscribe #1 and
subscribe #2) is now handled gracefully — the test reliably
finds both subscribe bidis regardless of the announce-watch's
launch ordering.

Also extends the listener-survives-publisher-recycle plan doc
with the full diagnosis of why
reconnecting_wrapper_keeps_handle_alive_across_session_swap
remains a separate, narrower failure (publisher single-group
architecture: NestMoqLiteBroadcaster never rotates groups, so
mid-stream listener resubscribes have nothing to attach to).

https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
2026-04-28 16:03:11 +00:00
Claude 851045c654 fix(nests): listener subscriptions survive 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
2026-04-28 15:06:07 +00:00
Claude 1c8c961762 docs(nests): plan for listener-survives-publisher-recycle gap
Discovered while validating connectReconnectingNestsSpeaker against
the real moq-rs relay: when a publisher cycles its session (e.g. via
JWT refresh), an existing listener-side SubscribeHandle does not
auto-reattach to the new publisher under the same broadcast suffix.
Frames stop until the listener's own JWT refresh fires.

Root cause is multi-layer:
  - moq-lite session: SubscribeHandle.frames is a
    Channel.consumeAsFlow() that the session never closes on remote
    disconnect.
  - moq-lite Lite-03: graceful close emits Announce(Ended), which
    the wrapper would need to observe to react.
  - ReconnectingNestsListener: reissuingSubscribe only re-issues on
    listener-session swaps, not on announce-Ended.

A wrapper-layer announce-driven re-subscribe was attempted (race
collect vs announce-Ended.first(), cancel loser, unsubscribe, retry).
It compiled and the unit tests passed, but interop against the real
relay still showed listener silence after the recycle — the listener's
QUIC session terminated 4 ms after the publisher's "subscribe
cancelled" log line, suggesting the announce-bidi cleanup or
sub-unsubscribe path is being interpreted as session close at the
moq-lite layer. Did not pin down the exact chain.

Reverted the wrapper change to keep the branch clean. Speaker-side
connectReconnectingNestsSpeaker is unaffected and continues to pass
its interop tests. Plan doc captures the diagnosis and three
candidate fix paths for follow-up — preferred direction is
session-layer (have MoqLiteSession close the frames Channel when the
relay forwards Announce(Ended) or FINs the subscribe bidi) so the
existing wrapper pump's natural collect-completion handles it.

https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
2026-04-28 13:29:10 +00:00
Claude 52a506dd92 test(nests): scope speaker-refresh interop to speaker invariants only
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
2026-04-28 13:02:29 +00:00
Claude 138ee12a6a fix(nests): align kind-4312 + kind-30312 wire format with nostrnests/EGG-07
After verifying the nostrnests reference (NestsUI-v2 @ main):
- ProfileCard.tsx writes kicks as ['action','kick'] tags with empty content
- useAdminCommands.ts reads action via tags.find(t => t==='action') and
  applies a 60-s relay since plus a processedRef Set to dedup re-deliveries
- p-tag role marker for moderators is 'admin', not 'moderator'

Amethyst was diverging on every one of those, which means our outbound
admin commands were invisible to nostrnests, theirs to us, and any
nostrnests admin (role='admin') failed our isModerator() / canSpeak()
gates entirely — kicks and force-mutes signed by them were silently
dropped.

Changes:

quartz/AdminCommandEvent.kt
  - Emit ['action', '<verb>'] tag with empty content
  - Reader prefers the tag, falls back to content for any in-flight
    Amethyst-built kick from before this commit
  - kick() and forceMute() share a common build() helper

quartz/ParticipantTag.kt
  - ROLE.MODERATOR.code = 'admin' (matches nostrnests + EGG-07)
  - Adds legacyCodes = ['moderator'] so older Amethyst-emitted
    kind-30312 events still parse as MODERATOR
  - effectiveRole() walks both code + legacyCodes

amethyst/AdminCommandsCollector
  - Filter carries since = now - 60 (EGG-07 #7)
  - Defensive per-event freshness re-check for cached events / clock skew
  - mutableSetOf<String>() processed-id dedup for the lifetime of the
    collector, mirroring useAdminCommands.ts's processedRef

EGG-07.md
  - Documents the Amethyst-only ['action','mute'] extension under a new
    'Implemented extensions' section. nostrnests doesn't emit or honour
    it today; cross-client force-mutes only work between Amethyst peers
  - 'warn' stays in the future-actions list — nostrnests has no plans
    for it either

Tests:
  - ParticipantTagTest: new asserts that 'admin' / 'Admin' / 'ADMIN'
    parse as ROLE.MODERATOR; pins the wire string to 'admin' and the
    legacy alias to 'moderator'
  - AdminCommandEventTest: kick/forceMute templates carry ['action', _]
    tag with empty content; legacy content-form still parses; tag wins
    over content when both are present
2026-04-28 12:59:48 +00:00
Claude d41a24f945 feat(nests): empty-stage hint, local hush, moderator/force-mute moderation
Three improvements stacked into one commit since they share files:

#3 Empty-stage hint: StageGrid no longer disappears when nobody is on
   stage. The "Stage" label stays and a quiet "Waiting for speakers…"
   line keeps the strip visible so the room doesn't look broken before
   the first speaker arrives.

#5 Local per-speaker hush: AudioPlayer gains a setVolume(Float)
   default-no-op method; AudioTrackPlayer composes mute and volume
   multiplicatively into AudioTrack.setVolume so a hushed stream stays
   silent regardless of mute state. NestViewModel exposes
   locallyHushed: ImmutableSet<String> in NestUiState plus
   setLocalHushed(pubkey, hushed) — applied at attach time so a
   re-subscribe of an already-hushed speaker stays silent. New "Hush
   this speaker" / "Restore this speaker" row in
   ParticipantHostActionsSheet, available to anyone (it affects only
   our own playback, nothing on the wire).

#4 Host moderation gaps: wires Promote-to-Moderator using the existing
   RoomParticipantActions.setRole(ROLE.MODERATOR) builder — UI gap
   only, no protocol change. Adds a new AdminCommandEvent.Action.MUTE
   variant + AdminCommandEvent.forceMute(room, target) builder; the
   sheet emits it on "Force-mute speaker" and the
   AdminCommandsCollector dispatches incoming MUTE actions to a new
   NestViewModel.onForceMuted() that routes through the existing
   setMicMuted(true) path. Honor-based, same trust model as KICK —
   relays don't enforce signer authority, the client checks the
   signer is host or moderator on the active kind-30312.
2026-04-28 12:41:23 +00:00
Claude 04ee2c3106 feat(nests): pulse the speaker ring with live audio level
Adds an end-to-end audio-amplitude pipeline so on-stage speakers'
green ring throbs in time with their voice (closer to Spaces /
Clubhouse than the previous binary "is in speakingNow" indicator).

NestPlayer.play() now takes an onLevel callback and computes the
normalized peak of each decoded 16-bit PCM frame. NestViewModel
exposes audioLevels: StateFlow<Map<String, Float>>; raw 50 Hz
updates from the decode loop are coalesced into a 10 Hz publish
tick (LEVEL_TICK_MS) so the StateFlow doesn't spam recompositions
across a busy stage. The map is cleared on speaker close, on the
speaking-timeout sweep, and on teardown.

In MemberCell the speaking ring's width animates between
AVATAR_RING_WIDTH (3 dp) and MAX_RING_WIDTH (7 dp) via
animateDpAsState, smoothing the tick into a continuous halo.
Muted-publisher and idle states are unchanged.

Adds NestPlayerTest coverage for the new callback (level math +
empty-PCM short-circuit).
2026-04-28 12:03:13 +00:00
Claude 837bde6579 feat(nests): leave on host-ended room + speaker-reconnect interop test
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
2026-04-28 07:58:01 +00:00
Claude d37eb10b8c feat(nests): proactive JWT refresh + reconnect for speaker path
Mirror the listener's ReconnectingNestsListener for the publish side.
moq-auth issues 600 s bearer tokens; without proactive refresh, a user
holding the stage past 10 minutes silently drops when the relay tears
down the WebTransport session and stays off the air until they manually
re-tap Talk. The new wrapper recycles the session at 540 s so the relay
never sees an expired token, and re-issues publishing onto each fresh
session with the user's mute intent replayed on the new handle.

VM swap is a one-line change to DefaultNestsSpeakerConnector. The
caller-owned BroadcastHandle is now the wrapper's stable handle that
survives every refresh.

Six unit tests cover happy path, refresh-without-failure-state, mute
replay across recycle, close idempotence, first-attempt-failure
exception propagation, and post-close startBroadcasting guard.

https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
2026-04-28 03:17:57 +00:00
Claude dc3ac31ae4 refactor: rename Audio Room → Nest project-wide
Aligns class names, package paths, string resource keys, UI text and
intent actions with the Nests branding used by the EGG specs in
nestsClient/specs/. Mechanical rename — no behavior change.

- Folders: audiorooms/ → nests/ (5 paths across amethyst, quartz)
- 30+ class renames (AudioRoom* → Nest*, AudioRooms* → Nests*)
- String resource keys audio_room_* → nest_*
- UI strings "Audio Room"/"Audio Rooms" → "Nest"/"Nests" (incl. all locales)
- Intent extras AUDIO_ROOM_* → NEST_*
- Compose route Route.AudioRooms → Route.Nests

Spec-aligned identifiers (MeetingSpaceEvent, meetingSpaces/, the nip53*
packages) are intentionally untouched — those are NIP-53 protocol
names, not "audio room" branding.

https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
2026-04-27 14:36:50 +00:00
Claude fd03122206 docs(nestsClient/specs): close hosting-side ambiguities
Define the host-side flow that was previously implicit. A new-room
implementer can now go from "I want to start broadcasting" to first
audio frame without reading our source.

README — new "Hosting a new room" section:
- 8-step ASCII walkthrough symmetrical to "Joining sequence".
- Covers service/endpoint selection, kind:30312 compose + publish, JWT
  mint with publish:true, WT/Setup, Announce, presence, Opus stream.
- Lists ongoing host duties (add speaker, kick, edit, close, recording).

EGG-01 — two new behavior rules:
- Rule 12 "Publish-before-mint ordering": peers MUST publish the
  kind:30312 to relays BEFORE requesting a JWT for it, since the auth
  sidecar reads the most-recent event by (pubkey, kind, d) to validate
  existence / status. 410 unknown_room → retry 1s/2s/4s. Closes the
  silent footgun where a host could mint a token before their event
  has propagated.
- Rule 13 "Service/endpoint selection (host-side guidance)": pre-fill
  from kind:10112 first entry, fall back to client default with user
  override, both MUST be https://.

EGG-07 — new "Audio publish authorisation" section:
- Spells out who gets a publish:true JWT: host (by authorship,
  implicitly — no need for ["p", _, _, "speaker"] on the host's own
  p-tag), explicit "speaker" role, or "admin" role. All others 403
  publish_forbidden per EGG-02.
- Relay does NOT re-read kind:30312; demoting a speaker mid-session
  does NOT terminate their stream — host MUST kick (kind:4312) to
  end an active broadcast.

https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
2026-04-27 13:05:49 +00:00
Claude bd12d5ef72 docs(nestsClient/specs): close interop gaps surfaced in spec review
Edits across all 13 EGGs to remove implementer guesswork. After this an
implementer can build a listener and speaker without reading our source.

README:
- Conventions section: hex casing rule (lowercase, 64 chars, no 0x),
  NIP-01 foundations, `a`-tag form, created_at tie-break, JSON / time.
- Joining sequence: numbered end-to-end walkthrough from `kind:30312`
  to first audio frame, referencing each EGG.

EGG-01 (room event):
- `relays` tag is one tag with multiple values (not multi-tag).
- `service` URL trailing-slash normalization.
- `private` status: gate is implementation-defined, not "render as open".
- `d`-tag charset locked to [A-Za-z0-9._-] so it interpolates safely
  into the moq-auth namespace.
- Relay-discovery rule: publish to `relays` tag ∪ NIP-65 outbox.
- Tie-break on identical created_at via smallest event id.

EGG-02 (auth):
- JWT signing pinned: ES256 over P-256, JWKS at /.well-known/jwks.json,
  5-minute relay cache.
- NIP-98 tags pinned: u / method / payload, base64 RFC 4648 standard
  (not base64url).
- Error taxonomy: full HTTP status + `error` slug table for /auth, plus
  WebTransport CONNECT 200/401/403/404 table.

EGG-03 (audio):
- Pin moq-lite Lite-03 to kixelated/moq-rs `rs/moq-lite/src/lite/`.
- One Opus packet per moq Frame (no container, no timestamp).
- Pubkey hex casing reaffirmed at the suffix / broadcast slots.
- AnnouncePlease prefix="" for speaker discovery.
- Mute = stop publishing (not silence frames, not Announce Ended).
- Mid-stream join: discard pre-skip per RFC 7845.

EGG-04 (presence):
- Heartbeat jitter ±5 s required (anti-thundering-herd).
- "0"/"1" are strings, not booleans.
- Single-room rule via replaceable-event semantics.

EGG-05 (chat): 8 KB suggested, 64 KB hard cap, 3 msg/s render rate.
EGG-06 (reactions): 30s window measured against created_at, drop-on-arrival
                    if already stale.
EGG-07 (moderation): replay protection — dedupe kicks by id within 120 s.
EGG-08 (scheduling): planned rooms MUST 403 at the auth sidecar.
EGG-10 (theming): bg image caps (1 MB / 4096 px soft, 8 MB / 8192 px hard).

Deferred (per review): EGG-00 (Conventions as a standalone spec), EGG-13
(capability advertisement), EGG-14 (discovery), test vectors corpus.
The "Conventions" section in README covers EGG-00's most urgent content
inline.

https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
2026-04-27 12:31:19 +00:00
Claude 5c40e27fde docs(nestsClient/specs): EGG-00..EGG-12 — Extensible Gossip Guidelines
Wire-protocol specs for nostrnests-style audio rooms, in the
style of Nostr NIPs and Blossom BUDs. Each spec documents one
self-contained capability that a client or relay can implement;
two compliant peers implementing the same set of EGGs round-trip
without further coordination.

Layout:

  README.md            cover, status table, conformance levels
  EGG-01.md  Room event (kind:30312)              required
  EGG-02.md  Auth + WebTransport handshake        required
  EGG-03.md  Audio plane (moq-lite)               required
  EGG-04.md  Presence (kind:10312)                required
  EGG-05.md  In-room chat (kind:1311)             optional
  EGG-06.md  Reactions (kind:7)                   optional
  EGG-07.md  Roles & moderation (kind:4312)       optional
  EGG-08.md  Scheduling (status=planned)          optional
  EGG-09.md  User server list (kind:10112)        optional
  EGG-10.md  Theming (c/f/bg tags)                decorative
  EGG-11.md  Recording                            decorative
  EGG-12.md  Catalog track (catalog.json)         optional

Conformance levels (Listener / Speaker / Host) defined in the
README so a deployment can declare "we implement EGG-01..EGG-04"
and other peers know exactly what to expect.

Each spec follows the same shape (Summary / Wire format /
Behavior numbered MUST/SHOULD/MAY rules / Example /
Compatibility) and fits on a single printed page. Wire formats
are documented exactly as nostrnests + amethyst implement them
on this branch — no hypothetical capabilities, no "future" tags
without an EGG number.
2026-04-27 03:54:45 +00:00
Claude 43c5313674 chore(audio-rooms): align catalog/announces error timing + comment (audit #5-7)
* announces() now throws UnsupportedOperationException at CALL
    time (not the first collect) on the IETF default — matches
    subscribeCatalog's timing so both can be guarded with one
    `runCatching { listener.announces() }` per session rather than
    a runCatching around every collect site (audit #6).

  * KDoc on announces() documents the deliberate hot-vs-cold
    asymmetry with subscribeSpeaker/subscribeCatalog: announce
    data is room-state with a single VM consumer (cold flow is
    sufficient), audio is per-frame playout with multi-collect
    resilience needs (hot SharedFlow with DROP_OLDEST). The
    different shapes are intentional, not accidental (audit #5).

  * MoqLiteNestsListener.wrapSubscription's "IETF SubscribeHandle
    path conventionally surfaces" comment was scoped to the audio
    track when first written; now the same body serves both audio
    and catalog. Re-frame so the comment applies to either track
    (audit #7).

Test: NestsListenerCatalogTest's announces-throws case now
asserts the call itself throws, not the collect.
2026-04-27 02:28:24 +00:00
Claude e4fe23a1e5 fix(audio-rooms): DROP_OLDEST on the SubscribeHandle pump (audit #8)
The re-issuing pump's MutableSharedFlow used the default
onBufferOverflow = SUSPEND. A stalled consumer (decoder, player,
or anything downstream) back-pressured the pump → the inner
handle.objects.collect → the underlying transport → the relay.
Effect: the room caches up to 64 frames (~1.3s) on the consumer
side, then the relay slows down sending us audio.

For Opus audio the desired behavior is the inverse: drop OLD
frames so playback stays live after a UI hiccup or a foreground
transition. Switch to BufferOverflow.DROP_OLDEST so the relay
keeps streaming at full rate and the consumer's audio stays
synchronized with the speaker, at the cost of dropped frames
during stalls (which would have been late anyway).
2026-04-27 02:26:30 +00:00
Claude 062944de83 feat(audio-rooms): announce-driven speaker discovery (T4 #17b)
Surface moq-lite's ANNOUNCE flow on NestsListener so the audio
room can render an authoritative "actively broadcasting"
indicator independent of kind-10312 presence's `publishing`
flag. Same channel nostrnests' web client uses for its live
badges (`useRoomAnnouncements` in the JS reference).

Wire-up:
  * RoomAnnouncement(pubkey, active) data class — one update
    per publisher transition (Active → broadcast came up,
    inactive → broadcast went down).
  * NestsListener.announces(): Flow<RoomAnnouncement> with a
    default body that throws UnsupportedOperationException on
    the IETF reference path.
  * MoqLiteNestsListener implements via session.announce("")
    against the room's namespace; the suffix carries the
    speaker pubkey hex straight through.
  * ReconnectingNestsListener routes via collectLatest so the
    consumer-facing flow restarts against each new session
    after a refresh / reconnect (no SubscribeHandle re-issuance
    pump needed — announces is a cold per-collect stream).
  * AudioRoomViewModel.announcedSpeakers: StateFlow<Set<String>>
    populated by observeAnnounces. Active emissions add the
    pubkey, inactive emissions remove it. Cleared on teardown.
    IETF listeners leave the set empty; UI falls back to
    presence's publishingNow flag.

Tests:
  * NestsListenerCatalogTest — adds the announces() default-
    throws-on-collect case.

Closes the audit's Tier-4 #17b gap. UI integration (e.g. a green
"live" dot driven by announcedSpeakers) is a follow-up — the data
flow is in place and downstream consumers can opt in.
2026-04-27 02:03:40 +00:00
Claude efcd32f987 feat(audio-rooms): proactive JWT refresh in the reconnecting wrapper (T4 #16)
moq-auth issues bearer tokens with a 600 s lifetime. When a token
expires the relay tears down the WebTransport session and the
wrapper recovers via the regular Failed → Reconnecting → Connected
path with a brief audible dropout (smoothed by the SubscribeHandle
buffer pump but still user-visible as a Reconnecting chip).

Add `tokenRefreshAfterMs` to connectReconnectingNestsListener
(default 540 s — 1 min before the relay's 600 s expiry). The
orchestrator now waits for either a terminal listener state OR
the refresh deadline; whichever fires first wins. On refresh:

  * close the still-healthy listener,
  * skip the reconnect-schedule path (refresh is a planned
    cutover, not a backoff event),
  * loop straight to openOnce() which mints a fresh JWT and
    establishes a new session.

The SubscribeHandle re-issuance pump cuts subs over to the new
session, and the wrapper's outward state never enters Reconnecting
during the cutover — the user-facing UI stays Connected end-to-end.
Setting tokenRefreshAfterMs <= 0 disables the behavior.

Tests:
  * ReconnectingNestsListenerTest gains
    `proactive_token_refresh_recycles_listener_without_failure_state`
    — drives the refresh path with a 50 ms window, captures every
    state emission, asserts neither Reconnecting nor Failed appear
    during a clean recycle.
2026-04-27 01:57:19 +00:00
Claude 2ec19fe961 docs(audio-rooms): catalog KDoc reflects the now-shipped subscribe path
The MoqLiteNestsListener KDoc still pointed to "we'll add a
parallel subscribe in a follow-up". The follow-up shipped in
the previous commit; refresh the doc to describe the actual
behavior so anyone reading the listener doesn't think catalog
support is still missing.
2026-04-27 01:21:29 +00:00
Claude e484e3b210 feat(audio-rooms): subscribeCatalog on NestsListener (moq-lite only)
Surface moq-lite's `catalog.json` track on the NestsListener API.
The catalog publishes one JSON object per group describing the
broadcast (codec, sample rate, optional speaker-side hints) and
is the canonical channel a watcher reads first to discover
available tracks.

Wire-up:
  * NestsListener.subscribeCatalog(pubkey) — interface method with
    a default body that throws UnsupportedOperationException, so
    the IETF DefaultNestsListener fails loud rather than returning
    a flow that never delivers data.
  * MoqLiteNestsListener overrides it to wrap
    `session.subscribe(broadcast = pubkey, track = "catalog.json")`
    through the same MoqObject mapping the audio path uses.
  * ReconnectingNestsListener routes both subscribeSpeaker and
    subscribeCatalog through a shared `reissuingSubscribe` helper —
    catalog handles also survive session swaps via the
    MutableSharedFlow re-issuance pump.

Tests:
  * NestsListenerCatalogTest — verifies the interface default
    rejects with UnsupportedOperationException so a caller wired
    against the IETF reference path fails fast.

VM/UI consumers (e.g. "speaker codec" tooltip) are intentionally
out of scope for this commit; this exposes the capability so a
follow-up can plug in a JSON parser + per-pubkey catalog cache.
2026-04-27 01:20:22 +00:00
Claude 21c6bf766e test(nests-interop): reconnecting-listener round-trip + session swap
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.
2026-04-27 00:55:58 +00:00
Claude 04ac8d3157 fix(nests-reconnect): break the StateFlow collect + survive session swaps (T4 #2)
Two related bugs in connectReconnectingNestsListener:

1. Orchestrator never advanced past the first Failed. The reconnect
   loop used `listener.state.collect { ... return@collect }` to
   "exit" on a terminal state, but `return@collect` only returns
   from the lambda — a StateFlow's collect keeps running. As a
   result, after the first transport failure the orchestrator
   re-entered the lambda for every subsequent emission and never
   reached the outer `delay(delayMs)` / openOnce() that opens the
   next session. Switch to `onEach { mirror } + first { terminal }`
   so the upstream completes deterministically.

2. SubscribeHandle stopped emitting after a reconnect (the
   long-deferred Tier-4 follow-up). Reissue the subscription on
   every activeListener swap by feeding a wrapper-side
   MutableSharedFlow from a `collectLatest` pump that re-calls
   `listener.subscribeSpeaker(...)` against each fresh session.
   The buffer (64 frames ≈ 1.3 s of Opus) covers a typical
   re-handshake without dropping speech. Unsubscribing the
   logical handle cancels the pump and forwards a best-effort
   unsubscribe to the live underlying handle.

Tests: ReconnectingNestsListenerTest covers both happy-path
session swap (frames keep arriving) and the unsubscribe-before-swap
path. Uses real coroutines + Dispatchers.Default rather than
runTest because the test scheduler doesn't auto-advance the
StateFlow + delay chain reliably under UnconfinedTestDispatcher.
2026-04-27 00:47:36 +00:00
Claude cc829f6b0b fix(audio-rooms): request audio focus + Tier-3 audit notes
Tier-3 background-audio audit (item #4 found the only gap):
AudioTrackPlayer.start() never called requestAudioFocus, so an
inbound phone call mixed on top of the room audio instead of
ducking it. Fixed by acquiring focus at the SERVICE level (one
owner per room session, not per player) in
AudioRoomForegroundService.requestAudioFocus():

  * AUDIOFOCUS_GAIN with USAGE_VOICE_COMMUNICATION +
    CONTENT_TYPE_SPEECH so the OS treats the room like a phone
    call.
  * setAcceptsDelayedFocusGain(false) — immediate focus or
    nothing.
  * On-change listener is a no-op; the OS handles duck-on-loss.
  * abandonAudioFocusRequest in onDestroy.
  * Best-effort acquire — runCatching swallows denials so a
    refused request doesn't fail service start (the OS will
    duck/pause us by its own policy, which is degraded but
    acceptable).

Audit notes file under nestsClient/plans/ documents items 1-5
with commit-time citations:
  1. PARTIAL_WAKE_LOCK — already correct
  2. FG type microphone (14+) — already correct
  3. FG type media-playback for listen-only — already correct
  4. Audio focus — fixed in THIS commit
  5. PIP keep-alive — already correct
2026-04-26 23:40:34 +00:00
Claude cdf930938d feat(audio-rooms): connectReconnectingNestsListener (T4 #2)
Wraps `connectNestsListener` with a transport-loss reconnect loop
that consumes [NestsReconnectPolicy] for exponential backoff.
Mirrors the JS reference's `Connection.Reload` behaviour.

  connectReconnectingNestsListener(httpClient, transport, scope,
                                    room, signer, policy = default,
                                    connector = real)

Behaviour:
  * Returned [NestsListener] forwards the underlying listener's
    state while a session is alive.
  * On Failed (transport / handshake error), the orchestrator
    increments the attempt counter, surfaces
    [NestsListenerState.Reconnecting(attempt, delayMs)] for that
    delay, then opens a fresh session via the injected connector.
  * On Closed (user-driven disconnect), the loop exits — Closed
    is terminal.
  * `policy.isExhausted(attempt+1)` halts the loop when
    [NestsReconnectPolicy.maxAttempts] hits. Default policy is
    Int.MAX_VALUE so a long-running room keeps trying.

Subscribe-handle preservation across a reconnect is intentionally
NOT in this commit. Caller-owned [SubscribeHandle]s bind to the
SESSION; once the session is replaced, the handle's flow stops
emitting. The KDoc spells this out so callers can either:
  1. Re-subscribe inside their own
     `state.collectLatest { if (Connected) sub() }` loop
  2. Wait for the future MutableSharedFlow-buffered upgrade flagged
     in the Tier-4 plan ("MutableSharedFlow per handle that the
     per-session pump emits into").

Test seam: the `connector: suspend () -> NestsListener` parameter
defaults to the real connect call. Tests can pass a scripted fake
that returns a sequence of (Failed, Connected, Failed, Connected)
listeners and verify the orchestrator walks the policy correctly
— production path is unchanged.
2026-04-26 23:38:09 +00:00
Claude 2e38aa6c77 feat(audio-rooms): NestsReconnectPolicy + Reconnecting state (T4 #2 foundation)
Lays the foundation for the moq-lite reconnect-with-backoff path:

  NestsReconnectPolicy — exponential backoff settings.
                         (initial=1s, multiplier=2, max=30s) by
                         default, mirroring kixelated/moq's JS
                         reference (`delay: { initial: 1000,
                         multiplier: 2, max: 30000 }`).
                         maxAttempts defaults to Int.MAX_VALUE
                         since a long-running room should keep
                         trying as long as the user hasn't left;
                         the Composable's onDispose is the cancel
                         signal.
                         NoRetry sentinel for first-shot-or-fail
                         tests / single-room demos.

  delayForAttempt(n)   — 1-indexed; doubles per attempt; clamps at
                         maxDelayMs. n<1 returns 0.
  isExhausted(n)       — n >= maxAttempts (next retry forbidden).
  init { require(...) } — ctor guards reject zero/negative initial,
                          multiplier <= 1, max < initial, attempts < 1.

  NestsListenerState.Reconnecting / NestsSpeakerState.Reconnecting
  — new state variants with (attempt, delayMs). UI consumes via
  the existing NestsListenerState → ConnectionUiState mapper which
  surfaces Reconnecting under OpeningTransport for the v1 chip;
  a future commit can add a dedicated "Attempt N in Mms" UI.

Tests:
  * First attempt = initialDelayMs
  * Doubling via attempt index until maxDelayMs ceiling
  * Non-standard multiplier still respects ceiling
  * Zero/negative attempt → 0 delay
  * isExhausted at the boundary
  * NoRetry exhausts after attempt 1
  * Constructor rejects invalid inputs

The orchestration layer (mint-fresh-JWT → reopen WT → re-issue
SubscribeHandles + the speaker-side equivalent) is the heavier
follow-up. Deliberately split because:
  1. The state + policy can ship and be consumed by callers ready
     to handle Reconnecting today — no behaviour change for those
     who don't.
  2. The full session-resurrection path needs `MutableSharedFlow`
     buffering per SubscribeHandle so app code's `Flow<MoqObject>`
     doesn't notice the swap. Substantial refactor; better as its
     own commit with its own tests.
2026-04-26 23:05:19 +00:00
Claude a2f4adda0c test(audio-rooms): pin current speaker-close listener-flow behaviour
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.
2026-04-26 21:25:46 +00:00
Claude 2b86d71c1d test(audio-rooms): one listener's unsubscribe doesn't tear down others
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.
2026-04-26 21:22:53 +00:00
Claude dcbe31d4c8 test(audio-rooms): late-joining listener doesn't replay history
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.
2026-04-26 21:21:47 +00:00
Claude d1b00dd346 test(audio-rooms): pin mute/unmute end-to-end
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.
2026-04-26 21:20:44 +00:00
Claude 8b7ad2ebe1 test(audio-rooms): pin moq-lite-03 not-found contract for early subscribe
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.
2026-04-26 21:06:24 +00:00
Claude 5a86cdd4e4 fix(audio-rooms): SubscribeResponse framing matches moq-lite-03
Lite-03's SubscribeResponse on the response side of a Subscribe bidi
is two pieces concatenated:

  type   varint  (0 = Ok, 1 = Drop)
  body   size-prefixed bytes

The type discriminator sits OUTSIDE the body's size prefix. Earlier
drafts wrapped the whole thing in one outer size prefix; Lite-03 split
them. See `rs/moq-lite/src/lite/subscribe.rs::SubscribeResponse::encode`
(the `_` arm, which matches Lite-03+).

Our codec was producing — and reading — the older outer-wrapped form,
so against a Lite-03 relay we mis-parsed the type discriminator (`0`
for Ok) as a "size=0" outer prefix and surfaced a confusing
`MoqCodecException: truncated varint at offset=0 (remaining=0)` from
inside `decodeSubscribeResponse`. The first byte the relay sent was
the type, not a size, and our reader peeled it off as the outer length.

Fix touches three pieces:

* `encodeSubscribeOk` / `encodeSubscribeDrop` emit
  `type + size_prefixed(body)` directly (no outer wrap), via a small
  `prefixWithType` helper.
* `decodeSubscribeResponse` reads `type` then a length-prefixed body,
  decodes the body in its own reader, and asserts the outer payload
  is fully consumed.
* `readSubscribeResponseFromBidi` walks chunks into the buffer until
  both the type varint and the size-prefixed body have arrived, then
  re-emits the contiguous `[type][size][body]` slab so the decoder
  parses it self-contained. Reuses the `EarlyExit` collector pattern
  the publisher-inbound path already uses; avoids `Flow.iterator()`
  which doesn't exist in kotlinx.coroutines.

Tests:
* `MoqLiteCodecTest::subscribe{Ok,Drop}_round_trips` no longer
  `peelSizePrefix` the encoded bytes — the new wire form has no outer
  prefix to strip; the bytes go straight through `decodeSubscribeResponse`.
* `MoqLiteSessionTest::publisher_acks_subscribe_and_pushes_group_data_on_uni_stream`
  also drops `MoqLiteFrameBuffer().readSizePrefixed()` on the ack chunk
  for the same reason.

Verified end-to-end against a bare-metal moq-relay 0.10.25 + moq-auth
(`-DnestsInteropExternal=true`):

* `NostrNestsRoundTripInteropTest::production_speaker_broadcasts_to_production_listener_via_real_relay`
  passes — speaker → listener → 8 frames round-trip cleanly.
* `NostrNestsMultiPeerInteropTest::one_speaker_fans_out_to_two_listeners`
  passes — same speaker reaches both listeners with the full frame
  stream.
* The `listener_subscribed_before_announce_receives_late_frames` and
  `two_speakers_in_same_room_deliver_independently_to_one_listener`
  cases still fail with `subscribe stream FIN before reply` — those
  are different relay-behavior issues (the v1 relay seems to FIN
  subscribes against unannounced broadcasts and against a second
  speaker on the same listener), unrelated to the codec.
2026-04-26 20:55:04 +00:00
Claude da1c4d3968 fix(audio-rooms): advertise moq-lite-03 in WT CONNECT sub-protocols
Without `wt-available-protocols`, moq-relay (`web-transport-quinn`) falls
back to the legacy in-band SETUP exchange (moq-lite-02) instead of
selecting the moq-lite-03 sub-protocol from the ALPN-style negotiation
header. Then the relay tries to decode our first post-CONNECT bytes as a
SETUP_CLIENT message, hits an unknown control type, and closes the QUIC
connection with `connection closed err=invalid value` — surfaced
client-side as a stuck SUBSCRIBE that ends with
`subscribe stream FIN before reply for id=0` (the bidi gets FIN'd because
the whole connection is being torn down).

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

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

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

Bare-metal harness: NostrNestsHarness.startExternal() now skips the
TCP probe of the moq-relay port. moq-relay binds UDP only; the Docker
forwarder happens to also open TCP, but a directly-launched binary
doesn't, so the previous `Socket(host, 4443)` probe failed with
ConnectException. The QUIC handshake from the test surfaces a real
transport problem if any.
2026-04-26 20:40:19 +00:00
Claude d00e406587 test(audio-rooms): -DnestsInteropExternal bypasses Docker
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.
2026-04-26 20:23:45 +00:00
Claude 1a26b23448 style(audio-rooms): import-and-use instead of inline FQNs
Replace inline `com.vitorpamplona.…` references in code + KDoc with
imports + the short class name across the audio-rooms surface. KDoc
references that would have introduced an `ui` → `model` import cycle
(e.g. NestsServerListState referring to CreateAudioRoomViewModel) are
demoted to plain text instead of clickable `[Class]` links.

No behavioural change.
2026-04-26 20:11:02 +00:00