From 6c2d7efccbc7ef011e7a60587f338ce0e352136c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 13:51:24 +0000 Subject: [PATCH] =?UTF-8?q?fix(nestsclient):=20skip=20Active=20announce=20?= =?UTF-8?q?when=20AnnouncePlease=20prefix=20doesn't=20match=20=E2=80=94=20?= =?UTF-8?q?moq-lite=20Lite-03=20announce.rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../nestsclient/moq/lite/MoqLiteSession.kt | 23 +++++++++++- .../moq/lite/MoqLiteSessionTest.kt | 37 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSession.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSession.kt index 944eb1ae1..65d2177de 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSession.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSession.kt @@ -838,8 +838,27 @@ class MoqLiteSession internal constructor( MoqLiteControlType.Announce -> { val pleasePayload = buffer.readSizePrefixed() ?: return@collect val please = MoqLiteCodec.decodeAnnouncePlease(pleasePayload) - val emittedSuffix = - MoqLitePath.stripPrefix(please.prefix, announcePublisher.suffix) ?: announcePublisher.suffix + // Per moq-lite Lite-03 (`rs/moq-lite/src/lite/announce.rs`), + // 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( MoqLiteCodec.encodeAnnounce( MoqLiteAnnounce( diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSessionTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSessionTest.kt index 37eaa430c..4de21a4f7 100644 --- a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSessionTest.kt +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSessionTest.kt @@ -533,6 +533,43 @@ class MoqLiteSessionTest { 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 fun publisher_replies_subscribeDrop_when_track_is_not_published() = runBlocking {