4cdf8c3ec25a5cd1fb0a7b88f11169c2fab8e2ed
69 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
39f2b939c3 |
fix: drop stale relay-replayed groups on Nests listener reconnect
On every re-subscribe (listener reconnect or publisher-cycle), the moq-lite relay re-serves its cached latest group from the first frame. The wrapper forwarded it straight into the decoder, so in a fully-muted room the same old clip looped once per reconnect. Track the highest group sequence delivered by prior subscriptions and drop any group not strictly newer, mirroring kixelated/hang's Container.Consumer.#run. https://claude.ai/code/session_01G9h2dzkEj6Y2F1Yr2kCojp |
||
|
|
2eec53472a | Adds a log mock | ||
|
|
b2362f6504 |
refactor(nestsclient,quic): simplify-pass cleanups on moq-lite Lite-03/04 audit
Surface a few code-quality wins surfaced by the simplify pass on the
moq-lite Lite-03/04 audit. All semantic-preserving; tests untouched
in behavior.
- **Derive `MoqLiteSession.version` from `transport.negotiatedSubProtocol`**
instead of carrying it as a separate constructor parameter. The
version was already redundant with the transport's negotiated
ALPN; deriving it eliminates the parameter on
`MoqLiteSession.client(...)` and the `resolveMoqLiteVersion()` /
`moqVersion` plumbing in `connectNestsListener` /
`connectNestsSpeaker`. Tests drive the version via
`FakeWebTransport.pair(negotiatedSubProtocol = …)`, which already
plumbs through to the session's derivation.
- **Extract `MoqLiteSession.rejectSubscribe(bidi, errorCode, reason)`**
helper that writes a `SubscribeDrop` body and `RESET_STREAM`s the
bidi. Both Subscribe-rejection arms (`BROADCAST_DOES_NOT_EXIST`
and `TRACK_DOES_NOT_EXIST`) now share this helper instead of
duplicating the runCatching / write / reset shape. Future drop
codes plug in without growing the duplication.
- **Make `StrippedWtStream.stopSending` non-nullable.** The demux
always wires it (every surfaced stream has a read side), so the
`?:` "defensive" fallbacks in `StrippedWtReadStreamAdapter` and
`StrippedWtBidiStreamAdapter` were dead code. Tightens the
`StrippedWtStream` contract and shrinks the adapters.
- **Extract `SEQ_BITS=23` / `SEQ_MASK=0x007F_FFFF` / `SEQ_MAX`
constants** in `MoqLiteSession.openGroupStream`. The bit-pack
formula now reads as `(trackPriority and 0xFF) shl SEQ_BITS) or
(sequence and SEQ_MASK)` — layout is named, not derived from the
hex literals scattered through the body.
Items deferred (noted but not done):
- `handleInboundBidi` arm extraction — the per-arm variable capture
(`dispatched`, `inboundSub`, `inboundSubPublisher`,
`inboundAnnouncePublisher`) makes a clean refactor harder than
the readability win. Defer until a future feature changes the
dispatch shape and the refactor falls out naturally.
- `Fake{Bidi,Read}Stream` / `ChannelWriteStream` constructor
parameter explosion — the cells-as-nullable-named-params shape
is already readable; the candidate `FakeStreamSignals` struct
would force a left/right-direction flag on the bidi side, which
is uglier than the explicit `myReset` / `peerReset` naming.
- `MoqLiteCodec` `object` → versioned `class` — the
`version: MoqLiteVersion = LITE_03` parameter on each method is
the idiomatic Kotlin shape for an optional-with-default
discriminator; converting to a class would bind state to
instances and complicate the test path that exercises both
versions per-call.
- `announce()` / `probe()` pump abstraction — borderline; the
embedded logging + tracing in `announce()` would have to be
parameterized into the abstraction. Worth revisiting if a third
similar pump appears.
- `MoqLiteSubscribeDropCode` / `MoqLiteStreamCancelCode` sealed-
class refactor — `Long` constants match the wire shape; sealed
types would force conversion at every API boundary
(codec accepts `Long`, transport accepts `Long`).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
|
||
|
|
064654512d |
fix(nestsclient): keep stream priority pack non-negative — Lite-03 priority bit-layout
Pre-fix, the M1 priority pack `((trackPriority and 0xFF) shl 24)`
would set bit 31 whenever `trackPriority >= 0x80`, producing a
negative `Int`. `QuicConnectionWriter.sortedByDescending { it.priority }`
sorts via signed `Int.compareTo`, so negative-signed values land
BELOW every positive priority — exactly inverting the intended
"higher trackPriority drains first" ordering.
The default `DEFAULT_TRACK_PRIORITY = 0x80` sits exactly on this
boundary, so this would have hit production the moment a hand-
tuned `trackPriority=0xFF` (highest intended) appeared in any
multi-track scenario.
Reshape the layout to keep bit 31 clear:
bit 31 : 0 (reserved as the sign bit)
bits 30..23 : trackPriority u8 (0..255)
bits 22..0 : sequence low 23 bits (~97 days at 1 grp/sec)
The trackPriority byte still occupies an 8-bit slot, just shifted
one bit lower. Sequence saturation moves from 24 bits (≈ 6 days)
to 23 bits (≈ 97 days) — still ample for the 9-minute JWT refresh
cycle.
Two regression tests:
- The existing `publisher_packs_trackPriority_and_sequence_into_setPriority_value`
is updated to assert the new bit layout AND that the encoded
value is non-negative.
- New `publisher_priority_pack_keeps_top_trackPriority_above_lower_trackPriority`
is the direct guard against the bug: `trackPriority=0xFF`
must encode HIGHER than `trackPriority=0x7F`. Pre-fix this
test fails with `0xFF` encoding to a negative Int below the
positive `0x7F` encoding; post-fix both are positive and
correctly ordered.
Surfaced by the merge-prep security review of the moq-lite
Lite-03/04 audit branch — agent flagged it as "QoS/correctness
not security, excluded by DoS rule" but it's a real bug worth
fixing before merge.
Audit doc updated: bit layout in `nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md`
(M1 + L4 entries).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
|
||
|
|
335a337283 |
feat(nestsclient): version-aware Lite-03/04 codec + ALPN negotiation — moq-lite Lite-04
Lite-03 audit L1: completes the moq-lite version surface so the
session speaks the wire-format version the server picks via
WebTransport ALPN negotiation. Pre-fix the codec hardcoded Lite-03;
advertising `moq-lite-04` was a footgun because a Lite-04-preferring
relay would desync on the very first Announce exchange.
Verified diff between Lite-03 and Lite-04 by WebFetching kixelated's
Rust reference (`rs/moq-lite/src/lite/{announce,subscribe,probe}.rs`,
`rs/moq-lite/src/version.rs`, `rs/moq-lite/src/model/origin.rs`):
exactly three fields differ; everything else is byte-identical.
- `Announce.hops`: Lite-03 wire = single varint count (the spec
explicitly fills with `Origin::UNKNOWN` placeholders on decode);
Lite-04 wire = `varint(count) + count × varint(originId)` (the
`OriginList`). MAX_HOPS = 32.
- `AnnouncePlease.excludeHop`: Lite-03 absent; Lite-04 single
varint after `prefix`. Sentinel `0` = no exclusion.
- `Probe.rtt`: Lite-03 absent; Lite-04 single varint after
`bitrate`. Sentinel `0` = unknown (decoded as null). Outgoing
`Some(0)` is clamped to `Some(1)` to avoid colliding with the
sentinel — mirrors kixelated's `encode_msg` clamp.
New surface:
- `MoqLiteVersion` enum (LITE_03, LITE_04) with `fromAlpn` lookup.
- `MoqLiteCodec.{encode,decode}{AnnouncePlease,Announce,Probe}`
take `version: MoqLiteVersion = LITE_03` parameter, branch on
it. Default LITE_03 keeps existing call sites compiling.
- `MoqLiteSession.client(transport, scope, version)` carries the
version through every codec invocation.
- `MoqLiteAnnouncePlease.excludeHop: Long = 0L` (default).
- `MoqLiteAnnounce.hops: List<Long>` (was `Long`) — list of
origin IDs, bounded to MAX_HOPS=32. Existing call sites
migrate `hops = 0L` → `hops = emptyList()`, `hops = 7L` →
`hops = List(7) { 0L }`.
- `MoqLiteProbe.rtt: Long? = null` (default).
Negotiation:
- `WebTransportSession.negotiatedSubProtocol: String?` exposes
the server's `wt-protocol` selection. `QuicWebTransportFactory`
parses the response HEADERS, extracts the SF-string from
`wt-protocol`, and threads it into `QuicWebTransportSession`.
`FakeWebTransport.pair(negotiatedSubProtocol = …)` lets tests
drive the same path.
- Default advertised list now `[moq-lite-04, moq-lite-03]` (was
`[moq-lite-03]`). Lite-04 sits first to match kixelated's
preference; servers that don't support it fall back to
Lite-03 cleanly.
- `connectNestsListener` / `connectNestsSpeaker` resolve the
version via `resolveMoqLiteVersion(webTransport.negotiatedSubProtocol)`
and pass it to `MoqLiteSession.client(...)`. Falls back to
Lite-03 when the server doesn't echo `wt-protocol` (older
deployments) or echoes something unrecognised (forward-compat).
- New `MOQ_LITE_04_VERSION = 0x6D71_6C04L` synthetic version
code; `versionCode(version)` mapping function.
Regression tests (all green):
- `MoqLiteCodecTest.announcePlease_lite04_round_trips_excludeHop`
- `MoqLiteCodecTest.announcePlease_lite03_omits_excludeHop_on_wire`
- `MoqLiteCodecTest.announce_lite04_round_trips_full_origin_list`
- `MoqLiteCodecTest.announce_lite03_drops_origin_ids_keeps_count`
- `MoqLiteCodecTest.announce_decoder_rejects_oversize_hop_count`
(MAX_HOPS bounds check)
- `MoqLiteCodecTest.probe_lite04_round_trips_rtt`
- `MoqLiteCodecTest.probe_lite04_clamps_some_zero_to_one_to_avoid_unknown_sentinel`
- `MoqLiteCodecTest.probe_lite04_decodes_zero_rtt_as_null`
- `MoqLiteCodecTest.probe_lite03_wire_omits_rtt`
- `MoqLiteSessionTest.lite04_announce_round_trips_full_origin_list_through_session`
(end-to-end Lite-04 session)
Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (L1).
https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
|
||
|
|
d24953d98c |
feat(nestsclient): subscriber-driven Probe API (MoqLiteSession.probe) — moq-lite Lite-03
Lite-03 audit L3: completes the subscriber-side Probe surface to mirror kixelated's `Subscriber::run_probe_stream` (`rs/moq-lite/src/lite/subscriber.rs`). `MoqLiteSession.probe()` opens a bidi, writes `ControlType::Probe` (varint 4), and returns a `MoqLiteProbeHandle` whose `updates` flow yields each size-prefixed `MoqLiteProbe` message the publisher pushes. The handle's `close()` FINs the bidi and cancels the pump. `updates` is a `MutableSharedFlow(replay=8)` so a collector that attaches after the publisher's first emit doesn't miss it (matches the same shape the announce-watch uses). No production consumer for the API today — Amethyst's nests listener doesn't run ABR on a fixed-rate Opus encoder. The API exists to round out the moq-lite Lite-03 surface: a diagnostic tool can now read a publisher's bitrate without subscribing to its data track. The publisher-side handler (existing) was already correct: it writes one `MoqLiteProbe` (32 kbps Opus voice hint) and FINs. Regression test: `MoqLiteSessionTest.subscriber_probe_writes_control_type_and_decodes_publisher_bitrate`. Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (L3). https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq |
||
|
|
44291b956b |
fix(nestsclient): SubscribeOk narrows startGroup to publisher.nextSequence — moq-lite Lite-03
Lite-03 audit L2: when accepting a SUBSCRIBE, the publisher MAY narrow the subscriber's `startGroup` / `endGroup` request bounds. Pre-fix we always echoed `null/null`, which lost the diagnostic "which group am I about to start sending?" information — particularly useful for hot-swap continuations where the publisher's [MoqLitePublisherHandle.nextSequence] is non-zero (the seeded `startSequence` from the previous moq-lite session). The fix narrows `startGroup` to `targetPublisher.nextSequence`. The subscriber decodes this as "the next group on this subscription will be sequence N" and can log / surface the join-point. `endGroup` stays null because live audio rooms have no end in sight; the subscriber's request bound is honoured implicitly when the publisher closes. Regression test: `MoqLiteSessionTest.publisher_subscribeOk_narrows_startGroup_to_next_sequence`. Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (L2). https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq |
||
|
|
4368875346 |
fix(nestsclient): STOP_SENDING on group uni when subscription already canceled — moq-lite Lite-03
Lite-03 audit M2: when a group's uni stream arrives for a `subscribeId` whose subscription has already been removed (typical case: listener canceled / unsubscribed before the publisher observed it), the listener MUST signal the publisher to abandon in-flight retransmits via `STOP_SENDING(applicationErrorCode)` on that uni stream. Pre-fix, `drainOneGroup` silently dropped every frame (`droppedNoSub++`) and let the publisher keep pushing until natural FIN — wasted bandwidth on bytes the listener would discard, plus relay queue pressure on the per-subscriber forward pipeline that already sits behind the production stream-cliff. Wire the latched `stopSending(MoqLiteStreamCancelCode.SUBSCRIPTION_GONE)` on the first frame that observes `sub == null`. Latched (one-shot) so concurrent frames in the same drain don't re-fire — RFC 9000 §3.5 says subsequent STOP_SENDINGs are ignored anyway, but it saves the syscall and keeps the trace clean. New error-code constant `MoqLiteStreamCancelCode.SUBSCRIPTION_GONE = 0x10L` lives next to `MoqLiteSubscribeDropCode` in `MoqLiteMessages.kt`. moq-lite leaves application error codes undefined; we follow the same "small non-zero varint" convention. Test seam: `ChannelWriteStream` is now a public class (was private) with a `peerStopSendingCode` accessor, so a test that holds the writer side (`serverSide.openUniStream() as ChannelWriteStream`) can assert the listener's stopSending code without racing for the peer-side `FakeReadStream` reference. Regression test: `MoqLiteSessionTest.listener_stopSending_group_uni_when_subscription_already_canceled`. Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M2). https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq |
||
|
|
c7a49d1679 |
fix(nestsclient): RESET_STREAM with typed code after SubscribeDrop body — moq-lite Lite-03
moq-lite Lite-03 conveys application-level errors on any stream via `RESET_STREAM(application_error_code u32)` (audit M3). Pre-fix, our publisher's two SubscribeDrop reply paths (`BROADCAST_DOES_NOT_EXIST`, `TRACK_DOES_NOT_EXIST`) wrote the Drop body and then `bidi.finish()` — a graceful FIN — which overlapped with "publisher gracefully shut down." A subscriber watching only the QUIC layer (no body decode) couldn't tell rejection from shutdown. Replace the trailing `bidi.finish()` with `bidi.reset(errorCode)` in both Drop paths. The errorCode matches the body's `errorCode` field so a peer that decodes the Drop sees the same number as one that only sees the RESET_STREAM frame. The plumbing landed in the prior commit (`feat(quic,nestsclient): plumb RESET_STREAM + STOP_SENDING through WebTransport`). Tests: existing `publisher_replies_subscribeDrop_when_*` regressions gain an additional `lastPeerResetCode` assertion via `FakeBidiStream`, locking the typed-error contract. Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M3). https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq |
||
|
|
38df70504a |
fix(nestsclient): pack trackPriority + sequence into stream priority — moq-lite Lite-03 priority.rs
Per moq-lite Lite-03 (kixelated/moq `rs/moq-lite/src/lite/priority.rs`), `Publisher::serve_group` calls `priority.insert(track.priority, sequence)` and feeds the resulting position into `stream.set_priority`. Priority sorts first by `track.priority u8` (higher track = drains ahead under congestion), then by group `sequence` within a track (newer = drains ahead). Pre-fix we passed raw `sequence.toInt()` directly to setPriority, ignoring the per-track byte. For our single-Opus-track production case this was unobservable — newer-first ordering held by sequence monotonicity — but a future multi-track broadcast (audio + companion catalog / status track) would have starved the lower-rate track the moment audio's outbound queue got congested. Fix: add `trackPriority: Int = DEFAULT_TRACK_PRIORITY` parameter to `MoqLiteSession.publish()`, store on PublisherStateImpl, and bit-pack in `openGroupStream`: bits 31..24 trackPriority u8 (0..255) bits 23..0 sequence low 24 bits The 24-bit sequence window is ample (≈ 6 days at 1 group/sec, beyond which all newer groups within a single track tie — but they still beat older groups of any LOWER-priority track via the top byte). Production sessions cycle on JWT refresh every 9 min, so the wrap is defensive only. `DEFAULT_TRACK_PRIORITY = 0x80` matches the existing subscriber-side DEFAULT_PRIORITY midpoint, so all existing call sites keep their prior behavior. Test seam: `FakeWebTransport.openUniStream` now records the most- recent `setPriority` value via a shared `AtomicInteger` cell that the peer-side `FakeReadStream` exposes as `lastSetPriority`. Lets the new regression test verify the bit-pack formula on the actual peer-side read stream rather than peeking into private state. Regression test: `MoqLiteSessionTest.publisher_packs_trackPriority_and_sequence_into_setPriority_value`. Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M1). https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq |
||
|
|
3dcee2a4e3 |
fix(nestsclient): reject inbound Subscribe whose broadcast doesn't match — moq-lite Lite-03 subscribe.rs
The publisher-side dispatcher in handleInboundBidi previously matched inbound Subscribe bidis on `track` only, never checking whether the requested `broadcast` field matched the suffix our publisher claimed on this session. A peer (or buggy relay) could open a Subscribe under broadcast="otherPubkey" track="audio/data" and we would route OUR audio frames to them — the dispatcher only filtered on track. Production never bit because moq-rs's relay routes Subscribe messages to the specific publisher whose broadcast matches the requested path upstream of us, so an off-target broadcast never landed on our inbound bidi pump. But a peer-to-peer connection without a relay in between would have been able to siphon our audio under any broadcast string they invented. The fix: validate `sub.broadcast == publisher.suffix` (both already path-normalised by the codec) before track matching. On mismatch, reply `SubscribeDrop(errorCode=BROADCAST_DOES_NOT_EXIST, reasonPhrase="<requested> not published on this session (we publish <ours>)")` and FIN. New error code `MoqLiteSubscribeDropCode.BROADCAST_DOES_NOT_EXIST = 0x05L` mirrors the IETF MoQ-transport `TRACK_NAMESPACE_DOES_NOT_EXIST` semantic so a watcher can tell apart "wrong room" from "wrong rendition". Regression test: `MoqLiteSessionTest.publisher_replies_subscribeDrop_when_broadcast_does_not_match`. Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M5). https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq |
||
|
|
6c2d7efccb |
fix(nestsclient): skip Active announce when AnnouncePlease prefix doesn't match — moq-lite Lite-03 announce.rs
Per moq-lite Lite-03 (kixelated/moq `rs/moq-lite/src/lite/announce.rs`), a publisher MUST only emit `Announce(Active)` for broadcasts whose path starts with the requested AnnouncePlease prefix. Pre-fix, when `MoqLitePath.stripPrefix(please.prefix, ourSuffix)` returned `null`, we fell through to `?: announcePublisher.suffix` and emitted Active under our full suffix anyway, falsely advertising the broadcast under a prefix the subscriber didn't request. Production never bit because the relay always opens its announce bidi to us with `prefix=""` (which always matches), but a future peer-to- peer or namespace-scoped subscriber would have observed a ghost broadcast under whatever prefix they asked about. The fix: if `stripPrefix` returns `null`, FIN the bidi cleanly without writing any Announce body. The subscriber sees an empty announce stream and moves on. Regression test: `MoqLiteSessionTest.publisher_skips_announce_when_announce_please_prefix_does_not_match`. Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M4). https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq |
||
|
|
23b8bfd34a |
refactor(nests): per-stream channel count + AudioBroadcastConfig (I4 prep)
Split the previously global `AudioFormat.CHANNELS = 1` into a `DEFAULT_CHANNELS` constant + per-call-site `channelCount` parameters so a single broadcast can advertise stereo Opus without forcing every mono call site to grow a new argument. Generalises the catalog factory to `MoqLiteHangCatalog.opus48k(name, channels)` with memoised JSON bytes per shape, threads a new `AudioBroadcastConfig(channelCount)` through `connectNestsSpeaker` / `connectReconnectingNestsSpeaker` / `MoqLiteNestsSpeaker`, and adds a `channelCount` parameter to `MediaCodecOpusEncoder`. Production behaviour is unchanged for mono callers (the new config defaults to mono); the listener side already discovers the channel count from the catalog via `NestViewModel.awaitAudioPipelineConfig`. No test or wire changes. Phase 1 of `nestsClient/plans/2026-05-06-i4-stereo-cross-stack-scenario.md`. The hang-interop test scaffolding (`HangInteropTest`, `runSpeakerToHangListen`, Rust `hang-listen` / `hang-publish`, `JvmOpusEncoder`) doesn't exist on this branch yet, so the I4 forward + reverse scenarios are deferred until the parent T16 plan lands. https://claude.ai/code/session_01EqJEADzH9yjSuoP5L9js8i |
||
|
|
75f572ba3d |
test+fix(nests): pin stripLegacyTimestampPrefix; primaryAudio filters to legacy
Two audit-pass gaps:
G1 — `MoqLiteNestsListener.Companion.stripLegacyTimestampPrefix`
is the entire receive-side wire-format converter for audio frames
(every web speaker's Opus packet flows through it) and had zero
unit tests. A regression here is invisible to users — silent decode
failure of every web speaker — and to interop tests, because both
sides agree on the same bug. New StripLegacyTimestampPrefixTest pins:
- all four QUIC varint-length tiers (1 / 2 / 4 / 8 bytes), one
test per tier with a representative timestamp value plus a
tag-byte assertion so the test fails loudly if Varint.encode's
tier-selection changes.
- empty-payload and malformed-payload fast paths (returns
payload unchanged, same instance — no allocation).
- round-trip against `NestMoqLiteBroadcaster`'s exact wire
shape (`varint(timestamp_us) + raw_opus_packet`) across five
timestamp values spanning all four tiers.
G6 — `RoomSpeakerCatalog.primaryAudio()` previously returned
`audio.renditions.values.firstOrNull()` with no container filter.
A future publisher that emits CMAF before legacy in iteration
order would surface the CMAF rendition, and our decoder pipeline
would then feed CMAF MOOF/MDAT bytes to its legacy
`varint(ts)+opus` parser and decode garbage. Filter to
`container.kind == Container.LEGACY_KIND` so:
- mixed CMAF+legacy publishers always surface the legacy entry
regardless of map iteration order
- CMAF-only publishers correctly return null (caller falls
back to "unknown codec / no audio")
- publishers that omit `container` entirely also return null —
treating "no container declared" as "legacy by default" would
silently mis-decode a CMAF-only publisher that just forgot
to spell out `container.kind`
LEGACY_KIND const lives on `Container.Companion` so call sites
share one canonical spelling. Three new test cases pin the new
filter behaviour (mixed iteration order, CMAF-only, missing
container). The pre-existing `toleratesUnknownKeys` test is
updated to declare a legacy container — the unknown-key
tolerance we're checking is about unrecognised siblings, not
container-presence.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
|
||
|
|
ade8da3b5b |
fix(nests): NestPlayer keeps existing decoder when boundary factory throws
Audit-1: the publisher-boundary rebuild path released the old decoder
BEFORE asking the factory for a replacement (`runCatching {
decoder.release() }; decoder = factory()`). If `factory()` threw —
MediaCodec contention, audio policy denial mid-session, etc. — the
released decoder stayed assigned to the field and every subsequent
`decoder.decode(payload)` would throw `IllegalStateException` with
no recovery path. The room would silently fail for that subscription
until torn down.
Build the replacement first; only release the old one and swap on
success. On factory failure, log and keep the existing decoder
running. Cross-publisher Opus predictor state is wrong for one
group but at least audio keeps playing.
Audit-4+5: companion test cleanup —
- Drop the `byteArrayOf(0x0A) + it` / `0x0B` dead expressions in
the FakeOpusDecoder closures of the existing boundary-rebuild
test. They allocated a ByteArray and concatenated, then threw
the result away.
- Drop the local `IntBox` helper and use
`java.util.concurrent.atomic.AtomicInteger` like the rest of
the codebase does (`MoqLiteSession`, `MoqLiteNestsListener`).
Same semantics, no new vocabulary.
New test pins the dangling-field fix:
`publisher_boundary_keeps_old_decoder_when_factory_throws` flows
two MoqObjects with different trackAliases through a NestPlayer
whose factory always throws; asserts both frames decode through
the original decoder and the original decoder is released exactly
once on `stop()`.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
|
||
|
|
4714e3c723 |
fix(nests): T13 reset Opus decoder on publisher boundary in NestPlayer
The reissuing-subscribe wrapper splices fresh-publisher frames into the
same `SharedFlow` that NestPlayer consumes, so across a JWT-refresh
hot-swap (speaker side) or a cliff-detector recycle (listener side)
the decoder receives a discontinuous frame stream while keeping its
Opus predictor state. Result: an audible warble at every publisher
cycle. Single-stream tests don't catch it because they never present
two distinct publishers.
Detect the boundary via `MoqObject.trackAlias` — the underlying
`MoqLiteSession.subscribe` assigns a fresh subscribeId per SUBSCRIBE,
which the listener wrapper surfaces verbatim as `trackAlias` on every
emitted object. A change between consecutive objects = new publisher.
Add an optional `decoderFactory: (() -> OpusDecoder)?` to NestPlayer.
When non-null, NestPlayer tracks `lastTrackAlias` and on a change
releases the current decoder and rebuilds via the factory. The
default `null` preserves the legacy single-decoder behaviour so
existing tests / callers that don't care about boundaries stand
unchanged.
NestViewModel.openSubscription wires the factory: a closure capturing
the catalog-derived `channelCount` so a rebuild reuses the SAME
channel layout — without that capture, a rebuild after a stereo-
publisher cycle would default to mono and silently downmix.
Two new NestPlayerTest cases pin the behaviour:
- `publisher_boundary_rebuilds_decoder_when_factory_provided`:
factory invoked twice (initial + boundary), first decoder
released on boundary, second released on stop.
- `publisher_boundary_no_op_when_factory_is_null`: legacy path
holds onto the same decoder across trackAlias changes (unchanged
semantics).
NestPlayer's first constructor parameter is now `initialDecoder`
(was `decoder`); positional-arg call sites are unchanged but the
named-arg call sites in the test suite are updated accordingly.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
|
||
|
|
be4e0b9f98 |
fix(nests): T12 carry audio group sequence across hot-swaps
Every JWT-refresh hot-swap reset the audio publisher's group sequence
counter to 0, because `PublisherStateImpl` always initialised
`nextSequence = 0L`. kixelated/hang's `Container.Consumer.#run`
discards any consumer with `sequence < this.#active`, so a watcher
whose `#active` had advanced to e.g. 50 from the previous session's
publisher would silently drop every group on the new session until
either `#active` rolls over or the watcher re-subscribes — audible as
"speaker goes silent for a minute every 9 minutes" (the proactive
JWT refresh window).
Carry the sequence forward end-to-end:
- MoqLiteSession.publish gains a `startSequence: Long = 0L`
parameter; PublisherStateImpl seeds `nextSequenceField` from it
instead of hard-coding 0.
- MoqLitePublisherHandle exposes a `nextSequence: Long` snapshot.
`@Volatile`-backed so the hot-swap caller can read it without
contending the publisher's gate; race-window between read and
a concurrent send is sub-millisecond and would only produce a
single duplicate sequence in the very unlikely overlap, which
listeners tolerate.
- HotSwappablePublisherSource.openPublisherForHotSwap takes
`startSequence: Long = 0L`. MoqLiteNestsSpeaker passes it
through to session.publish.
- NestMoqLiteBroadcaster exposes its current publisher via a
read-only `currentPublisher` so the hot-swap pump in
ReconnectingNestsSpeaker.runHotSwapIteration can read its
`nextSequence` before opening the replacement publisher and
pass it as the seed.
- Catalog publisher keeps `startSequence = 0L` (the default) —
catalog isn't subject to the same `#active` accumulation
because each new SUBSCRIBE triggers a fresh emit-on-subscribe
write that resets the watcher's catalog state.
Listener-side change is none — the watcher already reads whatever
sequence we send. A new MoqLiteSessionTest pins the contract:
publishing with startSequence=42 makes the first uni stream's
GroupHeader.sequence == 42 and advances to 43 after the first send.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
5310b3f574 | Merge remote-tracking branch 'origin/main' into claude/fix-nests-audio-receiver-HCgOY | ||
|
|
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.
|
||
|
|
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. |
||
|
|
003cf42564 |
fix(nests): self-audit pass — two real bugs + robustness + tests
Audit of the four prior commits found two genuine regressions and two
robustness gaps in the new code paths.
Bug A: NestForegroundService.networkCallback dropped the publish for
the most important scenario it was added to handle. The earlier guard
`if (previous != null && previous != network)` suppressed both the
registration-time first onAvailable AND the legitimate WiFi-loss-
then-cellular-available path. On a WiFi → cellular handover the
sequence is `onLost(wifi)` (clears currentDefaultNetwork to null)
followed by `onAvailable(cellular)` — which then looks identical to
the registration callback to the guard, so no publish fires and the
QUIC session sits on the dead socket until PTO. Replaced the implicit
"previous == null" suppression with an explicit `seenInitialNetwork`
flag that's set true on the first onAvailable and never cleared, so
post-onLost reconnects publish correctly.
Bug B: requestAudioFocus result handling was too permissive. The
shape `if (result == AUDIOFOCUS_REQUEST_FAILED) TransientLoss else
Granted` falls through to Granted on the runCatching exception path
(`result == null`) and on AUDIOFOCUS_REQUEST_DELAYED (= 2) — meaning
audio plays over an active call when the OS hasn't actually released
focus. Switched to a strict `if (result == AUDIOFOCUS_REQUEST_GRANTED)`
check; everything else (FAILED, DELAYED, exception) starts the VM
muted.
Robustness: NestViewModel.openSubscription's onError callback used to
swallow every AudioException, which fit the per-packet decoder-error
case but turned PlaybackFailed and DeviceUnavailable from a deferred
beginPlayback into a permanent "Connecting…" spinner on the speaker
tile. Now we discriminate by AudioException.Kind: decoder/encoder
errors stay swallowed (Opus PLC papers them over), but PlaybackFailed
and DeviceUnavailable roll the slot back so a future reconcile can
retry.
Pre-roll ordering swap + tests: NestPlayer.play used to call
beginPlayback BEFORE flushing the pre-roll buffer, leaving a
microsecond window where the AudioTrack hardware was playing against
an empty buffer. AudioTrack MODE_STREAM explicitly supports write()
before play(), so flush-then-beginPlayback is the textbook pattern
and what the fix now does. Three regression tests cover:
- pre-roll defers beginPlayback until threshold is met (and the
flush-then-begin ordering is observable)
- partial pre-roll flushes when the upstream flow ends early
- empty flow doesn't begin playback at all
The FakeAudioPlayer grows beginPlaybackCount + queuedAtBeginPlayback
fields so the tests can assert ordering directly.
|
||
|
|
43720dd14d |
fix(nests): scope moq-lite publisher to a single track to stop catalog hijack
Listeners stuck on a spinning speaker avatar with no audio (against
both Amethyst-hosted and nostrnests.com-hosted Nests). The trace
captured on a working speaker phone showed every group keyed to
subId=1 / track='catalog.json' even though the broadcaster was
pumping Opus frames:
13:21:27.570 inbound SUBSCRIBE id=1 track='catalog.json'
13:21:27.574 inbound SUBSCRIBE id=0 track='audio/data'
13:21:27.609 openGroup seq=0 keyedOnSubId=1 track='catalog.json'
13:21:27.610 broadcaster: send accepted (subscriber attached)
... every subsequent group keyed=1, every audio frame on the
catalog stream
Root cause: `PublisherStateImpl.openNextGroupLocked` keyed each uni
stream off `inboundSubs.first()` with no track filter. The kdoc
asserted "Inbound subscription set is expected to be small (1 in
nests's listener-per-room model)" — that was true when listeners
only opened the audio sub. Once the listener side started opening a
catalog sub alongside (commits ~Apr 2026), whichever SUBSCRIBE
arrived first (typically the catalog one, by ~4 ms in this trace)
became the routing target for all audio frames. The relay forwarded
those frames to the listener with subscribeId=catalog, the listener
routed them to its catalog handler, RoomSpeakerCatalog.parseOrNull
returned null (it's not JSON), and the audio subscription's frames
channel never received anything — so onSpeakerActivity never fired
and the spinner stayed forever.
Fix: per moq-lite Lite-03, a publisher is responsible for one
(broadcast, track) tuple. Thread `track` through `MoqLiteSession.publish`
and have `PublisherStateImpl.registerInboundSubscription` reject
inbound SUBSCRIBEs whose track doesn't match — those still receive
SUBSCRIBE_OK from the bidi handler (best-effort per Lite-03's
optional-content semantics for catalog) but don't influence the
audio routing. `MoqLiteNestsSpeaker.startBroadcasting` passes
`track = MoqLiteNestsListener.AUDIO_TRACK` (= "audio/data").
Test callsites (MoqLiteSessionTest, NostrnestsProdAudioTransmissionTest,
NostrNestsSustainedSendOutcomesInteropTest) updated to pass the new
required parameter; all use track="audio/data" matching what they
exercise.
The diagnostic logs from the prior commits stay in — they're how we
caught this and they'll catch the next one. They can be stripped in
a follow-up once the fix is confirmed in the field.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
|
||
|
|
5d955eff99 |
feat: animate local speaker ring from MoQ frame transmission
Wire the local user's avatar into the existing speaking-ring animation so the host/speaker sees the same green ring + amplitude glow on their own cell that remote speakers already get. Ground truth is the broadcaster's `publisher.send(opus)` success: the new `onLevel` callback fires only after a frame actually leaves on the wire, computed from the raw PCM peak (shared `peakAmplitude` util used by both the player decode loop and the broadcaster capture loop). This means the animation reflects what the relay sees, not any UI-side mute / role state that could be stale or buggy — if frames are flowing, the ring plays. Plumbing: `NestsSpeaker.startBroadcasting` gains an optional `onLevel` that the moq-lite + IETF broadcasters both honour, the reconnecting wrapper replays it on every session reissue (alongside the existing mute-intent replay), and `NestViewModel.startBroadcast` hands it a lambda that calls the existing `onSpeakerActivity` / `onAudioLevel` plumbing keyed on the local pubkey. The 250 ms speaking-timeout fades the ring naturally when the user mutes or stops broadcasting. |
||
|
|
7c1af48ae7 |
fix(nestsClient): wait for mute replay in setMuted_intent_replays test
ScriptedSpeaker publishes startCount > 0 from inside startBroadcasting, so polling startCount alone races the wrapper pump's subsequent `if (desiredMuted) handle.setMuted(true)` step. Under load (full suite run on CI) the assertion fired before the pump replayed mute intent, producing setMutedCalls=0. Wait for the post-condition the assertion checks instead. |
||
|
|
8b7785a7e8 |
fix(nestsClient): token-parse hardening, mint timeout, IPv6 [], broadcaster-bail signal
- NestsTokenResponse.parse catch list now includes IllegalStateException so a malformed escape from a misbehaving auth server surfaces as NestsException instead of crashing the listener. - OkHttpNestsClient gains a configurable callTimeoutMs (default 90 s) enforcing an upper bound on the entire mintToken round-trip including retries. Without it a stalled server suspends the reconnect orchestrator indefinitely (the orchestrator's openOnce step parks on mintToken). 90 s leaves headroom over the worst-case 63 s 429 retry chain documented in MAX_RATE_LIMIT_RETRIES. - parseEndpoint tightens IPv6 bracket check from `closeBracket > 0` to `> 1`, rejecting the empty `[]` literal. - NestBroadcaster + NestMoqLiteBroadcaster gain an `onTerminalFailure` callback that fires once when the consecutive-send-error guard bails. MoqLiteNestsSpeaker wires this to flip the speaker state to Failed, giving ReconnectingNestsSpeaker the signal it needs to recycle the session — without this hook the broadcaster bailed silently and the outward speaker state stayed on Broadcasting forever. Adds regression test: - onTerminalFailure_fires_once_after_consecutive_send_failures 225 tests pass, 0 failures. Android target compiles clean. |
||
|
|
702885f4cb |
fix(nestsClient): fix race + leaks + Android encoder/decoder bugs from second audit
A fresh audit caught a real race in the round-1 subscribe() rewrite plus several distinct bugs in the Android MediaCodec layer. **MoqLiteSession.subscribe() race regression** — the launched collector that reads the SubscribeResponse and watches for peer FIN ran cleanup (remove from subscriptionsBySubscribeId, close frames) before subscribe() itself reached the post-await registration. If the publisher FIN'd immediately after sending Ok, the collector exited first against an empty map; subscribe() then registered the subscription, leaving frames in the map with no live collector to ever close it on transport drop. Consumer hung forever — exactly the failure mode round-1 fix #3 was supposed to prevent. Fix: pre-register the subscription BEFORE launching the collector. Drop unused `ok` field from ListenerSubscription. **MoqLiteNestsSpeaker.startBroadcasting publisher leak** — if session.publish() succeeded but broadcaster.start() then threw (mic permission denied, AudioRecord allocation failure), the publisher was never closed and stayed registered as session.activePublisher, permanently blocking subsequent startBroadcasting calls. **runCatching{suspend close} swallowing CancellationException** — MoqLiteNestsSpeaker.close, MoqLiteBroadcastHandle.close, MoqLiteNestsListener.close all wrapped suspending closes in runCatching, breaking structured cancellation when the parent scope cancelled teardown. Replaced with explicit cancel-rethrowing try/catch. **MediaCodecOpusDecoder ArrayList<Short> boxing on every audio frame** — at 50 fps × 960 samples × N speakers ≈ 48 000 boxed Short allocations/sec/speaker on the audio hot path. Rewritten to write directly into a pre-sized ShortArray via ShortBuffer.get(dst, off, len). **MediaCodecOpusEncoder bugs** - KEY_AAC_PROFILE = AACObjectLC was being set on an audio/opus encoder. Meaningless for Opus; stricter Codec2 stacks on Android 13+ reject the configure() call with IllegalArgumentException and surface as DeviceUnavailable. Removed. - The drain loop's INFO_OUTPUT_FORMAT_CHANGED branch had no progress guard. A buggy encoder re-emitting FORMAT_CHANGED without producing output would busy-spin against the 10 ms dequeue timeout. Now absorbed at most once per encode call. Same guard added to the decoder. Adds regression test: - frames_flow_completes_when_peer_FINs_immediately_after_Ok 224 tests pass, 0 failures. Android target compiles clean. |
||
|
|
9729f3a20c |
fix(nestsClient): perf + robustness — jitter, frame buffer, defensive bounds, send-error guard
- NestsReconnectPolicy gains a `jitter` parameter (default 0.3, AWS-
style equal jitter). Without it, every client reconnecting after
a relay restart retries on the identical 1s/2s/4s/… schedule and
thunders the relay during recovery. delayForAttempt also accepts
an explicit Random source for deterministic tests.
- MoqLiteFrameBuffer decouples capacity from size so power-of-two
growth actually amortises (the old shape allocated a fresh
ByteArray then truncated capacity back to `needed` per chunk,
defeating doubling). Adds a guard against varint reads past the
live region into uninitialised slack capacity.
- prefixWithType inlines the size-prefix wrap so a publisher
SubscribeOk/Drop reply doesn't allocate three ByteArrays.
- MoqLiteSubscribeOk gains the same `init { require(priority in 0..255) }`
bounds check its sibling MoqLiteSubscribe has — a buggy caller
building a malformed Ok reply now fails loudly instead of writing
a truncated byte on the wire.
- NestBroadcaster + NestMoqLiteBroadcaster track consecutive
publisher.send exception count and bail after 250 (~5 s at 50 fps)
instead of holding the mic open forever on a permanently dead
transport. publisher.send returning `false` (no inbound
subscriber) is NOT counted — empty rooms are a normal state.
Adds 8 regression tests:
- 5 in MoqLiteFrameBufferTest (multi-chunk reads, back-to-back
payloads, growth amortisation, compact, varint past-live guard)
- 3 in NestsReconnectPolicyTest (jitter spread band, jitter=0
determinism, jitter=1 collapses to 0..base)
223 tests pass, 0 failures.
|
||
|
|
f3a942dbd0 |
fix(nestsClient): more bugs from review — IETF parser, leaks, IPv6, URL encoding, structured concurrency
- Unknown IETF control message types now skip just the unknown frame
instead of dropping the entire merge buffer (a peer sending a
draft-17 message we don't enumerate — FETCH, GOAWAY, MAX_SUBSCRIBE_ID
— would otherwise wedge the pump). Adds MoqUnknownTypeException
carrying bytesConsumed so runControlPump can advance past it.
- pumpUniStreams + pumpInboundBidis wrap their inner collect in
coroutineScope so per-stream drains are children of the pump's job
(was: launched on the outer scope as siblings, leaking past
cancelAndJoin until the transport's own flow errored out).
- parseEndpoint handles IPv6 authorities ([::1], [2001:db8::1]:4443).
Naive lastIndexOf(':') used to find a colon inside the address.
- buildRelayConnectTarget percent-encodes the namespace path so a
malicious / careless `d` tag containing `?`, `#`, `&`, ` ` etc.
can't truncate the URL and shove the JWT into the wrong slot.
- Reconnect orchestrators (listener + speaker) replace
`runCatching { openOnce / close / startBroadcasting }` with explicit
try/catch that rethrows CancellationException, so cooperative
cancellation from the parent scope dies promptly instead of running
one more iteration after the cancel.
Adds three regression tests:
- parseEndpoint_handles_IPv6_authorities
- buildRelayConnectTarget_percent_encodes_path_unsafe_chars
- unknown_message_type_throws_typed_exception_with_full_frame_size
215 tests pass, 0 failures.
|
||
|
|
eb20ac2c2a |
fix(nestsClient): bug + perf fixes from Nests implementation review
- send() now nulls currentGroup and returns false on uni-stream write failure (was: silently returned true while reusing the dead stream) - Subscribe bidi gets a single long-running collector that closes the frames channel on peer FIN / transport tear-down (was: consumer could hang forever on a black-holed UDP path with no Announce(Ended)) - AudioException.Kind gains EncoderError; mic-side encode failures no longer mis-route through DecoderError handlers - MoqCodec.decode bounds the payload-length varint before .toInt() so a peer-driven absurd length can't wedge the parser forever - Publish hot-path framing collapses to a single ByteArray allocation (was: Varint.encode + plus operator, two allocs per Opus frame at 50 fps) - Drops the now-unused readSubscribeResponseFromBidi / readSizePrefixedFromBidiInto / EarlyExit helpers Adds a regression test that asserts the frames flow completes when the peer FINs the subscribe bidi. 213 tests pass, 0 failures. |
||
|
|
897287be5f |
nestsClient: expose moq-lite max_latency on subscribeSpeaker + correct plan framing
The moq-lite Lite-03 spec puts subscriber-side group-staleness control in
max_latency on the SUBSCRIBE frame: 0 = unlimited (relay falls back to its
MAX_GROUP_AGE = 30 s default); a positive value tells the relay to evict
groups older than that in favour of newer ones during transient backpressure.
Plumb it through:
NestsListener.subscribeSpeaker(pubkey, maxLatencyMs = 0L)
└─ MoqLiteNestsListener → MoqLiteSession.subscribe(.., maxLatencyMillis)
└─ DefaultNestsListener (IETF) ignores — no equivalent on that wire
└─ ReconnectingHandle re-issues across reconnects
Default stays at 0L for back-compat with the JS reference watcher; callers
opt in for low-latency live audio.
Also rewrites nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md
with corrected framing: of the four "upstream issues" the prior draft listed,
only our own missing MAX_STREAMS_UNI emission was a real bug (RFC 9000 §4.6
violation, fixed in
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
98e62b167b |
Merge pull request #2621 from vitorpamplona/claude/review-nostr-nests-compliance-hKBnS
feat(nests): proactive JWT refresh + reconnect for speaker path |
||
|
|
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. |
||
|
|
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
|
||
|
|
cd9279d23b |
test(nests): skip housekeeping bidi in groups_are_demuxed_by_subscribeId
The session-layer fix in
|
||
|
|
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). |
||
|
|
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 |
||
|
|
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 |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|