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.
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.
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.
Adds a read-only diagnostic surface that lets a test (or any caller)
read the peer's transport parameters, the live connection-level send
credit / consumed counters, the current peer-granted MAX_STREAMS_*
values, and the total bytes sitting in stream send buffers but not
yet handed to STREAM frames.
Goal: pin which budget runs out at the production "stream cliff"
described in nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md.
The plan flagged three candidates — connection-level MAX_DATA, per-
stream MAX_STREAM_DATA, or the relay's MAX_STREAMS_UNI extension
policy. The snapshot makes it possible to attribute the stall by
reading the diff between the pre-pump, post-pump, and post-grace
snapshots from the test's stdout.
QuicConnection:
- flowControlSnapshot(): suspend, lock-protected, returns
QuicFlowControlSnapshot (new data class) — peer TPs + live
accounting + sum of enqueued-not-sent bytes across streams.
QuicWebTransportSession (jvmAndroid adapter):
- quicFlowControlSnapshot() passthrough so the test can downcast
its WebTransportSession and read the underlying connection's
state without poking through the common transport interface.
SendTraceScenario:
- Optional flowControlSnapshot lambda parameter; when supplied,
logs three checkpoints — fc-pre, fc-post-pump, fc-post-grace —
each on a single line with the full snapshot.
NostrnestsProdAudioTransmissionTest + NostrNestsSustainedSendOutcomesInteropTest:
- withProdSpeakerAndListeners / withHarnessSpeakerAndListeners now
yield a snapshot lambda to the scenario block. Every sweep test
automatically dumps fc-* lines to the JUnit XML system-out.
FlowControlSnapshotTest:
- Pre-handshake: peer TP fields are null, counters zero.
- Post-handshake: every TP field reflects what the in-process TLS
server advertised; sendConnectionFlowCredit equals
initial_max_data; consumed = 0.
- Post-allocate-and-enqueue: nextLocalUni/BidiIndex advance,
totalEnqueuedNotSentBytes sums the buffered chunks, and
streamsWithPendingBytes counts only streams with > 0 pending.
Reading the production sweep output after this commit:
- fc-pre dumps what the relay grants on handshake (initial_max_data,
initial_max_stream_data_uni, initial_max_streams_uni).
- fc-post-pump shows whether sendConnectionFlowConsumed has
plateaued at the cap (peer didn't extend MAX_DATA) or whether
bytes are stuck in stream send buffers.
- The diff between fc-post-pump and fc-post-grace tells us
whether the relay's eventual MAX_DATA / MAX_STREAMS update did
or didn't arrive during the 30-60 s grace window.
Production sweep against nostrnests.com cliffed at exactly 99 frames received
across every multi-frame scenario (frames200/400, 30s, 120s, etc.) when the
broadcaster opened one QUIC uni stream per Opus frame. The investigation in
nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md walks through
why the obvious "raise the stream cap" hypothesis didn't pan out (publisher.send
was never returning false past the cap, the local QuicConnection was already
seeing the relay extend MAX_STREAMS_UNI dynamically).
The same sweep showed sweep_frames_per_group_5 / _20 / _all delivering 100/100
frames cleanly. The cliff scales with stream-creation rate, not byte volume or
total stream count — so packing N frames per group (one uni stream per N frames
instead of per frame) bypasses whatever the underlying limit is.
NestMoqLiteBroadcaster:
- Add framesPerGroup constructor param, default 5 (≈ 100 ms of audio per
moq-lite group). Stream-creation rate drops from 50/sec to 10/sec, well
below the production cliff threshold.
- Counter resets after each endGroup; trailing partial group flushes its
FIN on capture EOF so the listener doesn't sit on a half-open stream.
MoqLiteNestsSpeaker + connectNestsSpeaker:
- Plumb framesPerGroup through to the broadcaster. Default flows from
NestMoqLiteBroadcaster.DEFAULT_FRAMES_PER_GROUP so production callers
get the mitigation without code changes; tests can override to 1 to
keep their groupId-per-frame assertions.
Tests:
- All harness interop tests (RoundTrip, LateJoin, MultiPeer, Mute,
Unsubscribe, SpeakerClose, both Reconnecting variants) plus the prod
NostrnestsProdAudioTransmissionTest now pass framesPerGroup = 1
explicitly to preserve their per-frame group/object id assertions.
- All :quic and :nestsClient JVM tests pass.
Trade-off: a brand-new subscriber that joins mid-broadcast picks up at the
next group boundary. With 5 frames/group the late-join initial gap is up
to 100 ms (perceptually inaudible for live audio). The previous wire shape
gave at most 20 ms.
Investigation plan for the underlying QUIC cliff lives in
nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md. The
attempted-then-reverted suspend/STREAMS_BLOCKED commit is summarised
under "Attempt 1" in that doc.
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>
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.
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>
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>
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.
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>
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.
The previous run pinned the production failure to "82/100 frames at 20 ms cadence
two-users, all 18 trailing groups lost; publisher.send returned true for every
frame". To narrow the cause the next run needs to vary one dimension at a time
and dump rich per-frame data, so we can attribute the loss to:
- QUIC stream-credit (RTT-bound) vs absolute count cap
- Per-stream-creation cost vs per-byte cost
- Listener-side buffer overflow vs relay-side drop
- Steady-state vs initial-burst behaviour
- Shared end-of-stream FIN flush race vs prod-only loss
SendTraceScenario.kt: shared per-frame instrumented pump driver. Records
publisher.send Boolean per frame, send latency, endGroup exceptions,
arrivals per subscriber with wall-clock timing, missing-objectId run
lengths, send-latency p50/p99/max + count of >1ms sends. Verbose logging
(InteropDebug.checkpoint) emits one line per tx and one per rx so the
JUnit XML system-out doubles as a timing trace.
Sweep methods (mirrored prod-mode and harness-mode):
- baseline 100×20ms (reproduce known cliff)
- cadence sweep: 5 / 10 / 40 / 80 / 200 ms (RTT-dependent?)
- burst (no cadence) 100 frames (max inflight)
- frame-count sweep: 50 / 200 / 400
- payload sweep: 1 KB / 4 KB / 16 KB
- frames-per-group: 5 / 20 / 100 (uni-stream vs bytes)
- late subscribe at frame 25, frame 50 (from-latest semantics)
- mid-pump 5 s pause after frame 50
- 2-, 3-subscriber fan-out
- 50 ms slow-consumer listener (per-subscription buffer overflow)
- long-run 30 s and 120 s (steady-state)
Each test method is a one-line Scenario literal; setup helpers
withProdSpeakerAndListeners / withHarnessSpeakerAndListeners build
publisher + N listeners, run the scenario, and tear down in order.
Smoke-tested against the local kixelated/moq reference relay: every
scenario except framesPerGroup=100 reproduces the same trailing-frame
artifact (received=99/100, missing=[99]) the original test surfaced.
framesPerGroup=100 receives every frame, confirming the local issue is
specifically per-uni-stream FIN flushing — independent of the production
82/100 cliff.
Mirror NostrnestsProdAudioTransmissionTest.sustained_per_frame_send_outcomes_two_users
but drive the local Docker-backed kixelated/moq reference relay via
NostrNestsHarness. Lets us reproduce the production "82/100" cliff
without nostrnests.com so we can attribute the loss to client code
(`:nestsClient` / `:quic`) vs. the production deployment.
Same per-frame instrumentation: bypass NestMoqLiteBroadcaster, call
publisher.send() / publisher.endGroup() directly, record the boolean
return per frame plus any endGroup exception, dump a per-frame
send/recv table on failure.
Test 4 showed a hard 82/100 cliff at 20 ms cadence two-users, all 18
trailing groups lost permanently. Root-cause-narrowing requires knowing
whether MoqLitePublisherHandle.send returned false (frame dropped at the
moq-lite layer due to inboundSubs.isEmpty() / publisherClosed) or true
(frame queued by moq-lite and lost downstream — uni-stream write error
swallowed by runCatching, QUIC flow control wedged, or relay drop).
The production NestMoqLiteBroadcaster wraps everything in
runCatching {…}.onFailure {…} and ignores the boolean, so frame loss is
structurally invisible to the app. This new test bypasses the broadcaster
and calls publisher.send / publisher.endGroup directly while mirroring
the auth + transport + session setup that connectNestsSpeaker performs.
Records sendOutcomes[i] and endGroupErrors[i] per frame; on failure
prints a per-frame send/recv table so we can see exactly when send
flips false (or whether all 100 sends report true and the loss is
strictly downstream of moq-lite).
Previously test 4 used .take(N).toList() inside withTimeoutOrNull, so a
timeout discarded every received frame and the only failure signal was
"only got partial audio". When the production run flagged frame loss,
we couldn't tell whether the stream stalled mid-flight, never started
fanning out, or dropped scattered groups.
Drain into an external CopyOnWriteArrayList via .onEach { } so the
partial contents survive the timeout, then on failure surface:
- received N/100 with first/last arrival timestamps
- missingGroups summary as run-length ranges (e.g. [3-7,42,90-99])
- first/last 20 arrivals so the front and tail of the sequence are
visible without flooding stdout
The gap pattern alone narrows the diagnosis: front-loaded losses point
to subscribe-settle being too short, tail-loaded losses point to a
broadcaster shutdown race, scattered drops point to per-subscription
buffer overflow / datagram loss.
Four progressively-narrower JVM tests that drive the production
connectNestsSpeaker / connectNestsListener / OkHttpNestsClient /
QuicWebTransportFactory / NestMoqLiteBroadcaster code paths against
the real moq.nostrnests.com + moq-auth.nostrnests.com infrastructure
(no Docker harness, no Nostr events) so we can isolate why audio is
not flowing between two real users:
1. auth_minting_works_for_publish_and_listen_paths
2. same_user_speaker_and_listener_round_trip
3. two_users_speaker_publishes_listener_subscribes
4. sustained_real_time_cadence_two_users (~2s @ 20ms cadence)
Skipped by default; opt in with -DnestsProd=true and (optionally)
override URLs via -DnestsProdEndpoint=... / -DnestsProdAuth=...
Both NestFullScreen and NestLobbyScreen render the kind-1311
transcript with LazyColumn(reverseLayout = true). When a new message
is prepended, LazyColumn preserves visual position by shifting
firstVisibleItemIndex from 0 to 1, leaving the new message just below
the viewport — the user has to scroll down to see it.
If the user is at (or one item from) the bottom when a new message
arrives, scroll back to item 0 so the newest message is visible. If
they are reading older history, leave them where they are.
Holds FLAG_KEEP_SCREEN_ON via the existing KeepScreenOn composable
while ConnectionUiState is Connected, so the device doesn't lock
and interrupt an active audio-room session.
NestFeedCard previously routed every tap on a joinable MeetingSpaceEvent
through NestLobbyScreen. The lobby exists to keep an accidental tap on
a stale room from booting the audio pipeline (and the host-side
kind-30312 republish path) — once the badge says the room is live with
fresh presence, that gating is just extra friction. Mirror the badge's
liveness heuristic in the click handler and launch NestActivity
directly for cards rendering as LIVE / PLANNED / PRIVATE; only EndedFlag
cards (status = ENDED, status = LIVE with stale presence, or unknown
status) keep flowing through the read-only lobby.