The publisher's inbound-bidi handler wrote SubscribeOk to the bidi
before calling registerInboundSubscription. The peer's first
publisher.send() after observing Ok could race the registration on
dispatchers that resume the peer's continuation before the handler's
(notably Windows under Dispatchers.Default), causing send to observe
an empty inboundSubs and return false. Reordering makes the peer's
view of Ok a happens-after of the registration.
Fixes the Windows-only failure in
MoqLiteSessionTest.publisher_acks_subscribe_and_pushes_group_data_on_uni_stream.
The previous SendBuffer suite (FlowControlEnforcementTest) is entirely
single-threaded — every test calls enqueue and takeChunk sequentially
on the same coroutine, so the race that crashed the audio path in
production (NoSuchElementException from chunks.first() under
concurrent enqueue + takeChunk) stayed invisible. The whole :quic
commonTest tree had no concurrent test at all.
Three new tests run real-thread races on Dispatchers.Default:
- concurrent_enqueue_and_takeChunk_does_not_throw drives multiple
producer coroutines + a consumer coroutine and asserts the buffer
drains cleanly with no exception.
- concurrent_takeChunk_callers_never_double_drain_a_chunk fans out
multiple consumers against a pre-populated buffer; asserts the
sum of bytes handed out equals the bytes enqueued (i.e. no chunk
is double-counted by overlapping head-peel paths).
- concurrent_finish_with_inflight_enqueue_emits_correct_fin races
finish() against in-flight writes and asserts the FIN comes
AFTER every enqueued byte.
Tests pass against the synchronised SendBuffer; running them against
the pre-fix unsynchronised version corrupts state badly enough that
the consumer wedges (an explicit "this is what the bug looked like"
demonstration). With internal synchronisation in place the suite
finishes in <0.2 s.
Documents the concurrent-access contract so a future "let's drop the
sync, it's hot" refactor immediately fails CI.
QuicConnectionDriver.sendLoop holds the connection mutex while it calls
SendBuffer.takeChunk via QuicConnectionWriter.drainOutbound, but
WtPeerStreamDemux's per-stream `send` callback calls
SendBuffer.enqueue from arbitrary application coroutines without the
connection lock. The two paths concurrently mutate
(chunks, pendingBytes, headOffset, finPending, finSent, sentEnd,
nextOffset). Under load this surfaced as
java.util.NoSuchElementException: ArrayDeque is empty.
at kotlin.collections.ArrayDeque.first(ArrayDeque.kt:102)
at com.vitorpamplona.quic.stream.SendBuffer.takeChunk(SendBuffer.kt:85)
at com.vitorpamplona.quic.connection.QuicConnectionWriterKt.buildApplicationPacket(...)
at com.vitorpamplona.quic.connection.QuicConnectionDriver.sendLoop(...)
The writer saw `pendingBytes > 0` (incremented by an in-flight
enqueue on another thread) before the matching `chunks.addLast`
became visible, fell into the head-peel branch, and tripped on
chunks.first().
Wrap every read and write of SendBuffer state in `synchronized(this)`,
including the cheap `readableBytes` / `sentOffset` / `finPending` /
`finSent` getters used by the writer's pre-flight checks (so they
can't read torn state either). The lock is uncontended in the common
case and short-held in the rare race; we already use synchronized
blocks elsewhere in commonMain (QuicConnectionDriver.kt).
PrimaryTabRow's BadgedBox places the count badge in the top-end corner
of its anchor, which on tight tab labels lands directly on top of the
text. Lay them out as a Row instead so "Audience 3" reads cleanly,
matching the tabbar comment's "Hands · 3" intent.
Two interrelated bugs from the deployed-schema flip:
1. Rooms created in Amethyst didn't appear in nostrnests's "Live Now"
lobby. The lobby filter in NestsUI's skeleton.js requires a `title`
tag on every kind:30312 — events without one are dropped from
`live`/`planned`/`ended`. RoomNameTag still emitted the legacy
`room` name, so our rooms passed our own readers (which tolerate
both names) but were invisible to NestsUI. Flip canonical to
`title`, keep `room` as legacy on read.
2. The mute badge over the avatar disappeared shortly after toggling
mute, even though the broadcaster was still muted. Two causes:
- ParticipantsGrid gated both the muted ring and the MicStateBadge
on `member.publishing`. But per the deployed kind-10312 semantics
`publishing=0` whenever `muted=1` (NestRoomPresencePublisher
mirrors this — `publishingNow = broadcasting && !isMuted`), so
the badge that was supposed to *indicate* mute was hidden
exactly when it became relevant. Show the badge when on stage
and either publishing OR muted; gate the muted ring on `muted`
alone.
- The presence heartbeat captured `micMutedTag` / `publishingTag`
/ `onstageTag` / `handRaised` at LaunchedEffect launch and never
refreshed them. The 500 ms debounced LaunchedEffect did publish
the new mute state, but ~30 s later the heartbeat overwrote it
with the captured pre-toggle values, flipping `publishing`
back to 1 and `muted` back to 0 server-side. Wrap the dynamic
fields in `rememberUpdatedState` so the heartbeat reads the
latest snapshot on every refresh; keep the existing key set
(event/handRaised/onstage) for prompt re-emission on those
transitions.
Tests updated for the new canonical `title` emission; both jvmTest
suites pass.
The deployed nostrnests reference (NestsUI v2 + moq-auth + moq-rs)
emits a different on-the-wire schema than the previous EGG-01 / EGG-09
drafts and Quartz writers. Verified by reading the production
NestsUI bundle. The deployed schema is now canonical:
kind:30312 (room event)
- relay URL → ["streaming", url] (was ["endpoint", url])
- auth URL → ["auth", url] (was ["service", url])
- live status → ["status", "live"] (was "open")
- ended status → ["status", "ended"] (was "closed")
- room name → ["title", name] (was ["room", name])
kind:10112 (user MoQ-server list)
- 3-element entries: ["server", relay, auth]
- first-element name "relay" accepted as a synonym (legacy)
- 2-element entries: derive auth host by replacing leading "moq."
with "moq-auth.", or prepending "moq-auth." otherwise
Implementation
- ServiceUrlTag.TAG_NAME = "auth", LEGACY_TAG_NAME = "service"
- EndpointUrlTag.TAG_NAME = "streaming", LEGACY_TAG_NAME = "endpoint"
- StatusTag enum: PLANNED/LIVE/PRIVATE/ENDED with code "live"/"ended";
legacy "open"/"closed" still parsed on read
- NestsServersEvent: emit/read 3-element [server, relay, auth] tags
with the legacy-shape tolerances above; expose a NestsServer pair
- Account.nestsServers.flow now produces List<NestsServer> pairs
- NestsServersScreen: rewritten edit-field asks for both URLs,
recommended row shows the nostrnests pair, list rows show both URLs
- NestsScreen first-time setup writes the nostrnests pair, not a
single URL; gate the create FAB on both URLs being parseable
- CreateNestViewModel: drop the resolveServerPair hardcoded mapping —
the saved pair is now authoritative; defaults stay correct for an
empty kind-10112 list
Specs
- EGG-01 rewritten to canonical streaming/auth/live/ended with a
legacy-spelling table; example uses the real nostrnests pair
- EGG-02 references "auth" tag throughout; error taxonomy says "ended"
- EGG-09 rewritten to 3-element server tag with derivation fallback
- New nestsClient/specs/nip-53-proposed.md — a single self-contained
proposed update to upstream NIP-53 covering kind 30312 + kind 10112
with the deployed schema and the JWT-mint flow
All existing unit tests adjusted; quartz:jvmTest, amethyst:testPlayDebugUnitTest,
and nestsClient:jvmTest pass.
The previous defaults pointed both `service` and `endpoint` URLs at
`moq.nostrnests.com:4443`. That host only listens on UDP/QUIC for the
WebTransport relay — TCP :4443 is unreachable. OkHttp can't speak HTTP/3,
so the JWT-mint POST hangs and fails with `ConnectException`, leaving
the room screen stuck on "Reconnecting".
The real nostrnests deployment co-locates auth and relay on different
hosts (verified against the production NestsUI bundle):
relay (WebTransport, QUIC only): https://moq.nostrnests.com:4443
auth (JWT mint, regular HTTPS): https://moq-auth.nostrnests.com
Split the defaults accordingly and add a `resolveServerPair` helper that
maps a single saved kind-10112 URL onto the (service, endpoint) pair the
kind-30312 event needs. The helper recognises both the auth-host and
the legacy relay-host the prior defaults wrote, so users upgrading from
the broken version migrate transparently the next time they open the
create-room sheet. Community deployments that genuinely co-locate keep
working via the fallback.
Update the recommended-servers list and the kind-10112 documentation to
match: the stored `server` URL is the moq-auth base (the URL that ends
up in the kind-30312 `service` tag).
Add a navigation row in AllSettingsScreen to open the existing
PaymentTargetsScreen, so users can configure their NIP-A3 payment
targets (kind 10133) from the account settings menu.
Every desktop packaging job (Windows MSI, macOS DMG, Linux DEB) currently
breaks on the smallest blip when fetching VLC from get.videolan.org. The
`ir.mahozad.vlc-setup` plugin registers `vlcDownload` / `upxDownload`
tasks that extend `de.undercouch.gradle.tasks.download.Download`, but it
does not configure retries or a generous read timeout, so a single
`SocketTimeoutException` aborts the build.
Recent main runs all failed at the same step:
> Task :desktopApp:vlcDownload FAILED
> A failure occurred while executing ...DefaultWorkAction
> java.net.SocketTimeoutException: Read timed out
Configure all `Download` tasks in this project with:
- 5 attempts total (initial + 4 retries),
- 30 s connect timeout,
- 5 min read timeout (VLC archives are 40-90 MB and the mirror can be
slow under load),
- `tempAndMove` so a partial download from one attempt cannot poison
the next.
This is a CI-only change \u2014 it does not alter what gets bundled, just
makes the fetch resilient.
The profile 'Last seen' line was rendered by feeding timeAgo() output
into the 'Last seen %1$s ago' template. timeAgo() returns a bare
absolute date (e.g. 'Jan 14' or 'Jul 27, 2024') for anything older
than a month, so the result was nonsensical:
- 'Last seen Jan 14 ago'
- 'Last seen Jul 27, 2024 ago'
Replace the call with a new lastSeenSentence() helper that always
returns a self-contained, grammatical sentence:
- 'Last seen 5 minutes ago'
- 'Last seen 2 hours ago'
- 'Last seen 3 days ago'
- 'Last seen 2 weeks ago'
- 'Last seen on Jul 27, 2024 (9 months ago)'
- 'Last seen on Jan 14, 2024 (1 year ago)'
Anything older than a week now also shows the absolute date alongside
a coarse relative duration, so users can see exactly when the activity
happened without giving up the human-readable 'X ago' framing.
Adds plural-aware duration_minutes/hours/days/weeks/months/years
resources plus last_seen_on_date / last_seen_just_now / last_seen_never
helper strings. The existing 'last_seen' string keeps its placeholder
shape so existing translations continue to work for the recent-duration
case.
Adds 124 missing translations across cs-rCZ, pt-rBR, sv-rSE, and de-rDE
covering the new Nests (NIP-53 audio-rooms) UI, AI-suggested alt-text
hints, tracked broadcasts setting, and meeting/live-stream tags.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`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.
The triple benchmark probed libschnorr256k1 with a try/catch that only
handled UnsatisfiedLinkError. On Windows the loader raises
IllegalStateException when the JNI .dll for the platform isn't packaged,
which propagated out as a test failure instead of skipping the C row.
Mirror the broader catch already used in Secp256k1CrossValidationTest so
the benchmark prints a SKIP-style note and continues with the ACINQ +
Kotlin rows on platforms where the C lib isn't available.
Activity used the system default soft-input behavior (pan), so opening
the keyboard slid the whole screen up, partially hiding the composer
and putting the top of the screen out of reach. Set adjustResize on
NestActivity and add consumeWindowInsets + imePadding to the Scaffold
body so the column reflows above the IME and the textfield stays in
view.
Bring the audio-room lobby filter closer to the NostrNests reference:
- Reject service/endpoint URLs that aren't HTTPS, dropping legacy
`wss+livekit://` rooms a moq-lite client can't reach.
- Drop PLANNED rooms whose `starts` is more than 1 h in the past or
more than 30 d in the future.
- Add a single lobby-wide kind-10312 REQ (10 min, limit 500) per
active relay and use it to hide OPEN/PRIVATE rooms whose host
crashed without flipping status to CLOSED. Brand-new rooms keep
showing for the freshness window so they don't pop in late.
- Expand follow-style top filters to include rooms whose p-tagged
speakers are followed, not just the host.
The bottom action bar in NestFullScreen was rendering under Android's
gesture navigation area in edge-to-edge mode, leaving the leave / talk
/ react buttons partially obscured. Apply BottomAppBarDefaults
windowInsets to the surface so it pads above the system navigation.