Commit Graph

12660 Commits

Author SHA1 Message Date
Vitor Pamplona a6fef4c813 Merge pull request #2691 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-01 16:03:42 -04:00
Crowdin Bot d3057b77e1 New Crowdin translations by GitHub Action 2026-05-01 20:01:13 +00:00
Vitor Pamplona 7be405847f Merge pull request #2690 from vitorpamplona/claude/audit-nest-interface-WCwBR
Improve Nest room UX: loading states, error handling, and performance
2026-05-01 15:59:14 -04:00
Claude ed89e2d542 fix(nests): UI polish — Stop button, unjoinable state, hand queue, DST
Audit-driven UI fixes that don't fit the perf bucket:

* NestActionBar — wire StopBroadcastButton between MicMuteToggle and
  LeaveStageButton while broadcasting. The doc table at the top of
  the file already advertised [Mute] [Stop] [Leave the Stage] but the
  button was defined and never used; speakers had to leave the stage
  to stop sending audio.

* NestActivityContent — render an explicit loading spinner while the
  kind-30312 resolves and a clear "this room can't be opened" page
  with a Back button when the event is missing service/endpoint.
  Replaces the previous black-screen dead end.

* HandRaiseQueueSection — wrap filter+sort in remember so a heartbeat
  doesn't rebuild the queue on every recompose, swap to LazyColumn
  so a long queue doesn't render every row every frame, and show
  UsernameDisplay instead of the raw 8-char hex prefix.

* CreateNestSheet schedule picker — compute the timezone offset at
  the picked instant rather than at Instant.now(); the previous
  variant was off by one hour for rooms scheduled across a DST
  transition.

* NestViewModelFactory — pass OkHttpNestsClient's httpClient via
  named parameter so the (String) -> OkHttpClient function reference
  doesn't get bound to the Long callTimeoutMs slot. Fixes the
  pre-existing :amethyst:compilePlayDebugKotlin failure on the
  branch base.

Adds three strings (loading, unjoinable title/body, back).

https://claude.ai/code/session_01DMeCvWyBYVVVPez2hwqCs4
2026-05-01 19:25:36 +00:00
Claude fc79faa468 fix(nests): clear NestBridge on logout and account switch
NestBridge holds a process-singleton AccountViewModel reference so
NestActivity (separately tasked) can read the active account without
serialising a NostrSigner through an Intent extra. The bridge's doc
promised it'd be cleared on logout / account switch but no call site
existed — so the audio-room activity could pick up a stale
AccountViewModel from the previous session and sign with the old
key after an account swap.

Wire NestBridge.clear() into both transition points in
AccountSessionManager: switchUserSync (always) and logOff (only when
the current account is being torn down).

https://claude.ai/code/session_01DMeCvWyBYVVVPez2hwqCs4
2026-05-01 19:25:14 +00:00
Claude 714159f816 perf(nests): dedicated kinds=[10312] limit=1 feed liveness probe
Feed thumbnails were reusing NestRoomFilterAssemblerSubscription to
flip the LIVE/ENDED badge based on the freshest kind-10312 presence.
That assembler subscribes to chat + presence + reactions (kinds 1311,
10312, 7) — so a feed of N rooms paid each popular room's full
chat-history pull per outbox relay just to render a status pill.

Add NestRoomLivenessAssembler, scoped to a single kinds=[10312],
#a=[room], limit=1 filter per relay, and switch the feed thumbnail
to it. The full chat / reaction / admin command REQ stays gated
behind the join flow.

https://claude.ai/code/session_01DMeCvWyBYVVVPez2hwqCs4
2026-05-01 19:25:03 +00:00
Claude 3acd84bf76 fix(nests): reactions dedup, drift-fade animation, 10s window
Audit-driven fixes for the audio-room reactions overlay:

1. RoomReactionsAggregator now dedups by event id. The previous code
   appended every event to a flat list, but LocalCache.observeEvents
   re-emits the full matching list on every cache mutation — so an
   N-reaction window grew quadratically and the overlay rendered the
   same emoji once per replay. Keyed by event id collapses re-emits.

2. RoomPresenceAggregator gains applyOrNull that returns null when an
   incoming heartbeat is older-or-equal to the cached presence; the VM
   skips the StateFlow write in that case. In a 200-peer room, every
   replay used to copy a 200-entry map plus run an O(N) StateFlow
   equality check 200 times per emission. Now it's one-and-skip.

3. ReactionsEvictionTicker only ticks while the aggregator is non-empty.
   Once the last reaction expires the loop self-cancels until the next
   reaction lands — a quiet room costs no scheduled work.

4. REACTION_WINDOW_SEC dropped 30s -> 10s. Reactions are about what
   the speaker is saying right now; a 10s window keeps the overlay
   timely instead of bleeding into the next paragraph.

5. SpeakerReactionOverlay drives a per-chip lifecycle animation: each
   chip drifts upward ~16dp and fades over the 10s window. A fresh
   reaction (same emoji) restarts the chip's animation. The
   AnimatedVisibility outer entry/exit still smooths first-arrival and
   final disappearance.

Tests: added a re-emit dedup case to RoomReactionsStateTest plus an
end-to-end replay assertion in NestViewModelTest. Existing tests
updated to use unique event ids.

https://claude.ai/code/session_01DMeCvWyBYVVVPez2hwqCs4
2026-05-01 19:24:48 +00:00
Vitor Pamplona c2fd8727e0 Merge pull request #2689 from vitorpamplona/claude/review-nests-implementation-5kO48
Fix race conditions and improve resilience in MOQ-Lite subscribe/broadcast
2026-05-01 14:45:54 -04:00
Claude 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.
2026-05-01 18:42:19 +00:00
Claude 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.
2026-05-01 18:42:19 +00:00
Claude 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.
2026-05-01 18:42:19 +00:00
Claude 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.
2026-05-01 18:42:19 +00:00
Claude 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.
2026-05-01 18:42:19 +00:00
Vitor Pamplona 1a912e5d7c Merge pull request #2688 from vitorpamplona/claude/audio-transmission-tests-zKzlB
Claude/audio transmission test learnings
2026-05-01 14:35:46 -04:00
Claude 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 d391ae1d). The relay's per-subscriber queue policy,
unbounded FuturesUnordered, no-timeout open_uni().await, and lack of
lagging-consumer detection are spec-permitted architectural decisions in a
gap moq-lite explicitly leaves to implementations — the spec puts staleness
control on the subscriber via max_latency, which we were sending as 0 the
whole time. Reframed as "feature request worth filing upstream", not "bugs".
2026-05-01 18:30:37 +00:00
Claude 9841e3ca2c plan: cite the actual moq-rs 0.10.25 source for the upstream stream-stop architectural gaps
Previous status note speculated about a per-subscriber forwarding ceiling.
This pins it down with file:line citations from the production version of
kixelated/moq so the upstream issue we file (or the next person to debug
this) has the exact code paths in hand:

  1. Unbounded per-track group queue (moq-lite/src/model/track.rs:69-90),
     evicted only by 30 s age (track.rs:29).
  2. serve_group's session.open_uni().await has no timeout
     (moq-lite/src/lite/publisher.rs:317-379) — blocks forever when the
     subscriber's CWND collapses.
  3. Publisher's FuturesUnordered task pool is unbounded
     (publisher.rs:325, 346) — keeps spawning blocked tasks with no
     backpressure to upstream.
  4. max_concurrent_uni_streams = 10000 (moq-native/src/quinn.rs:30-33)
     so the symptom is NOT the QUIC stream-id cap; it's the cascade
     above.
  5. No lagging-consumer RESET/STOP_SENDING anywhere; the relay never
     gives up on a stuck subscriber.

The framesPerGroup=5 mitigation we shipped sidesteps the cascade by
keeping upstream rate (10 streams/sec) below the threshold any of those
gaps would accumulate at. It doesn't fix the underlying issues — those
are upstream to address.
2026-05-01 15:53:43 +00:00
Vitor Pamplona 8cdeb13654 Merge pull request #2685 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-01 11:50:14 -04:00
Crowdin Bot 77318f14ce New Crowdin translations by GitHub Action 2026-05-01 15:47:46 +00:00
Vitor Pamplona ba568b5564 Merge pull request #2687 from vitorpamplona/claude/audio-transmission-tests-zKzlB
Final fix on missed audio frames.
2026-05-01 11:45:58 -04:00
Claude 85691cce26 nestsClient: bump framesPerGroup default to 5 to dodge prod relay's per-subscriber forward ceiling
Round-2 production sweep confirmed the residual sustained-broadcast
loss is on the relay side: every uni stream the relay opened to the
listener delivered cleanly through QUIC to the application
(peerInitiatedUni == received + 1 across every cliffed scenario,
pendingBytes always 0, cap headroom unused). The relay's
per-subscriber forward pipeline runs at ≈ 40 streams/sec sustained;
at 1 frame per group = 50 streams/sec we exceed it by ~9 streams/sec
and the relay's per-subscriber buffer overflows after 6–14 seconds
of continuous push, after which it stops opening new streams to that
subscriber entirely. Reproduced on:

  - sweep_30s    1500 frames -> 610-737 received (variance run-to-run)
  - sweep_120s   6000 frames -> 61-419 received
  - sweep_frames400  400 frames -> 36-400 received (huge variance)
  - sweep_3subs sub[1] 100 frames -> 94 received (others 100/100)

Packing 5 frames per moq-lite group cuts stream creation to 10/sec,
well under the relay's ceiling. Sweep-evidence: framesPerGroup=5
delivered 100/100 across every scenario including the long ones.
Late-join initial gap goes from ≤ 20 ms to ≤ 100 ms — imperceptible
for live audio rooms.

Updates the plan doc to mark status PRODUCTION-FIXED with the
two-layer fix (MAX_STREAMS_UNI extensions + framesPerGroup=5) and
flags the upstream-issue follow-up at kixelated/moq.

All :quic + :nestsClient JVM tests pass.
2026-05-01 15:43:33 +00:00
Claude e27abf49a0 diagnostic: snapshot listener-side flow control too, not just speaker
The previous round of fc-* lines exclusively reported the speaker's QUIC
connection state. That hid the real story for the residual stream loss:
each Opus frame the relay forwards arrives as a fresh peer-initiated
uni stream on the LISTENER side, so the listener's peerInitiatedUni
counter and advertisedMaxStreamsUni evolution are what matter — the
speaker's view is stable at peerInitiatedUni=1 for the whole run
(only the WT control stream is relay-initiated on that side).

Changes:

  - MoqLiteSession.transport changed from `private` to `internal val`
    so test code can downcast.
  - MoqLiteNestsListener exposes an `internal val transport` getter
    that returns the underlying WebTransportSession via the session's
    new internal accessor.
  - SendTraceScenario.run takes a new optional
    `listenerFlowControlSnapshots: List<suspend () -> QuicFlowControlSnapshot>`.
    When supplied, it emits `fc-listener[idx]-pre / -post-pump /
    -post-grace` lines in the same shape as the speaker side.
  - Refactored the three checkpoint emit sites into a shared
    `logFcSnapshot(scope, label, snap, includeRcvBuf)` helper so the
    speaker and listener lines print identical column layouts.
  - withProdSpeakerAndListeners + withHarnessSpeakerAndListeners cast
    each listener to MoqLiteNestsListener (the only impl in the tree)
    and downcast its WebTransportSession to QuicWebTransportSession,
    yielding a per-listener snapshot supplier list to the scenario
    block.

What the next sweep tells us: for each cliffed scenario, fc-listener[0]
should now show whether the listener is the bottleneck (e.g.
peerInitiatedUni stuck below the relay's intent, or udpDatagrams not
matching the relay's send count) or whether the relay just stopped
forwarding (peerInitiatedUni climbs but matches the partial received
count).

All :quic and :nestsClient JVM tests pass.
2026-05-01 15:22:58 +00:00
Claude 46af65591a chase residual stream loss: bump SO_RCVBUF to 4 MiB + diagnostic UDP counters + cheaper test collector
Three targeted changes against the residual sustained-load stream loss
that survived the MAX_STREAMS_UNI fix (long broadcasts and bursty
scenarios still cliffed mid-pump). Each addresses one of the round-1
hypotheses; the next prod sweep should tell us how much each
contributes.

quic/UdpSocket:
  - Bump SO_RCVBUF to 4 MiB at bind time. The kernel default (~200 KB
    on Linux/macOS, similar on Android) holds barely 130 MTU-sized
    datagrams; a multi-second moq-lite broadcast that the relay fans
    out to several subscribers transiently overflows this. Anything
    queued past `rmem` is silently dropped by the kernel and never
    reaches our QUIC stack.
  - Add lifetime diagnostic counters (receivedDatagramCount,
    receivedByteCount, receiveBufferSizeBytes) on the expect surface
    so commonMain code can read them via the new udpStatsSupplier
    hook on QuicConnection without each platform having to surface its
    own native bookkeeping.
  - QuicConnectionDriver wires the supplier on start; the connection's
    flowControlSnapshot bundles the stats into a new
    QuicFlowControlSnapshot.udp field.

QuicConnection.flowControlSnapshot:
  - Now also surfaces advertisedMaxStreamsUni / advertisedMaxStreamsBidi
    and peerInitiatedUniCount / peerInitiatedBidiCount so a sweep can
    see the listener-side stream-cap evolution alongside speaker-side
    counters that were already there.

SendTraceScenario:
  - verbosePerFrame default flipped from true to false. The per-frame
    `tx i=…` and `rx[idx] gid=…` logs go through InteropDebug ->
    JUnit's stdout capture; at 50 frames/sec the capture thread
    serialises the receive coroutine and starves the QUIC read loop,
    biasing the recorded received count downward on long runs. Tests
    that need the per-frame timeline opt in explicitly.
  - Replace the receive-side CopyOnWriteArrayList sink (O(N) per add,
    ~1.1M element copies cumulative for a 1500-frame run) with
    Collections.synchronizedList(ArrayList(capacityHint)) — O(1)
    amortised. Snapshot under the same lock at end of run so the
    JDK's iterator-locking contract is satisfied.
  - fc-pre / fc-post-pump / fc-post-grace lines now include
    `udpDatagrams=… udpBytes=… udpRcvBuf=…` plus
    `advertisedMaxStreamsUni=… peerInitiatedUni=…` so the next sweep's
    diagnostic dump shows directly whether the kernel actually
    delivered datagrams the relay sent.

Plan doc: nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md
documents round-2 changes and lists the remaining open hypotheses
(relay-side per-subscriber queue policy, receive-side
MAX_STREAM_DATA threshold).

All :quic + :nestsClient JVM tests pass.
2026-05-01 15:07:09 +00:00
Vitor Pamplona 253680fd82 Merge pull request #2686 from vitorpamplona/claude/audio-transmission-tests-zKzlB
Fixes some dropped frames in audio calls
2026-05-01 10:53:31 -04:00
Claude f1fc239ba4 plan: status update — original cliff fixed, residual loss documented
Production sweep on commit c0269859 confirms the MAX_STREAMS_UNI fix landed:
  - sweep_frames_200: received=200/200 missing=[] 
  - sweep_baseline 100×20ms: received=100/100 
  - sweep_2subs (both subscribers): 100/100 each 
  - sweep_cad{10,40,80,200}: all 100/100 
  - sweep_payload_{1kb,4kb}: 100/100 
  - sweep_slowconsumer: 100/100 

14 of 27 scenarios now deliver 100% of frames. The original "audio cuts
out at frame ~99 in two-user rooms" bug — the practical user-facing
issue — is fixed.

Residual loss in extreme scenarios (long broadcasts, very large payloads,
mid-stream pauses, fast bursts, multi-subscriber asymmetry) is documented
in the plan doc with three hypotheses to test next:
  1. Listener QUIC RX coalescing / UDP socket recv overhead
  2. Receive-side MAX_STREAM_DATA extension threshold
  3. moq-rs relay's per-subscriber buffer policy

Next step is separate investigation; the speaker side is now
demonstrably pristine (pendingBytes=0, consumed grows monotonically,
nextLocalUniIdx matches the frame count) so further work is on the
listener-RX or relay-forward side.
2026-05-01 14:46:51 +00:00
Claude c0269859b3 nestsClient: revert framesPerGroup default to 1 now that the QUIC fix lands frames
Production sweep_frames_200 confirms received=200/200 missing=[] after the
:quic MAX_STREAMS_UNI extension landed in d391ae1d. The framesPerGroup=5
mitigation in NestMoqLiteBroadcaster (commit 10ad69f1) is no longer
needed and was a workaround for a real QUIC bug; reverting to 1 brings
the wire shape in line with the JS reference broadcaster (one Opus frame
per moq-lite group → late-join initial gap drops from ≤100ms back to
≤20ms).

Tests that still want one-frame-per-group semantics keep their explicit
framesPerGroup=1 overrides — they pin the assumption independent of any
future default change. The broadcaster's framesPerGroup field kdoc is
updated to explain the history (mitigation → root-caused fix → revert).

All :quic and :nestsClient JVM tests pass.

Plan doc: nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md
status flipped to CLOSED with the production-validation numbers.
2026-05-01 14:26:36 +00:00
Claude d391ae1db9 quic: emit MAX_STREAMS_* to extend peer's stream-id cap (fixes prod cliff at frame ~99)
flowControlSnapshot dump from sweep_frames_200 against nostrnests.com proved
the speaker side was perfectly healthy (~5 EB conn-credit unused, 10 000-stream
peer cap unused, 0 bytes stuck in send buffers, all 200 streams opened) yet
the listener cliffed at frame 99. The constraint was on the listener's
receive side: our :quic was advertising initial_max_streams_uni = 100 at
handshake and never extending it, so the relay could only open 100 uni
streams to us *for the lifetime of the connection*. Each Opus frame the
relay forwards is a fresh peer-initiated uni stream, so any broadcast
longer than ~100 frames silently truncated at the audience.

QuicConnection:
  - peerInitiatedUniCount / peerInitiatedBidiCount counters (incremented
    in getOrCreatePeerStreamLocked).
  - advertisedMaxStreamsUni / advertisedMaxStreamsBidi tracking, starting
    at config.initialMaxStreams* and raised by the writer.

QuicConnectionWriter.appendFlowControlUpdates:
  - When peerInitiatedUniCount + cfg.initialMaxStreamsUni / 2 >=
    advertisedMaxStreamsUni, emit MaxStreamsFrame(bidi=false, newCap)
    where newCap = peerInitiatedUniCount + cfg.initialMaxStreamsUni.
    Same pattern the existing MaxDataFrame / MaxStreamDataFrame
    extension uses; same half-window threshold so we don't spam the
    peer.
  - Symmetric branch for bidi.

PeerStreamCreditExtensionTest:
  - Writer DOES emit MAX_STREAMS_UNI when peer's lifetime uni-stream count
    crosses the half-window threshold; advertisedMaxStreamsUni updates.
  - Writer DOES NOT emit MAX_STREAMS_UNI below the threshold;
    advertisedMaxStreamsUni stays at the initial cap.

Updated nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md
with the fc-snapshot dump that proved the cause and the fix that
addresses it. The framesPerGroup=5 mitigation in NestMoqLiteBroadcaster
can be reverted to 1 once the prod sweep confirms the cliff is gone.

All :quic and :nestsClient JVM tests pass.
2026-05-01 14:21:52 +00:00
Claude a0e5e04964 quic: expose flow-control snapshot for prod cliff investigation
Adds a read-only diagnostic surface that lets a test (or any caller)
read the peer's transport parameters, the live connection-level send
credit / consumed counters, the current peer-granted MAX_STREAMS_*
values, and the total bytes sitting in stream send buffers but not
yet handed to STREAM frames.

Goal: pin which budget runs out at the production "stream cliff"
described in nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md.
The plan flagged three candidates — connection-level MAX_DATA, per-
stream MAX_STREAM_DATA, or the relay's MAX_STREAMS_UNI extension
policy. The snapshot makes it possible to attribute the stall by
reading the diff between the pre-pump, post-pump, and post-grace
snapshots from the test's stdout.

QuicConnection:
  - flowControlSnapshot(): suspend, lock-protected, returns
    QuicFlowControlSnapshot (new data class) — peer TPs + live
    accounting + sum of enqueued-not-sent bytes across streams.

QuicWebTransportSession (jvmAndroid adapter):
  - quicFlowControlSnapshot() passthrough so the test can downcast
    its WebTransportSession and read the underlying connection's
    state without poking through the common transport interface.

SendTraceScenario:
  - Optional flowControlSnapshot lambda parameter; when supplied,
    logs three checkpoints — fc-pre, fc-post-pump, fc-post-grace —
    each on a single line with the full snapshot.

NostrnestsProdAudioTransmissionTest + NostrNestsSustainedSendOutcomesInteropTest:
  - withProdSpeakerAndListeners / withHarnessSpeakerAndListeners now
    yield a snapshot lambda to the scenario block. Every sweep test
    automatically dumps fc-* lines to the JUnit XML system-out.

FlowControlSnapshotTest:
  - Pre-handshake: peer TP fields are null, counters zero.
  - Post-handshake: every TP field reflects what the in-process TLS
    server advertised; sendConnectionFlowCredit equals
    initial_max_data; consumed = 0.
  - Post-allocate-and-enqueue: nextLocalUni/BidiIndex advance,
    totalEnqueuedNotSentBytes sums the buffered chunks, and
    streamsWithPendingBytes counts only streams with > 0 pending.

Reading the production sweep output after this commit:
  - fc-pre dumps what the relay grants on handshake (initial_max_data,
    initial_max_stream_data_uni, initial_max_streams_uni).
  - fc-post-pump shows whether sendConnectionFlowConsumed has
    plateaued at the cap (peer didn't extend MAX_DATA) or whether
    bytes are stuck in stream send buffers.
  - The diff between fc-post-pump and fc-post-grace tells us
    whether the relay's eventual MAX_DATA / MAX_STREAMS update did
    or didn't arrive during the 30-60 s grace window.
2026-05-01 14:14:06 +00:00
Claude 10ad69f1af nestsClient: pack 5 Opus frames per moq-lite group to dodge the prod stream cliff
Production sweep against nostrnests.com cliffed at exactly 99 frames received
across every multi-frame scenario (frames200/400, 30s, 120s, etc.) when the
broadcaster opened one QUIC uni stream per Opus frame. The investigation in
nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md walks through
why the obvious "raise the stream cap" hypothesis didn't pan out (publisher.send
was never returning false past the cap, the local QuicConnection was already
seeing the relay extend MAX_STREAMS_UNI dynamically).

The same sweep showed sweep_frames_per_group_5 / _20 / _all delivering 100/100
frames cleanly. The cliff scales with stream-creation rate, not byte volume or
total stream count — so packing N frames per group (one uni stream per N frames
instead of per frame) bypasses whatever the underlying limit is.

NestMoqLiteBroadcaster:
  - Add framesPerGroup constructor param, default 5 (≈ 100 ms of audio per
    moq-lite group). Stream-creation rate drops from 50/sec to 10/sec, well
    below the production cliff threshold.
  - Counter resets after each endGroup; trailing partial group flushes its
    FIN on capture EOF so the listener doesn't sit on a half-open stream.

MoqLiteNestsSpeaker + connectNestsSpeaker:
  - Plumb framesPerGroup through to the broadcaster. Default flows from
    NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP so production callers
    get the mitigation without code changes; tests can override to 1 to
    keep their groupId-per-frame assertions.

Tests:
  - All harness interop tests (RoundTrip, LateJoin, MultiPeer, Mute,
    Unsubscribe, SpeakerClose, both Reconnecting variants) plus the prod
    NostrnestsProdAudioTransmissionTest now pass framesPerGroup = 1
    explicitly to preserve their per-frame group/object id assertions.
  - All :quic and :nestsClient JVM tests pass.

Trade-off: a brand-new subscriber that joins mid-broadcast picks up at the
next group boundary. With 5 frames/group the late-join initial gap is up
to 100 ms (perceptually inaudible for live audio). The previous wire shape
gave at most 20 ms.

Investigation plan for the underlying QUIC cliff lives in
nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md. The
attempted-then-reverted suspend/STREAMS_BLOCKED commit is summarised
under "Attempt 1" in that doc.
2026-05-01 14:06:21 +00:00
Claude 96a585a67e Revert "quic: suspend openUni/BidiStream on peer-cap exhaustion + emit STREAMS_BLOCKED"
This reverts commit f0705e3ab1.
2026-05-01 13:59:38 +00:00
davotoula a22eef1fee i18n: translate bottom_bar_settings_restore_default and jump_to_parent_reply
Adds Czech, Brazilian Portuguese, Swedish, and German translations for
the two new strings introduced upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:23:51 +02:00
Claude f0705e3ab1 quic: suspend openUni/BidiStream on peer-cap exhaustion + emit STREAMS_BLOCKED
Production sweep showed every audio scenario opening >100 client-initiated
uni streams cliffed at received=99/N (one-line summary
[sweep-30s] sub[0] received=99/1500 missing=[99-1499]).
Same shape across every cadence, payload, and frame-count sweep variant —
the relay's initial_max_streams_uni=100 was being silently exhausted, after
which openUniStream threw QuicStreamLimitException, which the production
NestMoqLiteBroadcaster swallowed via its outer runCatching, dropping every
subsequent frame on the floor.

Fix:

  - QuicConnection.openBidiStream / openUniStream now SUSPEND when the
    peer-granted cap is reached, instead of throwing. They re-acquire
    the connection lock on each retry, so the parser's MAX_STREAMS update
    is observed atomically. Closing the connection wakes blocked openers
    with QuicConnectionClosedException so they don't hang.

  - QuicConnection.streamCapNotifier — single CompletableDeferred swapped
    after each fire so all blocked openers wake at once rather than
    serialising through Channel.receive.

  - QuicConnectionParser fires the notifier whenever an inbound
    MAX_STREAMS frame raises peerMaxStreams{Bidi,Uni}.

  - QuicConnectionWriter emits a STREAMS_BLOCKED frame (RFC 9000 §19.14)
    when an opener registers itself blocked, draining the slot once
    written so we send at most one STREAMS_BLOCKED per cap value.
    Frame.kt gains a real StreamsBlockedFrame class — previously the
    inbound bytes were just consumed and discarded.

  - QuicConnectionDriver.start wires connection.sendWakeupHook so an
    internal opener-blocked event nudges the send loop without callers
    needing a driver reference.

PeerStreamLimitTest rewritten:
  - "throws QuicStreamLimitException" → "suspends with withTimeoutOrNull"
  - Added: MAX_STREAMS_UNI frame wakes a suspended opener
  - Added: openUniStream queues the STREAMS_BLOCKED slot
  - Added: closing the connection unblocks waiters with the closed
    exception
  - Added: StreamsBlockedFrame round-trips through encode/decode

All :quic and :nestsClient JVM tests pass.
2026-05-01 12:52:22 +00:00
David Kaspar 161f127903 Merge pull request #2684 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-01 13:49:18 +02:00
Crowdin Bot a2ab08fef5 New Crowdin translations by GitHub Action 2026-05-01 11:47:15 +00:00
David Kaspar 68cbfc5381 Merge pull request #2683 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-01 13:47:14 +02:00
Crowdin Bot a012643cc9 New Crowdin translations by GitHub Action 2026-05-01 11:47:07 +00:00
Vitor Pamplona ee0481a38e Merge pull request #2681 from davotoula/claude/add-nav-default-button-PRxqx
Add restore-default button to bottom nav settings
2026-05-01 07:45:25 -04:00
Vitor Pamplona 19302092fd Merge pull request #2682 from davotoula/claude/add-reply-parent-jump-bpgWO
Jump-to-parent icon on replies in Full UI mode
2026-05-01 07:45:13 -04:00
Vitor Pamplona dc1cdca27a Merge pull request #2680 from davotoula/fix/zap-receipt-log-flood
Downgrade "Zap Request not found" log from error to debug
2026-05-01 07:44:45 -04:00
davotoula d8d6a9e63b i18n: translate home tabs, nest lobby, and Tor fallback strings
Adds Czech, Brazilian Portuguese, Swedish, and German translations for
15 strings introduced upstream covering the new Home Tabs settings, the
Nest lobby UI, and the Tor connection-failure dialog.

sv-rSE already had nest_tab_chat and nests_servers_relay_label, so only
13 of the 15 keys were appended there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:42:14 +02:00
davotoula 3d2dc7b300 Code review:
flatten parent-reply resolution in JumpToParentReplyButton
2026-05-01 13:25:24 +02:00
davotoula 86698dc3df fix(settings): reset drag state before restoring default bottom bar
Tapping Restore Default mid-drag would replace `items` with the default
list while `draggedItemIndex` still pointed into the old list. If the
old index exceeded the new list's lastIndex, the next pointer event
indexed `items` out of bounds. Clear drag state before save().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:05:10 +02:00
Claude c8818ed317 feat(settings): add restore-default button to bottom nav settings
Lets users who have over-customized the bottom navigation bar
return to the fresh-install layout (Home, Messages, Video, Discover,
Notifications) without manually toggling each item.
2026-05-01 08:59:17 +00:00
davotoula f1ac39f94c fix(zaps): downgrade "Zap Request not found" log from error to debug
Receipts (kind 9735) frequently arrive before their matching zap-request
(kind 9734) is in LocalCache when we are subscribed to receipt relays but
not the zapper's outbox. That race is expected and not actionable from a
user's perspective, yet the previous Log.e wrote a full stack-aiding
error line — including the entire receipt JSON — to logcat for every
unmatched receipt, often six or more times per id within a 15s window.

Downgrade to Log.d so the message stays available for debugging while
no longer flooding error logs in production benchmark runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:43:57 +02:00
Claude 448cd1c1d8 feat: jump-to-parent icon on replies in Full UI mode
Adds a small chevron-up button next to the timestamp on each reply.
Tapping it navigates to the note this reply is replying to so users
can follow conversation chains in dense threads. Hidden in Simplified
and Performance UI modes.
2026-05-01 07:34:12 +00:00
Claude 2da1885975 Add diagnostic sweep matrix across cadence, frame count, payload, group packing, fan-out
The previous run pinned the production failure to "82/100 frames at 20 ms cadence
two-users, all 18 trailing groups lost; publisher.send returned true for every
frame". To narrow the cause the next run needs to vary one dimension at a time
and dump rich per-frame data, so we can attribute the loss to:

  - QUIC stream-credit (RTT-bound) vs absolute count cap
  - Per-stream-creation cost vs per-byte cost
  - Listener-side buffer overflow vs relay-side drop
  - Steady-state vs initial-burst behaviour
  - Shared end-of-stream FIN flush race vs prod-only loss

SendTraceScenario.kt: shared per-frame instrumented pump driver. Records
publisher.send Boolean per frame, send latency, endGroup exceptions,
arrivals per subscriber with wall-clock timing, missing-objectId run
lengths, send-latency p50/p99/max + count of >1ms sends. Verbose logging
(InteropDebug.checkpoint) emits one line per tx and one per rx so the
JUnit XML system-out doubles as a timing trace.

Sweep methods (mirrored prod-mode and harness-mode):
  - baseline 100×20ms (reproduce known cliff)
  - cadence sweep:  5 / 10 / 40 / 80 / 200 ms (RTT-dependent?)
  - burst (no cadence) 100 frames (max inflight)
  - frame-count sweep: 50 / 200 / 400
  - payload sweep: 1 KB / 4 KB / 16 KB
  - frames-per-group: 5 / 20 / 100 (uni-stream vs bytes)
  - late subscribe at frame 25, frame 50 (from-latest semantics)
  - mid-pump 5 s pause after frame 50
  - 2-, 3-subscriber fan-out
  - 50 ms slow-consumer listener (per-subscription buffer overflow)
  - long-run 30 s and 120 s (steady-state)

Each test method is a one-line Scenario literal; setup helpers
withProdSpeakerAndListeners / withHarnessSpeakerAndListeners build
publisher + N listeners, run the scenario, and tear down in order.

Smoke-tested against the local kixelated/moq reference relay: every
scenario except framesPerGroup=100 reproduces the same trailing-frame
artifact (received=99/100, missing=[99]) the original test surfaced.
framesPerGroup=100 receives every frame, confirming the local issue is
specifically per-uni-stream FIN flushing — independent of the production
82/100 cliff.
2026-05-01 02:42:41 +00:00
Vitor Pamplona 8f843be41b Merge pull request #2679 from vitorpamplona/claude/audio-transmission-tests-zKzlB
Claude/audio transmission tests z kzl b
2026-04-30 22:31:07 -04:00
Claude a97f494735 Reproduce per-frame send-outcome trace against the local moq-relay
Mirror NostrnestsProdAudioTransmissionTest.sustained_per_frame_send_outcomes_two_users
but drive the local Docker-backed kixelated/moq reference relay via
NostrNestsHarness. Lets us reproduce the production "82/100" cliff
without nostrnests.com so we can attribute the loss to client code
(`:nestsClient` / `:quic`) vs. the production deployment.

Same per-frame instrumentation: bypass NestMoqLiteBroadcaster, call
publisher.send() / publisher.endGroup() directly, record the boolean
return per frame plus any endGroup exception, dump a per-frame
send/recv table on failure.
2026-05-01 02:17:48 +00:00
Claude d143053796 Add per-frame send-outcome trace to isolate moq-lite vs downstream loss
Test 4 showed a hard 82/100 cliff at 20 ms cadence two-users, all 18
trailing groups lost permanently. Root-cause-narrowing requires knowing
whether MoqLitePublisherHandle.send returned false (frame dropped at the
moq-lite layer due to inboundSubs.isEmpty() / publisherClosed) or true
(frame queued by moq-lite and lost downstream — uni-stream write error
swallowed by runCatching, QUIC flow control wedged, or relay drop).

The production NestMoqLiteBroadcaster wraps everything in
runCatching {…}.onFailure {…} and ignores the boolean, so frame loss is
structurally invisible to the app. This new test bypasses the broadcaster
and calls publisher.send / publisher.endGroup directly while mirroring
the auth + transport + session setup that connectNestsSpeaker performs.

Records sendOutcomes[i] and endGroupErrors[i] per frame; on failure
prints a per-frame send/recv table so we can see exactly when send
flips false (or whether all 100 sends report true and the loss is
strictly downstream of moq-lite).
2026-05-01 02:13:27 +00:00
Vitor Pamplona 02f5a31c9f Merge pull request #2678 from vitorpamplona/claude/audio-transmission-tests-zKzlB
Sustained-cadence test: dump partial arrivals + gap pattern
2026-04-30 22:06:37 -04:00
Claude 7e3d622399 Sustained-cadence test: dump partial arrivals + gap pattern
Previously test 4 used .take(N).toList() inside withTimeoutOrNull, so a
timeout discarded every received frame and the only failure signal was
"only got partial audio". When the production run flagged frame loss,
we couldn't tell whether the stream stalled mid-flight, never started
fanning out, or dropped scattered groups.

Drain into an external CopyOnWriteArrayList via .onEach { } so the
partial contents survive the timeout, then on failure surface:
  - received N/100 with first/last arrival timestamps
  - missingGroups summary as run-length ranges (e.g. [3-7,42,90-99])
  - first/last 20 arrivals so the front and tail of the sequence are
    visible without flooding stdout

The gap pattern alone narrows the diagnosis: front-loaded losses point
to subscribe-settle being too short, tail-loaded losses point to a
broadcaster shutdown race, scattered drops point to per-subscription
buffer overflow / datagram loss.
2026-05-01 02:05:40 +00:00