diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteMessages.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteMessages.kt index 5859cbac2..8a5991f0b 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteMessages.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteMessages.kt @@ -172,6 +172,30 @@ object MoqLiteSubscribeDropCode { const val BROADCAST_DOES_NOT_EXIST: Long = 0x05L } +/** + * Application error codes a listener passes to + * [com.vitorpamplona.nestsclient.transport.WebTransportReadStream.stopSending] + * when canceling a group's uni stream. moq-lite leaves these + * application-defined; we follow the same convention as + * [MoqLiteSubscribeDropCode] (small non-zero varints) so a future + * cross-protocol mapping is straightforward. + * + * Used by [com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession.drainOneGroup] + * when a group arrives for a subscription that's already been + * removed: rather than silently dropping every frame the publisher + * pushes (and letting the publisher waste bandwidth on + * retransmits), we `stopSending` the uni stream so the publisher + * abandons it. + */ +object MoqLiteStreamCancelCode { + /** + * The receiving subscription has been canceled / unsubscribed + * since this group started. The publisher should abandon any + * pending retransmits. + */ + const val SUBSCRIPTION_GONE: Long = 0x10L +} + /** * Header at the start of a Group uni stream. After the * [MoqLiteDataType.Group] type byte, the publisher writes one 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 82d247105..6fef07761 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 @@ -604,6 +604,15 @@ class MoqLiteSession internal constructor( var frameCount = 0 var droppedNoSub = 0 var trySendFailures = 0 + // Audit M2: once we observe the subscription is gone, fire + // STOP_SENDING(SUBSCRIPTION_GONE) once on the uni stream so + // the publisher abandons any in-flight retransmits. Pre-fix + // we silently dropped every frame; the publisher kept pushing + // and the relay kept buffering bytes the listener would never + // read. Latched so concurrent frames don't fire it N times + // (RFC 9000 ยง3.5: subsequent STOP_SENDING calls are ignored + // by the peer, but we save the syscall). + var stopSendingFired = false try { stream.incoming().collect { chunk -> buffer.push(chunk) @@ -630,6 +639,17 @@ class MoqLiteSession internal constructor( val sub = state.withLock { subscriptionsBySubscribeId[subscribeId] } if (sub == null) { droppedNoSub += 1 + if (!stopSendingFired) { + stopSendingFired = true + Log.w("NestRx") { + "drainOneGroup#$streamSeq subId=$subscribeId no longer subscribed โ€” stopSending(SUBSCRIPTION_GONE)" + } + NestsTrace.emit("group_stop_sending") { + "\"stream_seq\":$streamSeq,\"sub_id\":$subscribeId,\"group_seq\":$groupSequence," + + "\"code\":${MoqLiteStreamCancelCode.SUBSCRIPTION_GONE}" + } + runCatching { stream.stopSending(MoqLiteStreamCancelCode.SUBSCRIPTION_GONE) } + } } else { val sent = sub.frames.trySend( @@ -641,16 +661,13 @@ class MoqLiteSession internal constructor( if (!sent.isSuccess) trySendFailures += 1 } frameCount += 1 - // If the subscription has been closed already we - // silently drop the frame โ€” the publisher hasn't - // observed the unsubscribe yet (its uni streams - // are independent of our bidi FIN). } } - Log.d("NestRx") { "drainOneGroup#$streamSeq FIN subId=$subscribeId groupSeq=$groupSequence frames=$frameCount droppedNoSub=$droppedNoSub trySendFail=$trySendFailures" } + Log.d("NestRx") { "drainOneGroup#$streamSeq FIN subId=$subscribeId groupSeq=$groupSequence frames=$frameCount droppedNoSub=$droppedNoSub trySendFail=$trySendFailures stopSent=$stopSendingFired" } NestsTrace.emit("group_fin") { "\"stream_seq\":$streamSeq,\"sub_id\":$subscribeId,\"group_seq\":$groupSequence," + - "\"frames\":$frameCount,\"dropped_no_sub\":$droppedNoSub,\"try_send_fail\":$trySendFailures" + "\"frames\":$frameCount,\"dropped_no_sub\":$droppedNoSub,\"try_send_fail\":$trySendFailures," + + "\"stop_sent\":$stopSendingFired" } } catch (ce: CancellationException) { throw ce diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt index 3073faccf..28d952c04 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt @@ -329,8 +329,13 @@ class FakeReadStream internal constructor( * [FakeWebTransport.openUniStream] (production: locally-opened uni * stream that the paired peer reads via incomingUniStreams) and any * test that wants to drive uni-stream bytes through a known channel. + * + * Public so tests that hold the writer reference (e.g. via + * `serverSide.openUniStream()`) can introspect the peer-side + * `stopSending` code via [peerStopSendingCode] without racing the + * listener's uni-stream pump on the read side. */ -private class ChannelWriteStream( +class ChannelWriteStream internal constructor( private val channel: Channel, /** * Optional shared cell with the peer-side reader. When present, @@ -338,6 +343,12 @@ private class ChannelWriteStream( */ private val priorityCell: java.util.concurrent.atomic.AtomicInteger? = null, private val resetCell: java.util.concurrent.atomic.AtomicLong? = null, + /** + * Cell that records the *peer's* `stopSending(code)` call, since + * the writer is on the other side of the stream from the + * `stopSending` caller. Same cell that + * [FakeReadStream.lastStopSendingCode] reads. + */ private val stopSendingCell: java.util.concurrent.atomic.AtomicLong? = null, ) : WebTransportWriteStream { override suspend fun write(chunk: ByteArray) { @@ -360,8 +371,15 @@ private class ChannelWriteStream( priorityCell?.set(priority) } - @Suppress("unused") - private val unusedStopSendingCellTether: java.util.concurrent.atomic.AtomicLong? = stopSendingCell + /** + * Code the peer-side reader passed to + * [WebTransportReadStream.stopSending], or `null` if not called. + * Lets a test that holds the writer side inspect the listener's + * group-cancel without racing for the peer-side + * [WebTransportReadStream] reference. + */ + val peerStopSendingCode: Long? + get() = stopSendingCell?.get()?.takeIf { it != NO_CODE } } /** 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 00d6be8f5..c51368c49 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 @@ -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 {