fix(nests): scope moq-lite publisher to a single track to stop catalog hijack

Listeners stuck on a spinning speaker avatar with no audio (against
both Amethyst-hosted and nostrnests.com-hosted Nests). The trace
captured on a working speaker phone showed every group keyed to
subId=1 / track='catalog.json' even though the broadcaster was
pumping Opus frames:

    13:21:27.570 inbound SUBSCRIBE id=1 track='catalog.json'
    13:21:27.574 inbound SUBSCRIBE id=0 track='audio/data'
    13:21:27.609 openGroup seq=0 keyedOnSubId=1 track='catalog.json'
    13:21:27.610 broadcaster: send accepted (subscriber attached)
    ... every subsequent group keyed=1, every audio frame on the
        catalog stream

Root cause: `PublisherStateImpl.openNextGroupLocked` keyed each uni
stream off `inboundSubs.first()` with no track filter. The kdoc
asserted "Inbound subscription set is expected to be small (1 in
nests's listener-per-room model)" — that was true when listeners
only opened the audio sub. Once the listener side started opening a
catalog sub alongside (commits ~Apr 2026), whichever SUBSCRIBE
arrived first (typically the catalog one, by ~4 ms in this trace)
became the routing target for all audio frames. The relay forwarded
those frames to the listener with subscribeId=catalog, the listener
routed them to its catalog handler, RoomSpeakerCatalog.parseOrNull
returned null (it's not JSON), and the audio subscription's frames
channel never received anything — so onSpeakerActivity never fired
and the spinner stayed forever.

Fix: per moq-lite Lite-03, a publisher is responsible for one
(broadcast, track) tuple. Thread `track` through `MoqLiteSession.publish`
and have `PublisherStateImpl.registerInboundSubscription` reject
inbound SUBSCRIBEs whose track doesn't match — those still receive
SUBSCRIBE_OK from the bidi handler (best-effort per Lite-03's
optional-content semantics for catalog) but don't influence the
audio routing. `MoqLiteNestsSpeaker.startBroadcasting` passes
`track = MoqLiteNestsListener.AUDIO_TRACK` (= "audio/data").

Test callsites (MoqLiteSessionTest, NostrnestsProdAudioTransmissionTest,
NostrNestsSustainedSendOutcomesInteropTest) updated to pass the new
required parameter; all use track="audio/data" matching what they
exercise.

The diagnostic logs from the prior commits stay in — they're how we
caught this and they'll catch the next one. They can be stripped in
a follow-up once the fix is confirmed in the field.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
This commit is contained in:
Claude
2026-05-04 17:28:18 +00:00
parent 9b6e09838e
commit 43720dd14d
5 changed files with 55 additions and 14 deletions
@@ -121,7 +121,7 @@ class NostrNestsSustainedSendOutcomesInteropTest {
val speakerSession = MoqLiteSession.client(speakerWt, pumpScope)
val publisher =
InteropDebug.stepSuspending(scope, "host: session.publish(broadcastSuffix=hostPub)") {
speakerSession.publish(broadcastSuffix = hostSigner.pubKey)
speakerSession.publish(broadcastSuffix = hostSigner.pubKey, track = "audio/data")
}
// ---- listener side: production code path, unchanged.
@@ -452,7 +452,7 @@ class NostrNestsSustainedSendOutcomesInteropTest {
val speakerSession = MoqLiteSession.client(speakerWt, pumpScope)
val publisher =
InteropDebug.stepSuspending(scope, "host: session.publish(broadcastSuffix=hostPub)") {
speakerSession.publish(broadcastSuffix = hostSigner.pubKey)
speakerSession.publish(broadcastSuffix = hostSigner.pubKey, track = "audio/data")
}
val listeners = mutableListOf<com.vitorpamplona.nestsclient.NestsListener>()
@@ -644,7 +644,7 @@ class NostrnestsProdAudioTransmissionTest {
.client(speakerWt, pumpScope)
val publisher =
InteropDebug.stepSuspending(scope, "host: session.publish(broadcastSuffix=hostPub)") {
speakerSession.publish(broadcastSuffix = hostSigner.pubKey)
speakerSession.publish(broadcastSuffix = hostSigner.pubKey, track = "audio/data")
}
// ---- listener side: production code path, unchanged.
@@ -1013,7 +1013,7 @@ class NostrnestsProdAudioTransmissionTest {
.client(speakerWt, pumpScope)
val publisher =
InteropDebug.stepSuspending(scope, "host: session.publish(broadcastSuffix=hostPub)") {
speakerSession.publish(broadcastSuffix = hostSigner.pubKey)
speakerSession.publish(broadcastSuffix = hostSigner.pubKey, track = "audio/data")
}
val listeners = mutableListOf<com.vitorpamplona.nestsclient.NestsListener>()