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=...
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.
Move the workaround out of ChatroomList: use the DisappearingScaffold's
padding lambda parameter (previously unused) to position the left pane
below the top bar and above the bottom bar. Reset
LocalDisappearingScaffoldPadding to zero only at that boundary so the
inner LazyColumn inside ChatroomListFeedView doesn't double-count the
bar inset and produce a gap below the TabRow.
The right pane keeps systemBarsPadding because ChatroomView /
PublicChatChannelView bring their own DisappearingScaffold; layering the
outer padding there would stack the chat header below the outer search
bar.
ChatroomList stays a flat Column with no scaffold awareness.
In MessagesSinglePane the MessagesTabHeader sits inside the
DisappearingScaffold's topBar slot, so LocalDisappearingScaffoldPadding's
top component already covers the bar + tabs and the inner LazyColumn's
contentPadding lines the first row up flush below the tabs.
In MessagesTwoPane the tabs are part of the column content rather than
the scaffold's top slot. The scaffold still publishes its top-app-bar
height as the top inset, so rememberFeedContentPadding inside the feed
double-counts it and renders a top-app-bar-tall gap between the TabRow
and the first chatroom row.
Override LocalDisappearingScaffoldPadding around MessagesPager to zero
the top component while preserving start/end/bottom, so the feed still
clears the bottom bar.
The room metadata (RoomHeader) and live listener count were items
inside the reverse-layout LazyColumn, so they scrolled with the chat
and forced the chat to share its viewport. Move them to a regular
Column above the LazyColumn so the chat list owns most of the screen
and the header sits as a fixed row on top.
Drop the redundant "Recent chat" label that was only there to
separate the in-list header from messages.
The README's "Wiring relays into the store" tutorial used a
per-subscription SubscriptionListener.onEvent callback to feed the
store. EventCollector landed exactly to remove that boilerplate, so
update the example to register one connection-level collector at app
init and stop wiring listeners per subscribe() call.
The "Building a reactive feed UI" example showed project() ->
stateIn(WhileSubscribed) but never opened the relay subscription.
Bind it to the projection's collection lifecycle with .onStart {
client.subscribe } / .onCompletion { client.unsubscribe }, so the
relay subscription's lifetime equals the UI collector's. Also
demonstrate ProjectionState.filterItems for narrowing without a
re-query.
Drop the redundant client.connect() call from the wiring example
and add an explicit note (in both README and CLIENT) that
NostrClient connects on demand from subscribe()/publish() — connect()
is only useful after disconnect().
Other small fixes:
- client.send(...) -> client.publish(...) (the actual API name).
- Rename the variable from `observable` to `db` to match the demoApp
code and the conceptual "this is the local database" framing.
- Wrap the publish path in a viewModelScope.launch so the snippet
is copy-pasteable into a ViewModel.
A TextButton in the AppBar actions slot read as too subtle and small
next to the title. Switch to a filled Button so the primary CTA is
unmistakable. The actions Row already centers children vertically;
trim the trailing modifier padding so the button sits flush with
the bar's edge.
Replace the static Column-based CachedChatList with the same
LazyColumn(reverseLayout = true) the in-room screen uses
(NestChatPanel.NestChatMessageList). Items are keyed by note id,
and the room metadata header / listener count / "Recent chat"
label are stacked above the oldest message so the user can scroll
up through history into the room info.
Wire onSendNewMessage to animateScrollToItem(0) so a freshly sent
message snaps the list back to the newest entry — same behaviour
the in-room composer has.
- Replace bottom Open Room button with a TextButton "Open" action in
the top app bar, freeing the screen bottom for the chat composer.
- Mount NestEditFieldRow + NestNewMessageViewModel on the lobby so
users can post kind-1311 messages without joining the audio plane;
in-room composer features (mentions, file upload, drafts, emojis)
carry over.
- Switch the Scaffold to contentWindowInsets=0 and use imePadding +
navigation-bar inset on the composer so the chat list draws behind
the 3-button system navigation while the composer stays reachable
above it (and rises above the IME when the keyboard opens).
- Drop the now-unused OpenNestRoomButton helper and its string.
NestActionBar.kt had grown to ~470 lines with most of the volume in
OnStageControls, where four broadcast-state branches each inlined a
56dp filled icon button, a tonal toggle button, and an outlined
"Leave the Stage" button. The result was hard to scan for state
coverage, and the top-level KDoc still described the pre-cleanup
layout.
Changes:
* Rewrote the top-level KDoc as a state-by-state table matching the
current UI.
* Extracted reusable affordances:
ConnectButton, StatusChip, LeaveStageButton, TalkButton,
StopBroadcastButton, MicMuteToggle, HandRaiseToggle,
LeaveRoomButton.
* Merged the duplicate Connect button branches in StartCluster
(Idle / Closed / Failed all share one). The failure reason already
appears in the status strip above.
* Split OnStageControls: kept the dispatcher thin and moved the
permission state + Settings deep-link into OnStageIdleControls,
which is the only state that needs them.
* Replaced the inline qualified Settings Intent with a
Context.openAppSettings() helper, plus a Context.hasMicPermission()
helper for the two RECORD_AUDIO checks.
* Pulled the status-strip text resolution into a small
NestUiState.statusStripText() so ActionBarStatusStrip is one Text.
No behavior changes — just structure, naming, and docs.
The red Mic-on FilledIconButton already conveys "you are live, tap
to stop" — the AssistChip labelled "Live" beside it was duplicate
visual noise. Removed the chip and its now-orphan string. Mute toggle
+ red Mic + Leave the Stage are sufficient to express the broadcasting
state.
Several gaps in the action bar were causing dead-end UI states or
making non-actionable controls visible:
* Hand-raise was rendered while disconnected / connecting / failed.
Raising can't be delivered to the room until we're Connected, so
hide it until the connection state agrees. Threaded `isConnected`
into `EndCluster` from the root composable.
* Audience listen-mute toggle (Volume Up/Off) removed. System volume
keys cover local volume; the on-screen control was redundant and
shared the same speaker glyph as the broadcasting mic-mute toggle,
which made the two affordances easy to confuse.
* On-stage user without `canBroadcast` had no way to step down without
leaving the whole room. Show a "Leave the Stage" outlined button
in that branch so the speaker slot can be released.
* Broadcast `Connecting` and `Failed` substates also had no
step-down affordance — forcing the user to either retry the mic or
leave the room. Added "Leave the Stage" to both. `stopBroadcast()`
cancels the in-flight `speakerConnectJob` (NestViewModel
`teardownBroadcast`), so cancelling mid-handshake is safe.
* `BroadcastUiState.Broadcasting.muteError` was tracked in state but
never surfaced. Route it through `ActionBarStatusStrip`.
* Permission denial pill said "Open settings"; renamed to
"No permissions" so the label communicates the *state* (the tap
still deep-links to the system settings page).
Material3 expressive defaults the FilledTonalIconToggleButton container
shorter than ButtonDefaults.MinHeight, leaving the audience listen-mute
and on-stage mic-mute toggles visibly shorter than the neighboring
"Leave the Stage" / "Leave" outlined buttons. Pin the toggle height to
ButtonDefaults.MinHeight so the row aligns.
When Tor stays in Connecting for >60s, prompt the user to drop the proxy
for the rest of the session. The choice is persisted as a 1-hour window
so subsequent failures (across cold starts, network changes) silently
fall back without re-prompting; after the hour the dialog returns.
Bypass clears on user-initiated TorType changes and on network identity
changes so Tor gets a fresh attempt under new conditions.
Adds Route.NestLobby — a regular Compose destination — between every
Nests entry point (feed card, channel-view card, in-feed CTA, naddr
deep links) and NestActivity. The lobby is read-only: it warms cached
chat, host info, listener count, and recent messages via the existing
NestRoomFilterAssemblerSubscription, but never opens a NestViewModel,
publishes kind-10312 presence, or launches MoQ. Only the new
OpenNestRoomButton inside the lobby launches NestActivity, so a user
coming back to peek at an old room no longer triggers the audio
pipeline (or the host-side kind-30312 republish path) by accident.
JoinNestButton now navigates to the lobby instead of launching the
activity; OpenNestRoomButton owns the actual launch and lives only
on the lobby screen.
Tutorial-style README covering the full pipeline:
relay → NostrClient → ObservableEventStore → InterningEventStore →
EventStore → project() → ViewModel → Compose.
Two worked examples:
1. Wiring NostrClient subscriptions into the store via
SubscriptionListener.onEvent.
2. Building a reactive feed UI with project().stateIn(...) and
Compose, showing how the three reactivity layers (Loading/Loaded
state, list membership, per-event handles) map to Compose's
recomposition model.
Cross-links the existing CLIENT.md, RELAY.md, and store/sqlite
README. Pointers to per-class KDoc for projection internals.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5