feat(nestsclient): subscriber-driven Probe API (MoqLiteSession.probe) — moq-lite Lite-03

Lite-03 audit L3: completes the subscriber-side Probe surface to
mirror kixelated's `Subscriber::run_probe_stream`
(`rs/moq-lite/src/lite/subscriber.rs`).

`MoqLiteSession.probe()` opens a bidi, writes
`ControlType::Probe` (varint 4), and returns a `MoqLiteProbeHandle`
whose `updates` flow yields each size-prefixed `MoqLiteProbe`
message the publisher pushes. The handle's `close()` FINs the
bidi and cancels the pump.

`updates` is a `MutableSharedFlow(replay=8)` so a collector that
attaches after the publisher's first emit doesn't miss it
(matches the same shape the announce-watch uses).

No production consumer for the API today — Amethyst's nests
listener doesn't run ABR on a fixed-rate Opus encoder. The API
exists to round out the moq-lite Lite-03 surface: a diagnostic
tool can now read a publisher's bitrate without subscribing to
its data track.

The publisher-side handler (existing) was already correct: it
writes one `MoqLiteProbe` (32 kbps Opus voice hint) and FINs.

Regression test:
`MoqLiteSessionTest.subscriber_probe_writes_control_type_and_decodes_publisher_bitrate`.

Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (L3).

https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
This commit is contained in:
Claude
2026-05-09 14:51:54 +00:00
parent 44291b956b
commit d24953d98c
3 changed files with 127 additions and 0 deletions
@@ -940,6 +940,50 @@ class MoqLiteSessionTest {
session.close()
}
@Test
fun subscriber_probe_writes_control_type_and_decodes_publisher_bitrate() =
runBlocking {
// Lite-03 audit L3: the subscriber side opens a Probe bidi
// by writing ControlType=Probe and reads size-prefixed
// MoqLiteProbe messages from the publisher. Mirrors
// kixelated's `Subscriber::run_probe_stream`
// (`rs/moq-lite/src/lite/subscriber.rs`).
val (clientSide, serverSide) = FakeWebTransport.pair()
val session = MoqLiteSession.client(clientSide, pumpScope)
// Server side: read the subscriber's control byte, then
// write two Probe messages.
val serverPump =
async {
val bidi = serverSide.peerOpenedBidiStreams().first()
val buf = MoqLiteFrameBuffer()
// Read the leading ControlType varint. The session
// writes it as a single chunk via bidi.write.
val ctChunk = withTimeout(2_000) { bidi.incoming().first() }
buf.push(ctChunk)
val ct = buf.readVarint()
assertEquals(MoqLiteControlType.Probe.code, ct, "subscriber writes ControlType=Probe")
// Push two probes (different bitrates) then FIN.
bidi.write(MoqLiteCodec.encodeProbe(MoqLiteProbe(bitrate = 32_000L)))
bidi.write(MoqLiteCodec.encodeProbe(MoqLiteProbe(bitrate = 64_000L)))
bidi.finish()
}
val handle = session.probe()
// Drain both probes from the cold flow. SharedFlow with
// replay=8 means we can collect after the publisher has
// already pushed without losing emissions.
val received = withTimeout(2_000) { handle.updates.take(2).toList() }
assertEquals(2, received.size)
assertEquals(32_000L, received[0].bitrate)
assertEquals(64_000L, received[1].bitrate)
serverPump.await()
handle.close()
session.close()
}
@Test
fun unsubscribe_FINs_the_subscribe_bidi() =
runBlocking {