Merge pull request #2621 from vitorpamplona/claude/review-nostr-nests-compliance-hKBnS

feat(nests): proactive JWT refresh + reconnect for speaker path
This commit is contained in:
Vitor Pamplona
2026-04-28 13:56:06 -04:00
committed by GitHub
15 changed files with 2543 additions and 149 deletions
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -39,6 +40,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicReference
@@ -169,9 +171,20 @@ suspend fun connectReconnectingNestsListener(
// never enters Reconnecting.
continue
}
val terminal = state.value
if (terminal is NestsListenerState.Closed) break
// Note: we do NOT break on terminal=Closed. The
// user-driven stop path goes through
// [ReconnectingHandle.close], which calls
// `orchestrator.cancel()` BEFORE closing the inner
// listener; cancellation propagates through the
// next suspending call (typically `delay` below or
// `openOnce` on the next loop iteration) and the
// orchestrator exits cleanly. Any *other* path that
// produces a Closed inner listener — peer-driven
// transport close, half-broken session that was
// closed by some internal cleanup — should be
// treated as an unexpected drop and reconnected.
if (policy.isExhausted(attempt + 1)) break
val terminal = state.value
val delayMs =
if (terminal is NestsListenerState.Reconnecting) {
terminal.delayMs
@@ -263,9 +276,38 @@ private class ReconnectingHandle(
)
val liveHandleRef = AtomicReference<SubscribeHandle?>(null)
// Re-subscribe pump: every time activeListener changes, drop
// the prior subscription (collectLatest cancels the inner
// body) and open a new one against the fresh session.
// Re-subscribe pump. Two re-issue triggers, layered:
//
// 1. Listener session swap (outer collectLatest) — fires
// when the orchestrator opens a fresh listener after
// the 540 s JWT-refresh window or a transport-loss
// reconnect. collectLatest cancels the prior pump
// iteration so the next iteration runs against the
// new listener.
//
// 2. Publisher session swap (inner while loop) — fires
// when the underlying SubscribeHandle.objects flow
// completes mid-stream because the *publisher*
// cycled. The moq-lite session layer detects publisher
// disconnect via the announce stream's Ended event
// and closes the underlying frames channel; that
// naturally ends `handle.objects.collect` here. We
// then loop into a fresh subscribe — moq-lite supports
// subscribe-before-announce, so the new subscribe
// attaches cleanly to whichever publisher serves the
// suffix next, including one that comes up AFTER us.
//
// Without the inner loop, a remote speaker's JWT refresh
// (every 9 min on the speaker side via
// [connectReconnectingNestsSpeaker]) would silently kill
// every listener's audio — the listener's own JWT refresh
// fires on a different cadence and can't be relied on to
// coincide.
//
// Bounded by:
// - listener swap → outer collectLatest cancels us
// - unsubscribeAction → pumpJob.cancel()
// - opener-throws → break + wait for next swap
val pumpJob =
scope.launch {
activeListener.collectLatest { listener ->
@@ -277,14 +319,23 @@ private class ReconnectingHandle(
state is NestsListenerState.Failed
}
if (terminalOrConnected !is NestsListenerState.Connected) return@collectLatest
val handle =
runCatching { opener(listener) }
.getOrNull() ?: return@collectLatest
liveHandleRef.set(handle)
try {
handle.objects.collect { frames.emit(it) }
} finally {
if (liveHandleRef.get() === handle) liveHandleRef.set(null)
while (currentCoroutineContext().isActive) {
val handle =
runCatching { opener(listener) }
.getOrNull() ?: break
liveHandleRef.set(handle)
try {
handle.objects.collect { frames.emit(it) }
} finally {
if (liveHandleRef.get() === handle) liveHandleRef.set(null)
}
// Brief backoff so a permanently-gone
// publisher doesn't tight-loop the relay
// with re-subscribes. 100 ms stays well
// under the SUBSCRIBE_BUFFER's 1.3 s of
// audio headroom.
delay(RESUBSCRIBE_BACKOFF_MS)
}
}
}
@@ -318,6 +369,12 @@ private class ReconnectingHandle(
// grow the queue unbounded.
private const val SUBSCRIBE_BUFFER = 64
// Inner-pump backoff between publisher-cycle re-subscribes.
// Short enough to stay well under the SUBSCRIBE_BUFFER's
// ~1.3 s of audio headroom; long enough that a permanently-
// gone publisher doesn't spin the relay with re-subscribes.
private const val RESUBSCRIBE_BACKOFF_MS = 100L
private val SYNTH_OK =
SubscribeOk(
subscribeId = -1L,
@@ -0,0 +1,405 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient
import com.vitorpamplona.nestsclient.audio.AudioCapture
import com.vitorpamplona.nestsclient.audio.OpusEncoder
import com.vitorpamplona.nestsclient.transport.WebTransportFactory
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicReference
/**
* `connectNestsSpeaker` plus a transport-loss reconnect loop with
* exponential backoff and proactive JWT refresh. Mirror of
* [connectReconnectingNestsListener] for the publish side.
*
* The returned [NestsSpeaker]'s state surfaces the underlying speaker's
* state directly while a session is alive, but flips to
* [NestsSpeakerState.Reconnecting] between attempts. The speaker is
* auto-redirected to the freshly-opened session under the hood —
* `startBroadcasting()` returns a stable [BroadcastHandle] whose
* `setMuted` and `close` survive every refresh.
*
* **Why this exists** — moq-auth issues 600 s bearer tokens
* (`moq-auth/src/index.ts`). Without proactive refresh, any room a
* user keeps the stage in for >10 min hits an authorisation failure
* the moment the relay tears down the session, the publish stream
* goes silent, and the user has to manually re-tap "Talk". The
* proactive recycle keeps the WebTransport session young so the
* relay never sees an expired token.
*
* **Broadcast-handle re-issuance** — the caller-owned
* [BroadcastHandle] survives a refresh / reconnect. Internally the
* wrapper opens a fresh underlying `BroadcastHandle` against each
* new session, replays the user's mute intent on it, and forwards
* `setMuted` calls to whichever live handle exists at the time.
* `close` cancels the re-issue pump and best-effort closes the
* latest live handle.
*
* **Audio gap during refresh** — the wrapper closes the current
* underlying speaker (which stops the mic capture + Opus encoder +
* publisher) before opening the next, so the listener side will
* hear ~50150 ms of silence at each recycle boundary. That's the
* trade-off we pay for a clean session swap; the alternative
* (carrying the mic capture across sessions) would require deeper
* plumbing into the audio pipeline. Acceptable for v1 —
* 9-min-spaced 150 ms gaps are well below the noise floor of a
* voice call.
*
* Cancellation: cancelling [scope] (typically the room screen's VM
* scope) cancels the reconnect loop and closes both the active
* session and the active broadcast. [NestsSpeaker.close] is
* idempotent.
*/
suspend fun connectReconnectingNestsSpeaker(
httpClient: NestsClient,
transport: WebTransportFactory,
scope: CoroutineScope,
room: NestsRoomConfig,
signer: NostrSigner,
speakerPubkeyHex: String,
captureFactory: () -> AudioCapture,
encoderFactory: () -> OpusEncoder,
policy: NestsReconnectPolicy = NestsReconnectPolicy(),
/**
* Proactive JWT refresh window. moq-auth issues bearer tokens
* with a 600 s lifetime; once the token expires the relay
* tears down the WebTransport session and we'd otherwise
* recover via the regular reconnect path with a brief audible
* dropout AND a permanent broadcast loss until the user taps
* Talk again. By recycling the session a minute before expiry
* we stay ahead of the relay's tear-down: the new session
* opens, the broadcast pump reopens publishing on it (carrying
* the user's mute intent), and the wrapper's outward state
* never enters the user-visible Reconnecting state.
*
* Set to 0 or negative to disable.
*/
tokenRefreshAfterMs: Long = 540_000L,
/**
* Test seam — defaults to the production [connectNestsSpeaker].
* Tests pass a fake that returns a scripted [NestsSpeaker] so
* the reconnect state machine can be exercised without a real
* WebTransport stack.
*/
connector: suspend () -> NestsSpeaker = {
connectNestsSpeaker(
httpClient = httpClient,
transport = transport,
scope = scope,
room = room,
signer = signer,
speakerPubkeyHex = speakerPubkeyHex,
captureFactory = captureFactory,
encoderFactory = encoderFactory,
)
},
): NestsSpeaker {
val state = MutableStateFlow<NestsSpeakerState>(NestsSpeakerState.Idle)
val activeSpeaker = MutableStateFlow<NestsSpeaker?>(null)
suspend fun openOnce(): NestsSpeaker {
val speaker = connector()
activeSpeaker.value = speaker
state.value = speaker.state.value
return speaker
}
val orchestrator =
scope.launch {
var attempt = 0
while (true) {
val speaker =
runCatching { openOnce() }.getOrElse {
state.value = NestsSpeakerState.Failed("connect failed: ${it.message}", it)
null
}
var refreshTriggered = false
if (speaker != null) {
// Wait for either a terminal state OR the proactive
// JWT-refresh deadline. withTimeoutOrNull returns
// null when the timer fires first; we then close
// the (still-healthy) speaker and loop to mint a
// fresh JWT via openOnce(). The broadcast-pump
// re-issues publishing onto the new session
// without the wrapper's outward state ever
// showing Reconnecting.
//
// The `onEach { mirror } + first` pattern (rather
// than `state.collect { mirror; if terminal break }`)
// is what lets `withTimeoutOrNull` cancel cleanly
// mid-mirror — once cancelled, the underlying
// speaker's subsequent state changes (e.g. the
// Closed we trigger on the next line) don't leak
// out to the wrapper's state.
val terminalAwait: suspend () -> NestsSpeakerState = {
speaker.state
.onEach { state.value = it }
.first { s ->
s is NestsSpeakerState.Failed || s is NestsSpeakerState.Closed
}
}
val terminal =
if (tokenRefreshAfterMs > 0L) {
withTimeoutOrNull(tokenRefreshAfterMs) { terminalAwait() }
} else {
terminalAwait()
}
if (terminal == null) {
// Refresh deadline hit before any terminal state —
// planned recycle, not a failure. Close the old
// speaker; don't bump `attempt` (it's not a
// backoff event) so the next openOnce() runs
// immediately.
runCatching { speaker.close() }
attempt = 0
refreshTriggered = true
} else if (terminal is NestsSpeakerState.Failed && !isUserCancelledSpeaker(terminal)) {
// Transport-side failure → schedule a reconnect.
attempt++
if (!policy.isExhausted(attempt)) {
val delayMs = policy.delayForAttempt(attempt)
state.value = NestsSpeakerState.Reconnecting(attempt, delayMs)
}
}
}
if (refreshTriggered) {
// Skip the reconnect-schedule path entirely — a
// refresh is a planned cutover, not a backoff event.
continue
}
val terminal = state.value
if (terminal is NestsSpeakerState.Closed) break
if (policy.isExhausted(attempt + 1)) break
val delayMs =
if (terminal is NestsSpeakerState.Reconnecting) {
terminal.delayMs
} else {
policy.delayForAttempt(++attempt)
}
state.value = NestsSpeakerState.Reconnecting(attempt.coerceAtLeast(1), delayMs)
delay(delayMs)
}
}
// Match the existing [connectNestsSpeaker] semantics: suspend
// until the first session is up (or hard-fails) so the VM's
// call site `val s = speakerConnector.connect(...); s.startBroadcasting()`
// keeps working without changes. This is a deliberate departure
// from the listener wrapper, which returns immediately and
// expects the VM to gate `subscribeSpeaker` on the Connected
// state — the speaker side has a tighter `startBroadcasting`
// contract that requires a live session at call time.
val firstReady =
state.first { s ->
s is NestsSpeakerState.Connected ||
s is NestsSpeakerState.Broadcasting ||
s is NestsSpeakerState.Failed
}
if (firstReady is NestsSpeakerState.Failed) {
// Unwind: cancel the orchestrator + close any speaker that
// managed to open before the failure surfaced.
orchestrator.cancel()
runCatching { activeSpeaker.value?.close() }
throw NestsException(firstReady.reason, firstReady.cause)
}
return ReconnectingSpeakerHandle(state, activeSpeaker, orchestrator, scope)
}
private fun isUserCancelledSpeaker(state: NestsSpeakerState.Failed): Boolean {
val msg = state.reason
// Forward-compat seam — same shape as [isUserCancelled] on the
// listener side. User-driven close goes through Closed today;
// anything else surfaced as Failed is a transport / handshake
// error worth retrying.
return msg.contains("user cancelled", ignoreCase = true)
}
private class ReconnectingSpeakerHandle(
private val mutableState: MutableStateFlow<NestsSpeakerState>,
private val activeSpeaker: MutableStateFlow<NestsSpeaker?>,
private val orchestrator: Job,
private val scope: CoroutineScope,
) : NestsSpeaker {
override val state: StateFlow<NestsSpeakerState> = mutableState.asStateFlow()
private val gate = Mutex()
@Volatile private var activeBroadcast: ReissuingBroadcastHandle? = null
override suspend fun startBroadcasting(): BroadcastHandle =
gate.withLock {
check(state.value !is NestsSpeakerState.Closed) {
"startBroadcasting on a closed speaker"
}
check(activeBroadcast == null) {
"speaker is already broadcasting"
}
// Require a live (or just-connected) session — matches
// the listener wrapper's `subscribeSpeaker` contract.
// The wrapper's own `connect()` already suspended until
// the first session was up, so this check almost never
// fails in practice; it guards the second-call-after-
// close case.
activeSpeaker.value
?: error("no live session — wait for state == Connected before startBroadcasting")
val handle =
ReissuingBroadcastHandle(activeSpeaker, scope) { closed ->
if (activeBroadcast === closed) activeBroadcast = null
}
handle.start()
activeBroadcast = handle
handle
}
override suspend fun close() {
orchestrator.cancel()
runCatching { activeBroadcast?.close() }
runCatching { activeSpeaker.value?.close() }
if (mutableState.value !is NestsSpeakerState.Closed) {
mutableState.value = NestsSpeakerState.Closed
}
}
}
/**
* Stable [BroadcastHandle] backed by a re-issuing pump. Each time
* the wrapper opens a fresh session the pump cancels its prior
* iteration, calls [NestsSpeaker.startBroadcasting] on the new
* session, replays the cached mute intent on the resulting
* underlying handle, and parks until the next session swap.
*
* `setMuted` updates the cached intent unconditionally and forwards
* to whichever live underlying handle exists at the time. If no
* underlying handle is up (e.g. a brief gap during recycle), the
* intent is replayed on the next handle the pump opens, so the
* user-observed mute state is monotonic across recycles.
*/
private class ReissuingBroadcastHandle(
private val activeSpeaker: StateFlow<NestsSpeaker?>,
private val scope: CoroutineScope,
private val onClose: (ReissuingBroadcastHandle) -> Unit,
) : BroadcastHandle {
@Volatile private var desiredMuted: Boolean = false
@Volatile private var closed: Boolean = false
private val liveHandle = AtomicReference<BroadcastHandle?>(null)
private var pumpJob: Job? = null
override val isMuted: Boolean get() = desiredMuted
fun start() {
// Re-broadcast pump: every time activeSpeaker changes, drop
// the prior broadcast (collectLatest cancels the inner
// body via awaitCancellation) and open a new one against
// the fresh session. The pattern mirrors the listener's
// SubscribeHandle re-issuance pump.
pumpJob =
scope.launch {
activeSpeaker.collectLatest { sp ->
if (sp == null || closed) return@collectLatest
// Wait until the underlying speaker is ready to
// broadcast (or has gone terminal). For a fresh
// session this resolves immediately because the
// wrapper's openOnce already saw Connected.
val ready =
sp.state.first { st ->
st is NestsSpeakerState.Connected ||
st is NestsSpeakerState.Broadcasting ||
st is NestsSpeakerState.Closed ||
st is NestsSpeakerState.Failed
}
if (ready !is NestsSpeakerState.Connected && ready !is NestsSpeakerState.Broadcasting) {
return@collectLatest
}
if (closed) return@collectLatest
val handle =
runCatching { sp.startBroadcasting() }
.getOrNull() ?: return@collectLatest
if (closed) {
runCatching { handle.close() }
return@collectLatest
}
// Apply current mute intent BEFORE storing the
// handle so a setMuted that races us applies
// exactly once: either (a) we set intent →
// apply intent → store, and the racing setMuted
// sees the live handle and applies again (no-op
// on the broadcaster); or (b) the racing
// setMuted updates intent → we read intent →
// apply. Order doesn't matter; idempotent.
if (desiredMuted) {
runCatching { handle.setMuted(true) }
}
liveHandle.set(handle)
try {
// Park until activeSpeaker emits a new value
// (collectLatest cancels us) or close() runs
// (pumpJob.cancel).
awaitCancellation()
} finally {
// Clear our slot only if we still own it —
// close() may have already swapped in null.
if (liveHandle.get() === handle) liveHandle.set(null)
// Best-effort close on the way out: the user
// may have called wrapper.close (closed=true,
// pump cancelling), or activeSpeaker swapped
// (the prior speaker is about to be closed
// by the orchestrator anyway, but defensively
// closing here releases the broadcaster +
// publisher promptly rather than waiting for
// the speaker.close()).
runCatching { handle.close() }
}
}
}
}
override suspend fun setMuted(muted: Boolean) {
if (closed) return
desiredMuted = muted
liveHandle.get()?.let { runCatching { it.setMuted(muted) } }
}
override suspend fun close() {
if (closed) return
closed = true
pumpJob?.cancel()
liveHandle.getAndSet(null)?.let { runCatching { it.close() } }
onClose(this)
}
}
@@ -87,17 +87,33 @@ class NestMoqLiteBroadcaster(
}
if (opus.isEmpty()) continue
if (muted) continue
runCatching { publisher.send(opus) }
.onFailure { t ->
if (t is CancellationException) throw t
onError(
AudioException(
AudioException.Kind.PlaybackFailed,
"publisher.send failed",
t,
),
)
}
// One Opus frame per moq-lite group — mirrors the
// nests JS reference's audio publish path, and is
// load-bearing for the listener-survives-publisher-
// recycle invariant: a brand-new subscriber that
// attaches mid-broadcast (e.g. listener wrapper
// re-subscribing after a publisher cycle) gets the
// NEXT group's frames per moq-lite "from-latest"
// semantics. Without endGroup, the entire broadcast
// is one giant group and new subscribers wait
// indefinitely. The 20 ms cadence here means at
// most one frame of audio missed for any new
// subscriber. See
// `nestsClient/plans/2026-04-26-moq-lite-gap.md`'s
// "Group size: 1 frame per group" line.
runCatching {
publisher.send(opus)
publisher.endGroup()
}.onFailure { t ->
if (t is CancellationException) throw t
onError(
AudioException(
AudioException.Kind.PlaybackFailed,
"publisher.send failed",
t,
),
)
}
}
} catch (ce: CancellationException) {
throw ce
@@ -33,7 +33,6 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -77,6 +76,17 @@ class MoqLiteSession internal constructor(
/** Lazily-launched relay→us inbound bidi pump; only runs while a publisher is active. */
private var bidiPump: Job? = null
/**
* Single shared announce-watch pump that runs while we have any
* listener-side subscription. Closes the frames channel of any
* subscription whose broadcast suffix goes Ended on the relay's
* announce stream see [pumpAnnounceWatch] for why this is the
* only reliable signal of publisher disconnect under moq-lite
* Lite-03. Lazily launched on first subscribe; lives until the
* session scope is cancelled.
*/
private var announceWatchJob: Job? = null
/** Single active publisher per session (moq-lite doesn't model multi-broadcast publishers). */
private var activePublisher: PublisherStateImpl? = null
@@ -152,6 +162,18 @@ class MoqLiteSession internal constructor(
endGroup: Long? = null,
): MoqLiteSubscribeHandle {
ensureOpen()
// Open the announce-watch bidi BEFORE the subscribe goes
// out. moq-rs uses the announce stream to propagate
// broadcast availability into the subscriber session — a
// subscribe that arrives before our session has any
// announce bidi open is rejected with "not found", even
// when the publisher's session is alive on the relay. The
// bidi must be on the wire before subscribe sends; lazy-
// launching after subscribe (the obvious-but-wrong shape)
// races the relay's discovery and produces flaky misses,
// especially for fresh listener sessions opened after a
// wrapper-driven reconnect.
ensureAnnounceWatchStarted()
val id =
state.withLock {
check(!closed) { "session is closed" }
@@ -173,6 +195,27 @@ class MoqLiteSession internal constructor(
bidi.write(Varint.encode(MoqLiteControlType.Subscribe.code))
bidi.write(MoqLiteCodec.encodeSubscribe(request))
// Single long-running collector pump for the bidi's response
// side. Reads the SubscribeResponse, then keeps collecting
// until the peer FINs (or scope is cancelled, or
// handle.unsubscribe() FINs our side and the relay echoes).
// The flow completion IS the moq-lite-03 signal that the
// publisher has disconnected mid-broadcast — Lite-03 has no
// explicit "publisher gone" message; bidi close is it.
// Without this watch, the frames Channel below would never
// close on remote disconnect, and any consumer collecting
// from the wrapper-level [MoqLiteSubscribeHandle.frames]
// flow would sit silent indefinitely after a publisher
// cycle even though the relay is happy to serve a fresh
// subscribe under the same broadcast suffix.
//
// Why a single pump (vs separate response read + death
// watch): the underlying QUIC stream's `incoming` is
// backed by `Channel<ByteArray>.consumeAsFlow()` — which
// CANCELS the channel when the first collect ends. A second
// collect on a fresh `bidi.incoming()` Flow would see an
// already-cancelled channel and fire prematurely. Keeping
// one collect alive sidesteps that entirely.
// moq-lite's subscribe-response is a single size-prefixed
// message on the response side of the bidi. Read incoming
// chunks into a buffer until the buffer holds a full payload,
@@ -213,6 +256,102 @@ class MoqLiteSession internal constructor(
}
}
/**
* Lock that serializes lazy-launch of the announce-watch.
* Distinct from [state] so the synchronous `announce(prefix="")`
* inside [ensureAnnounceWatchStarted] can suspend without
* blocking other state-mutating operations.
*/
private val announceWatchLock = Mutex()
/**
* Open the shared announce-watch bidi *synchronously* (and
* launch its collector coroutine) if it isn't already running.
* Idempotent. Called from [subscribe] before the subscribe
* message goes on the wire so moq-rs has a chance to propagate
* broadcast availability into our session before the subscribe
* arrives see the comment in [subscribe].
*/
private suspend fun ensureAnnounceWatchStarted() {
announceWatchLock.withLock {
if (announceWatchJob != null) return
val handle =
try {
announce(prefix = "")
} catch (ce: kotlinx.coroutines.CancellationException) {
throw ce
} catch (_: Throwable) {
// Couldn't open the announce bidi — best effort,
// bail. Subscriptions still work; we just lose
// automatic cycle detection (the wrapper still
// re-issues on listener swap / explicit failure).
return
}
announceWatchJob =
scope.launch {
try {
pumpAnnounceWatch(handle)
} finally {
announceWatchLock.withLock { announceWatchJob = null }
}
}
}
}
/**
* Single shared announce-watch pump for ALL subscriptions on
* this session. Driven by the bidi opened in
* [ensureAnnounceWatchStarted]. For each
* [MoqLiteAnnounceStatus.Ended] update, iterates the
* subscription map and closes the frames channel of any
* subscription whose `broadcast` matches the announce suffix.
* The closed channel ends the consumer-facing
* `frames.consumeAsFlow()` flow naturally same shape as a
* user-driven `handle.unsubscribe()` from the consumer's POV
* which lets the wrapper's re-issuance pump drive a fresh
* subscribe against the same broadcast path. moq-lite supports
* subscribe-before-announce, so a subscribe issued during the
* gap (between Ended and the next Active under the same suffix)
* attaches cleanly when the new publisher comes up.
*
* This pump survives announce-bidi errors via best-effort
* silence the session itself recovers via its own reconnect
* path. Cancelled when [scope] is cancelled (session close).
*/
private suspend fun pumpAnnounceWatch(handle: MoqLiteAnnouncesHandle) {
try {
handle.updates.collect { update ->
if (update.status != MoqLiteAnnounceStatus.Ended) return@collect
val targets =
state.withLock {
subscriptionsBySubscribeId.values
.filter { it.request.broadcast == update.suffix }
.toList()
}
for (sub in targets) {
// Just close the frames channel — the
// wrapper-level collect of `frames.consumeAsFlow()`
// ends naturally and the wrapper pump re-issues.
// Don't fire `unsubscribe(id)` here: that'd FIN
// OUR side of the (still-alive) subscribe bidi,
// and the wrapper's re-issue would have to open
// a fresh bidi anyway. Keeping the subscribe
// bidi open lets a future subscribe-before-
// announce land cleanly.
sub.frames.close()
state.withLock { subscriptionsBySubscribeId.remove(sub.id) }
runCatching { sub.bidi.finish() }
}
}
} catch (ce: kotlinx.coroutines.CancellationException) {
throw ce
} catch (_: Throwable) {
// Announce bidi died — same best-effort fallback.
} finally {
runCatching { handle.close() }
}
}
/**
* Drain inbound uni streams and route each one's group frames to
* the matching subscription. The relay opens a fresh uni stream
@@ -355,86 +494,106 @@ class MoqLiteSession internal constructor(
}
private suspend fun handleInboundBidi(bidi: com.vitorpamplona.nestsclient.transport.WebTransportBidiStream) {
val buffer = MoqLiteFrameBuffer()
val publisher = state.withLock { activePublisher } ?: return
// Single long-running collector for the bidi's full lifetime.
// Pre-fix this dispatch was split into a `firstOrNull()` to
// peek the control byte + a `readSizePrefixedFromBidiInto`
// to read the body — but `bidi.incoming()` is backed by
// `Channel<ByteArray>.consumeAsFlow(consume=true)`, which
// CANCELS the channel when the first collect ends. Any
// attempt to re-collect from the bidi (e.g. to watch for
// subscriber-disconnect FIN) saw an immediately-empty
// closed flow, firing the cleanup right after registration
// and starving the publisher's send path. With one collector
// for the bidi's whole life, the dispatch reads the message,
// the collector continues silently until peer FIN, and the
// post-collect cleanup runs exactly once — same shape the
// moq-lite session's [announce] pump already uses.
val buffer = MoqLiteFrameBuffer()
// typeCode is hoisted outside the collect lambda so it
// survives across invocations — `buffer.readVarint()`
// advances `pos`, so calling it again on the next collect
// tick would read body bytes as if they were the control
// varint and tear the dispatch state apart.
var typeCode: Long? = null
var dispatched = false
var inboundSub: MoqLiteSubscribe? = null
try {
// Read the leading ControlType varint from the first chunk.
val first =
bidi.incoming().firstOrNull() ?: return
buffer.push(first)
val controlCode = buffer.readVarint() ?: return
val controlType = MoqLiteControlType.fromCode(controlCode) ?: return
when (controlType) {
MoqLiteControlType.Announce -> {
handleAnnounceRequest(bidi, buffer, publisher)
}
bidi.incoming().collect { chunk ->
buffer.push(chunk)
if (!dispatched) {
if (typeCode == null) typeCode = buffer.readVarint()
val tc = typeCode ?: return@collect
val controlType =
MoqLiteControlType.fromCode(tc) ?: run {
dispatched = true
runCatching { bidi.finish() }
return@collect
}
when (controlType) {
MoqLiteControlType.Announce -> {
val pleasePayload = buffer.readSizePrefixed() ?: return@collect
val please = MoqLiteCodec.decodeAnnouncePlease(pleasePayload)
val emittedSuffix =
MoqLitePath.stripPrefix(please.prefix, publisher.suffix) ?: publisher.suffix
bidi.write(
MoqLiteCodec.encodeAnnounce(
MoqLiteAnnounce(
status = MoqLiteAnnounceStatus.Active,
suffix = emittedSuffix,
hops = 0L,
),
),
)
publisher.registerAnnounceBidi(bidi, emittedSuffix)
dispatched = true
}
MoqLiteControlType.Subscribe -> {
handleSubscribeRequest(bidi, buffer, publisher)
}
MoqLiteControlType.Subscribe -> {
val subPayload = buffer.readSizePrefixed() ?: return@collect
val sub = MoqLiteCodec.decodeSubscribe(subPayload)
bidi.write(
MoqLiteCodec.encodeSubscribeOk(
MoqLiteSubscribeOk(
priority = sub.priority,
ordered = sub.ordered,
maxLatencyMillis = sub.maxLatencyMillis,
startGroup = null,
endGroup = null,
),
),
)
publisher.registerInboundSubscription(sub)
inboundSub = sub
dispatched = true
}
else -> {
// Lite-03 treats Session/Fetch/Probe as separate flows;
// we don't implement them here. Drop the bidi.
runCatching { bidi.finish() }
else -> {
// Lite-03 treats Session/Fetch/Probe as
// separate flows; we don't implement them.
runCatching { bidi.finish() }
dispatched = true
}
}
}
// Post-dispatch chunks are silently discarded —
// Lite-03's announce / subscribe bidis are idle
// after the response. The signal we care about is
// the flow's natural completion (peer FIN =
// subscriber-disconnect, or transport drop).
}
} catch (ce: CancellationException) {
throw ce
} catch (_: Throwable) {
runCatching { bidi.finish() }
// Bidi errored — fall through to the same cleanup.
}
}
private suspend fun handleAnnounceRequest(
bidi: com.vitorpamplona.nestsclient.transport.WebTransportBidiStream,
seedBuffer: MoqLiteFrameBuffer,
publisher: PublisherStateImpl,
) {
val pleasePayload = readSizePrefixedFromBidiInto(bidi.incoming(), seedBuffer)
val please = MoqLiteCodec.decodeAnnouncePlease(pleasePayload)
// The relay sets the prefix to the namespace it expects us to
// publish under (typically `claims.root`). Our broadcast path
// (after stripping the prefix) is `publisher.suffix`. moq-lite
// requires the suffix on the wire to be the *remaining* part
// after `please.prefix` — so strip it.
val emittedSuffix = MoqLitePath.stripPrefix(please.prefix, publisher.suffix) ?: publisher.suffix
bidi.write(
MoqLiteCodec.encodeAnnounce(
MoqLiteAnnounce(
status = MoqLiteAnnounceStatus.Active,
suffix = emittedSuffix,
hops = 0L,
),
),
)
// Hold the bidi open until the publisher closes; if/when the
// application stops broadcasting, send `Ended`.
publisher.registerAnnounceBidi(bidi, emittedSuffix)
}
private suspend fun handleSubscribeRequest(
bidi: com.vitorpamplona.nestsclient.transport.WebTransportBidiStream,
seedBuffer: MoqLiteFrameBuffer,
publisher: PublisherStateImpl,
) {
val subPayload = readSizePrefixedFromBidiInto(bidi.incoming(), seedBuffer)
val sub = MoqLiteCodec.decodeSubscribe(subPayload)
// Reply Ok right away — moq-lite is permissive on the publisher
// side; the relay decides whether the subscriber is allowed to
// see this broadcast.
bidi.write(
MoqLiteCodec.encodeSubscribeOk(
MoqLiteSubscribeOk(
priority = sub.priority,
ordered = sub.ordered,
maxLatencyMillis = sub.maxLatencyMillis,
startGroup = null,
endGroup = null,
),
),
)
publisher.registerInboundSubscription(sub)
// Flow ended (peer FIN or error). Remove the inbound
// subscribe so the publisher's send path stops keying new
// groups off this dead subscriber. Announce bidis are
// owned by the publisher state for sending Ended on
// publisher-close — we don't remove them here.
inboundSub?.let { publisher.removeInboundSubscription(it) }
}
/**
@@ -622,6 +781,24 @@ class MoqLiteSession internal constructor(
}
}
/**
* Remove an inbound subscription whose bidi was FIN'd by the
* relay (subscriber disconnected). FINs the current group
* defensively because [openNextGroupLocked] keys each uni
* stream off `inboundSubs.first()`'s id; if the dropped sub
* was first, the current uni stream is dead-routed and the
* next send must open a fresh group keyed off whatever
* live sub is now first.
*/
suspend fun removeInboundSubscription(sub: MoqLiteSubscribe) {
gate.withLock {
if (publisherClosed) return
if (!inboundSubs.remove(sub)) return
runCatching { currentGroup?.uni?.finish() }
currentGroup = null
}
}
override suspend fun startGroup() {
gate.withLock {
if (publisherClosed) return