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:
+204
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user