fix(nests): T10 endGroup() on unmuted→muted transition

When the user mutes mid-broadcast, `NestMoqLiteBroadcaster`'s send loop
just `continue`s past the mute check, leaving the current group's QUIC
uni stream open with no FIN. The watcher's
`Container.Consumer.#runGroup` parks on `await group.consumer.readFrame()`
waiting for either another frame or a stream FIN — and gets neither.
kixelated/hang's UI surfaces this as a "stalled" indicator on the
speaker tile while we're muted, which is wrong: we're muted, not
stalled.

FIN the open uni stream once on the unmuted→muted edge via a
`wasMuted` latch that the muted→unmuted edge clears. Doesn't change
the timestamp counter — it still advances on muted frames so an
unmute's first timestamp reflects the muted duration as a real wall-
clock gap (not a collapse to zero).  `runCatching` around endGroup
because a transport-side close racing with the FIN is non-fatal —
the broadcaster's terminal-failure path picks up real breakage on
the next live frame.

Also resets `framesInCurrentGroup` to 0 so the post-unmute send path
opens a fresh group cleanly via the standard `currentGroup ?:
openNextGroupLocked()` branch in `PublisherStateImpl.send`.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
This commit is contained in:
Claude
2026-05-06 17:37:51 +00:00
parent 96cfa1235a
commit c23da52795
@@ -238,6 +238,21 @@ class NestMoqLiteBroadcaster(
// wall-clock TimeMark did: timestamps are codec-payload
// metadata, not stream-position.
var nextFrameIndex = 0L
// Mute-edge tracking. On the unmuted→muted transition we
// FIN the open uni stream so the watcher sees a clean
// group boundary instead of a half-delivered group that
// never completes — kixelated/hang's
// `Container.Consumer.#runGroup` parks on
// `await group.consumer.readFrame()` until either a
// frame arrives or the group hits FIN, and renders a
// "stalled" indicator while it waits. Without the FIN,
// muting Amethyst lights up that indicator on every web
// watcher even though the broadcast is intentionally
// silent. The FIN clears it; unmuting opens a fresh
// group cleanly via the standard
// `currentGroup ?: openNextGroupLocked()` path inside
// `PublisherStateImpl.send`.
var wasMuted = false
try {
while (true) {
val pcm = capture.readFrame() ?: break
@@ -259,7 +274,27 @@ class NestMoqLiteBroadcaster(
continue
}
if (opus.isEmpty()) continue
if (muted) continue
if (muted) {
// Unmuted → muted edge: FIN the current
// group's uni stream once. Doesn't reset
// [framesInCurrentGroup] since
// `endGroup` is a no-op when no group is
// open, and the next post-unmute send will
// open a fresh group anyway. `runCatching`
// because a transport-side close during
// mute is non-fatal — the broadcaster's
// terminal-failure path picks up real
// breakage on the next live frame.
if (!wasMuted) {
wasMuted = true
runCatching { publisher.endGroup() }
framesInCurrentGroup = 0
}
continue
}
// Muted → unmuted edge: clear the latch so the
// next mute transition fires endGroup again.
wasMuted = false
// Snapshot the publisher reference once per frame.
// If [swapPublisher] mid-loop installed a new
// reference, pick it up here and reset the group