refactor(nests): catalog emit-on-subscribe hook replaces 2s republish loop
The catalog publisher's previous shape was "send once at startup
(silently fails if no subs are attached yet) + a 2 s republish loop".
That worked but had two problems:
1. The startup send is a no-op (PublisherStateImpl.send returns false
when inboundSubs is empty), so the catalog isn't actually on the
wire until the first loop tick — up to 2 s of catalog dead time
for a watcher that subscribes immediately.
2. Re-emitting a static blob every 2 s for the broadcast's lifetime
wastes a fresh group on the relay's per-track cache; not a hot path
today but unnecessary.
Replace with an emit-on-subscribe hook. New API on MoqLitePublisherHandle:
fun setOnNewSubscriber(hook: (suspend () -> Unit)?)
PublisherStateImpl fires the hook once per accepted (track-matching)
inbound SUBSCRIBE, OUTSIDE its serialisation lock so the hook can
safely call send/endGroup without deadlock. Track-mismatched subs
(SubscribeDrop reply) do NOT fire the hook — covered by a new
"hook does not fire on track mismatch" test.
MoqLiteNestsSpeaker.startBroadcasting and ReconnectingNestsSpeaker's
hot-swap iteration both now register a hook that writes the catalog
JSON on every fresh subscribe instead of looping. The
catalogRepublishJob field on MoqLiteBroadcastHandle and the
hotSwapCatalogJob field on ReissuingBroadcastHandle (plus their close
paths) are dropped.
Net effect for hang.js / NestsUI-v2 watchers:
- Catalog reaches the watcher on the SAME group as the subscribe ack
(no 0–2 s startup gap).
- Late joiners hit the relay's track-latest cache for the catalog
blob — a fresh hook fire only happens when the relay opens a new
SUBSCRIBE bidi to us (cliff-detector recycle on listener side, or
JWT-refresh on our side).
- One catalog group per relay-side subscribe instead of one every
2 s for the broadcast lifetime.
Two tests pin the new behaviour: hook fires on inbound subscribe;
hook does NOT fire on a track-mismatched subscribe (which gets a
SubscribeDrop reply and never enters inboundSubs).
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
This commit is contained in:
+16
-59
@@ -28,13 +28,9 @@ import com.vitorpamplona.nestsclient.moq.lite.MoqLitePublisherHandle
|
||||
import com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
@@ -150,39 +146,25 @@ class MoqLiteNestsSpeaker internal constructor(
|
||||
runCatching { publisher.close() }
|
||||
throw t
|
||||
}
|
||||
// Push catalog once before launching the periodic republish
|
||||
// pump so the manifest is on the wire before any subscriber
|
||||
// attaches; the republisher then re-emits a fresh group
|
||||
// every CATALOG_REPUBLISH_INTERVAL_MS so late-joining
|
||||
// watchers don't wait an arbitrary amount of time for the
|
||||
// next refresh.
|
||||
// Catalog emit-on-subscribe: every time the relay opens a
|
||||
// SUBSCRIBE bidi for catalog.json, fire the hook to write
|
||||
// one group + FIN. moq-lite serves new listeners from the
|
||||
// relay's per-track latest-group cache, so emitting once
|
||||
// per relay-side subscribe is enough — late-joining
|
||||
// watchers behind the same relay get the cached blob
|
||||
// without us having to maintain a periodic re-emit loop.
|
||||
// Set BEFORE the relay can race a SUBSCRIBE in; in
|
||||
// practice the relay's SUBSCRIBE bidi takes a network
|
||||
// round-trip after our ANNOUNCE Active, so this is safe
|
||||
// even though the setter is non-suspending.
|
||||
val catalogJson =
|
||||
MoqLiteHangCatalog.opusMono48k(MoqLiteNestsListener.AUDIO_TRACK).encodeJsonBytes()
|
||||
val republishJob =
|
||||
try {
|
||||
catalogPublisher.setOnNewSubscriber {
|
||||
runCatching {
|
||||
catalogPublisher.send(catalogJson)
|
||||
catalogPublisher.endGroup()
|
||||
scope.launch {
|
||||
try {
|
||||
while (true) {
|
||||
delay(CATALOG_REPUBLISH_INTERVAL_MS)
|
||||
catalogPublisher.send(catalogJson)
|
||||
catalogPublisher.endGroup()
|
||||
}
|
||||
} catch (ce: CancellationException) {
|
||||
throw ce
|
||||
} catch (_: Throwable) {
|
||||
// Best-effort: a transient catalog send
|
||||
// failure is non-fatal — the audio path
|
||||
// owns terminal-failure detection.
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
runCatching { catalogPublisher.close() }
|
||||
runCatching { broadcaster.stop() }
|
||||
runCatching { publisher.close() }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
mutableState.value =
|
||||
NestsSpeakerState.Broadcasting(
|
||||
room = current.room,
|
||||
@@ -194,7 +176,6 @@ class MoqLiteNestsSpeaker internal constructor(
|
||||
broadcaster = broadcaster,
|
||||
publisher = publisher,
|
||||
catalogPublisher = catalogPublisher,
|
||||
catalogRepublishJob = republishJob,
|
||||
parent = this,
|
||||
)
|
||||
activeHandle = handle
|
||||
@@ -202,17 +183,6 @@ class MoqLiteNestsSpeaker internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* How often the catalog group is re-emitted. The relay drops
|
||||
* the catalog group when its last subscriber drops, so a
|
||||
* watcher that attaches mid-broadcast needs a freshly-published
|
||||
* group to receive the manifest. 2 s keeps late-attach worst
|
||||
* case bounded without spamming the relay.
|
||||
*/
|
||||
const val CATALOG_REPUBLISH_INTERVAL_MS: Long = 2_000L
|
||||
}
|
||||
|
||||
/**
|
||||
* [HotSwappablePublisherSource] implementation. See the interface
|
||||
* kdoc — this method mints a fresh publisher on the session
|
||||
@@ -302,7 +272,6 @@ internal class MoqLiteBroadcastHandle(
|
||||
private val broadcaster: NestMoqLiteBroadcaster,
|
||||
private val publisher: MoqLitePublisherHandle,
|
||||
private val catalogPublisher: MoqLitePublisherHandle,
|
||||
private val catalogRepublishJob: Job,
|
||||
private val parent: MoqLiteNestsSpeaker,
|
||||
) : BroadcastHandle {
|
||||
@Volatile private var muted: Boolean = false
|
||||
@@ -321,20 +290,8 @@ internal class MoqLiteBroadcastHandle(
|
||||
override suspend fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
// Stop the catalog republisher first so it doesn't race a
|
||||
// concurrent send against the publisher.close below.
|
||||
try {
|
||||
catalogRepublishJob.cancelAndJoin()
|
||||
} catch (ce: kotlinx.coroutines.CancellationException) {
|
||||
// The parent scope is being cancelled. Continue cleanup of
|
||||
// the audio + publisher resources, then rethrow.
|
||||
runCatching { catalogPublisher.close() }
|
||||
runCatching { publisher.close() }
|
||||
parent.broadcastClosed(this)
|
||||
throw ce
|
||||
} catch (_: Throwable) {
|
||||
// Best-effort.
|
||||
}
|
||||
// Stop the broadcaster first so the audio capture + encoder
|
||||
// don't keep producing into a closing publisher.
|
||||
try {
|
||||
broadcaster.stop()
|
||||
} catch (ce: kotlinx.coroutines.CancellationException) {
|
||||
|
||||
Reference in New Issue
Block a user