chore(audio-rooms): align catalog/announces error timing + comment (audit #5-7)

* announces() now throws UnsupportedOperationException at CALL
    time (not the first collect) on the IETF default — matches
    subscribeCatalog's timing so both can be guarded with one
    `runCatching { listener.announces() }` per session rather than
    a runCatching around every collect site (audit #6).

  * KDoc on announces() documents the deliberate hot-vs-cold
    asymmetry with subscribeSpeaker/subscribeCatalog: announce
    data is room-state with a single VM consumer (cold flow is
    sufficient), audio is per-frame playout with multi-collect
    resilience needs (hot SharedFlow with DROP_OLDEST). The
    different shapes are intentional, not accidental (audit #5).

  * MoqLiteNestsListener.wrapSubscription's "IETF SubscribeHandle
    path conventionally surfaces" comment was scoped to the audio
    track when first written; now the same body serves both audio
    and catalog. Re-frame so the comment applies to either track
    (audit #7).

Test: NestsListenerCatalogTest's announces-throws case now
asserts the call itself throws, not the collect.
This commit is contained in:
Claude
2026-04-27 02:28:24 +00:00
parent e4fe23a1e5
commit 43c5313674
3 changed files with 26 additions and 20 deletions
@@ -80,10 +80,11 @@ class MoqLiteNestsListener internal constructor(
try {
session.subscribe(broadcast = broadcast, track = track)
} catch (e: MoqLiteSubscribeException) {
// The IETF SubscribeHandle path conventionally surfaces
// protocol-level rejections through the same exception
// shape MoqProtocolException uses, so wrapping here
// avoids leaking moq-lite types into UI consumers.
// Surface protocol-level rejections (audio OR catalog)
// through MoqProtocolException, matching the IETF
// listener's contract — keeps moq-lite types out of
// UI / VM consumers regardless of which track was
// refused.
throw com.vitorpamplona.nestsclient.moq.MoqProtocolException(
"moq-lite subscribe rejected: ${e.message}",
e,
@@ -30,7 +30,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flow
/**
* High-level listener handle for an audio-room. Hides the layered HTTP +
@@ -82,20 +81,26 @@ interface NestsListener {
* independent of the kind-10312 `publishing` presence tag —
* the JS reference's `useRoomAnnouncements` consumes this.
*
* Default body throws [UnsupportedOperationException] on the
* IETF reference path (`MoqSession` doesn't define an
* announce-prefix subscription that fits this shape). Callers
* mixing the two should `runCatching` the collect.
* The flow is COLD (re-collected starts a fresh subscription)
* — different from [subscribeSpeaker], which returns a hot
* SubscribeHandle backed by a fan-out [kotlinx.coroutines.flow.MutableSharedFlow].
* The asymmetry is intentional: announce data is room-state
* (one consumer, no buffered replay), while audio is per-frame
* playout (multi-collect resilient, drop-oldest fan-out).
*
* Throws on the IETF reference path because moq-transport-17
* doesn't define an announce-prefix flow. Throws at call time
* (not at first collect) so callers can `runCatching` the
* single call rather than wrapping every collect — same
* timing as [subscribeCatalog].
*
* @throws UnsupportedOperationException on the IETF listener.
* @throws IllegalStateException if the listener is not in [NestsListenerState.Connected].
*/
fun announces(): Flow<RoomAnnouncement> =
flow {
throw UnsupportedOperationException(
"announces() is moq-lite-only; IETF listener has no announce-prefix flow.",
)
}
throw UnsupportedOperationException(
"announces() is moq-lite-only; IETF listener has no announce-prefix flow.",
)
/** Tear down the MoQ session + underlying transport. Idempotent. */
suspend fun close()
@@ -55,17 +55,17 @@ class NestsListenerCatalogTest {
}
@Test
fun ietfDefaultListenerAnnouncesFlowThrowsOnCollect() =
fun ietfDefaultListenerAnnouncesFlowThrowsAtCallTime() =
runTest {
// The announce-prefix channel is moq-lite only as well —
// the IETF reference path's announces() default body
// throws UnsupportedOperationException at collect time.
// Callers mixing the two protocols can `runCatching` the
// collect to no-op cleanly.
// throws UnsupportedOperationException at CALL time,
// matching subscribeCatalog's timing. Callers can
// `runCatching { listener.announces() }` once per session
// rather than wrapping every collect.
val listener = IetfStyleListener()
val flow = listener.announces()
assertFailsWith<UnsupportedOperationException> {
flow.collect { /* unreachable */ }
listener.announces()
}
}
}