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:
+59
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user