fix(nestsClient): fix race + leaks + Android encoder/decoder bugs from second audit

A fresh audit caught a real race in the round-1 subscribe() rewrite plus
several distinct bugs in the Android MediaCodec layer.

**MoqLiteSession.subscribe() race regression** — the launched collector
that reads the SubscribeResponse and watches for peer FIN ran cleanup
(remove from subscriptionsBySubscribeId, close frames) before subscribe()
itself reached the post-await registration. If the publisher FIN'd
immediately after sending Ok, the collector exited first against an
empty map; subscribe() then registered the subscription, leaving frames
in the map with no live collector to ever close it on transport drop.
Consumer hung forever — exactly the failure mode round-1 fix #3 was
supposed to prevent. Fix: pre-register the subscription BEFORE launching
the collector. Drop unused `ok` field from ListenerSubscription.

**MoqLiteNestsSpeaker.startBroadcasting publisher leak** — if
session.publish() succeeded but broadcaster.start() then threw (mic
permission denied, AudioRecord allocation failure), the publisher was
never closed and stayed registered as session.activePublisher,
permanently blocking subsequent startBroadcasting calls.

**runCatching{suspend close} swallowing CancellationException** —
MoqLiteNestsSpeaker.close, MoqLiteBroadcastHandle.close,
MoqLiteNestsListener.close all wrapped suspending closes in
runCatching, breaking structured cancellation when the parent scope
cancelled teardown. Replaced with explicit cancel-rethrowing try/catch.

**MediaCodecOpusDecoder ArrayList<Short> boxing on every audio frame**
— at 50 fps × 960 samples × N speakers ≈ 48 000 boxed Short
allocations/sec/speaker on the audio hot path. Rewritten to write
directly into a pre-sized ShortArray via ShortBuffer.get(dst, off, len).

**MediaCodecOpusEncoder bugs**
- KEY_AAC_PROFILE = AACObjectLC was being set on an audio/opus
  encoder. Meaningless for Opus; stricter Codec2 stacks on Android
  13+ reject the configure() call with IllegalArgumentException and
  surface as DeviceUnavailable. Removed.
- The drain loop's INFO_OUTPUT_FORMAT_CHANGED branch had no progress
  guard. A buggy encoder re-emitting FORMAT_CHANGED without producing
  output would busy-spin against the 10 ms dequeue timeout. Now
  absorbed at most once per encode call. Same guard added to the
  decoder.

Adds regression test:
  - frames_flow_completes_when_peer_FINs_immediately_after_Ok

224 tests pass, 0 failures. Android target compiles clean.
This commit is contained in:
Claude
2026-05-01 18:28:41 +00:00
parent 9729f3a20c
commit 702885f4cb
6 changed files with 174 additions and 53 deletions
@@ -76,21 +76,27 @@ class MoqLiteNestsSpeaker internal constructor(
// Per the audio-rooms NIP draft + JS reference
// (`@moq/publish/screen-B680RFft.js:5641`), publishers
// claim a broadcast suffix equal to their pubkey hex.
val publisher =
val publisher = session.publish(broadcastSuffix = speakerPubkeyHex)
// From here on, the publisher is registered in the session
// and has a live announce/subscribe path. Anything that
// throws before we hand a working handle back to the caller
// must close the publisher to avoid leaking it inside the
// session's `activePublisher` slot — otherwise a subsequent
// startBroadcasting fails with "already publishing" and the
// publisher's announce state never tears down.
val broadcaster =
try {
session.publish(broadcastSuffix = speakerPubkeyHex)
NestMoqLiteBroadcaster(
capture = captureFactory(),
encoder = encoderFactory(),
publisher = publisher,
scope = scope,
framesPerGroup = framesPerGroup,
).also { it.start() }
} catch (t: Throwable) {
runCatching { publisher.close() }
throw t
}
val broadcaster =
NestMoqLiteBroadcaster(
capture = captureFactory(),
encoder = encoderFactory(),
publisher = publisher,
scope = scope,
framesPerGroup = framesPerGroup,
)
broadcaster.start()
mutableState.value =
NestsSpeakerState.Broadcasting(
room = current.room,
@@ -141,8 +147,25 @@ class MoqLiteNestsSpeaker internal constructor(
activeHandle = null
mutableState.value = NestsSpeakerState.Closed
}
handle?.runCatching { close() }
runCatching { session.close() }
// Don't `runCatching { handle.close() }` — that swallows
// CancellationException too, breaking structured cancellation
// when the parent scope is cancelling teardown.
if (handle != null) {
try {
handle.close()
} catch (ce: kotlinx.coroutines.CancellationException) {
throw ce
} catch (_: Throwable) {
// Best-effort — already cleared activeHandle.
}
}
try {
session.close()
} catch (ce: kotlinx.coroutines.CancellationException) {
throw ce
} catch (_: Throwable) {
// Best-effort.
}
}
}
@@ -167,11 +190,29 @@ internal class MoqLiteBroadcastHandle(
override suspend fun close() {
if (closed) return
closed = true
runCatching { broadcaster.stop() }
try {
broadcaster.stop()
} catch (ce: kotlinx.coroutines.CancellationException) {
// Even on cancel, run the rest of cleanup before rethrowing
// — broadcaster.stop already cancels its own job, so the
// mic + encoder + publisher are owed their close paths.
runCatching { publisher.close() }
parent.broadcastClosed(this)
throw ce
} catch (_: Throwable) {
// Best-effort; fall through to the defensive publisher.close.
}
// broadcaster.stop() already calls publisher.close(); call again
// defensively to make this method idempotent against partial
// failures on the broadcaster.stop path.
runCatching { publisher.close() }
try {
publisher.close()
} catch (ce: kotlinx.coroutines.CancellationException) {
parent.broadcastClosed(this)
throw ce
} catch (_: Throwable) {
// Best-effort.
}
parent.broadcastClosed(this)
}
}