feat(audio-rooms): M5 MoQ publisher path — ANNOUNCE + inbound SUBSCRIBE + OBJECT emit

Lifts MoqSession from listener-only to bidirectional. A session can now
ANNOUNCE a track namespace, register one TrackPublisher per track name
under it, accept inbound SUBSCRIBEs from the peer, and emit
OBJECT_DATAGRAMs that fan out to every attached subscriber. This is what
the speaker / host path needs in nests — phases M6/M7 layer audio
capture + a NestsSpeaker API on top.

Public API additions:
- `MoqSession.announce(namespace, parameters)` — sends ANNOUNCE, awaits
  ANNOUNCE_OK, returns an `AnnounceHandle`.
- `AnnounceHandle.openTrack(name)` — registers a `TrackPublisher` for a
  track under the announced namespace. Idempotent insert is rejected.
- `TrackPublisher.send(payload)` — encodes one MoQ OBJECT_DATAGRAM and
  emits it to every currently-attached subscriber. Group id is fixed at
  zero per the audio-rooms NIP draft; object ids are monotonic.
- `TrackPublisher.close()` — sends SUBSCRIBE_DONE to attached
  subscribers, removes the track from its parent announce.
- `AnnounceHandle.unannounce()` — sends UNANNOUNCE on the wire, closes
  every registered publisher.
- `ErrorCode.TRACK_DOES_NOT_EXIST` and `SubscribeDoneStatus.{TRACK_ENDED,
  UNSUBSCRIBED}` constants for the SUBSCRIBE_ERROR / SUBSCRIBE_DONE
  values we emit.

Internal additions:
- `pendingAnnounces` keyed by namespace, mirroring the existing
  `pendingSubscribes` pattern.
- `announces` map tracking live publishers per namespace.
- `inboundSubscribers` + `publisherSubscribers` so the control-pump can
  fan an inbound SUBSCRIBE to the right TrackPublisher and so
  TrackPublisher.send can snapshot subscribers without per-OBJECT
  locking.
- Control pump now routes ANNOUNCE_OK / ANNOUNCE_ERROR (publisher-side
  ack) and inbound SUBSCRIBE / UNSUBSCRIBE (we're being asked to send
  OBJECTs, or stop). Unknown-track SUBSCRIBE replies with
  SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST) so the peer doesn't hang.
- `close()` propagates session death into every AnnounceHandleImpl /
  TrackPublisherImpl so any in-flight `send` short-circuits cleanly.

All shared-state mutation is funneled through the existing `stateMutex`
(no `synchronized` — that's JVM-only and would break commonMain). The
session-wide `writeMutex` continues to serialize control-stream writes.

Tests (new in MoqSessionTest):
- announce → ANNOUNCE_OK happy path; UNANNOUNCE wire frame on close.
- ANNOUNCE_ERROR surfaces as MoqProtocolException.
- End-to-end: announce + openTrack + peer SUBSCRIBE → SUBSCRIBE_OK +
  three OBJECT_DATAGRAMs round-trip with intact group/object ids.
- send() returns false when no subscribers are attached (no buffering).
- Inbound SUBSCRIBE for unknown track under an announced namespace
  replies SUBSCRIBE_ERROR.

Verified: `:nestsClient:jvmTest` (12 tests, 0 failures) + `:quic:jvmTest`
(green) + `./gradlew spotlessApply` clean.
This commit is contained in:
Claude
2026-04-26 03:11:33 +00:00
parent 46f693800d
commit 5c32996f93
2 changed files with 614 additions and 5 deletions
@@ -21,6 +21,7 @@
package com.vitorpamplona.nestsclient.moq
import com.vitorpamplona.nestsclient.transport.FakeWebTransport
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
@@ -271,4 +272,207 @@ class MoqSessionTest {
assertEquals(1, received.size)
assertContentEquals(byteArrayOf(0xAA.toByte()), received.single())
}
// ----- Publisher path (M5) -------------------------------------------
@Test
fun announce_completes_on_announce_ok_then_unannounce_sends_unannounce_frame() =
runTest {
val (publisherSide, peerSide) = FakeWebTransport.pair()
val publisherSession = MoqSession.client(publisherSide, backgroundScope)
val ns = TrackNamespace.of("nests", "test-room")
val peer =
async {
val ctrl = peerSide.peerOpenedBidiStreams().first()
// Setup
val cs = MoqCodec.decode(ctrl.incoming().first())!!.message as ClientSetup
ctrl.write(MoqCodec.encode(ServerSetup(cs.supportedVersions.first())))
// ANNOUNCE
val announce = MoqCodec.decode(ctrl.incoming().first())!!.message as Announce
assertEquals(ns, announce.namespace)
ctrl.write(MoqCodec.encode(AnnounceOk(announce.namespace)))
// Read UNANNOUNCE after publisher tears down
val unannounce = MoqCodec.decode(ctrl.incoming().first())!!.message as Unannounce
assertEquals(ns, unannounce.namespace)
}
publisherSession.setup(listOf(MoqVersion.DRAFT_17))
val handle = publisherSession.announce(ns)
assertEquals(ns, handle.namespace)
handle.unannounce()
peer.await()
publisherSession.close()
}
@Test
fun announce_throws_MoqProtocolException_when_peer_replies_announce_error() =
runTest {
val (publisherSide, peerSide) = FakeWebTransport.pair()
val publisherSession = MoqSession.client(publisherSide, backgroundScope)
val ns = TrackNamespace.of("nests", "no-perms")
val peer =
async {
val ctrl = peerSide.peerOpenedBidiStreams().first()
val cs = MoqCodec.decode(ctrl.incoming().first())!!.message as ClientSetup
ctrl.write(MoqCodec.encode(ServerSetup(cs.supportedVersions.first())))
val ann = MoqCodec.decode(ctrl.incoming().first())!!.message as Announce
ctrl.write(
MoqCodec.encode(
AnnounceError(
namespace = ann.namespace,
errorCode = 0x10,
reasonPhrase = "no permission",
),
),
)
}
publisherSession.setup(listOf(MoqVersion.DRAFT_17))
val ex = assertFailsWith<MoqProtocolException> { publisherSession.announce(ns) }
assert("0x10" in ex.message!! || "no permission" in ex.message!!)
peer.await()
publisherSession.close()
}
@Test
fun publisher_routes_inbound_subscribe_and_emits_object_datagrams() =
runTest {
val (publisherSide, peerSide) = FakeWebTransport.pair()
val publisherSession = MoqSession.client(publisherSide, backgroundScope)
val ns = TrackNamespace.of("nests", "test-room")
val trackName = "speaker-pub-1".encodeToByteArray()
// Real nests servers wait for a viewer's UI to subscribe; they don't
// race ANNOUNCE_OK. The test models that with an explicit gate so
// openTrack runs before we forge an inbound SUBSCRIBE.
val publisherReady = CompletableDeferred<Unit>()
val peerJob =
async {
val ctrl = peerSide.peerOpenedBidiStreams().first()
// Setup
val cs = MoqCodec.decode(ctrl.incoming().first())!!.message as ClientSetup
ctrl.write(MoqCodec.encode(ServerSetup(cs.supportedVersions.first())))
// ANNOUNCE
val announce = MoqCodec.decode(ctrl.incoming().first())!!.message as Announce
ctrl.write(MoqCodec.encode(AnnounceOk(announce.namespace)))
publisherReady.await()
// Send a SUBSCRIBE for the publisher's track
val subscribeId = 42L
val trackAlias = 7L
ctrl.write(
MoqCodec.encode(
Subscribe(
subscribeId = subscribeId,
trackAlias = trackAlias,
namespace = announce.namespace,
trackName = trackName,
),
),
)
// Expect a SUBSCRIBE_OK reply
val ok = MoqCodec.decode(ctrl.incoming().first())!!.message as SubscribeOk
assertEquals(subscribeId, ok.subscribeId)
Triple(subscribeId, trackAlias, ok)
}
publisherSession.setup(listOf(MoqVersion.DRAFT_17))
val handle = publisherSession.announce(ns)
val publisher = handle.openTrack(trackName)
publisherReady.complete(Unit)
val (subscribeId, trackAlias, _) = peerJob.await()
// Now push 3 objects through the publisher; they should arrive on
// the peer's incoming-datagram channel.
repeat(3) { i ->
publisher.send(byteArrayOf(i.toByte()))
}
val received = peerSide.incomingDatagrams().take(3).toList()
val decoded = received.map { MoqObjectDatagram.decode(it) }
assertEquals(listOf(0L, 1L, 2L), decoded.map { it.objectId })
assertEquals(List(3) { trackAlias }, decoded.map { it.trackAlias })
assertContentEquals(byteArrayOf(0), decoded[0].payload)
assertContentEquals(byteArrayOf(1), decoded[1].payload)
assertContentEquals(byteArrayOf(2), decoded[2].payload)
// subscribeId is intentionally read so the test fails if the order
// ever flips silently.
assertEquals(42L, subscribeId)
publisher.close()
handle.unannounce()
publisherSession.close()
}
@Test
fun publisher_send_returns_false_when_no_subscribers_attached() =
runTest {
val (publisherSide, peerSide) = FakeWebTransport.pair()
val publisherSession = MoqSession.client(publisherSide, backgroundScope)
val ns = TrackNamespace.of("nests", "lonely")
val peer =
async {
val ctrl = peerSide.peerOpenedBidiStreams().first()
val cs = MoqCodec.decode(ctrl.incoming().first())!!.message as ClientSetup
ctrl.write(MoqCodec.encode(ServerSetup(cs.supportedVersions.first())))
val a = MoqCodec.decode(ctrl.incoming().first())!!.message as Announce
ctrl.write(MoqCodec.encode(AnnounceOk(a.namespace)))
}
publisherSession.setup(listOf(MoqVersion.DRAFT_17))
val handle = publisherSession.announce(ns)
peer.await()
val publisher = handle.openTrack("nobody-listens".encodeToByteArray())
assertEquals(false, publisher.send(byteArrayOf(1, 2, 3)))
publisher.close()
handle.unannounce()
publisherSession.close()
}
@Test
fun publisher_replies_subscribe_error_for_unknown_track_under_announced_namespace() =
runTest {
val (publisherSide, peerSide) = FakeWebTransport.pair()
val publisherSession = MoqSession.client(publisherSide, backgroundScope)
val ns = TrackNamespace.of("nests", "test-room")
val peer =
async {
val ctrl = peerSide.peerOpenedBidiStreams().first()
val cs = MoqCodec.decode(ctrl.incoming().first())!!.message as ClientSetup
ctrl.write(MoqCodec.encode(ServerSetup(cs.supportedVersions.first())))
val a = MoqCodec.decode(ctrl.incoming().first())!!.message as Announce
ctrl.write(MoqCodec.encode(AnnounceOk(a.namespace)))
// Send SUBSCRIBE for a track we never opened
ctrl.write(
MoqCodec.encode(
Subscribe(
subscribeId = 1L,
trackAlias = 1L,
namespace = a.namespace,
trackName = "ghost".encodeToByteArray(),
),
),
)
val err = MoqCodec.decode(ctrl.incoming().first())!!.message as SubscribeError
assertEquals(1L, err.subscribeId)
assertEquals(ErrorCode.TRACK_DOES_NOT_EXIST, err.errorCode)
}
publisherSession.setup(listOf(MoqVersion.DRAFT_17))
val handle = publisherSession.announce(ns)
peer.await()
handle.unannounce()
publisherSession.close()
}
}