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:
+24
@@ -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
|
||||
|
||||
+23
-6
@@ -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
|
||||
|
||||
+21
-3
@@ -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<ByteArray>,
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+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