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:
+70
-13
@@ -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,
|
||||
|
||||
+405
@@ -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 ~50–150 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)
|
||||
}
|
||||
}
|
||||
+27
-11
@@ -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
|
||||
|
||||
+248
-71
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user