feat(nestsClient): MoQ session pump + subscribe API
Phase 3c-3 of the Clubhouse/nests integration. Promotes MoqSession
from a one-shot SETUP runner to a real concurrent session: the
control-stream and datagram pumps run on a caller-supplied scope
after the handshake, and a public subscribe()/unsubscribe() API
delivers per-track OBJECT_DATAGRAMs as a Flow<MoqObject>.
commonMain (nestsclient.moq):
- MoqSession now takes a `pumpScope: CoroutineScope` so callers can
bind the pumps to their lifecycle (test backgroundScope, viewmodel
scope, etc.).
- After setup() succeeds, two background coroutines start:
* Control-stream pump — buffers chunks across multiple writes,
decodes complete frames, dispatches SUBSCRIBE_OK / SUBSCRIBE_ERROR
to the matching CompletableDeferred, drops malformed frames
without killing the session.
* Datagram pump — decodes OBJECT_DATAGRAMs and routes each to the
matching per-track sink keyed by track_alias.
- `subscribe(namespace, trackName, priority, filter)` sends SUBSCRIBE,
awaits SUBSCRIBE_OK (throws MoqProtocolException on SUBSCRIBE_ERROR
with the publisher's reason), and returns a SubscribeHandle whose
`objects` flow emits inbound OBJECT_DATAGRAMs.
- `unsubscribe(subscribeId)` is idempotent; second call is a no-op.
close() cancels pumps + tears down all in-flight subscribes cleanly.
- Per-track sink uses a bounded Channel with DROP_OLDEST overflow
rather than MutableSharedFlow, so objects buffered between
subscribe() returning and the consumer attaching are preserved
(SharedFlow with replay=0 silently drops pre-subscription emissions).
transport (FakeWebTransport):
- Switched from `consumeAsFlow()` to `receiveAsFlow()` so a `.first()`
during setup followed by a long-running pump `.collect{}` works on
the same channel without the first call closing it.
Tests:
- Updated existing setup tests for the new `pumpScope` parameter.
- New `subscribe_completes_on_subscribe_ok_then_delivers_datagrams_in_order`
end-to-end: client subscribes, raw server peer (no MoqSession to
avoid pump-vs-test contention) replies SUBSCRIBE_OK + 3 datagrams,
client's flow yields all three in order with correct payloads.
- `subscribe_throws_MoqProtocolException_when_publisher_replies_with_subscribe_error`.
- `datagrams_for_unknown_track_alias_are_dropped_silently` ensures
the pump doesn't crash on orphan datagrams and the session stays
closeable.
This completes the pure-Kotlin MoQ work. Remaining 3.x phases all
touch real audio (MediaCodec Opus, AudioTrack, AudioRecord) or the
real WebTransport handshake (Phase 3b-2 with Kwik).
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
This commit is contained in:
+173
-16
@@ -23,8 +23,11 @@ package com.vitorpamplona.nestsclient.moq
|
||||
import com.vitorpamplona.nestsclient.transport.FakeWebTransport
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
@@ -36,7 +39,7 @@ class MoqSessionTest {
|
||||
|
||||
val clientJob =
|
||||
async {
|
||||
val session = MoqSession.client(clientSide)
|
||||
val session = MoqSession.client(clientSide, backgroundScope)
|
||||
session.setup(listOf(MoqVersion.DRAFT_17))
|
||||
session.selectedVersion
|
||||
}
|
||||
@@ -44,16 +47,11 @@ class MoqSessionTest {
|
||||
val serverJob =
|
||||
async {
|
||||
val control = serverSide.peerOpenedBidiStreams().first()
|
||||
val session = MoqSession.server(serverSide, control)
|
||||
val session = MoqSession.server(serverSide, control, backgroundScope)
|
||||
session.setup(
|
||||
supportedVersions = listOf(MoqVersion.DRAFT_17, MoqVersion.DRAFT_11),
|
||||
clientParameters =
|
||||
listOf(
|
||||
SetupParameter(
|
||||
SetupParameter.KEY_MAX_SUBSCRIBE_ID,
|
||||
byteArrayOf(0x10),
|
||||
),
|
||||
),
|
||||
listOf(SetupParameter(SetupParameter.KEY_MAX_SUBSCRIBE_ID, byteArrayOf(0x10))),
|
||||
)
|
||||
session.selectedVersion
|
||||
}
|
||||
@@ -69,8 +67,7 @@ class MoqSessionTest {
|
||||
|
||||
val clientJob =
|
||||
async {
|
||||
val session = MoqSession.client(clientSide)
|
||||
// Client prefers 17, falls back to 11.
|
||||
val session = MoqSession.client(clientSide, backgroundScope)
|
||||
session.setup(listOf(MoqVersion.DRAFT_17, MoqVersion.DRAFT_11))
|
||||
session.selectedVersion
|
||||
}
|
||||
@@ -78,8 +75,7 @@ class MoqSessionTest {
|
||||
val serverJob =
|
||||
async {
|
||||
val control = serverSide.peerOpenedBidiStreams().first()
|
||||
val session = MoqSession.server(serverSide, control)
|
||||
// Server only speaks 11 → overlap is 11.
|
||||
val session = MoqSession.server(serverSide, control, backgroundScope)
|
||||
session.setup(supportedVersions = listOf(MoqVersion.DRAFT_11))
|
||||
session.selectedVersion
|
||||
}
|
||||
@@ -95,23 +91,184 @@ class MoqSessionTest {
|
||||
|
||||
val clientJob =
|
||||
async {
|
||||
val session = MoqSession.client(clientSide)
|
||||
val session = MoqSession.client(clientSide, backgroundScope)
|
||||
runCatching { session.setup(listOf(MoqVersion.DRAFT_17)) }
|
||||
}
|
||||
|
||||
val serverJob =
|
||||
async {
|
||||
val control = serverSide.peerOpenedBidiStreams().first()
|
||||
val session = MoqSession.server(serverSide, control)
|
||||
val session = MoqSession.server(serverSide, control, backgroundScope)
|
||||
assertFailsWith<MoqProtocolException> {
|
||||
session.setup(supportedVersions = listOf(MoqVersion.DRAFT_11))
|
||||
}
|
||||
}
|
||||
|
||||
serverJob.await()
|
||||
// Client's result: its control stream closes with no reply, which throws.
|
||||
// We just verify that some throwable propagated.
|
||||
val clientOutcome = clientJob.await()
|
||||
assert(clientOutcome.isFailure) { "client setup should have failed, got: $clientOutcome" }
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end: client subscribes; a *raw* server peer (no MoqSession on
|
||||
* the server side, since that would compete with the test's manual reads
|
||||
* for control-stream items) responds with SUBSCRIBE_OK and three
|
||||
* OBJECT_DATAGRAMs. The client's flow should yield them in order.
|
||||
*/
|
||||
@Test
|
||||
fun subscribe_completes_on_subscribe_ok_then_delivers_datagrams_in_order() =
|
||||
runTest {
|
||||
val (clientSide, serverSide) = FakeWebTransport.pair()
|
||||
val clientSession = MoqSession.client(clientSide, backgroundScope)
|
||||
|
||||
// Server-side raw peer: handshake + serve one subscription manually.
|
||||
val serverWork =
|
||||
async {
|
||||
val serverControl = serverSide.peerOpenedBidiStreams().first()
|
||||
// Handshake: read CLIENT_SETUP, write SERVER_SETUP.
|
||||
val clientSetupFrame = serverControl.incoming().first()
|
||||
val clientSetup = MoqCodec.decode(clientSetupFrame)!!.message as ClientSetup
|
||||
val version = clientSetup.supportedVersions.first()
|
||||
serverControl.write(MoqCodec.encode(ServerSetup(selectedVersion = version)))
|
||||
|
||||
// Wait for SUBSCRIBE, reply with SUBSCRIBE_OK + three datagrams.
|
||||
val sub = MoqCodec.decode(serverControl.incoming().first())!!.message as Subscribe
|
||||
serverControl.write(
|
||||
MoqCodec.encode(
|
||||
SubscribeOk(
|
||||
subscribeId = sub.subscribeId,
|
||||
expiresMs = 60_000,
|
||||
groupOrder = 0,
|
||||
contentExists = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
repeat(3) { i ->
|
||||
val obj =
|
||||
MoqObject(
|
||||
trackAlias = sub.trackAlias,
|
||||
groupId = 0,
|
||||
objectId = i.toLong(),
|
||||
publisherPriority = 0x80,
|
||||
payload = byteArrayOf(i.toByte()),
|
||||
)
|
||||
serverSide.sendDatagram(MoqObjectDatagram.encode(obj))
|
||||
}
|
||||
sub
|
||||
}
|
||||
|
||||
clientSession.setup(listOf(MoqVersion.DRAFT_17))
|
||||
|
||||
val handle =
|
||||
clientSession.subscribe(
|
||||
namespace = TrackNamespace.of("nests", "test-room"),
|
||||
trackName = "speaker-1".encodeToByteArray(),
|
||||
)
|
||||
val sub = serverWork.await()
|
||||
|
||||
assertEquals(sub.subscribeId, handle.subscribeId)
|
||||
assertEquals(sub.trackAlias, handle.trackAlias)
|
||||
assertEquals(60_000L, handle.ok.expiresMs)
|
||||
|
||||
val received = handle.objects.take(3).toList()
|
||||
assertEquals(listOf(0L, 1L, 2L), received.map { it.objectId })
|
||||
assertContentEquals(byteArrayOf(0), received[0].payload)
|
||||
assertContentEquals(byteArrayOf(1), received[1].payload)
|
||||
assertContentEquals(byteArrayOf(2), received[2].payload)
|
||||
|
||||
clientSession.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscribe_throws_MoqProtocolException_when_publisher_replies_with_subscribe_error() =
|
||||
runTest {
|
||||
val (clientSide, serverSide) = FakeWebTransport.pair()
|
||||
val clientSession = MoqSession.client(clientSide, backgroundScope)
|
||||
|
||||
val rejector =
|
||||
async {
|
||||
val serverControl = serverSide.peerOpenedBidiStreams().first()
|
||||
val clientSetup =
|
||||
MoqCodec.decode(serverControl.incoming().first())!!.message as ClientSetup
|
||||
serverControl.write(MoqCodec.encode(ServerSetup(clientSetup.supportedVersions.first())))
|
||||
|
||||
val sub =
|
||||
MoqCodec.decode(serverControl.incoming().first())!!.message as Subscribe
|
||||
serverControl.write(
|
||||
MoqCodec.encode(
|
||||
SubscribeError(
|
||||
subscribeId = sub.subscribeId,
|
||||
errorCode = 404,
|
||||
reasonPhrase = "track not found",
|
||||
trackAlias = sub.trackAlias,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
clientSession.setup(listOf(MoqVersion.DRAFT_17))
|
||||
|
||||
val ex =
|
||||
assertFailsWith<MoqProtocolException> {
|
||||
clientSession.subscribe(
|
||||
namespace = TrackNamespace.of("nests", "test-room"),
|
||||
trackName = "missing".encodeToByteArray(),
|
||||
)
|
||||
}
|
||||
rejector.await()
|
||||
assert("404" in ex.message!!) { "error message should mention the code: ${ex.message}" }
|
||||
|
||||
clientSession.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun datagrams_for_unknown_track_alias_are_dropped_silently() =
|
||||
runTest {
|
||||
val (clientSide, serverSide) = FakeWebTransport.pair()
|
||||
val clientSession = MoqSession.client(clientSide, backgroundScope)
|
||||
|
||||
val handshake =
|
||||
async {
|
||||
val serverControl = serverSide.peerOpenedBidiStreams().first()
|
||||
val cs = MoqCodec.decode(serverControl.incoming().first())!!.message as ClientSetup
|
||||
serverControl.write(MoqCodec.encode(ServerSetup(cs.supportedVersions.first())))
|
||||
}
|
||||
clientSession.setup(listOf(MoqVersion.DRAFT_17))
|
||||
handshake.await()
|
||||
|
||||
// Send a datagram for a track no one subscribed to. The pump should
|
||||
// silently drop it; the session stays usable + closes cleanly.
|
||||
serverSide.sendDatagram(
|
||||
MoqObjectDatagram.encode(
|
||||
MoqObject(
|
||||
trackAlias = 9_999,
|
||||
groupId = 0,
|
||||
objectId = 0,
|
||||
publisherPriority = 0x80,
|
||||
payload = byteArrayOf(0xAA.toByte()),
|
||||
),
|
||||
),
|
||||
)
|
||||
testScheduler.runCurrent()
|
||||
clientSession.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing FakeWebTransport bidi-stream test still passes after the
|
||||
* receiveAsFlow refactor: write + finish + collect should still surface
|
||||
* the chunk and complete cleanly.
|
||||
*/
|
||||
@Test
|
||||
fun fake_bidi_stream_finish_completes_the_consumer_flow() =
|
||||
runTest {
|
||||
val (a, b) = FakeWebTransport.pair()
|
||||
val clientStream = a.openBidiStream()
|
||||
clientStream.write(byteArrayOf(0xAA.toByte()))
|
||||
clientStream.finish()
|
||||
|
||||
val serverStream = b.peerOpenedBidiStreams().first()
|
||||
val received = serverStream.incoming().toList()
|
||||
assertEquals(1, received.size)
|
||||
assertContentEquals(byteArrayOf(0xAA.toByte()), received.single())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user