fix(nestsclient): skip Active announce when AnnouncePlease prefix doesn't match — moq-lite Lite-03 announce.rs
Per moq-lite Lite-03 (kixelated/moq `rs/moq-lite/src/lite/announce.rs`), a publisher MUST only emit `Announce(Active)` for broadcasts whose path starts with the requested AnnouncePlease prefix. Pre-fix, when `MoqLitePath.stripPrefix(please.prefix, ourSuffix)` returned `null`, we fell through to `?: announcePublisher.suffix` and emitted Active under our full suffix anyway, falsely advertising the broadcast under a prefix the subscriber didn't request. Production never bit because the relay always opens its announce bidi to us with `prefix=""` (which always matches), but a future peer-to- peer or namespace-scoped subscriber would have observed a ghost broadcast under whatever prefix they asked about. The fix: if `stripPrefix` returns `null`, FIN the bidi cleanly without writing any Announce body. The subscriber sees an empty announce stream and moves on. Regression test: `MoqLiteSessionTest.publisher_skips_announce_when_announce_please_prefix_does_not_match`. Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M4). https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
This commit is contained in:
+21
-2
@@ -838,8 +838,27 @@ class MoqLiteSession internal constructor(
|
|||||||
MoqLiteControlType.Announce -> {
|
MoqLiteControlType.Announce -> {
|
||||||
val pleasePayload = buffer.readSizePrefixed() ?: return@collect
|
val pleasePayload = buffer.readSizePrefixed() ?: return@collect
|
||||||
val please = MoqLiteCodec.decodeAnnouncePlease(pleasePayload)
|
val please = MoqLiteCodec.decodeAnnouncePlease(pleasePayload)
|
||||||
val emittedSuffix =
|
// Per moq-lite Lite-03 (`rs/moq-lite/src/lite/announce.rs`),
|
||||||
MoqLitePath.stripPrefix(please.prefix, announcePublisher.suffix) ?: announcePublisher.suffix
|
// a publisher MUST only emit Active for broadcasts whose
|
||||||
|
// path starts with the requested prefix. If our suffix
|
||||||
|
// doesn't match, FIN cleanly without writing any
|
||||||
|
// Announce — the relay/peer sees an empty announce
|
||||||
|
// stream and moves on. Pre-fix we'd fall through the
|
||||||
|
// `null` branch of stripPrefix and falsely emit
|
||||||
|
// `Active(suffix=ourFullSuffix)`, advertising under a
|
||||||
|
// prefix the subscriber didn't ask for. Production
|
||||||
|
// never bit because the relay always asks for
|
||||||
|
// `prefix=""`, but a future peer-to-peer or
|
||||||
|
// namespace-scoped subscriber would see a ghost.
|
||||||
|
val emittedSuffix = MoqLitePath.stripPrefix(please.prefix, announcePublisher.suffix)
|
||||||
|
if (emittedSuffix == null) {
|
||||||
|
Log.w("NestTx") {
|
||||||
|
"ANNOUNCE inbound prefix='${please.prefix}' does not match publisher.suffix='${announcePublisher.suffix}' — FIN without Active"
|
||||||
|
}
|
||||||
|
runCatching { bidi.finish() }
|
||||||
|
dispatched = true
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
bidi.write(
|
bidi.write(
|
||||||
MoqLiteCodec.encodeAnnounce(
|
MoqLiteCodec.encodeAnnounce(
|
||||||
MoqLiteAnnounce(
|
MoqLiteAnnounce(
|
||||||
|
|||||||
+37
@@ -533,6 +533,43 @@ class MoqLiteSessionTest {
|
|||||||
session.close()
|
session.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun publisher_skips_announce_when_announce_please_prefix_does_not_match() =
|
||||||
|
runBlocking {
|
||||||
|
// Lite-03 audit M4: when the relay opens an Announce bidi
|
||||||
|
// with a non-empty prefix that doesn't match our broadcast
|
||||||
|
// suffix, the publisher MUST NOT emit Active under the
|
||||||
|
// requested prefix (or under our own suffix) — kixelated's
|
||||||
|
// `rs/moq-lite/src/lite/announce.rs::Producer` only emits
|
||||||
|
// for matching prefixes. Pre-fix we'd fall through the
|
||||||
|
// `null` branch of `MoqLitePath.stripPrefix` and announce
|
||||||
|
// ourselves anyway. Verified here by FINing the bidi
|
||||||
|
// cleanly without writing any Announce body.
|
||||||
|
val (clientSide, serverSide) = FakeWebTransport.pair()
|
||||||
|
val session = MoqLiteSession.client(clientSide, pumpScope)
|
||||||
|
|
||||||
|
val publisher = session.publish(broadcastSuffix = "speakerPubkey", track = "audio/data")
|
||||||
|
|
||||||
|
// Relay opens Announce bidi with a non-matching prefix.
|
||||||
|
val relayBidi = serverSide.openBidiStream()
|
||||||
|
relayBidi.write(Varint.encode(MoqLiteControlType.Announce.code))
|
||||||
|
relayBidi.write(
|
||||||
|
MoqLiteCodec.encodeAnnouncePlease(
|
||||||
|
MoqLiteAnnouncePlease(prefix = "differentRoom"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The bidi MUST end with no body (peer FIN), and
|
||||||
|
// `relayBidi.incoming().toList()` therefore returns empty.
|
||||||
|
// Use a generous timeout so a slow fake doesn't false-pass
|
||||||
|
// by hanging instead of FINing.
|
||||||
|
val chunks = withTimeout(2_000) { relayBidi.incoming().toList() }
|
||||||
|
assertEquals(emptyList(), chunks, "non-matching prefix must FIN without writing any Announce body")
|
||||||
|
|
||||||
|
publisher.close()
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun publisher_replies_subscribeDrop_when_track_is_not_published() =
|
fun publisher_replies_subscribeDrop_when_track_is_not_published() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
Reference in New Issue
Block a user