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:
+117
@@ -357,6 +357,123 @@ class MoqLiteSessionTest {
|
||||
session.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publisher_setOnNewSubscriber_hook_fires_per_inbound_subscribe() =
|
||||
runBlocking {
|
||||
val (clientSide, serverSide) = FakeWebTransport.pair()
|
||||
val session = MoqLiteSession.client(clientSide, pumpScope)
|
||||
|
||||
val publisher = session.publish(broadcastSuffix = "speakerPubkey", track = "audio/data")
|
||||
val hookFireCount =
|
||||
java.util.concurrent.atomic
|
||||
.AtomicInteger(0)
|
||||
publisher.setOnNewSubscriber {
|
||||
hookFireCount.incrementAndGet()
|
||||
}
|
||||
|
||||
// First inbound SUBSCRIBE → hook fires once.
|
||||
val subBidi1 = serverSide.openBidiStream()
|
||||
subBidi1.write(Varint.encode(MoqLiteControlType.Subscribe.code))
|
||||
subBidi1.write(
|
||||
MoqLiteCodec.encodeSubscribe(
|
||||
MoqLiteSubscribe(
|
||||
id = 0L,
|
||||
broadcast = "speakerPubkey",
|
||||
track = "audio/data",
|
||||
priority = 0x80,
|
||||
ordered = true,
|
||||
maxLatencyMillis = 0L,
|
||||
startGroup = null,
|
||||
endGroup = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
// Wait for SubscribeOk to drain so we know registerInboundSubscription
|
||||
// ran (and therefore the hook had its chance to launch).
|
||||
withTimeout(2_000) { subBidi1.incoming().first() }
|
||||
// Hook fires asynchronously on the session scope; give it a
|
||||
// moment to land. Use a bounded retry rather than a flat
|
||||
// delay so the test is fast on the happy path.
|
||||
withTimeout(2_000) {
|
||||
while (hookFireCount.get() < 1) kotlinx.coroutines.yield()
|
||||
}
|
||||
assertEquals(1, hookFireCount.get())
|
||||
|
||||
// Second inbound SUBSCRIBE → hook fires again.
|
||||
val subBidi2 = serverSide.openBidiStream()
|
||||
subBidi2.write(Varint.encode(MoqLiteControlType.Subscribe.code))
|
||||
subBidi2.write(
|
||||
MoqLiteCodec.encodeSubscribe(
|
||||
MoqLiteSubscribe(
|
||||
id = 1L,
|
||||
broadcast = "speakerPubkey",
|
||||
track = "audio/data",
|
||||
priority = 0x80,
|
||||
ordered = true,
|
||||
maxLatencyMillis = 0L,
|
||||
startGroup = null,
|
||||
endGroup = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
withTimeout(2_000) { subBidi2.incoming().first() }
|
||||
withTimeout(2_000) {
|
||||
while (hookFireCount.get() < 2) kotlinx.coroutines.yield()
|
||||
}
|
||||
assertEquals(2, hookFireCount.get())
|
||||
|
||||
publisher.close()
|
||||
session.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publisher_setOnNewSubscriber_hook_does_not_fire_on_track_mismatch() =
|
||||
runBlocking {
|
||||
val (clientSide, serverSide) = FakeWebTransport.pair()
|
||||
val session = MoqLiteSession.client(clientSide, pumpScope)
|
||||
|
||||
// Publisher serves audio/data only.
|
||||
val publisher = session.publish(broadcastSuffix = "speakerPubkey", track = "audio/data")
|
||||
val hookFireCount =
|
||||
java.util.concurrent.atomic
|
||||
.AtomicInteger(0)
|
||||
publisher.setOnNewSubscriber {
|
||||
hookFireCount.incrementAndGet()
|
||||
}
|
||||
|
||||
// Inbound SUBSCRIBE for a different track. Replies
|
||||
// SubscribeDrop (covered in another test); hook MUST NOT
|
||||
// fire because no subscriber was actually registered on
|
||||
// this publisher.
|
||||
val subBidi = serverSide.openBidiStream()
|
||||
subBidi.write(Varint.encode(MoqLiteControlType.Subscribe.code))
|
||||
subBidi.write(
|
||||
MoqLiteCodec.encodeSubscribe(
|
||||
MoqLiteSubscribe(
|
||||
id = 7L,
|
||||
broadcast = "speakerPubkey",
|
||||
track = "video/data",
|
||||
priority = 0x80,
|
||||
ordered = true,
|
||||
maxLatencyMillis = 0L,
|
||||
startGroup = null,
|
||||
endGroup = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
// Drain the Drop reply.
|
||||
withTimeout(2_000) { subBidi.incoming().first() }
|
||||
// No way to wait deterministically for "the hook didn't
|
||||
// fire"; sleep briefly to let any racing launch surface,
|
||||
// then assert. Short delay because the hook would launch
|
||||
// on the same scope as registerInboundSubscription's caller.
|
||||
kotlinx.coroutines.delay(100)
|
||||
assertEquals(0, hookFireCount.get())
|
||||
|
||||
publisher.close()
|
||||
session.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publisher_replies_subscribeDrop_when_track_is_not_published() =
|
||||
runBlocking {
|
||||
|
||||
Reference in New Issue
Block a user