fix(nests): NestPlayer keeps existing decoder when boundary factory throws

Audit-1: the publisher-boundary rebuild path released the old decoder
BEFORE asking the factory for a replacement (`runCatching {
decoder.release() }; decoder = factory()`). If `factory()` threw —
MediaCodec contention, audio policy denial mid-session, etc. — the
released decoder stayed assigned to the field and every subsequent
`decoder.decode(payload)` would throw `IllegalStateException` with
no recovery path. The room would silently fail for that subscription
until torn down.

Build the replacement first; only release the old one and swap on
success. On factory failure, log and keep the existing decoder
running. Cross-publisher Opus predictor state is wrong for one
group but at least audio keeps playing.

Audit-4+5: companion test cleanup —

  - Drop the `byteArrayOf(0x0A) + it` / `0x0B` dead expressions in
    the FakeOpusDecoder closures of the existing boundary-rebuild
    test. They allocated a ByteArray and concatenated, then threw
    the result away.

  - Drop the local `IntBox` helper and use
    `java.util.concurrent.atomic.AtomicInteger` like the rest of
    the codebase does (`MoqLiteSession`, `MoqLiteNestsListener`).
    Same semantics, no new vocabulary.

New test pins the dangling-field fix:
`publisher_boundary_keeps_old_decoder_when_factory_throws` flows
two MoqObjects with different trackAliases through a NestPlayer
whose factory always throws; asserts both frames decode through
the original decoder and the original decoder is released exactly
once on `stop()`.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
This commit is contained in:
Claude
2026-05-06 18:33:24 +00:00
parent 7e76ab1139
commit ade8da3b5b
2 changed files with 73 additions and 30 deletions
@@ -216,13 +216,36 @@ class NestPlayer(
// side) or cliff-detector recycle (listener side).
// The prior-trackAlias guard avoids a spurious
// rebuild on the very first frame.
//
// Build the replacement BEFORE releasing the old
// decoder so a factory failure (e.g. MediaCodec
// contention, audio policy denial mid-session)
// doesn't leave the field referencing a
// released codec — every subsequent decode
// would then throw `IllegalStateException` with
// no recovery path. On factory failure, log
// and keep using the existing decoder; cross-
// publisher predictor state is wrong but at
// least audio keeps playing.
val factory = decoderFactory
if (factory != null && lastTrackAlias != null && obj.trackAlias != lastTrackAlias) {
com.vitorpamplona.quartz.utils.Log.d("NestPlay") {
"NestPlayer publisher boundary: trackAlias $lastTrackAlias${obj.trackAlias}; rebuilding decoder"
val replacement =
runCatching { factory() }
.onFailure { t ->
if (t is CancellationException) throw t
com.vitorpamplona.quartz.utils.Log.w("NestPlay") {
"NestPlayer decoder factory threw on trackAlias " +
"$lastTrackAlias${obj.trackAlias}; keeping old decoder " +
"(${t::class.simpleName}: ${t.message})"
}
}.getOrNull()
if (replacement != null) {
com.vitorpamplona.quartz.utils.Log.d("NestPlay") {
"NestPlayer publisher boundary: trackAlias $lastTrackAlias${obj.trackAlias}; rebuilding decoder"
}
runCatching { decoder.release() }
decoder = replacement
}
runCatching { decoder.release() }
decoder = factory()
}
lastTrackAlias = obj.trackAlias
val pcm =