Commit Graph

12878 Commits

Author SHA1 Message Date
Claude 73722d2ad2 fix(nests): T14 recognise Goaway control type instead of silent FIN
moq-rs's `Publisher::run` accepts `ControlType::Goaway = 5` as a
graceful-shutdown signal that asks the publisher to migrate to a
different relay node (`rs/moq-lite/src/lite/publisher.rs`). Our enum
only defined Session/Announce/Subscribe/Fetch/Probe; an inbound
Goaway bidi fell through `MoqLiteControlType.fromCode` as `null`,
hit the unknown-control branch in `handleInboundBidi`, and was FIN'd
silently — losing the relay's shutdown notification entirely.

Add `Goaway(5L)` to the enum and a dedicated arm in `handleInboundBidi`
that logs the event and FINs cleanly. We don't act on the migration
request today (no body decode, no preferred-relay failover); the
`connectReconnecting*` wrappers' transport-loss reconnect path
already recovers from the eventual hard disconnect, so all this arm
needs is to surface the relay's intent in logcat instead of
swallowing it. Wire body decoding is left as a follow-up.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 17:37:19 +00:00
Claude ea90685a86 revert(nests): drop moq-lite-04 ALPN advertisement (codec is wire-incompatible)
Re-investigation of `kixelated/moq` commit 45db108 ("moq-lite/moq-relay:
hop-based clustering") revealed that Lite-04 IS wire-incompatible with
our Lite-03 codec on Announce / AnnounceInterest / Probe — contradicting
the earlier reasoning that motivated 4b73626. Specifically:

  - Announce.hops: Lite-03 writes a single varint count of hops; Lite-04
    writes count + count × varint Origin ids. (`rs/moq-lite/src/lite/
    announce.rs:67-73` branches on Version explicitly.)

  - AnnounceInterest: Lite-04 added an `exclude_hop` varint after the
    prefix.

  - Probe: Lite-04 added an `rtt` varint after `bitrate`.

Subscribe / SubscribeOk / SubscribeDrop / Group / GroupHeader framing is
unchanged across Lite-03↔Lite-04 — but the Announce/Probe drift alone is
enough to desync a Lite-04-preferring relay if it picks `moq-lite-04`
from our advertised list. We'd encode Announce hops as a bare varint
where the peer expects `len + len × u62`, and the connection aborts on
the first Announce exchange.

Pin `wt-available-protocols` back to `["moq-lite-03"]` until
`MoqLiteCodec` gains version-aware Announce / AnnounceInterest / Probe
codecs and `MoqLiteAnnounce.hops` becomes a list rather than a single
varint. Reverting 4b73626 — the LITE_04 constant stays in MoqLiteAlpn
for documentation, but is no longer plumbed into the factory's
sub-protocol list.

Reverts the wire-format expansion in 4b73626. The factory and ALPN
kdocs are rewritten to spell out the codec diff so the next person who
considers re-enabling Lite-04 reads the actual blocker first.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 17:37:02 +00:00
Claude 1c47fd2403 feat(nests): drive Opus decoder + AudioTrack channel count from catalog
Web speakers (kixelated/hang reference) emit stereo Opus when the
user's AudioContext picks a stereo input — and our catalog now lets
us know that ahead of decoder construction. Previously the decoder
+ AudioTrack were both hardcoded to mono via AudioFormat.CHANNELS, so
a stereo web publisher's frames either threw on the Opus TOC byte
mismatch, decoded with downmix artifacts, or output left-channel-only
depending on the device's MediaCodec implementation.

Wire the catalog-discovered numberOfChannels through:

- MediaCodecOpusDecoder accepts `channelCount: Int = 1`. Drives
  MediaFormat.createAudioFormat's channel count, the OpusHead CSD-0
  channel byte, and the per-frame output buffer size (stereo frame =
  2× shorts vs mono). Validates 1..2 — RFC 7845 mapping family 0
  covers both mono and stereo without an explicit channel mapping
  table; multichannel needs family 1 which we don't support.

- AudioTrackPlayer accepts `channelCount: Int = 1`. Drives the
  AudioTrack channel mask (CHANNEL_OUT_MONO vs CHANNEL_OUT_STEREO)
  and the 250 ms wall-clock buffer-size target (stereo doubles bytes
  per sample).

- NestViewModel.openSubscription now starts the catalog fetch
  BEFORE waiting for it, then awaits `_speakerCatalogs[pubkey]` for
  CATALOG_AWAIT_TIMEOUT_MS (500 ms) before constructing the decoder
  + player. With the speaker-side emit-on-subscribe hook in place
  (3417e44), the catalog frame is on the wire within one round-trip
  of the SUBSCRIBE_OK, so the wait typically resolves in tens of ms.
  Falls back to mono if the catalog never arrives — covers legacy
  publishers that don't emit a catalog and the failure-mode where
  the relay never forwards the catalog group.

- decoderFactory + playerFactory signatures change from
  `() -> X` to `(channelCount: Int) -> X`. Two call sites updated:
  NestViewModelFactory (production) and NestViewModelTest's
  newViewModel helper.

Speaker side stays mono-only — our encoder is fixed at
AudioFormat.CHANNELS=1 and the catalog we publish declares
numberOfChannels=1. This change is exclusively about handling
incoming audio from publishers that don't share that constraint.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 17:26:47 +00:00
Claude 87b43d4d78 fix(nests): bump preroll to 200 ms + surface persistent decode failures
Two audit follow-ups for receive-path interop with kixelated/moq web
publishers (NestsUI v2's reference encoder):

1. ROOM_PLAYER_PREROLL_FRAMES 5 → 10 (≈100 ms → ≈200 ms). The web
   reference encoder uses `groupDuration: 100ms` (5 Opus frames per
   group) and stacks another 100–200 ms of jitter on top via the
   relay's outbound forward queue under residential network
   conditions. 100 ms of preroll reliably underran on web speakers,
   producing audible clicks between adjacent groups; 200 ms covers
   the observed envelope while keeping perceived join latency well
   below the wire latency a listener already sees.

2. Per-subscription consecutive Opus decode-error counter in
   NestViewModel.openSubscription. Single-frame decode errors are
   normal noise — Opus PLC papers over a single bad frame and the
   prior code silently swallowed them — but a structural mismatch
   (wrong wire format, missing legacy-container varint strip,
   codec/channel-count mismatch, corrupted Opus) fails *every* frame
   in a row. The previous behaviour made exactly that class of bug
   invisible: no audio, no log, no UI signal.

   New logic increments a per-subscription AtomicLong on each
   DecoderError / EncoderError, resets on every successful onLevel
   tick. When the streak hits DECODE_ERROR_STREAK_LOG_THRESHOLD
   (50 frames ≈ 1 s of solid failures) we log ONE conspicuous warning
   tagged `NestRx` with the underlying throwable's stacktrace.
   Logged once per crossing (exact-equality, not modulus) so a stuck
   stream produces a single attention-grabbing line instead of a
   50 Hz flood.

   Picks the non-lambda Log.w overload deliberately so the throwable
   stacktrace survives — fires exactly once per stuck-stream streak,
   so the unconditional message-string build is fine.

Both changes are additive — no callers changed, no public API
touched. The stale `ROOM_PLAYER_PREROLL_FRAMES = 5` reference in
NestMoqLiteBroadcaster's framesPerGroup kdoc is updated to match.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 16:43:43 +00:00
Claude 3417e44a6a refactor(nests): catalog emit-on-subscribe hook replaces 2s republish loop
The catalog publisher's previous shape was "send once at startup
(silently fails if no subs are attached yet) + a 2 s republish loop".
That worked but had two problems:

1. The startup send is a no-op (PublisherStateImpl.send returns false
   when inboundSubs is empty), so the catalog isn't actually on the
   wire until the first loop tick — up to 2 s of catalog dead time
   for a watcher that subscribes immediately.
2. Re-emitting a static blob every 2 s for the broadcast's lifetime
   wastes a fresh group on the relay's per-track cache; not a hot path
   today but unnecessary.

Replace with an emit-on-subscribe hook. New API on MoqLitePublisherHandle:

  fun setOnNewSubscriber(hook: (suspend () -> Unit)?)

PublisherStateImpl fires the hook once per accepted (track-matching)
inbound SUBSCRIBE, OUTSIDE its serialisation lock so the hook can
safely call send/endGroup without deadlock. Track-mismatched subs
(SubscribeDrop reply) do NOT fire the hook — covered by a new
"hook does not fire on track mismatch" test.

MoqLiteNestsSpeaker.startBroadcasting and ReconnectingNestsSpeaker's
hot-swap iteration both now register a hook that writes the catalog
JSON on every fresh subscribe instead of looping. The
catalogRepublishJob field on MoqLiteBroadcastHandle and the
hotSwapCatalogJob field on ReissuingBroadcastHandle (plus their close
paths) are dropped.

Net effect for hang.js / NestsUI-v2 watchers:
  - Catalog reaches the watcher on the SAME group as the subscribe ack
    (no 0–2 s startup gap).
  - Late joiners hit the relay's track-latest cache for the catalog
    blob — a fresh hook fire only happens when the relay opens a new
    SUBSCRIBE bidi to us (cliff-detector recycle on listener side, or
    JWT-refresh on our side).
  - One catalog group per relay-side subscribe instead of one every
    2 s for the broadcast lifetime.

Two tests pin the new behaviour: hook fires on inbound subscribe;
hook does NOT fire on a track-mismatched subscribe (which gets a
SubscribeDrop reply and never enters inboundSubs).

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 16:10:12 +00:00
Claude 79c76d5831 fix(nests): reply SubscribeDrop on unsupported tracks instead of silent FIN
When a relay opens a SUBSCRIBE bidi for a track this session doesn't
publish (e.g. a watcher subscribes to `video/data` against a broadcast
that only declares `audio/data` + `catalog.json`), the session was
silently FINing the bidi without writing any reply. The peer's wait
on its receive side resolves to end-of-stream with no indication WHY —
indistinguishable from "publisher disappeared mid-subscribe."

Reply with [MoqLiteSubscribeDrop] carrying
[MoqLiteSubscribeDropCode.TRACK_DOES_NOT_EXIST] (0x04, mirroring the
IETF-MoQ `ErrorCode.TRACK_DOES_NOT_EXIST` convention) and an
informational reason phrase listing the tracks we DO publish, then
FIN. Matches what kixelated's `rs/moq-lite/src/lite/subscribe.rs`
expects on this path.

Doesn't change the happy path — every track NestsUI-v2 subscribes to
(`audio/data` + `catalog.json`) is now published — but is the
spec-correct behaviour for any future watcher that prospectively
subscribes to a track our publisher hasn't declared.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 15:32:32 +00:00
Claude 4b736263e0 feat(nests): advertise both moq-lite-03 and moq-lite-04 ALPNs
Production moq-relay (moq.nostrnests.com) and the kixelated browser
client both ship Lite-04 now. We previously only advertised
moq-lite-03 in the wt-available-protocols header, so a Lite-04-only
relay deployment would refuse the WebTransport CONNECT (no mutual
sub-protocol).

Add moq-lite-04 to the advertised list. The on-the-wire framing for
Subscribe / Group / Announce did NOT change between Lite-03 and
Lite-04, so the existing codec handles either selection unchanged —
this is purely a handshake-layer addition. moq-lite-03 stays listed
first so a relay that supports both still picks the previously-tested
path.

Constants now live on MoqLiteAlpn alongside LITE_03 and LEGACY rather
than being a string literal in QuicWebTransportFactory.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 15:27:21 +00:00
Claude 361dbbe201 fix(nests): stamp Opus frames at frame-index × frame-duration, not wall clock
The legacy-container timestamp on each audio frame was previously
captured at send-time via `frameStartMark.elapsedNow()`. That mark
advances on wall clock, but `current.send(...)` is a suspending call
that backs off on transport backpressure — so a frame captured at T
gets stamped at T+δ once the previous send drains, and the watcher's
WebCodecs AudioDecoder schedules playback at the wrong instant. Audible
as a slowly-growing offset between speaker and listener over the life
of a session that's seen any meaningful send-side queueing.

Replace with a frame-index counter pinned to the codec grid:
`timestampUs = nextFrameIndex * AudioFormat.FRAME_DURATION_US`. The
counter advances once per captured PCM frame BEFORE the encode/mute/
send path, so:

  - Encoder failures, empty-encoded frames, and muted frames each
    consume one slot — preserving the wall-clock gap on the wire so
    a 5 s mute shows up as a 5 s timestamp jump (matches the prior
    wall-clock behaviour for mute and silence).
  - Send-time stalls don't drift the timestamp: it's computed at the
    top of the iteration, not after the suspending send.
  - Survives publisher hot-swap (single-broadcast, single-encoder;
    the new publisher inherits the running counter) — same guarantee
    the prior `TimeMark` had.

Also adds [AudioFormat.FRAME_DURATION_US] = 20_000 (960 / 48 kHz × 1e6)
so the constant lives next to FRAME_SIZE_SAMPLES rather than being
implicit in every call site.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 15:23:29 +00:00
Claude 1b0740d4a8 feat(nests): emit catalog jitter hint matching Opus frame duration
hang.js's encoder publishes a `jitter` field (ms) in every audio
rendition; the watcher uses it to size its jitter buffer. Without it
the watcher falls back to a built-in default — works in practice today
but means our publishes deviate from the canonical hang shape and the
deviation could surface as audible buffer-sizing differences on a
future watcher revision that takes the field as required.

Add `jitter: 20` to MoqLiteHangCatalog.AudioRendition (matching the
Opus frame cadence: 960 samples at 48 kHz = 20 ms; same value hang.js
hard-codes at `js/publish/src/audio/encoder.ts:#runConfig`). Updates
the byte-exact MoqLiteHangCatalogTest assertion and the parallel
inline-literal in RoomSpeakerCatalogTest so both sides stay pinned.

The commons-side `RoomSpeakerCatalog` parser doesn't surface the new
field — `JsonMapper`'s `ignoreUnknownKeys = true` lets the watcher
ingest it silently. Adding a typed `jitter` field there is a
forward-compat task; not needed for interop with hang today.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 15:18:54 +00:00
Claude ff9bb25921 refactor(nests): replace hand-built catalog JSON with typed MoqLiteHangCatalog
The two SPEAKER_CATALOG_JSON call sites (MoqLiteNestsSpeaker for the
non-reconnecting path, ReconnectingNestsSpeaker for the JWT-refresh hot-
swap path) both built the manifest as a `\\` + `\"` + `+`-concatenated
string literal. One missed escape and the watcher's parser fails
silently — the broadcast becomes invisible to hang.js with no
indication of why. The two literals had also drifted independently in
review (same content today; nothing prevents drift tomorrow).

Replace both with `MoqLiteHangCatalog.opusMono48k(track).encodeJsonBytes()`,
a Serializable data class that encodes via kotlinx.serialization with
`encodeDefaults = false` + `explicitNulls = false` pinned (so a future
global default-flip can't surface `"bitrate": null` on hang.js's
parser).

The new type lives in :nestsClient because :nestsClient does not depend
on :commons and the parser-side `RoomSpeakerCatalog` (in :commons)
isn't reachable from the publish path. The two classes target the same
wire shape independently; a byte-exact assertion in the new
MoqLiteHangCatalogTest plus the existing RoomSpeakerCatalogTest's
inline-literal round-trip together pin both sides — desync on either
end fails one of the two tests immediately.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 15:13:47 +00:00
Claude aa37a931e1 test(okhttp): widen SurgeDns stale-refresh TTL to defeat double-expiry race
The 1ms positiveTtlMs let the second refreshed entry expire again
between the post-refresh assertion and the cache-hit lookup that
followed, triggering an extra background refresh that broke the
calls=2 invariant on slow CI runners. 100ms TTL with sleep(150)
keeps the expiry boundary clearly outside the assertion window.
2026-05-06 03:48:57 +00:00
Claude 5310b3f574 Merge remote-tracking branch 'origin/main' into claude/fix-nests-audio-receiver-HCgOY 2026-05-06 03:18:09 +00:00
Claude 5b7e347ac0 feat(nests): align moq-lite catalog with kixelated/hang shape
Switch RoomSpeakerCatalog to the canonical hang catalog format
(camelCase audio.renditions[<track>] with container.kind), wrap
audio frames in the legacy varint(timestamp_us) prefix on publish,
and strip it on subscribe. Also reply to inbound Probe/Fetch/Session
bidis with a bitrate hint or clean FIN instead of hanging.
2026-05-06 03:16:01 +00:00
Claude 5389b32e52 feat(nests): keep catalog.json published across JWT refresh
ReissuingBroadcastHandle's hot-swap iteration now opens its own
catalog.json publisher on every fresh session, pushes the manifest
once, and launches the same 2 s republish loop that
MoqLiteNestsSpeaker.startBroadcasting uses for the legacy
non-reconnecting path. Each iteration tears down the prior session's
catalog publisher + republisher AFTER the new pair is installed, so
watchers see at most a brief overlap rather than a gap when the
600 s moq-auth bearer triggers a session recycle.

Without this, the catalog goes silent the moment the wrapper recycles
the underlying session; any watcher that attaches AFTER the recycle
finds nothing to subscribe to even though the audio path is hot-
swapped seamlessly. With it, both audio and catalog survive every
JWT refresh.

Catalog teardown is also added to ReissuingBroadcastHandle.close()
because — unlike the audio publisher which broadcaster.stop() handles
internally — the catalog has no broadcaster wrapper.
2026-05-06 01:06:36 +00:00
Claude babf7623d8 feat(nests): publish catalog.json so browsers can discover broadcasts
Two-phone Amethyst sessions stream Opus frames just fine on the wire,
but the kixelated/moq browser watcher (the canonical web reference) sees
nothing — because we never published a catalog.json track. moq-lite
watchers discover a broadcast's tracks by subscribing to the
`catalog.json` track on the broadcast suffix and parsing the JSON
manifest. Without it the watcher has nothing to subscribe to.

Two parts:

1. MoqLiteSession.publish now supports multiple tracks per session,
   sharing one broadcast suffix. The previous "one publisher per
   session" invariant rejected the second publish() with
   "already publishing", forcing a single track per session.

2. MoqLiteNestsSpeaker.startBroadcasting now opens both an audio
   publisher AND a catalog publisher, pushes the manifest once, and
   launches a 2s republish loop so late-attaching watchers don't wait
   indefinitely for the next refresh.

The manifest shape mirrors RoomSpeakerCatalog (kotlinx.serialization
data class on the listener side):

    {"version":1,"audio":[{"track":"audio/data","codec":"opus",
                           "sample_rate":48000,"channel_count":1}]}

Hard-coded for now since OpusEncoder is fixed at 48kHz mono.

Hot-swap path (ReconnectingNestsSpeaker session refresh) is NOT yet
wired to recycle the catalog publisher — acceptable trade-off for a
diagnostic landing; follow-up will move the catalog republisher into
HotSwappablePublisherSource so it survives JWT refreshes.
2026-05-06 00:56:43 +00:00
Vitor Pamplona 0874706b31 Merge pull request #2742 from vitorpamplona/claude/fix-eventstore-expiration-test-AbtSm
test(quartz): widen NIP-40 expiration buffer to outlive insert race
2026-05-05 20:54:39 -04:00
Claude d6be9e4574 test(quartz): drive nip40 expiration test through projection directly
Replace the wall-clock dance with a direct EventStoreProjection
construction wired to a controllable nowProvider. Both events are
inserted with expirations safely in the future so the SQL
reject_expired_events trigger never fires; the test then bumps the
fake clock and replays the StoreChange.DeleteExpired the observable
would emit after a sweep. Tests the projection's reaction to the
sweep signal — which is what this case was always about — without a
6s real-time delay or a flake window. Full SQL sweep behavior
remains covered by ExpirationTest.testDeletingExpiredEvents.
2026-05-06 00:50:30 +00:00
Claude 5f4d8ba747 test(quartz): widen NIP-40 expiration buffer to outlive insert race
The reject_expired_events trigger checks NEW.expiration <= unixepoch()
at insert time, so signing/insert latency on a slow runner could push
unixepoch() to time+1 before the row was committed, raising
SQLiteException at runBlocking entry. Bumping the short event to
time+5 with a matching delay(6000) mirrors
ExpirationTest.testDeletingExpiredEvents and gives the trigger room
to breathe without changing the behavior under test.
2026-05-06 00:42:14 +00:00
Vitor Pamplona 44f3776e86 Merge pull request #2741 from vitorpamplona/claude/investigate-build-size-458NE
Optimize CI builds and reduce APK size with Gradle caching and font subsetting
2026-05-05 20:39:07 -04:00
Claude bedf21f57e chore(nests): auto-enable NestsTrace recorder in debug builds
Hook into Amethyst.onCreate behind the existing isDebug check so
the JSONL session-trace recorder lights up the moment a debug APK
launches — no per-room toggle to find, no rebuild needed to flip
the flag, no extra step on top of the existing logcat capture
flow.

Release builds are unaffected: setRecording() is gated by
`if (isDebug)`, so the volatile flag stays false and every emit
site short-circuits at the field-load + branch.

Capture from a connected receiver phone with:

  adb logcat -c
  adb logcat -s NestsTraceJsonl:D -v raw > nest-trace.jsonl

The `-v raw` formatter strips the `D NestsTraceJsonl:` prefix so
the file is valid JSONL ready for the (planned)
TraceReplayingTransport to feed back through MoqLiteSession +
NestViewModel for headless cliff-scenario regression tests.
2026-05-06 00:22:43 +00:00
Claude a86f19f069 feat(nests): NestsTrace recorder for replayable session captures
Adds an opt-in JSONL event recorder behind every receiver-side
moq-lite + NestViewModel decision point so a real two-phone
production session can be captured and (in a follow-up) replayed
through the unmodified pipeline as a unit test. Step 1 of the
"capture-then-replay" plan from the prior conversation.

Off by default — production release builds that never call
`NestsTrace.setRecording(true)` pay one volatile-load + branch per
emit site. The fields-builder lambda doesn't run on the disabled
path, so call sites can do non-trivial string concat freely.

Output goes to logcat under tag `NestsTraceJsonl`. Capture with:

  adb logcat -c
  adb logcat -s NestsTraceJsonl:D -v raw > nest-trace.jsonl

The `-v raw` formatter strips the `D NestsTraceJsonl:` prefix so
the captured file is valid JSONL ready for replay tooling.

Schema per line: `{"t_ms":N,"kind":"K", ...kind-specific fields}`,
where `t_ms` is milliseconds since `setRecording(true)` was first
called. Field names are lower_snake_case for stability across
clients.

Wired into 13 call sites this commit (matched 1:1 with existing
NestRx/NestTx human log lines so the trace and the log line stay
adjacent and the diff stays small):

`MoqLiteSession`:
  - announce_bidi_opened (per session.announce call)
  - announce_pump_emit (per Active/Ended received on a bidi)
  - announce_bidi_ended_naturally / announce_bidi_threw
  - subscribe_send / subscribe_ok / subscribe_drop
  - subscribe_bidi_exited
  - announce_watch_update / announce_watch_ended_closing_subs
  - uni_pump_started
  - group_header / group_fin / group_threw

`NestViewModel`:
  - vm_observe_announce (per ann emission)
  - cliff_tick (every CLIFF_DIAG_LOG_EVERY ticks: active + announced
    sets + per-pubkey lastFrameAt elapsed-ms)
  - cliff_recycle (when the detector forces a recycleSession())

Anonymisation: pubkeys + track names recorded verbatim — they're
already in the existing `NestRx`/`NestTx` log lines the user is
sharing. Frame payloads are NEVER recorded, only sizes — audio
content can't leak through a trace dump.

Tests: NestsTraceTest (9 cases) — exhaustive jsonStr/jsonArrStr
quoting + setRecording state-machine + emit-lambda-noop-when-
disabled coverage. The `emit` log-output side itself is untestable
in commonTest because `Log.d` writes to a platform actual; the
schema correctness we DO want to pin (a JSON syntax bug at one of
the 13 call sites would silently break replay) is covered by the
quoting helpers.

CliffDetectorTest 12/12 + MoqLiteSessionTest 11/11 still pass —
the trace wiring is purely additive next to existing log statements.

Follow-up: a `TraceReplayingTransport` reading these JSONL files
back through `WebTransportSession` to drive end-to-end regression
tests for the cliff-recovery scenarios captured from production.
2026-05-06 00:17:43 +00:00
Claude 19588be84b build(perf): trim build outputs for hooks, CI, and the shipped font
Headline numbers from a fresh `./gradlew assemble`:
- :commons material_symbols_outlined.ttf 11M -> 409K (subset to the 210
  codepoints actually referenced from MaterialSymbols.kt)
- :amethyst per-ABI APKs no longer built in CI (-PdisableAbiSplits=true);
  ~600M of stripped_native_libs intermediates skipped per run

Changes:
- .git-hooks/pre-push runs only :amethyst:testPlayDebugUnitTest plus jvmTest
  on the KMP modules instead of `./gradlew test`, which was compiling all six
  amethyst variants (play/fdroid x debug/release/benchmark)
- amethyst/build.gradle adds three opt-in fast-build flags:
    -PdisableAbiSplits=true       skip per-ABI APK splits
    -PdisableUniversalApk=true    skip the universal APK output
    -Pamethyst.skipMapping=true   disable R8 (release+benchmark)
  Defaults are unchanged; release pipelines must not set skipMapping.
- .github/workflows/build.yml uses gradle/actions/setup-gradle@v4 with
  cache-read-only on PRs / cache-write on main, drops --no-daemon, collapses
  the Android job to a single Gradle invocation (lint + focused debug unit
  tests + assembleBenchmark with -PdisableAbiSplits=true), and globs both
  APK naming patterns for the upload step.
- tools/material-symbols-subset/{subset.sh,README.md} regenerates the font
  from upstream + MaterialSymbols.kt; run after adding/removing icons.

Verified: ./gradlew :amethyst:help with all three new -P flags parses cleanly,
and ./gradlew :commons:jvmJar --rerun-tasks succeeds with the subsetted font
(commons-jvm jar shrinks from ~13M to 2.9M).

https://claude.ai/code/session_01YSmkagXXN5AwGcY4upiUh1
2026-05-05 22:49:43 +00:00
Claude a36ccb5692 fix(nests): close the relay-cliff at the source — framesPerGroup 10→50, cliff-threshold 4s→2.5s
Two-phone production logs at commit 6e4df4a (run 18:37) showed the
listener-side cliff still hits at ~16 s of streaming (51 groups
delivered, sender continued to seq=80 = ~6 s of audio lost at the
tail before the receiver recycled). Recycling alone can't fix this
— it only spaces the pain out. The user wants no audio drops mid-
transmission, period. The leverage is to keep the relay's
per-subscriber forward queue from filling at all.

The cliff is rate-limited per uni-stream-creation, not per byte or
per frame — moq-rs's `serve_group` task pool can't drain new
`open_uni().await`s fast enough at high stream rates. Cutting the
stream creation rate cuts the cliff window proportionally:

  framesPerGroup |  streams/sec @ 50 fps  |  observed cliff window
  ---------------+------------------------+----------------------
       1         |       50               |   ~3 s (commit d6517cf)
       5         |       10               |   ~13 s
      10         |        5               |   ~16 s (commit 6e4df4a)
      50         |        1               |   not reached in
                                              `fpg-all` sweeps
     100         |      0.5               |   never observed

Bumping default to 50 (1 s of audio per uni stream, 1 stream/sec)
puts us in the regime where the relay's queue does not measurably
fill. `fpg-all` (100 frames in a single group) routinely delivers
100/100 in production sweeps, so 50 is well clear of the cliff
edge. We pick 50 not 100 because:

  - Late-join gap is bounded by group size — a listener joining
    mid-broadcast waits one group boundary for the first frame.
    1 s is within the ~250 ms AudioTrack jitter buffer +
    `ROOM_PLAYER_PREROLL_FRAMES = 5` audio-buffer envelope; 2 s
    starts to be perceptible.
  - QUIC stream RST drops the whole group on full reset. 1 s of
    skip on rare RST is bearable; 2 s is a noticeable seam.
  - Sender encode latency stays at 1 s — no worse than the
    natural buffering audio rooms already do for jitter.

Belt-and-suspenders: also tighten the cliff-detector's silence
threshold from 4 s to 2.5 s. Healthy streams now arrive every ~1 s
(one `drainOneGroup` FIN per group), so 2.5 s of silence is
unambiguously past two missed group cycles. Catches a still-
possible cliff event ~1.5 s earlier, shaving the audible gap from
~6 s (4 s detection + 2 s reconnect) to ~4.5 s if recovery is
needed at all.

Tests: existing `framesPerGroup`-explicit interop scenarios
(`fpg5`, `fpg20`, `fpg-all`) are unaffected — they pass an
explicit value and don't read the default. `CliffDetectorTest`
boundary cases updated to the new 2.5 s threshold;
`returnsAllStalledSpeakersAtOnceMixedWithFreshOnes` re-spaced its
fixture so charlie's 1.5 s frame age stays under threshold while
alice/bob's 4 s ages exceed it. 12/12 pass.
2026-05-05 22:47:07 +00:00
Claude 6e4df4aad4 fix(nests): cliff-detector — reset lastFrameAt on recycle + bump cooldown to 30s
Production logs at commit ea08c43 (run 18:05:14) showed the cliff
detector now correctly fires (`active=1 announced=1` after the
channelFlow chain fix) — but it then thrashes the relay:

  18:05:25.488  cliff #1 → recycle, session 1 had 4 groups (3 s of audio)
  18:05:40.512  cliff #2 → recycle, session 2 had 36 groups (7 s of audio)
  18:05:48.538  cliff #3 → recycle, session 3 had 0 groups, then ...
  18:05:56.566  cliff #4 → recycle, session 4 can't even subscribe
                ("subscribe stream FIN before reply" — relay refusing)

Two compounding issues:

1. `lastFrameAt[pubkey]` was NOT reset when the cliff detector
   triggered a recycle. The next 1-second tick after the 8 s
   cooldown re-evaluated `lastFrameAt[pubkey].elapsedNow()`, which
   was still the giant pre-recycle value (now > 12 s). The check
   `>= 4_000ms` immediately tripped, recycling AGAIN regardless of
   whether the new session had begun delivering frames. The
   detector treated the recycle moment as if no time had passed.

2. The 8 s cooldown was the bare minimum needed for one reconnect
   handshake. It did NOT include time for moq-rs to drain its
   per-subscriber forward task queue from the prior subscription.
   Aggressive recycles every ~12 s drove the relay into a state
   where it FIN'd new subscribe bidis without responding —
   visible as "NestsListener.subscribe requires Connected state,
   was Closed" loops on the receiver after the 4th cliff.

Two changes:

a) When the cliff detector fires, before calling `recycleSession()`
   it now writes `lastFrameAt[pubkey] = recycleMark` for every
   stalled pubkey. This treats the recycle as a synthetic frame
   event so the cliff timer restarts from the recycle moment.
   The new session has the full `ROOM_AUDIO_CLIFF_TIMEOUT_MS`
   (4 s) to deliver before the next cliff check trips, instead of
   tripping the moment the cooldown ends.

b) `ROOM_AUDIO_CLIFF_COOLDOWN_MS: 8_000L → 30_000L`. Gives moq-rs
   enough wall-clock between recycles to drain its per-subscriber
   forward queue before the next subscribe lands. Audible-gap
   cost rises from ~5 s to ~30 s in the worst-case fully-stalled
   relay, but this is the correct trade: 30 s of silence followed
   by recovered audio beats endless silence with the relay locked
   out.

Combined, the worst-case recycle cycle goes from ~12 s (8 s
cooldown + 4 s threshold = next recycle, relay degrades fast) to
~34 s (30 s cooldown + 4 s threshold = next recycle, relay can
recover). For the steady state where the relay is healthy and
the recycle DOES help, the new session's first frames clear the
threshold on `lastFrameAt` and no second recycle fires at all.

Tests: `CliffDetectorTest`'s 12 pure-predicate cases stay green
(updated `cooldownSuppressesRecycleEvenWhenStalled` and
`cooldownReleasesAfterTimeoutPasses` to use the new 30 s default).
The `lastFrameAt` reset on recycle is in the live detector loop,
not the predicate, so it's covered by integration runs rather
than the pure-function suite.
2026-05-05 22:13:12 +00:00
Claude ea08c43b71 fix(nests): don't teardown on wrapper Closed — let orchestrator reconnect
The cliff-detector recycle path emits Closed → Reconnecting → Connecting
→ Connected from the wrapper. The Closed branch was calling teardown(),
which cancels cliffDetectorJob, announcesJob, and the wrapper itself,
preventing the orchestrator from ever reopening the inner listener.

Result pre-fix (visible in receiver log): cliff-detector fires after 4s
of silence, teardown runs ~500ms later, wrapper never reopens, room
permanently dead.

User-initiated close goes through disconnect() / onCleared() which call
teardown directly; the wrapper's subsequent Closed emission is redundant
for those paths, so no-op'ing it is safe. UI still reflects Closed via
state.toUiState(ui.connection).

https://claude.ai/code/session_01UHN3fnXzWdj8UbSXnxSwwv
2026-05-05 21:27:35 +00:00
Claude 457e0f5997 fix(nests): wrapper.announces() ALSO needs channelFlow (collectLatest emits cross-coroutine)
The channelFlow conversion in f17e7ad fixed `MoqLiteNestsListener.announces`
but the SAME `IllegalStateException: Flow invariant is violated` kept
firing in the next two-phone repro (commit f17e7ad, run 15:46:51) — this
time the offending `flow{}` was one layer up, in
`ReconnectingNestsListener.announces`:

  override fun announces(): Flow<RoomAnnouncement> =
      flow {
          activeListener.collectLatest { listener ->
              ...
              runCatching {
                  listener.announces().collect { emit(it) }  // <-- HERE
              }
          }
      }

`Flow.collectLatest { lambda }` cancels and restarts a CHILD coroutine
for each upstream emission. The lambda body runs in that child, NOT in
the surrounding flow{} body's coroutine. So when the lambda invokes
`emit(it)`, it's emitting from a child coroutine. `flow{}`'s
SafeFlow guard rejects this with the same "Emission from another
coroutine is detected" error the inner listener was throwing before
my last fix.

Net effect on the user's repro: the inner channelFlow now correctly
sends RoomAnnouncement to the wrapper's `listener.announces().collect`
lambda, but that lambda's `emit(it)` to the wrapper's flow{} body
fails the same SafeFlow check, the wrapper's runCatching swallows the
exception (as `iter=2 inner collect ended IllegalStateException ...
fwd=1`), `_announcedSpeakers` stays empty, cliff detector never fires.

Fix: convert `ReconnectingNestsListener.announces` from `flow{} + emit`
to `channelFlow{} + send`, matching the inner listener's shape. The
combination of `channelFlow + collectLatest + send` is the canonical
pattern in kotlinx-coroutines for "switch-map" semantics with cross-
coroutine production. `awaitClose { }` is empty because `collectLatest`
on an infinite-StateFlow never completes naturally — cancellation
propagates through structured concurrency when the consumer cancels
the channelFlow.

Tests: every nestsClient + commons unit test still passes, including
the 12 CliffDetectorTest cases pinning the predicate's behaviour.

Together with f17e7ad (inner channelFlow), this should finally close
the chain: inner emits RoomAnnouncement on its channelFlow → wrapper's
collectLatest receives it inside its child coroutine → wrapper's
channelFlow.send forwards across the channel → consumer's
observeAnnounces collect receives → `_announcedSpeakers` populates →
cliff detector tick reports `announced=1` → on a real cliff event the
recycle fires.
2026-05-05 19:50:23 +00:00
Claude f17e7adfa7 fix(nests): announces flow MUST use channelFlow, not flow{}
The diagnostic logging in 1fc8dbc surfaced the real root cause —
not the SharedFlow replay race I fixed in d6517cf, but a strict
flow{}-builder concurrency-confinement violation.

The receiver-side log (15:34:40, repro after the diagnostic patch)
showed:

  session.announce(prefix='') bidi pump emit #1 status=Active suffix='fe52579aa30e' (chunks=1)
  MoqLiteNestsListener.announces bidi#2 #1 status=Active suffix='fe52579aa30e'
  MoqLiteNestsListener.announces bidi#2 finally (emissions=1)
  wrapper.announces() iter=2 inner collect ended IllegalStateException:
    Flow invariant is violated:
    Emission from another coroutine is detected.
    Child of StandaloneCoroutine{Active}@e9b5981, expected child of
    StandaloneCoroutine{Active}@8309a26.
    FlowCollector is not thread-safe and concurrent emissions are
    prohibited.
    To mitigate this restriction please use 'channelFlow' builder
    instead of 'flow'

The session's bidi pump (`scope.launch { bidi.incoming().collect …
updates.emit(decoded) }`) emits to the announce SharedFlow from the
PUMP's coroutine. SharedFlow's `collect { … }` resumes the lambda
inline on the emitter's coroutine when there's no dispatcher hop,
so `MoqLiteNestsListener.announces`' `handle.updates.collect {
emit(RoomAnnouncement(…)) }` lambda fires on the pump's coroutine,
not the flow{} body's coroutine. `flow {}`'s SafeFlow guard throws
on the cross-coroutine emit. The wrapper's
`runCatching { listener.announces().collect { emit(it) } }` swallows
the exception, the wrapper's collect ends with `fwd=1`, and
`_announcedSpeakers` stays empty for the lifetime of the session.

That, in turn, kept the cliff detector permanently gated:

  cliff-detector tick=N active=1 announced=0 lastFrameAges=[fe52579a=…ms]

even though the receiver was clearly subscribed and clearly seeing
frames. The cliff detector requires the speaker to be in
`announcedSpeakers` to recycle, so the relay-forward cliff at ~135
streams went undetected — the original two-phone receiver-side
silence symptom.

Fix: switch `MoqLiteNestsListener.announces()` from `flow { … }` to
`channelFlow { … }`. `channelFlow` is the documented escape hatch
for cross-coroutine emission (the error message itself recommends
it). The new shape:

  channelFlow {
      val handle = session.announce(prefix = "")
      val pump = launch {
          try {
              handle.updates.collect { send(RoomAnnouncement(…)) }
          } finally {
              withContext(NonCancellable) {
                  runCatching { handle.close() }
              }
          }
      }
      awaitClose { pump.cancel() }
  }

Notes:
  - `send` (channel) replaces `emit` (flow) — channelFlow buffers
    via a Channel, so cross-coroutine producers are explicitly
    supported.
  - The inner `collect` is wrapped in a launched child of the
    channelFlow scope so we can use `awaitClose` for the producer-
    side teardown contract channelFlow requires.
  - `handle.close` is suspend (FINs the bidi + joins the pump);
    putting it in the launched pump's `finally` under
    `NonCancellable` keeps the FIN reliable when the consumer
    cancels mid-stream.

Diagnostic logging from 1fc8dbc preserved — the next two-phone
repro should now show:

  observeAnnounces #1 active=true pubkey='fe52579aa30e' → ADD
  cliff-detector tick=N active=1 announced=1 lastFrameAges=[…]

…and on a real cliff event, the recycle should fire.

Tests: full suite still passes
  - CliffDetectorTest: 12/12
  - NestPlayerTest: 12/12
  - NestBroadcasterTest: 6/6
  - MoqLiteSessionTest: 11/11
  - All other nestsClient jvmTest classes (~240 tests across 37
    classes) green.
2026-05-05 19:39:23 +00:00
Claude 1fc8dbceee debug(nests): trace announce-flow chain to localise still-empty _announcedSpeakers
The replay=64 SharedFlow fix in d6517cf did NOT close the receiver's
"announced=0" symptom — the next two-phone repro (15:20:08 onward)
still shows the cliff detector ticking with `active=1 announced=0`
indefinitely, even after the session-internal pumpAnnounceWatch
clearly received an Active update. Either moq-rs sends Active to
only the FIRST announce bidi (so the VM-level second bidi never
gets it), OR the chain from that bidi to `_announcedSpeakers` is
broken at a layer my last fix didn't visit.

Adds focused logging at every step of the announce chain so the
next repro pins down which one. All NestRx-tagged:

`MoqLiteSession.announce` (per-bidi pump):
  - "session.announce(prefix='…') bidi opened, pump launching"
  - "session.announce(prefix='…') bidi pump emit #N status=… suffix='…' chunks=…"
  - "session.announce(prefix='…') bidi.incoming() ended naturally chunks=… emits=…"
  - per-emission so we can compare bidi#1 (session-internal) vs
    bidi#2 (VM-level) emit counts. If bidi#2 has 0 emits, the
    relay is sending Actives to only one bidi.

`MoqLiteNestsListener.announces`:
  - "MoqLiteNestsListener.announces flow STARTING (state=…)"
  - "MoqLiteNestsListener.announces opened bidi#2 (VM-level)"
  - per-emission "bidi#2 #N status=… suffix='…'"
  - "bidi#2 collect ENDED naturally" / "finally emissions=N"

`ReconnectingNestsListener.announces` (wrapper):
  - "wrapper.announces() flow starting collect on activeListener"
  - "wrapper.announces() iter=N activeListener=set/null"
  - "wrapper.announces() iter=N inner state=Connected/Closed/…"
  - "wrapper.announces() iter=N inner collect ended naturally/X fwd=N"

`NestViewModel.observeAnnounces`:
  - "observeAnnounces launching collect on l.announces()"
  - per-emission "observeAnnounces #N active=… pubkey='…' → ADD/REMOVE"
  - "observeAnnounces collect ENDED naturally/X (emissions=N,
    _announcedSpeakers.size=…)"

After the next repro, the receiver-side log will show one of:
- bidi#2 never opens (terminalOrConnected != Connected somehow)
- bidi#2 opens, no chunks/emits → relay-side issue
- bidi#2 emits, but `MoqLiteNestsListener.announces` doesn't
  forward → bug in the flow body
- forwarding works but observeAnnounces collect ends → bug in
  `ReconnectingNestsListener.announces` collectLatest behavior
- everything works but `_announcedSpeakers.size=0` → mutation lost

Tests: full suite still passes (CliffDetectorTest 12, NestPlayerTest
12, NestBroadcasterTest 6, MoqLiteSessionTest 11, …).
2026-05-05 19:27:19 +00:00
Claude d6517cf346 fix(nests): announce SharedFlow late-attach race dropped Active updates
The cliff-detector diagnostic logs from the user's two-phone repro
(commit f2205dc, run 15:06) showed the smoking gun — every tick from
the receiver-side cliff detector said `announced=0` even though an
Active announce had clearly been received earlier (the session-level
`pumpAnnounceWatch` logged it). The cliff detector only acts on
speakers that are in `_announcedSpeakers`, so it never tripped, even
when frames clearly stopped flowing for 6+ s.

Root cause: `MoqLiteSession.announce` builds a `MutableSharedFlow`
with `replay = 0` to bridge the bidi pump and the consumer's
`collect`. With Kotlin's `MutableSharedFlow(replay = 0)`, an `emit`
that happens BEFORE the first collector subscribes is silently
dropped — there's no buffer for items emitted into a flow with no
subscribers. The bidi pump is launched inside `announce()` and
starts collecting `bidi.incoming()` immediately; the caller (the
flow returned by `MoqLiteNestsListener.announces()`) attaches its
collector AFTER `session.announce()` returns. In between, if the
relay sends an Active update (which it does the moment the
broadcaster's session is registered), the pump's emit hits a flow
with no collectors and is lost.

In the user's logs both an internal session-level
`pumpAnnounceWatch` AND the VM-level `observeAnnounces` open
separate announce bidis. The internal one started first and won
the race for its bidi's Active. The VM-level one lost — the bidi
pump emitted Active before the consumer subscribed, the SharedFlow
dropped it, and `_announcedSpeakers` never populated.

Fix: change the announce SharedFlow to `replay = 64` with
`BufferOverflow.DROP_OLDEST`. Late-attaching collectors now see up
to the last 64 announces (far more than nests rooms have
speakers — typical stage size is ≤ 10). The bidi pump's emit
still never suspends, so QUIC-level backpressure can't propagate.
The replay cache evicts the oldest item if more than 64 announces
arrive before any collector attaches; for the production workload
this is unreachable.

Tests: nestsClient jvmTest passes 57/57 unchanged
(MoqLiteCodecTest, MoqLiteFrameBufferTest, MoqLitePathTest,
MoqLiteSessionTest, NestBroadcasterTest, NestPlayerTest). The
session-level pumpAnnounceWatch path used the same SharedFlow but
its caller subscribes from `scope.launch { pumpAnnounceWatch(...) }`
inside `ensureAnnounceWatchStarted` — close enough in time that it
won the race in production. The fix closes the race for both
callers regardless of timing.
2026-05-05 19:11:51 +00:00
Vitor Pamplona 46a07cfe43 Merge pull request #2739 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-05 15:10:04 -04:00
Vitor Pamplona 84bd3560e5 Merge pull request #2736 from vitorpamplona/claude/disable-vlc-ci-checks-4BD0q
Cache vlc-setup downloads in desktop CI
2026-05-05 15:09:55 -04:00
Claude f2205dc97e test(nests): pure unit tests for cliff-detector predicate + diagnostics + Connected restart
The two-phone repro on commit cb61082 showed the cliff still triggers
at ~13 s with framesPerGroup=10 (last drainOneGroup at ts 14:26:14,
broadcaster keeps pushing through 14:26:25+ unimpeded), but the cliff
detector did NOT fire — no `cliff-detector: announced+subscribed but
silent…` log in the receiver's NestRx output despite the gap clearly
exceeding the 4 s threshold. Three changes here narrow the gap.

1. Extract `computeStalledSpeakers` as a pure top-level internal
   function over `Map<String, TimeMark>`. The detector loop now calls
   it; the rest of the predicate logic (cooldown, announced ∩ active
   ∩ stale, ≥ inclusive boundary) is now testable without standing up
   the NestViewModel coroutine machinery.

2. New `CliffDetectorTest` (commonTest, 12 tests) drives
   `computeStalledSpeakers` with a `kotlin.time.TestTimeSource`.
   Pins the boundary cases the next refactor would otherwise drift
   on: at-threshold inclusive, just-under empty, no-frame-yet ignored,
   not-announced ignored, multi-speaker classification, cooldown
   suppress, cooldown release, and a few more. All 12 pass.

3. Add diagnostic logging to the live cliff detector. The two-phone
   logs proved the predicate's logic works (the `lastFrameAt` mark
   was clearly aged past the threshold), so the silence has to be
   in the gating: `listener` null, connection state not Connected,
   or `_announcedSpeakers` not containing the pubkey at scan time.
   New logs:
     - "cliff-detector launched" once on start
     - "cliff-detector tick=N gated: listener=… conn=…" every 5 s
       when gated
     - "cliff-detector tick=N active=… announced=… lastFrameAges=…"
       every 5 s when running
     - "cliff-detector EXITED after N ticks (closed=…)" on cancel
   so the next repro tells us which gate is failing.

4. Defensive restart on `NestsListenerState.Connected`. The Closed
   branch in `observeListenerState` calls `teardown(targetState =
   Closed)`, which cancels `cliffDetectorJob` along with the rest of
   the per-session jobs. If the cliff detector itself is what
   triggered the recycle, the wrapper emits Closed → Reconnecting
   → Connected; we'd come back online with a dead detector and the
   next cliff event would slip past undetected. Calling
   `startCliffDetector()` (idempotent) on every Connected entry
   keeps the detector alive across the cycle.

Defaults stay where they were: 4 s `ROOM_AUDIO_CLIFF_TIMEOUT_MS`,
1 s scan, 8 s cooldown — the unit tests also pin those default
constants by relying on the function's defaults in two of the cases.
2026-05-05 18:54:25 +00:00
Crowdin Bot 0887c65ce9 New Crowdin translations by GitHub Action 2026-05-05 18:18:54 +00:00
Vitor Pamplona 3915d98774 Merge pull request #2738 from vitorpamplona/claude/fix-session-swap-timeout-VB9NT
Fix race condition in ReconnectingNestsListenerTest frame delivery
2026-05-05 14:17:08 -04:00
Claude bd681a0a5f test(nests): close FRAME1 delivery race in subscribeSpeaker_survives_session_swap
The test emitted FRAME1 into first.frames and immediately called
first.fail(...), trusting that FRAME1 would propagate through the pump
to the wrapper's frames before the session swap. emit() is
non-suspending — it only enqueues FRAME1 into the pump's slot. On
slower hosts (observed on macOS CI) the orchestrator's reconnect path
can flip activeListener to the second session before the pump's
collect lambda runs, at which point collectLatest cancels
pump-iteration-1 mid-resume and FRAME1 is dropped.  The consumer's
take(2) then only ever sees FRAME2 and the async's withTimeout fires
after 5 s.

Add a consumerProgress StateFlow that the consumer's collector bumps
on each frame, and wait for it to reach 1 before failing the listener.
Same shape as the existing consumerSubscribed gate that closed the
"emit before consumer subscribes" race.
2026-05-05 18:06:56 +00:00
Claude cb61082bc4 fix(nests): close receiver-audio dropout cluster + auto-start broadcast on host create
Six changes that together close out the bug class the user reported
(receiver "blinks green ring for ~5 s then stops") and the related
host-side "circular spinner until first unmute" UX issue. Each is
defensible independently; together they form a defense-in-depth
against the moq-rs production-relay forward-queue cliff documented
in `nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md`.

#3 — `NestMoqLiteBroadcaster.onLevel` only fires when
`current.send(opus)` returned `true` (frame reached an inbound
subscriber). Two-phone logs showed the host's green/glow firing for
the first ~7 s while no listener was attached and after a relay-side
cliff while no audio was on the wire. The local "I'm broadcasting"
ring now tracks the wire, not the runCatching outcome.

#5 — `DEFAULT_FRAMES_PER_GROUP: 5 → 10` (200 ms of audio per
group, 5 streams/sec). Two-phone production logs showed the relay
still cliffing at ~135 streams under sustained load with the old
5-frame default — same bug class the plan thought it had closed,
just shifted by half. Doubling the pack roughly doubles the
worst-case cliff window. Kept the existing `framesPerGroup = 5`
test scenarios intact since they're explicit on the call site.

#4 — `SUBSCRIBE_RETRY_BACKOFF_INITIAL_MS: 250 → 100`. Logs of the
join path showed 5 retries (250+500+1000+1000+1000 = 3.75 s) of
"subscribe stream FIN before reply" before the relay observed the
broadcast's announce. New ladder is 100+200+400+800+1000 = 2.5 s.
The MAX cap stays at 1000 ms so a permanently-gone publisher still
doesn't hammer the relay.

#2 — `NestViewModel.openSubscription` now passes
`maxLatencyMs = 500L` on every speaker SUBSCRIBE (new constant
`ROOM_AUDIO_MAX_LATENCY_MS`). Tells moq-rs to evict groups older
than 500 ms from the per-subscriber forward queue rather than
queuing them up to the relay's `MAX_GROUP_AGE = 30 s` default —
caps the queue growth that triggers the cliff. The wire support
existed in the listener; only the call site was passing 0L.

#1 — listener-side cliff detector. New `cliffDetectorJob` in
`NestViewModel` periodically scans `activeSubscriptions` against
`lastFrameAt` (per-pubkey monotonic TimeMark, updated in
`onSpeakerActivity`). When a speaker has been announced Active AND
we have an active subscription AND no frames have arrived for
`ROOM_AUDIO_CLIFF_TIMEOUT_MS = 4 s`, force a `recycleSession()` so
the orchestrator opens a fresh QUIC transport — a clean reset of
the relay's per-subscriber forward queue. `ROOM_AUDIO_CLIFF_COOLDOWN_MS = 8 s`
prevents back-to-back recycles while the new session is mid-handshake.
Started from `launchConnect` after `observeAnnounces`, cancelled in
`teardown`. Per-pubkey `lastFrameAt` entry dropped on
`closeSubscription` so a removed speaker doesn't trigger a stale
recycle.

#6 — auto-start broadcast pre-muted on host create.
`NestViewModel.startBroadcast` gets an `initialMuted: Boolean = false`
parameter; when `true` the broadcaster opens the publisher session,
calls `setMuted(true)` before the UI flips to `Broadcasting`, and the
mic stays open + capture loop keeps running with `if (muted) continue`
gating sends. New `LaunchedEffect(speakerPubkeyHex)` in
`OnStageIdleControls` calls `startBroadcast(initialMuted = true)`
automatically when mic permission is already granted, so a host who
just created a room sees their avatar transition to "Live (silent)"
and listeners can subscribe immediately without waiting for the host
to tap "Talk" first. Tap-to-talk path also passes `initialMuted = true`
now — unmute is a separate, explicit step on the mic toggle that
appears once we're in `Broadcasting(isMuted = true)`. Permission-
denied case still requires the manual Talk-button tap to launch the
runtime permission prompt.

Diagnostic logs from the previous two debug commits remain in place
(throttled to ≤ 1 line/sec/speaker at 50 fps capture cadence) so the
next two-phone repro can validate the fixes against logcat directly.
2026-05-05 16:44:39 +00:00
Claude 0c8e1ef58e chore(desktop): drop skipVlcSetup escape hatch
The -PskipVlcSetup / AMETHYST_SKIP_VLC opt-out is no longer used: CI
relies on the actions/cache step in build.yml to avoid hitting
get.videolan.org, and the in-build retry budget covers cache misses.
Remove the dead opt-out from desktopApp/build.gradle.kts and the env
export from the session-start hook.
2026-05-05 16:35:28 +00:00
Vitor Pamplona c0262abe59 Merge pull request #2735 from vitorpamplona/claude/fix-keyboard-padding-messages-nIBGm
Add IME padding to MessagesTwoPane Scaffold
2026-05-05 12:33:59 -04:00
Claude 697a749243 ci(desktop): cache vlc-setup downloads, drop skipVlcSetup bypass
Replace the -PskipVlcSetup=true CI bypass with a per-OS GitHub Actions
cache of ~/.gradle/vlcSetup. Cache key is hashFiles('desktopApp/build.gradle.kts')
so a VLC version bump invalidates cleanly; restore-keys lets unrelated
edits to that file still warm-start.

Cache hit: vlcDownload / upxDownload are up-to-date and we never touch
get.videolan.org. Cache miss (first run after version bump, or a new
runner): we fall back to the network, where the existing 5-attempt
retry budget in build.gradle.kts already handles flakes.

The skipVlcSetup property + AMETHYST_SKIP_VLC env hooks stay in
desktopApp/build.gradle.kts as a local-dev / CCW escape hatch — the
CCW egress can't reach get.videolan.org at all and has no actions/cache.
2026-05-05 16:28:41 +00:00
Claude f922cfd5bc fix(desktop): also disable vlc/upx Extract tasks under skipVlcSetup
Disabling only vlcDownload/upxDownload/vlcSetup left the chained
*Extract tasks in the graph with @InputFile properties pointing at
archives that were never downloaded — Gradle 9 then fails the build
during input validation:

  property 'upxArchiveFile' specifies file
  '/root/.gradle/vlcSetup/upx-4.2.4.tar.xz' which doesn't exist.

Disable the full vlc-setup plugin task set
(vlcDownload, vlcExtract, vlcFilterPlugins, vlcCompressPlugins,
upxDownload, upxExtract, vlcSetup) so packaging skips VLC bundling
cleanly.

Verified locally: :desktopApp:packageDeb now proceeds past those tasks
straight to jpackage with -PskipVlcSetup=true.
2026-05-05 16:11:55 +00:00
Vitor Pamplona 3364890a01 Merge pull request #2737 from vitorpamplona/claude/fix-group-message-notifications-UNKZq
Add notifications for Marmot group messages (kind:445)
2026-05-05 12:10:54 -04:00
Claude 30d8d1bb6f refactor(notifications): type notifyGroupMessage as ChatEvent
The previous signature took Event and runtime-checked kind == 9 inside
the notifier. That left the contract implicit: any caller could pass a
reaction or control message and the call would silently no-op.

Type the parameter as ChatEvent so the kind:9 restriction is structural,
and move the narrowing (`is ChatEvent`) to the GroupEventHandler call
site where the inner event is parsed. Reactions, deletions, and other
inner kinds now can't reach the notifier in the first place.

Drops the runtime kind check and the now-stale comment about reactions.
2026-05-05 15:57:36 +00:00
Claude be8dd0a3bc debug(nests): add playback-path logs to localise receiver silence
The wire is healthy: drainOneGroup logs show every group reaching the
receiver with 5 frames each, no drops, no parse errors, no pump exits,
no announce-ended teardown. Yet the green ring on the receiver still
blinks-and-stops. The bug must be in the playback pipeline below
drainOneGroup. Add NestPlay-tagged logs there so the next repro
identifies whether the failure is decoder, AudioTrack allocation,
beginPlayback, or write-blocking.

NestPlayer.play (`NestPlay` tag):
  - log start with prerollFrames
  - log preroll flush + beginPlayback transition
  - throttled per-N counters: receivedObjects / decodedFrames /
    emptyDecodes / enqueued
  - log decoder.decode throws explicitly
  - log empty-pcm decoder returns (separate from throws)
  - log enqueue duration when > 50 ms (catches AudioTrack.write-blocking)
  - log flow COMPLETED / cancelled / pipeline threw with final counts

AudioTrackPlayer (`NestPlay` tag):
  - log start() + the constructed AudioTrack's state / playState /
    bufferSizeBytes / minBuffer
  - log beginPlayback's t.play() return + post-call state
  - log AudioTrack.write partial / error returns
  - log setMuted / setVolume changes (silent mute is a known
    suspect for "audio not playing")

MediaCodecOpusDecoder (`NestPlay` tag):
  - log codec name on successful allocation
  - log allocation failure with exception type / message

All log calls use the lambda overload so format work only runs when
the tag is enabled. Throttled counters keep logcat at < 1 line/sec
per speaker even at 50 fps capture cadence.
2026-05-05 15:56:47 +00:00
Claude ab68f827ce ci(desktop): add skipVlcSetup opt-out to bypass flaky VLC downloads
get.videolan.org regularly times out from GitHub-hosted runners and
Claude Code web sandboxes, taking the desktop CI build down with it
even though the failure is unrelated to the change under review.

Wire a -PskipVlcSetup=true (env: AMETHYST_SKIP_VLC=true) opt-out in
desktopApp/build.gradle.kts that disables the vlcDownload, upxDownload,
and vlcSetup tasks. Pass it from build.yml so PR CI no longer gates on
VLC reachability, and export the env from the CCW session-start hook.

Release packaging (create-release.yml) intentionally does not set the
flag, so shipped artifacts still bundle VLC.
2026-05-05 15:41:01 +00:00
Claude cfde4a5bf7 fix(notifications): popup for Marmot group messages (kind:445)
Welcomes (kind:444) already had a direct-dispatch path because they have
no `p` tag and the cache-observer route can't match them to an account.
Group messages (kind:445) have the same problem — recipients are routed
by the `h` tag carrying the nostr_group_id — but were silently missed,
so the user only saw a popup when first added to a group, never for any
chat that followed.

Mirror the notifyWelcome path: after MarmotInboundProcessor decrypts
and verifies an ApplicationMessage and we've persisted the inner event
for the first time, fire notifyGroupMessage on NotificationDispatcher.
The notifier filters to ChatEvent (kind:9) so reactions, deletions and
control messages stay silent (matching how NIP-17 only notifies on
kind:14), applies the same 15-min freshness and self-author gates as
the other DM paths, and uses the marmot:<groupHex>?account=<npub>
deep-link scheme so taps land in the right chatroom.

Only fires on first-time decryption (isNew) so a relay re-broadcast or
on-disk persist replay can't double-notify.
2026-05-05 15:36:35 +00:00
Claude 3a2010d9c0 debug(nests): add diagnostic logs to localise receiver audio dropout
Adds focused logs at every suspicious point on the moq-lite receive +
publish paths so we can pin down why the receiver phone shows a brief
~5s window of activity then nothing while the speaker phone's UI looks
healthy.

Receiver-side (`NestRx` tag):
  - SUBSCRIBE send / SUBSCRIBE_OK / SUBSCRIBE bidi exit (with reason)
  - announce-watch update per Active/Ended event, plus which subs get
    closed when an Ended hits
  - pumpUniStreams start + naturally-ended / threw with stream count
  - drainOneGroup header, FIN (frames + droppedNoSub + trySend fail
    counters), and per-throw with stream sequence
  - wrapper handle attached / objects flow ENDED with emitted count

Broadcaster-side (`NestTx` tag):
  - inbound ANNOUNCE / SUBSCRIBE accepted / track-mismatch ignored
  - registerInboundSubscription / removeInboundSubscription with
    inboundSubs.size
  - throttled "send returning false (no subs / publisher closed)" so
    the speaker UI's "I'm broadcasting" claim can be cross-checked
    against frames actually leaving the wire
  - send threw (consecutive count) so a sustained openUniStream
    failure surfaces before MAX_CONSECUTIVE_SEND_ERRORS bails
  - openGroupStream open + per-throw

All log calls use the lambda overload so the throttled/string-format
work only runs when the tag is enabled.
2026-05-05 15:36:17 +00:00
Claude d7c3c22522 fix(messages): apply imePadding to two-pane Scaffold so edit field clears keyboard
The single-pane chat path wraps content in DisappearingScaffold, which applies
Modifier.imePadding() at the root surface. The dual-pane MessagesTwoPane uses a
plain Scaffold with no IME inset handling, so when the keyboard opens the
PrivateMessageEditFieldRow stayed hidden behind it.

https://claude.ai/code/session_012sPZ9KbbkTzdPL2KTn29ak
2026-05-05 15:23:01 +00:00
Vitor Pamplona f2c8e154cf Merge pull request #2734 from vitorpamplona/claude/fix-moq-protocol-exception-q1gTL
Fix header protection sampling for short QUIC payloads
2026-05-05 10:56:49 -04:00
Claude 410123e281 fix(nests): reset CreateNest sheet state after successful publish
CreateNestViewModel is keyed by user pubkey via `viewModel(key = …)`,
so the same instance survives across sheet open/close cycles. After a
successful publish the form was left untouched: room name, summary,
image URL stayed populated and — most visibly — `isPublishing` was
never cleared, so on the second open the Submit button rendered as a
spinning progress indicator forever.

Reset the form to fresh defaults (re-seeded from the user's saved
kind-10112 list) at the end of `publishAndBuildLaunchInfo()`, right
before returning the launch info. The sheet is about to dismiss
anyway, so the user never sees the in-place reset; the next open
behaves like a first open.
2026-05-05 14:17:05 +00:00
Claude edd6eb5c10 fix(quic): pad short plaintext payloads for HP sample (RFC 9001 §5.4.2)
ShortHeaderPacket.build / LongHeaderPacket.build crashed with
`IllegalArgumentException: packet too short for HP sample` whenever the
plaintext payload was small enough that pnLen + payload < 4 — most
visibly on the 1-RTT path when buildApplicationPacket emitted a single
1-byte PING (PTO probe with no ACKs queued and no streams to drain) and
the packet number still fit in 1 byte. The crash tore down the writer
loop, which surfaced upstream as moq-lite "subscribe stream FIN before
reply" because in-flight bidi streams got FIN'd by the peer.

RFC 9001 §5.4.2 mandates the sender pad the plaintext so the encrypted
output (plaintext + 16-byte AEAD tag) has at least 20 bytes after the
packet-number offset for the 16-byte HP sample. The fix pads the
plaintext with trailing 0x00 bytes — those decode as PADDING frames per
RFC 9000 §19.1 and decodeFrames already absorbs them. For long-header
packets the padded size feeds back into the Length varint.
2026-05-05 13:37:41 +00:00