fix(nests): T12 carry audio group sequence across hot-swaps

Every JWT-refresh hot-swap reset the audio publisher's group sequence
counter to 0, because `PublisherStateImpl` always initialised
`nextSequence = 0L`. kixelated/hang's `Container.Consumer.#run`
discards any consumer with `sequence < this.#active`, so a watcher
whose `#active` had advanced to e.g. 50 from the previous session's
publisher would silently drop every group on the new session until
either `#active` rolls over or the watcher re-subscribes — audible as
"speaker goes silent for a minute every 9 minutes" (the proactive
JWT refresh window).

Carry the sequence forward end-to-end:

  - MoqLiteSession.publish gains a `startSequence: Long = 0L`
    parameter; PublisherStateImpl seeds `nextSequenceField` from it
    instead of hard-coding 0.

  - MoqLitePublisherHandle exposes a `nextSequence: Long` snapshot.
    `@Volatile`-backed so the hot-swap caller can read it without
    contending the publisher's gate; race-window between read and
    a concurrent send is sub-millisecond and would only produce a
    single duplicate sequence in the very unlikely overlap, which
    listeners tolerate.

  - HotSwappablePublisherSource.openPublisherForHotSwap takes
    `startSequence: Long = 0L`. MoqLiteNestsSpeaker passes it
    through to session.publish.

  - NestMoqLiteBroadcaster exposes its current publisher via a
    read-only `currentPublisher` so the hot-swap pump in
    ReconnectingNestsSpeaker.runHotSwapIteration can read its
    `nextSequence` before opening the replacement publisher and
    pass it as the seed.

  - Catalog publisher keeps `startSequence = 0L` (the default) —
    catalog isn't subject to the same `#active` accumulation
    because each new SUBSCRIBE triggers a fresh emit-on-subscribe
    write that resets the watcher's catalog state.

Listener-side change is none — the watcher already reads whatever
sequence we send. A new MoqLiteSessionTest pins the contract:
publishing with startSequence=42 makes the first uni stream's
GroupHeader.sequence == 42 and advances to 43 after the first send.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
This commit is contained in:
Claude
2026-05-06 17:51:45 +00:00
parent c23da52795
commit be4e0b9f98
5 changed files with 167 additions and 6 deletions
@@ -340,6 +340,65 @@ class MoqLiteSessionTest {
session.close()
}
@Test
fun publisher_startSequence_seeds_first_group_for_hot_swap_continuation() =
runBlocking {
val (clientSide, serverSide) = FakeWebTransport.pair()
val session = MoqLiteSession.client(clientSide, pumpScope)
// Mint a publisher with a non-zero startSequence — simulates
// the hot-swap path's "carry forward old publisher's
// nextSequence" contract.
val publisher =
session.publish(
broadcastSuffix = "speakerPubkey",
track = "audio/data",
startSequence = 42L,
)
assertEquals(42L, publisher.nextSequence, "fresh publisher reports startSequence as next")
// Wire up a subscriber so send() doesn't short-circuit.
val subBidi = serverSide.openBidiStream()
subBidi.write(Varint.encode(MoqLiteControlType.Subscribe.code))
subBidi.write(
MoqLiteCodec.encodeSubscribe(
MoqLiteSubscribe(
id = 11L,
broadcast = "speakerPubkey",
track = "audio/data",
priority = 0x80,
ordered = true,
maxLatencyMillis = 0L,
startGroup = null,
endGroup = null,
),
),
)
withTimeout(2_000) { subBidi.incoming().first() }
assertEquals(true, publisher.send("opus-1".encodeToByteArray()))
// FIN before draining so toList() terminates rather than
// blocking indefinitely on the still-open uni stream.
publisher.endGroup()
// After the first send, nextSequence should advance to 43.
assertEquals(43L, publisher.nextSequence)
// First uni stream's GroupHeader.sequence MUST be 42, not 0.
val relayUni = withTimeout(2_000) { serverSide.incomingUniStreams().first() }
val uniChunks = relayUni.incoming().toList()
val buf = MoqLiteFrameBuffer()
uniChunks.forEach { buf.push(it) }
assertEquals(MoqLiteDataType.Group.code, buf.readVarint())
val header =
MoqLiteCodec.decodeGroupHeader(
buf.readSizePrefixed() ?: error("group header missing"),
)
assertEquals(42L, header.sequence, "first group's sequence is the seeded startSequence")
publisher.close()
session.close()
}
@Test
fun publisher_send_returns_false_when_no_inbound_subscriber() =
runBlocking {