fix(nestsclient): STOP_SENDING on group uni when subscription already canceled — moq-lite Lite-03
Lite-03 audit M2: when a group's uni stream arrives for a `subscribeId` whose subscription has already been removed (typical case: listener canceled / unsubscribed before the publisher observed it), the listener MUST signal the publisher to abandon in-flight retransmits via `STOP_SENDING(applicationErrorCode)` on that uni stream. Pre-fix, `drainOneGroup` silently dropped every frame (`droppedNoSub++`) and let the publisher keep pushing until natural FIN — wasted bandwidth on bytes the listener would discard, plus relay queue pressure on the per-subscriber forward pipeline that already sits behind the production stream-cliff. Wire the latched `stopSending(MoqLiteStreamCancelCode.SUBSCRIPTION_GONE)` on the first frame that observes `sub == null`. Latched (one-shot) so concurrent frames in the same drain don't re-fire — RFC 9000 §3.5 says subsequent STOP_SENDINGs are ignored anyway, but it saves the syscall and keeps the trace clean. New error-code constant `MoqLiteStreamCancelCode.SUBSCRIPTION_GONE = 0x10L` lives next to `MoqLiteSubscribeDropCode` in `MoqLiteMessages.kt`. moq-lite leaves application error codes undefined; we follow the same "small non-zero varint" convention. Test seam: `ChannelWriteStream` is now a public class (was private) with a `peerStopSendingCode` accessor, so a test that holds the writer side (`serverSide.openUniStream() as ChannelWriteStream`) can assert the listener's stopSending code without racing for the peer-side `FakeReadStream` reference. Regression test: `MoqLiteSessionTest.listener_stopSending_group_uni_when_subscription_already_canceled`. Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M2). https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
This commit is contained in:
+56
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.nestsclient.moq.lite
|
||||
|
||||
import com.vitorpamplona.nestsclient.transport.ChannelWriteStream
|
||||
import com.vitorpamplona.nestsclient.transport.FakeBidiStream
|
||||
import com.vitorpamplona.nestsclient.transport.FakeReadStream
|
||||
import com.vitorpamplona.nestsclient.transport.FakeWebTransport
|
||||
@@ -835,6 +836,61 @@ class MoqLiteSessionTest {
|
||||
session.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listener_stopSending_group_uni_when_subscription_already_canceled() =
|
||||
runBlocking {
|
||||
// Lite-03 audit M2: when a group's uni stream arrives for a
|
||||
// subscribe id that was canceled before the first frame
|
||||
// landed, the listener MUST stopSending() the uni stream so
|
||||
// the publisher abandons retransmits instead of wasting
|
||||
// bandwidth. Pre-fix the listener silently dropped every
|
||||
// frame; the publisher kept pushing until natural FIN.
|
||||
val (clientSide, serverSide) = FakeWebTransport.pair()
|
||||
val session = MoqLiteSession.client(clientSide, pumpScope)
|
||||
|
||||
// Set up a subscription so the uni-stream pump is active.
|
||||
val peer =
|
||||
async {
|
||||
val (bidi, _) = nextSubscribeBidi(serverSide)
|
||||
bidi.write(MoqLiteCodec.encodeSubscribeOk(okFor(0L)))
|
||||
bidi
|
||||
}
|
||||
val handle = session.subscribe("speakerX", "audio/data")
|
||||
val subBidi = peer.await()
|
||||
|
||||
// Cancel the subscription before any group flows.
|
||||
handle.unsubscribe()
|
||||
// Drain the subscribe bidi's FIN so the peer's pump
|
||||
// stays clean — not strictly required for the assertion
|
||||
// but keeps the test isolated.
|
||||
subBidi.incoming().toList()
|
||||
|
||||
// Now the peer (= the relay) opens a uni stream for that
|
||||
// dead subscribe id and writes one frame.
|
||||
val uni = serverSide.openUniStream() as ChannelWriteStream
|
||||
uni.write(Varint.encode(MoqLiteDataType.Group.code))
|
||||
uni.write(MoqLiteCodec.encodeGroupHeader(MoqLiteGroupHeader(subscribeId = handle.id, sequence = 0L)))
|
||||
uni.write(framePayload(byteArrayOf(0x01)))
|
||||
|
||||
// Spin until the listener's drainOneGroup has processed
|
||||
// the first frame, found no matching subscription, and
|
||||
// fired stopSending. We hold the writer side, so reading
|
||||
// [peerStopSendingCode] doesn't race the listener's
|
||||
// pump for the peer-side [FakeReadStream] reference.
|
||||
val deadline = System.currentTimeMillis() + 2_000
|
||||
while (uni.peerStopSendingCode == null && System.currentTimeMillis() < deadline) {
|
||||
kotlinx.coroutines.delay(10)
|
||||
}
|
||||
assertEquals(
|
||||
MoqLiteStreamCancelCode.SUBSCRIPTION_GONE as Long?,
|
||||
uni.peerStopSendingCode,
|
||||
"listener must stopSending(SUBSCRIPTION_GONE) when a group arrives for a canceled subscription",
|
||||
)
|
||||
|
||||
uni.finish()
|
||||
session.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsubscribe_FINs_the_subscribe_bidi() =
|
||||
runBlocking {
|
||||
|
||||
Reference in New Issue
Block a user