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
@@ -190,7 +190,15 @@ class MoqLiteNestsSpeaker internal constructor(
* reconnect wrapper's hot-swap path; not called from the
* non-reconnecting path which goes through [startBroadcasting].
*/
override suspend fun openPublisherForHotSwap(track: String): MoqLitePublisherHandle = session.publish(broadcastSuffix = speakerPubkeyHex, track = track)
override suspend fun openPublisherForHotSwap(
track: String,
startSequence: Long,
): MoqLitePublisherHandle =
session.publish(
broadcastSuffix = speakerPubkeyHex,
track = track,
startSequence = startSequence,
)
/**
* Compare-and-clear that runs from inside [close] (already holds
@@ -348,8 +356,20 @@ internal interface HotSwappablePublisherSource {
* session. Caller owns the returned handle's lifetime (typically
* via [com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster.swapPublisher]'s
* close-the-old contract).
*
* @param startSequence first group sequence the new publisher will
* assign. Used by the hot-swap path to seed the new session's
* audio track with the previous session's
* [MoqLitePublisherHandle.nextSequence] so kixelated/hang's
* `Container.Consumer.#run` doesn't drop every post-recycle
* group as `sequence < #active`. Pass `0L` for fresh (non-
* continuation) publishers — the catalog track is one such
* case, since its `#active` semantics are different from audio.
*/
suspend fun openPublisherForHotSwap(track: String): MoqLitePublisherHandle
suspend fun openPublisherForHotSwap(
track: String,
startSequence: Long = 0L,
): MoqLitePublisherHandle
/**
* Surface a broadcast-pipeline terminal failure (e.g. sustained