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:
Claude
2026-05-09 14:38:25 +00:00
parent c7a49d1679
commit 4368875346
4 changed files with 124 additions and 9 deletions
@@ -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 {