fix(audio-rooms): SubscribeResponse framing matches moq-lite-03

Lite-03's SubscribeResponse on the response side of a Subscribe bidi
is two pieces concatenated:

  type   varint  (0 = Ok, 1 = Drop)
  body   size-prefixed bytes

The type discriminator sits OUTSIDE the body's size prefix. Earlier
drafts wrapped the whole thing in one outer size prefix; Lite-03 split
them. See `rs/moq-lite/src/lite/subscribe.rs::SubscribeResponse::encode`
(the `_` arm, which matches Lite-03+).

Our codec was producing — and reading — the older outer-wrapped form,
so against a Lite-03 relay we mis-parsed the type discriminator (`0`
for Ok) as a "size=0" outer prefix and surfaced a confusing
`MoqCodecException: truncated varint at offset=0 (remaining=0)` from
inside `decodeSubscribeResponse`. The first byte the relay sent was
the type, not a size, and our reader peeled it off as the outer length.

Fix touches three pieces:

* `encodeSubscribeOk` / `encodeSubscribeDrop` emit
  `type + size_prefixed(body)` directly (no outer wrap), via a small
  `prefixWithType` helper.
* `decodeSubscribeResponse` reads `type` then a length-prefixed body,
  decodes the body in its own reader, and asserts the outer payload
  is fully consumed.
* `readSubscribeResponseFromBidi` walks chunks into the buffer until
  both the type varint and the size-prefixed body have arrived, then
  re-emits the contiguous `[type][size][body]` slab so the decoder
  parses it self-contained. Reuses the `EarlyExit` collector pattern
  the publisher-inbound path already uses; avoids `Flow.iterator()`
  which doesn't exist in kotlinx.coroutines.

Tests:
* `MoqLiteCodecTest::subscribe{Ok,Drop}_round_trips` no longer
  `peelSizePrefix` the encoded bytes — the new wire form has no outer
  prefix to strip; the bytes go straight through `decodeSubscribeResponse`.
* `MoqLiteSessionTest::publisher_acks_subscribe_and_pushes_group_data_on_uni_stream`
  also drops `MoqLiteFrameBuffer().readSizePrefixed()` on the ack chunk
  for the same reason.

Verified end-to-end against a bare-metal moq-relay 0.10.25 + moq-auth
(`-DnestsInteropExternal=true`):

* `NostrNestsRoundTripInteropTest::production_speaker_broadcasts_to_production_listener_via_real_relay`
  passes — speaker → listener → 8 frames round-trip cleanly.
* `NostrNestsMultiPeerInteropTest::one_speaker_fans_out_to_two_listeners`
  passes — same speaker reaches both listeners with the full frame
  stream.
* The `listener_subscribed_before_announce_receives_late_frames` and
  `two_speakers_in_same_room_deliver_independently_to_one_listener`
  cases still fail with `subscribe stream FIN before reply` — those
  are different relay-behavior issues (the v1 relay seems to FIN
  subscribes against unannounced broadcasts and against a second
  speaker on the same listener), unrelated to the codec.
This commit is contained in:
Claude
2026-04-26 20:55:04 +00:00
parent da1c4d3968
commit 5a86cdd4e4
5 changed files with 108 additions and 38 deletions
@@ -177,7 +177,10 @@ class MoqLiteCodecTest {
startGroup = null,
endGroup = null,
)
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribeOk(msg))
// moq-lite-03 SubscribeResponse is `[type][size][body]` with the
// type discriminator OUTSIDE the size prefix — the encoder no
// longer emits an outer wrap, so feed the bytes through directly.
val payload = MoqLiteCodec.encodeSubscribeOk(msg)
val resp = MoqLiteCodec.decodeSubscribeResponse(payload)
val ok = assertIs<MoqLiteCodec.SubscribeResponse.Ok>(resp)
assertEquals(msg, ok.ok)
@@ -186,7 +189,7 @@ class MoqLiteCodecTest {
@Test
fun subscribeDrop_round_trips() {
val msg = MoqLiteSubscribeDrop(errorCode = 0x12L, reasonPhrase = "publisher gone")
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribeDrop(msg))
val payload = MoqLiteCodec.encodeSubscribeDrop(msg)
val resp = MoqLiteCodec.decodeSubscribeResponse(payload)
val drop = assertIs<MoqLiteCodec.SubscribeResponse.Dropped>(resp)
assertEquals(msg, drop.drop)
@@ -300,12 +300,12 @@ class MoqLiteSessionTest {
),
)
// Step 2: we reply SubscribeOk.
// Step 2: we reply SubscribeOk. moq-lite-03's SubscribeResponse
// is `[type][size][body]` with the type discriminator OUTSIDE
// the size prefix — no outer wrap to strip, the chunk itself
// is the wire form decodeSubscribeResponse expects.
val ackChunk = withTimeout(2_000) { subBidi.incoming().first() }
val resp =
MoqLiteCodec.decodeSubscribeResponse(
MoqLiteFrameBuffer().apply { push(ackChunk) }.readSizePrefixed()!!,
)
val resp = MoqLiteCodec.decodeSubscribeResponse(ackChunk)
assertIs<MoqLiteCodec.SubscribeResponse.Ok>(resp)
// Step 3: publisher pushes one frame, which opens a uni