feat(audio-rooms): MoqLiteSession listener-side (phase 5c-listener)

Listener-side moq-lite (Lite-03) session that wraps a
WebTransportSession and exposes:

  - announce(prefix) — opens an Announce bidi with
    ControlType=Announce + AnnouncePlease(prefix), surfaces a flow
    of relay Announce updates (Active/Ended)
  - subscribe(broadcast, track, ...) — opens a Subscribe bidi with
    ControlType=Subscribe + body, reads the size-prefixed response,
    returns a MoqLiteSubscribeHandle whose frames flow yields each
    frame the publisher pushes
  - close() — cancels pumps, FINs every active subscribe bidi
    (moq-lite UNSUBSCRIBE is "FIN the bidi"), closes the transport

Group demux: a single inbound-uni-stream pump per session reads the
DataType byte (Group=0) + size-prefixed group header, then routes
each subsequent (size, payload) frame to the matching
subscriptionsBySubscribeId entry's frame channel.

Speaker side stops at the announce/subscribe interface — moq-lite
publisher flows depend on accept_bi() / server-initiated bidi
streams (see clarifying agent summary in plans/2026-04-26-moq-lite-gap.md).
That's a follow-up; the WebTransportSession interface needs an
acceptBidiStream() method first.

FakeWebTransport.openPeerUniStream() — new helper so tests can
inject group data from the peer side without going through the
session's outbound uni stream API.

Tests in MoqLiteSessionTest — runBlocking-based (not runTest) so
real-time channel coordination works without virtual-time
artifacts:
  - subscribe_writes_request_and_returns_handle_on_ok
  - subscribe_throws_on_drop_response
  - announce_streams_relay_updates
  - groups_are_demuxed_by_subscribeId (no cross-talk)
  - unsubscribe_FINs_the_subscribe_bidi
This commit is contained in:
Claude
2026-04-26 17:24:17 +00:00
parent fb47a4cf75
commit 4e136cacf4
4 changed files with 890 additions and 0 deletions
@@ -0,0 +1,137 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.moq.lite
import com.vitorpamplona.nestsclient.moq.MoqCodecException
import com.vitorpamplona.quic.Varint
import kotlinx.coroutines.CancellationException
/**
* Streaming frame buffer for moq-lite. Every wire datum on a control
* stream is a varint length followed by `length` bytes; every uni
* stream starts with a one-varint type byte then a size-prefixed
* group header then a sequence of size-prefixed frames until QUIC FIN.
*
* Network reads arrive as opaque chunks, so this class holds a rolling
* buffer and exposes:
* - [readVarint] — pull one varint when enough bytes have arrived
* - [readSizePrefixed] — pull one size-prefixed payload (varint
* length + payload bytes) when complete
*
* Both return null when more bytes are needed; the caller buffers
* additional chunks via [push] and retries.
*
* Not thread-safe — used from a single coroutine per stream.
*/
class MoqLiteFrameBuffer {
private var buf: ByteArray = ByteArray(0)
private var pos: Int = 0
/** Append [chunk] to the buffer, growing/compacting as needed. */
fun push(chunk: ByteArray) {
if (chunk.isEmpty()) return
compact()
val needed = buf.size + chunk.size
// Power-of-two doubling matches MoqWriter's growth policy and
// avoids quadratic copies under bursty arrivals.
var newCap = if (buf.size == 0) 64 else buf.size
while (newCap < needed) newCap *= 2
val grown = ByteArray(newCap)
buf.copyInto(grown, 0, 0, buf.size)
chunk.copyInto(grown, buf.size, 0, chunk.size)
buf = grown.copyOf(needed)
}
/** Try to read one varint. Returns null if not enough bytes. */
fun readVarint(): Long? {
val dec = Varint.decode(buf, pos) ?: return null
pos += dec.bytesConsumed
return dec.value
}
/**
* Try to read one size-prefixed payload. Returns the payload bytes
* (no length prefix) or null if either the length varint or the
* payload itself isn't fully buffered yet. On invalid lengths
* (negative, > Int.MAX_VALUE) throws.
*/
fun readSizePrefixed(): ByteArray? {
val savedPos = pos
val len = readVarint() ?: return null
if (len < 0 || len > Int.MAX_VALUE) {
throw MoqCodecException("absurd moq-lite size prefix: $len")
}
if (pos + len.toInt() > buf.size) {
// Roll the cursor back so the next call sees the same
// varint and only commits when the whole payload arrives.
pos = savedPos
return null
}
val payload = buf.copyOfRange(pos, pos + len.toInt())
pos += len.toInt()
return payload
}
/** Bytes still buffered after [pos] — exposed for diagnostic / EOF detection. */
val remaining: Int get() = buf.size - pos
/** Drop the consumed prefix when half the buffer is dead weight. */
private fun compact() {
if (pos == 0) return
if (pos < buf.size / 2) return
val live = buf.size - pos
val newBuf = ByteArray(live)
buf.copyInto(newBuf, 0, pos, buf.size)
buf = newBuf
pos = 0
}
}
/**
* Pull *one* size-prefixed message off a chunk channel. Drains chunks
* one at a time into [buffer] until [MoqLiteFrameBuffer.readSizePrefixed]
* returns a full payload. Returns null if [chunks] closes before a
* complete message arrives.
*
* Channel-based rather than Flow-based — `collect { throw EarlyExit }`
* is fragile under virtual-time test dispatchers and doesn't compose
* well with multiple sequential reads on the same channel. The caller
* owns the channel and is responsible for keeping it pumped (typically
* a single launched collector that forwards `incoming` chunks into the
* channel).
*/
internal suspend fun readOneSizePrefixedFrom(
chunks: kotlinx.coroutines.channels.ReceiveChannel<ByteArray>,
buffer: MoqLiteFrameBuffer,
): ByteArray? {
while (true) {
buffer.readSizePrefixed()?.let { return it }
val next =
try {
chunks.receive()
} catch (ce: CancellationException) {
throw ce
} catch (_: kotlinx.coroutines.channels.ClosedReceiveChannelException) {
return null
}
buffer.push(next)
}
}
@@ -0,0 +1,410 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.moq.lite
import com.vitorpamplona.nestsclient.moq.MoqCodecException
import com.vitorpamplona.nestsclient.transport.WebTransportSession
import com.vitorpamplona.quic.Varint
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Listener-side moq-lite (Lite-03) session. Wraps a connected
* [WebTransportSession] and exposes:
*
* - [announce] — open an Announce bidi with a prefix, observe live
* `Active` / `Ended` updates from the relay.
* - [subscribe] — open a Subscribe bidi for a `(broadcast, track)`
* pair, await SubscribeOk, return a [MoqLiteSubscribeHandle] whose
* [MoqLiteSubscribeHandle.frames] yields each frame the publisher
* pushes.
*
* Speaker-side is **not yet implemented** — the publisher direction
* needs server-initiated bidi acceptance (relay → us) which the
* current `WebTransportSession` interface does not surface. Tracked in
* `nestsClient/plans/2026-04-26-moq-lite-gap.md` phase-5c-speaker.
*
* Wire-protocol scope: Lite-03 — no SETUP, no datagrams, one fresh
* client-initiated bidi per request, group data on uni streams. See
* the gap doc for the byte-level layout.
*/
class MoqLiteSession internal constructor(
private val transport: WebTransportSession,
private val scope: CoroutineScope,
) {
private val state = Mutex()
private val subscriptionsBySubscribeId: MutableMap<Long, ListenerSubscription> = HashMap()
private var nextSubscribeId: Long = 0L
private var groupPump: Job? = null
@Volatile private var closed: Boolean = false
val isClosed: Boolean get() = closed
/**
* Open an Announce bidi against the relay. Sends
* `ControlType=Announce` + `AnnouncePlease(prefix)`, then surfaces
* a flow of [MoqLiteAnnounce] updates the relay streams back.
*
* The returned [MoqLiteAnnouncesHandle] keeps the bidi open until
* [MoqLiteAnnouncesHandle.close] is called. FINing the bidi is
* how moq-lite signals "no longer interested" — there is no
* ANNOUNCE_CANCEL message.
*/
suspend fun announce(prefix: String): MoqLiteAnnouncesHandle {
ensureOpen()
val bidi = transport.openBidiStream()
bidi.write(Varint.encode(MoqLiteControlType.Announce.code))
bidi.write(MoqLiteCodec.encodeAnnouncePlease(MoqLiteAnnouncePlease(prefix)))
val updates = MutableSharedFlow<MoqLiteAnnounce>(replay = 0, extraBufferCapacity = 64)
val pump =
scope.launch {
val buffer = MoqLiteFrameBuffer()
try {
bidi.incoming().collect { chunk ->
buffer.push(chunk)
while (true) {
val payload = buffer.readSizePrefixed() ?: break
updates.emit(MoqLiteCodec.decodeAnnounce(payload))
}
}
} catch (ce: CancellationException) {
throw ce
} catch (_: Throwable) {
// Flow terminated (peer FIN or transport close).
// The Announce stream's emit-side just stops; consumers
// see an end-of-flow.
}
}
return MoqLiteAnnouncesHandle(
updates = updates,
close = {
runCatching { bidi.finish() }
pump.cancelAndJoin()
},
)
}
/**
* Open a Subscribe bidi for `(broadcast, track)`. Sends
* `ControlType=Subscribe` + the [MoqLiteSubscribe] body, awaits
* the [MoqLiteCodec.SubscribeResponse] reply, and on Ok returns a
* [MoqLiteSubscribeHandle] whose `frames` flow yields each frame
* the publisher pushes through the relay (one [MoqLiteFrame] per
* payload, bundled into [MoqLiteFrame] structs that carry the
* group sequence).
*
* Throws [MoqLiteSubscribeException] if the publisher / relay
* replies with Drop, or if the response stream tears down before
* any reply arrives.
*/
suspend fun subscribe(
broadcast: String,
track: String,
priority: Int = DEFAULT_PRIORITY,
ordered: Boolean = true,
maxLatencyMillis: Long = 0L,
startGroup: Long? = null,
endGroup: Long? = null,
): MoqLiteSubscribeHandle {
ensureOpen()
val id =
state.withLock {
check(!closed) { "session is closed" }
val next = nextSubscribeId++
next
}
val request =
MoqLiteSubscribe(
id = id,
broadcast = broadcast,
track = track,
priority = priority,
ordered = ordered,
maxLatencyMillis = maxLatencyMillis,
startGroup = startGroup,
endGroup = endGroup,
)
val bidi = transport.openBidiStream()
bidi.write(Varint.encode(MoqLiteControlType.Subscribe.code))
bidi.write(MoqLiteCodec.encodeSubscribe(request))
// moq-lite's subscribe-response is a single size-prefixed
// message on the response side of the bidi. Read incoming
// chunks into a buffer until the buffer holds a full payload,
// then stop. We don't need a separate collector pump because
// post-Ok the bidi is idle (group data flows on its own uni
// streams).
val responseBuffer = MoqLiteFrameBuffer()
val responsePayload = readSubscribeResponseFromBidi(bidi.incoming(), responseBuffer, id)
when (val resp = MoqLiteCodec.decodeSubscribeResponse(responsePayload)) {
is MoqLiteCodec.SubscribeResponse.Dropped -> {
throw MoqLiteSubscribeException(
"publisher rejected subscribe id=$id: errorCode=${resp.drop.errorCode} " +
"reason='${resp.drop.reasonPhrase}'",
)
}
is MoqLiteCodec.SubscribeResponse.Ok -> {
val frames = Channel<MoqLiteFrame>(capacity = DEFAULT_FRAME_BUFFER, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val sub =
ListenerSubscription(
id = id,
request = request,
ok = resp.ok,
bidi = bidi,
frames = frames,
)
state.withLock {
subscriptionsBySubscribeId[id] = sub
if (groupPump == null) groupPump = scope.launch { pumpUniStreams() }
}
return MoqLiteSubscribeHandle(
id = id,
ok = resp.ok,
frames = frames.consumeAsFlow(),
unsubscribeAction = { unsubscribe(id) },
)
}
}
}
/**
* Drain inbound uni streams and route each one's group frames to
* the matching subscription. The relay opens a fresh uni stream
* per (subscribe id, group sequence); the first byte is
* [MoqLiteDataType] (0 = Group), followed by a size-prefixed
* [MoqLiteGroupHeader], followed by `varint(size) + payload`
* frames until QUIC FIN.
*
* One pump per session — started lazily on the first subscribe.
*/
private suspend fun pumpUniStreams() {
try {
transport.incomingUniStreams().collect { stream ->
scope.launch { drainOneGroup(stream) }
}
} catch (ce: CancellationException) {
throw ce
} catch (_: Throwable) {
// Transport closed — subscriptions will surface end-of-flow
// via their own bidi pumps as well.
}
}
private suspend fun drainOneGroup(stream: com.vitorpamplona.nestsclient.transport.WebTransportReadStream) {
val buffer = MoqLiteFrameBuffer()
var typeRead = false
var subscribeId: Long = -1L
var groupSequence: Long = -1L
var headerRead = false
try {
stream.incoming().collect { chunk ->
buffer.push(chunk)
if (!typeRead) {
val type = buffer.readVarint() ?: return@collect
if (type != MoqLiteDataType.Group.code) {
throw MoqCodecException("unknown moq-lite uni-stream type code: $type")
}
typeRead = true
}
if (!headerRead) {
val payload = buffer.readSizePrefixed() ?: return@collect
val hdr = MoqLiteCodec.decodeGroupHeader(payload)
subscribeId = hdr.subscribeId
groupSequence = hdr.sequence
headerRead = true
}
while (true) {
val frame = buffer.readSizePrefixed() ?: break
val sub = state.withLock { subscriptionsBySubscribeId[subscribeId] }
if (sub != null) {
sub.frames.trySend(
MoqLiteFrame(
groupSequence = groupSequence,
payload = frame,
),
)
}
// If the subscription has been closed already we
// silently drop the frame — the publisher hasn't
// observed the unsubscribe yet (its uni streams
// are independent of our bidi FIN).
}
}
} catch (ce: CancellationException) {
throw ce
} catch (_: Throwable) {
// Stream errored / FIN'd. Nothing to do — the next group
// arrives on a fresh uni stream.
}
}
private suspend fun unsubscribe(id: Long) {
val sub =
state.withLock { subscriptionsBySubscribeId.remove(id) } ?: return
runCatching { sub.bidi.finish() }
sub.frames.close()
}
suspend fun close() {
if (closed) return
closed = true
val toClose: List<ListenerSubscription>
state.withLock {
toClose = subscriptionsBySubscribeId.values.toList()
subscriptionsBySubscribeId.clear()
}
for (sub in toClose) {
runCatching { sub.bidi.finish() }
sub.frames.close()
}
groupPump?.cancelAndJoin()
runCatching { transport.close() }
}
private fun ensureOpen() {
check(!closed) { "MoqLiteSession is closed" }
}
/**
* Read the single size-prefixed subscribe response off a fresh
* subscribe bidi. moq-lite's subscribe-response is one wire write,
* so the first chunk we receive contains the entire payload (matches
* what the IETF [com.vitorpamplona.nestsclient.moq.MoqSession] does
* during its SETUP read).
*
* Throws [MoqLiteSubscribeException] if the bidi closes before any
* chunk arrives.
*/
private suspend fun readSubscribeResponseFromBidi(
incoming: kotlinx.coroutines.flow.Flow<ByteArray>,
buffer: MoqLiteFrameBuffer,
id: Long,
): ByteArray {
val chunk =
incoming.firstOrNull()
?: throw MoqLiteSubscribeException("subscribe stream FIN before reply for id=$id")
buffer.push(chunk)
return buffer.readSizePrefixed()
?: throw MoqLiteSubscribeException(
"first chunk on subscribe response did not carry a complete size-prefixed payload " +
"(id=$id, chunkSize=${chunk.size})",
)
}
private class ListenerSubscription(
val id: Long,
val request: MoqLiteSubscribe,
val ok: MoqLiteSubscribeOk,
val bidi: com.vitorpamplona.nestsclient.transport.WebTransportBidiStream,
val frames: Channel<MoqLiteFrame>,
)
companion object {
/** moq-lite priority byte midpoint — neutral default. */
const val DEFAULT_PRIORITY: Int = 0x80
/**
* Per-subscription channel buffer for inbound frames. 128 audio
* frames at Opus 20 ms ≈ 2.5 s of backlog before DROP_OLDEST,
* matching a real-time listener's tolerance.
*/
const val DEFAULT_FRAME_BUFFER: Int = 128
/**
* Attach to a [WebTransportSession] in the client role. Lite-03
* has no SETUP — the WT handshake itself is the handshake — so
* this returns a ready-to-use session immediately.
*
* @param pumpScope where the inbound-uni-stream + per-bidi pumps
* live. Production code passes the owning ViewModel scope.
*/
fun client(
transport: WebTransportSession,
pumpScope: CoroutineScope,
): MoqLiteSession = MoqLiteSession(transport, pumpScope)
}
}
/**
* One frame received from a subscription. moq-lite's wire format
* carries no per-frame envelope beyond the size; [groupSequence] is
* pulled from the group header so consumers can detect group rollover
* (e.g. for keyframe boundaries).
*/
data class MoqLiteFrame(
val groupSequence: Long,
val payload: ByteArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MoqLiteFrame) return false
return groupSequence == other.groupSequence && payload.contentEquals(other.payload)
}
override fun hashCode(): Int = 31 * groupSequence.hashCode() + payload.contentHashCode()
}
/**
* Active subscription handle returned by [MoqLiteSession.subscribe].
* [frames] emits every frame the publisher pushes; [unsubscribe]
* FINs the bidi to signal "no longer interested" (moq-lite has no
* UNSUBSCRIBE message — FIN is the protocol).
*/
class MoqLiteSubscribeHandle internal constructor(
val id: Long,
val ok: MoqLiteSubscribeOk,
val frames: Flow<MoqLiteFrame>,
private val unsubscribeAction: suspend () -> Unit,
) {
suspend fun unsubscribe() = unsubscribeAction()
}
/**
* Active announce-discovery handle returned by [MoqLiteSession.announce].
* [updates] emits every [MoqLiteAnnounce] update the relay streams
* back; [close] FINs the bidi to stop receiving updates.
*/
class MoqLiteAnnouncesHandle internal constructor(
val updates: Flow<MoqLiteAnnounce>,
private val close: suspend () -> Unit,
) {
suspend fun close() = close.invoke()
}
/** Thrown when subscribe is rejected (Drop) or the response stream dies. */
class MoqLiteSubscribeException(
message: String,
cause: Throwable? = null,
) : RuntimeException(message, cause)
@@ -46,6 +46,7 @@ class FakeWebTransport private constructor(
private val outboundBidiStreams: Channel<FakeBidiStream>,
private val inboundBidiStreams: Channel<FakeBidiStream>,
private val inboundUniStreams: Channel<WebTransportReadStream>,
private val outboundUniStreams: Channel<WebTransportReadStream>,
) : WebTransportSession {
private val stateLock = Mutex()
private var open = true
@@ -86,6 +87,7 @@ class FakeWebTransport private constructor(
outboundBidiStreams.close()
inboundBidiStreams.close()
inboundUniStreams.close()
outboundUniStreams.close()
}
/**
@@ -94,6 +96,19 @@ class FakeWebTransport private constructor(
*/
fun peerOpenedBidiStreams(): Flow<FakeBidiStream> = inboundBidiStreams.receiveAsFlow()
/**
* Open a uni stream from this side toward the paired peer. Tests use this
* to simulate a publisher pushing a moq-lite group uni stream — write the
* full sequence of chunks via [WebTransportWriteStream.write] then call
* [WebTransportWriteStream.finish] to FIN.
*/
suspend fun openPeerUniStream(): WebTransportWriteStream {
stateLock.withLock { check(open) { "session closed" } }
val pipe = Channel<ByteArray>(Channel.BUFFERED)
outboundUniStreams.send(FakeReadStream(pipe))
return ChannelWriteStream(pipe)
}
companion object {
/**
* Create two linked fakes that act as "client" and "server" endpoints of
@@ -115,6 +130,7 @@ class FakeWebTransport private constructor(
outboundBidiStreams = aToBBidi,
inboundBidiStreams = bToABidi,
inboundUniStreams = bToAUni,
outboundUniStreams = aToBUni,
)
val b =
FakeWebTransport(
@@ -123,6 +139,7 @@ class FakeWebTransport private constructor(
outboundBidiStreams = bToABidi,
inboundBidiStreams = aToBBidi,
inboundUniStreams = aToBUni,
outboundUniStreams = bToAUni,
)
return a to b
}
@@ -149,3 +166,20 @@ class FakeReadStream internal constructor(
) : WebTransportReadStream {
override fun incoming(): Flow<ByteArray> = read.receiveAsFlow()
}
/**
* Write-only adapter over a [Channel]. Used by [FakeWebTransport.openPeerUniStream]
* so a test can drive a peer-initiated uni stream by writing chunks then
* FIN'ing via [finish].
*/
private class ChannelWriteStream(
private val channel: Channel<ByteArray>,
) : WebTransportWriteStream {
override suspend fun write(chunk: ByteArray) {
channel.send(chunk)
}
override suspend fun finish() {
channel.close()
}
}