fix(nests): listener subscriptions survive publisher recycle

Two layered fixes for the gap captured in
nestsClient/plans/2026-04-28-listener-survives-publisher-recycle.md.

Session layer (MoqLiteSession.kt):
  - Lazy single shared announce-watch pump per session, opened on
    first subscribe. moq-lite Lite-03 has no explicit "publisher
    gone" message on the subscribe bidi (the relay keeps that
    bidi open across publisher cycles in case a fresh publisher
    takes over the suffix), so the announce stream's Ended event
    is the only reliable signal.
  - On Announce(Ended) for a broadcast suffix, close the
    matching ListenerSubscription's frames Channel and remove it
    from the map. The wrapper-level frames.consumeAsFlow() flow
    ends naturally — same shape as a user-driven
    handle.unsubscribe() — so the wrapper pump's collect-
    completion path drives the re-issue.

Wrapper layer (ReconnectingNestsListener.kt):
  - Inner re-subscribe `while (currentCoroutineContext().isActive)`
    loop in reissuingSubscribe. When the underlying frames flow
    completes (publisher cycled, signalled by the session layer
    above), re-issue subscribe against the same listener with a
    100 ms backoff. moq-lite supports subscribe-before-announce so
    a re-subscribe issued during the gap attaches cleanly when
    the next publisher comes up under the same suffix.

Verified against the real moq-rs relay (host build, external
mode): the new
NostrNestsReconnectingListenerInteropTest.subscribe_handle_survives_publisher_recycle
test passes — single SubscribeHandle keeps emitting frames across
multiple speaker JWT-refresh cycles. Speaker reconnect tests
still pass too.

In production, this closes the audio dropout that would have
fired every 9 minutes per speaker JWT refresh on long Nest calls
(see the plan doc for the original diagnosis).

https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
This commit is contained in:
Claude
2026-04-28 15:06:07 +00:00
parent 1c8c961762
commit 851045c654
3 changed files with 351 additions and 11 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
@@ -263,9 +265,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 +308,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 +358,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,
@@ -77,6 +77,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
@@ -173,6 +184,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,
@@ -202,6 +234,29 @@ class MoqLiteSession internal constructor(
state.withLock {
subscriptionsBySubscribeId[id] = sub
if (groupPump == null) groupPump = scope.launch { pumpUniStreams() }
// Lazy-launch the publisher-disconnect watcher
// — one shared announce bidi per session whose
// sole job is to close the frames channel on any
// ListenerSubscription whose broadcast path goes
// Ended. moq-lite Lite-03 has no explicit
// "publisher gone" message on the subscribe
// bidi (the relay keeps that bidi open across
// publisher cycles in case a fresh publisher
// takes over the suffix), so the announce
// stream IS the only signal.
//
// Without this, a wrapper-layer consumer
// collecting from [MoqLiteSubscribeHandle.frames]
// would sit silent indefinitely after a
// publisher cycle even though the relay is happy
// to serve a fresh subscribe under the same
// suffix. The wrapper-level
// [com.vitorpamplona.nestsclient.ReconnectingNestsListener]
// pump's natural collect-completion path then
// re-issues the subscribe.
if (announceWatchJob == null) {
announceWatchJob = scope.launch { pumpAnnounceWatch() }
}
}
return MoqLiteSubscribeHandle(
id = id,
@@ -213,6 +268,71 @@ class MoqLiteSession internal constructor(
}
}
/**
* Single shared announce-watch pump for ALL subscriptions on
* this session. Opens one announce bidi (prefix="") on first
* subscribe, then 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() {
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.
return
}
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() }
state.withLock { announceWatchJob = null }
}
}
/**
* Drain inbound uni streams and route each one's group frames to
* the matching subscription. The relay opens a fresh uni stream