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:
Claude
2026-04-22 06:58:52 +00:00
parent d65ab7b616
commit b091b50fe6
3 changed files with 475 additions and 58 deletions
@@ -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())
}
}