fix(nests): rotate moq-lite groups + reconnect after publisher cycle

To make a SubscribeHandle survive a publisher session swap on the same
relay, three things had to change together:

1. Broadcaster emits one Opus frame per moq-lite group
   (`publisher.send` + `publisher.endGroup`). moq-lite's "from-latest"
   subscribe semantics deliver a new subscriber the NEXT group's
   frames; without per-frame rotation a subscriber that attaches
   mid-broadcast waits forever for the (single, never-ending) group
   to end.
2. MoqLiteSession opens its announce-watch bidi synchronously before
   the first subscribe, dispatches subscribe + announce frames over a
   single long-running collector (varint type code hoisted outside
   `collect`), and FINs the publisher's currentGroup when an inbound
   subscribe bidi closes — so the next send opens a fresh group keyed
   off the live subscriber instead of the recycled one.
3. ReconnectingNestsListener no longer breaks on terminal=Closed in
   the orchestrator; the user-driven stop path goes through
   `orchestrator.cancel()`, so any other Closed (peer-driven
   transport close, half-broken session, publisher recycle) is a
   reconnect trigger.

Round-trip interop test updated to assert `groupId == idx` (one group
per frame) rather than `groupId == 0`. All three reconnecting-listener
interop scenarios now pass against the real moq-rs relay:
happy-path, session-swap, and listener-survives-publisher-recycle.
This commit is contained in:
Claude
2026-04-28 17:06:09 +00:00
parent 5f71dc7c76
commit d8ab4fd99b
5 changed files with 218 additions and 133 deletions
@@ -200,9 +200,13 @@ class NostrNestsRoundTripInteropTest {
assertEquals(
idx.toLong(),
obj.objectId,
"object id at index $idx — MoQ requires monotonic ids per group",
"object id at index $idx — MoqLiteNestsListener uses a session-level counter, monotonic across all groups",
)
assertEquals(
idx.toLong(),
obj.groupId,
"group id at index $idx — broadcaster emits one moq-lite group per Opus frame (audio-rooms NIP draft) so groupId increments 1:1 with the frame index",
)
assertEquals(0L, obj.groupId, "single-group track per audio-rooms NIP draft")
assertContentEquals(
"FRAME-".encodeToByteArray() + byteArrayOf(idx.toByte()),
obj.payload,