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:
Claude
2026-05-06 16:10:12 +00:00
parent 79c76d5831
commit 3417e44a6a
4 changed files with 200 additions and 103 deletions
@@ -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 {