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:
+137
@@ -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)
|
||||
}
|
||||
}
|
||||
+410
@@ -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)
|
||||
+34
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* 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.transport.FakeBidiStream
|
||||
import com.vitorpamplona.nestsclient.transport.FakeWebTransport
|
||||
import com.vitorpamplona.quic.Varint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
/**
|
||||
* Drives a [MoqLiteSession] from the listener side against a fake peer
|
||||
* that plays the relay role. Validates:
|
||||
*
|
||||
* - subscribe() writes the right ControlType + body, blocks until the
|
||||
* peer's SubscribeOk arrives, then yields a SubscribeHandle whose
|
||||
* frames flow surfaces every incoming group.
|
||||
* - subscribe() throws MoqLiteSubscribeException on a Drop response.
|
||||
* - announce() writes the AnnouncePlease and surfaces every Announce
|
||||
* update the peer streams back.
|
||||
* - Group uni streams are demuxed by subscribeId — frames for sub A
|
||||
* never leak into sub B.
|
||||
*
|
||||
* No transport mock magic — these tests use the production codec to
|
||||
* encode/decode bytes, so a wire-shape regression on either side fails
|
||||
* here.
|
||||
*/
|
||||
class MoqLiteSessionTest {
|
||||
/** Real-time scope for session pumps. Cancelled in [tearDown] so each
|
||||
* test ends with a clean coroutine ledger.
|
||||
*/
|
||||
private val supervisor = SupervisorJob()
|
||||
private val pumpScope = CoroutineScope(supervisor)
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() =
|
||||
runBlocking {
|
||||
supervisor.cancelAndJoin()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscribe_writes_request_and_returns_handle_on_ok() =
|
||||
runBlocking {
|
||||
val (clientSide, serverSide) = FakeWebTransport.pair()
|
||||
val session = MoqLiteSession.client(clientSide, pumpScope)
|
||||
|
||||
val peerHandlesSubscribe =
|
||||
async {
|
||||
val bidi = serverSide.peerOpenedBidiStreams().first()
|
||||
val req = readSubscribeRequest(bidi)
|
||||
assertEquals("speakerPubkey", req.broadcast)
|
||||
assertEquals("audio/data", req.track)
|
||||
assertEquals(MoqLiteSession.DEFAULT_PRIORITY, req.priority)
|
||||
val ok =
|
||||
MoqLiteSubscribeOk(
|
||||
priority = req.priority,
|
||||
ordered = req.ordered,
|
||||
maxLatencyMillis = req.maxLatencyMillis,
|
||||
startGroup = null,
|
||||
endGroup = null,
|
||||
)
|
||||
bidi.write(MoqLiteCodec.encodeSubscribeOk(ok))
|
||||
req
|
||||
}
|
||||
|
||||
val handle = session.subscribe("speakerPubkey", "audio/data")
|
||||
val request = peerHandlesSubscribe.await()
|
||||
assertEquals(request.id, handle.id)
|
||||
assertEquals(0L, handle.id, "first subscribe id is 0")
|
||||
|
||||
// Now push one group with two frames from the server side.
|
||||
val uni = serverSide.openPeerUniStream()
|
||||
uni.write(Varint.encode(MoqLiteDataType.Group.code))
|
||||
uni.write(MoqLiteCodec.encodeGroupHeader(MoqLiteGroupHeader(subscribeId = handle.id, sequence = 7L)))
|
||||
uni.write(framePayload(byteArrayOf(0x10, 0x11)))
|
||||
uni.write(framePayload(byteArrayOf(0x20, 0x21)))
|
||||
uni.finish()
|
||||
|
||||
val frames =
|
||||
withTimeout(2_000) {
|
||||
handle.frames.take(2).toList()
|
||||
}
|
||||
assertEquals(2, frames.size)
|
||||
assertEquals(7L, frames[0].groupSequence)
|
||||
assertEquals(7L, frames[1].groupSequence)
|
||||
assertContentEquals(byteArrayOf(0x10, 0x11), frames[0].payload)
|
||||
assertContentEquals(byteArrayOf(0x20, 0x21), frames[1].payload)
|
||||
|
||||
session.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscribe_throws_on_drop_response() =
|
||||
runBlocking {
|
||||
val (clientSide, serverSide) = FakeWebTransport.pair()
|
||||
val session = MoqLiteSession.client(clientSide, pumpScope)
|
||||
|
||||
val peer =
|
||||
async {
|
||||
val bidi = serverSide.peerOpenedBidiStreams().first()
|
||||
readSubscribeRequest(bidi)
|
||||
bidi.write(
|
||||
MoqLiteCodec.encodeSubscribeDrop(
|
||||
MoqLiteSubscribeDrop(errorCode = 4L, reasonPhrase = "no such broadcast"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertFailsWith<MoqLiteSubscribeException> {
|
||||
session.subscribe("nope", "audio/data")
|
||||
}
|
||||
peer.await()
|
||||
session.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun announce_streams_relay_updates() =
|
||||
runBlocking {
|
||||
val (clientSide, serverSide) = FakeWebTransport.pair()
|
||||
val session = MoqLiteSession.client(clientSide, pumpScope)
|
||||
|
||||
val peer =
|
||||
async {
|
||||
val bidi = serverSide.peerOpenedBidiStreams().first()
|
||||
val chunks = bidi.incoming().take(2).toList()
|
||||
val controlByte = MoqLiteFrameBuffer().apply { push(chunks[0]) }.readVarint()
|
||||
assertEquals(MoqLiteControlType.Announce.code, controlByte)
|
||||
val plea =
|
||||
MoqLiteFrameBuffer().apply { push(chunks[1]) }.readSizePrefixed()
|
||||
?: error("AnnouncePlease chunk did not contain a complete size-prefixed payload")
|
||||
assertEquals("nests/30312:abc:room", MoqLiteCodec.decodeAnnouncePlease(plea).prefix)
|
||||
|
||||
// Send two Announce updates back.
|
||||
bidi.write(
|
||||
MoqLiteCodec.encodeAnnounce(
|
||||
MoqLiteAnnounce(
|
||||
MoqLiteAnnounceStatus.Active,
|
||||
"speakerOne",
|
||||
hops = 1L,
|
||||
),
|
||||
),
|
||||
)
|
||||
bidi.write(
|
||||
MoqLiteCodec.encodeAnnounce(
|
||||
MoqLiteAnnounce(
|
||||
MoqLiteAnnounceStatus.Active,
|
||||
"speakerTwo",
|
||||
hops = 1L,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val announces = session.announce("nests/30312:abc:room")
|
||||
val updates = withTimeout(2_000) { announces.updates.take(2).toList() }
|
||||
peer.await()
|
||||
|
||||
assertEquals(2, updates.size)
|
||||
assertEquals("speakerOne", updates[0].suffix)
|
||||
assertEquals(MoqLiteAnnounceStatus.Active, updates[0].status)
|
||||
assertEquals("speakerTwo", updates[1].suffix)
|
||||
|
||||
announces.close()
|
||||
session.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groups_are_demuxed_by_subscribeId() =
|
||||
runBlocking {
|
||||
val (clientSide, serverSide) = FakeWebTransport.pair()
|
||||
val session = MoqLiteSession.client(clientSide, pumpScope)
|
||||
|
||||
// Set up two parallel subscriptions — peer accepts both, replies
|
||||
// with Ok, then pushes one group per subscription out of order.
|
||||
val subAck =
|
||||
async {
|
||||
val bidiA = serverSide.peerOpenedBidiStreams().first()
|
||||
readSubscribeRequest(bidiA)
|
||||
bidiA.write(MoqLiteCodec.encodeSubscribeOk(okFor(0L)))
|
||||
bidiA
|
||||
}
|
||||
val handleA = session.subscribe("speakerA", "audio/data")
|
||||
subAck.await()
|
||||
|
||||
val subAck2 =
|
||||
async {
|
||||
val bidiB = serverSide.peerOpenedBidiStreams().first()
|
||||
readSubscribeRequest(bidiB)
|
||||
bidiB.write(MoqLiteCodec.encodeSubscribeOk(okFor(1L)))
|
||||
bidiB
|
||||
}
|
||||
val handleB = session.subscribe("speakerB", "audio/data")
|
||||
subAck2.await()
|
||||
|
||||
assertEquals(0L, handleA.id)
|
||||
assertEquals(1L, handleB.id)
|
||||
|
||||
// Push one group for A with payload "a", one for B with payload "b".
|
||||
val uniB = serverSide.openPeerUniStream()
|
||||
uniB.write(Varint.encode(MoqLiteDataType.Group.code))
|
||||
uniB.write(MoqLiteCodec.encodeGroupHeader(MoqLiteGroupHeader(subscribeId = handleB.id, sequence = 0L)))
|
||||
uniB.write(framePayload("b".encodeToByteArray()))
|
||||
uniB.finish()
|
||||
|
||||
val uniA = serverSide.openPeerUniStream()
|
||||
uniA.write(Varint.encode(MoqLiteDataType.Group.code))
|
||||
uniA.write(MoqLiteCodec.encodeGroupHeader(MoqLiteGroupHeader(subscribeId = handleA.id, sequence = 0L)))
|
||||
uniA.write(framePayload("a".encodeToByteArray()))
|
||||
uniA.finish()
|
||||
|
||||
val fromA = withTimeout(2_000) { handleA.frames.first() }
|
||||
val fromB = withTimeout(2_000) { handleB.frames.first() }
|
||||
assertContentEquals("a".encodeToByteArray(), fromA.payload)
|
||||
assertContentEquals("b".encodeToByteArray(), fromB.payload)
|
||||
|
||||
handleA.unsubscribe()
|
||||
handleB.unsubscribe()
|
||||
session.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsubscribe_FINs_the_subscribe_bidi() =
|
||||
runBlocking {
|
||||
val (clientSide, serverSide) = FakeWebTransport.pair()
|
||||
val session = MoqLiteSession.client(clientSide, pumpScope)
|
||||
|
||||
var peerBidi: FakeBidiStream? = null
|
||||
val peer =
|
||||
async {
|
||||
val bidi = serverSide.peerOpenedBidiStreams().first()
|
||||
readSubscribeRequest(bidi)
|
||||
bidi.write(MoqLiteCodec.encodeSubscribeOk(okFor(0L)))
|
||||
peerBidi = bidi
|
||||
// Drain whatever the listener writes after Ok — moq-lite
|
||||
// unsubscribe is a FIN, which surfaces here as the
|
||||
// incoming flow completing.
|
||||
bidi.incoming().toList()
|
||||
}
|
||||
|
||||
val handle = session.subscribe("speakerX", "audio/data")
|
||||
handle.unsubscribe()
|
||||
withTimeout(2_000) { peer.await() }
|
||||
assertEquals(true, peerBidi != null, "peer received the bidi")
|
||||
|
||||
session.close()
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
/**
|
||||
* Read a Subscribe request from the peer side of the bidi. Each
|
||||
* `bidi.write` on the session side becomes one channel send (so
|
||||
* one chunk on this end), and the session writes ControlType then
|
||||
* the encoded body — exactly two chunks. Pull both with a single
|
||||
* [take]+[toList] and parse them, no streaming buffer needed.
|
||||
*/
|
||||
private suspend fun readSubscribeRequest(bidi: FakeBidiStream): MoqLiteSubscribe {
|
||||
val chunks = bidi.incoming().take(2).toList()
|
||||
check(chunks.size == 2) { "expected 2 chunks (control byte + body), got ${chunks.size}" }
|
||||
val controlByte = MoqLiteFrameBuffer().apply { push(chunks[0]) }.readVarint()
|
||||
assertEquals(MoqLiteControlType.Subscribe.code, controlByte)
|
||||
val payload =
|
||||
MoqLiteFrameBuffer().apply { push(chunks[1]) }.readSizePrefixed()
|
||||
?: error("subscribe body chunk did not contain a complete size-prefixed payload")
|
||||
return MoqLiteCodec.decodeSubscribe(payload)
|
||||
}
|
||||
|
||||
private fun framePayload(bytes: ByteArray): ByteArray = Varint.encode(bytes.size.toLong()) + bytes
|
||||
|
||||
private fun okFor(
|
||||
@Suppress("UNUSED_PARAMETER") id: Long,
|
||||
) = MoqLiteSubscribeOk(
|
||||
priority = MoqLiteSession.DEFAULT_PRIORITY,
|
||||
ordered = true,
|
||||
maxLatencyMillis = 0L,
|
||||
startGroup = null,
|
||||
endGroup = null,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user