fix(audio-rooms): audit follow-up — MoQ HIGH/MED + VM concurrency + Android polish
Round 2 of the audit-driven cleanup. Lands every HIGH and most MED findings from the protocol / ViewModel / Android lifecycle audits that weren't fixed in the previous commit. The wire-format draft pinning (audit MoQ #1, #8) is intentionally deferred until the M4 manual interop pass against `nostrnests.com` reveals which draft revision the relay is actually speaking. MoQ session fixes: - #5 UNANNOUNCE wire-write now happens BEFORE removing announces[ns], so an inbound SUBSCRIBE during the teardown window sees the namespace as withdrawn (sessionClosed=true → SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST)) instead of "namespace never existed". - #4 dispatchControlMessage(AnnounceError) now distinguishes pre-OK and post-OK errors. Post-OK is a session-level kick: mark the handle closed, send UNANNOUNCE, then drop. Pre-OK still rolls back the optimistic announces[] insert as before. - #6 close() now joins the cancelled control + datagram pumps before calling controlStream.finish(), so an in-flight SUBSCRIBE_OK / SUBSCRIBE_DONE / SUBSCRIBE_ERROR write can complete its writeMutex.withLock { ... } critical section. Previously cancellation could truncate a frame mid-flight and we'd send FIN over a corrupted stream. - #9 pumps wrapped in try/catch that calls close(...) on unexpected exceptions, so a transport-died-mid-session no longer leaves the session thinking it's healthy with new subscribe/announce calls hanging on a dead peer. - #10 TrackPublisher.send rolls back nextObjectId when every datagram fan-out fails (transport down). The audio-rooms NIP wants strictly contiguous object ids per group; a gap from a fully-failed send would trip strict subscribers. - #13 DefaultNestsSpeaker.close drops `gate` before calling activeHandle.close() / session.close(). The teardown chain runs cancelAndJoin on the broadcaster + sends SUBSCRIBE_DONE per attached subscriber + joins MoQ pumps; holding the gate through all of that blocked any other concurrent API call on this speaker. Resource lifecycle (audit MoQ #11/#12): - AudioRoomBroadcaster.stop() now `cancelAndJoin`s the loop before releasing the encoder + closing the publisher. The loop's last encoder.encode/publisher.send no longer races encoder.release()/publisher.close() — both produced use-after-release on native MediaCodec on Android, the latter sent orphan OBJECT_DATAGRAMs to subscribers we'd just told SUBSCRIBE_DONE. - AudioRoomPlayer.stop() promoted to `suspend` + cancelAndJoin for the same reason: decoder.release() ran while the decode loop was still inside MediaCodec.decode(...), undefined behaviour. Updated VM call sites (closeSubscription, teardown) to route both player.stop() and handle.unsubscribe() through one launched coroutine via the new `detach(): Pair<AudioRoomPlayer?, SubscribeHandle?>` shape. ViewModel fixes: - #4 auto-retry uses a single `retryPending: Boolean` flag instead of `Job.isActive`. Two scheduleAutoRetry calls could previously both pass `Job.isActive == false` (the launched body had just started) and stack a second retry on top of one already running. - #7 setMicMuted updates the UI INSIDE the launched coroutine, after the suspending broadcastHandle.setMuted() returns. Previously the indicator could claim "muted" while audio was still on the wire if the handle's setMuted suspended on a gate. - #8 connect() cancels the previous stateObserverJob before kicking off the new attempt, so a delayed Failed/Closed emission from the old listener can no longer clobber the fresh Connecting UI. - #9 disconnect() clears requestedSpeakers, so a fresh connect() to a different room (or the same room after a long pause) doesn't reuse a stale speaker snapshot. - #12 updateSpeakers filters out the user's own pubkey: subscribing to your own forwarded audio would echo through the local playback device whenever the broadcast track loops back from the relay. Android lifecycle / PIP / service: - #6 PIP aspect ratio flipped from 9:16 (portrait sliver) to 16:9 (landscape) so the row of avatars under the title actually fits. - #7 process-death recovery: when AudioRoomBridge is empty (previous process's AccountViewModel is gone), redirect to MainActivity before finish() so the user lands somewhere meaningful instead of a black-flash. - #8 AudioRoomForegroundService.onStartCommand always calls startForeground first, on every invocation including ACTION_STOP. startForegroundService's 5-second contract requires it; previously the STOP path skipped it. startForeground itself wrapped in runCatching so a foreground-not-allowed exception bails cleanly rather than leaking the wake-lock. - #10 wake-lock timeout reduced 12 h → 4 h. Stuck connections that fail to detect a network drop no longer hold the device awake for half a day. - #11 presence-event spam fix: split the publish loop into a heartbeat keyed only on (address, handRaised) and a separate debounced state-change publisher keyed on micMutedTag. Every mute toggle previously triggered a full sign + publish + relay round trip; now we coalesce within a 500 ms window. - #12 final "leaving" presence routed through GlobalScope.launch instead of rememberCoroutineScope (which is cancelled on dispose, so the leave event almost never reached the relay). - #14 RECORD_AUDIO denial recovery: when the user has tapped "Don't ask again", the launcher silently returns false. New "Open settings" button deep-links to the app's settings page so the user can re-grant the permission and try again. - #15 setPictureInPictureParams now updates outside PIP too, so the next entry shows the correct mute-state icon without an extra flip. - #16 onNewIntent override: a second Join tap for a different room finishes the current Activity and starts a fresh one with the new extras, instead of singleTask silently keeping the old room running. Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest (80 tests), :amethyst:compilePlayDebugKotlin all green. Audit findings still deferred (all documented inline / in this commit): - MoQ #1, #8: wire-format draft pinning (draft-17 vs draft-11). Needs the M4 manual interop pass to confirm what nests is actually speaking. - VM #6: confined map mutation under a single dispatcher. Current setup (Dispatchers.Main via setMain in tests, viewModelScope in prod) is functionally fine; full belt-and-suspenders confining is a separate concurrency review. - VM #10: test coverage gaps for retry + speaker reconcile cycle + setMicMuted no-handle case + server-initiated Closed leaves stale state. Each is a dedicated test. - Android #18: startListening / promoteToMicrophone race. Mitigation (always declaring microphone foreground type) requires unconditional RECORD_AUDIO grant which listener-only users won't have.
This commit is contained in:
+98
-40
@@ -123,16 +123,28 @@ class AudioRoomViewModel(
|
||||
private var autoRetryAttempts = 0
|
||||
private var autoRetryJob: Job? = null
|
||||
|
||||
// True between scheduleAutoRetry() and the launched coroutine's
|
||||
// `finally`. Single source of truth — Job.isActive flips to false
|
||||
// the moment the launched body starts running, which created a
|
||||
// window where two retries could both pass the gate.
|
||||
private var retryPending = false
|
||||
|
||||
// Speaker / publisher path
|
||||
private var speaker: NestsSpeaker? = null
|
||||
private var broadcastHandle: BroadcastHandle? = null
|
||||
private var speakerStateJob: Job? = null
|
||||
private var speakerConnectJob: Job? = null
|
||||
|
||||
/** Push the latest known speaker set from the room event. */
|
||||
/**
|
||||
* Push the latest known speaker set from the room event. The user's
|
||||
* own pubkey (when broadcasting) is filtered out so we don't subscribe
|
||||
* to our own forwarded audio — that would echo through the local
|
||||
* playback device whenever our broadcast track loops back from the relay.
|
||||
*/
|
||||
fun updateSpeakers(speakerPubkeys: Set<String>) {
|
||||
if (closed) return
|
||||
requestedSpeakers = speakerPubkeys
|
||||
val selfHex = signer.pubKey
|
||||
requestedSpeakers = if (selfHex.isEmpty()) speakerPubkeys else speakerPubkeys - selfHex
|
||||
if (_uiState.value.connection is ConnectionUiState.Connected) {
|
||||
reconcileSubscriptions()
|
||||
}
|
||||
@@ -151,6 +163,12 @@ class AudioRoomViewModel(
|
||||
autoRetryJob?.cancel()
|
||||
autoRetryJob = null
|
||||
autoRetryAttempts = 0
|
||||
retryPending = false
|
||||
// Cancel any previous state observer too — otherwise its delayed
|
||||
// emissions can clobber the fresh Connecting UI we set below
|
||||
// (audit VM #8).
|
||||
stateObserverJob?.cancel()
|
||||
stateObserverJob = null
|
||||
|
||||
_uiState.update { it.copy(connection = ConnectionUiState.Connecting(ConnectionUiState.Step.ResolvingRoom)) }
|
||||
|
||||
@@ -258,19 +276,31 @@ class AudioRoomViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle the speaker-side mic mute. Cheap; the network keeps running. */
|
||||
/**
|
||||
* Toggle the speaker-side mic mute. Cheap; the network keeps running.
|
||||
*
|
||||
* UI flips AFTER the suspending `broadcastHandle.setMuted(...)` returns
|
||||
* so the indicator never claims muted while audio is still flowing
|
||||
* (audit VM #7). We accept a small UI latency in exchange for an
|
||||
* accurate state machine.
|
||||
*/
|
||||
fun setMicMuted(muted: Boolean) {
|
||||
if (closed) return
|
||||
val handle = broadcastHandle ?: return
|
||||
viewModelScope.launch {
|
||||
broadcastHandle?.runCatching { setMuted(muted) }
|
||||
}
|
||||
_uiState.update {
|
||||
val current = it.broadcast
|
||||
if (current is BroadcastUiState.Broadcasting) {
|
||||
it.copy(broadcast = current.copy(isMuted = muted))
|
||||
} else {
|
||||
it
|
||||
}
|
||||
handle
|
||||
.runCatching { setMuted(muted) }
|
||||
.onSuccess {
|
||||
if (closed) return@onSuccess
|
||||
_uiState.update {
|
||||
val current = it.broadcast
|
||||
if (current is BroadcastUiState.Broadcasting) {
|
||||
it.copy(broadcast = current.copy(isMuted = muted))
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +316,11 @@ class AudioRoomViewModel(
|
||||
autoRetryJob?.cancel()
|
||||
autoRetryJob = null
|
||||
autoRetryAttempts = 0
|
||||
retryPending = false
|
||||
// Forget the requested speaker set so a fresh connect() to a
|
||||
// different room (or same room after a long pause) doesn't reuse
|
||||
// a stale snapshot that may no longer be on stage.
|
||||
requestedSpeakers = emptySet()
|
||||
teardownBroadcast(BroadcastUiState.Idle, finalCleanup = false)
|
||||
teardown(targetState = ConnectionUiState.Idle, finalCleanup = false)
|
||||
}
|
||||
@@ -383,24 +418,34 @@ class AudioRoomViewModel(
|
||||
* Auto-reconnect on listener Failed with capped exponential backoff.
|
||||
* The user can still tap Connect manually at any time; that resets the
|
||||
* retry counter via the `connect()` path.
|
||||
*
|
||||
* `retryPending` is the single source of truth — `Job.isActive` returns
|
||||
* false the moment a coroutine starts running its body, which created a
|
||||
* window where two scheduleAutoRetry calls could both pass the gate
|
||||
* (audit VM #4).
|
||||
*/
|
||||
private fun scheduleAutoRetry() {
|
||||
if (closed) return
|
||||
if (autoRetryJob?.isActive == true) return
|
||||
if (retryPending) return
|
||||
if (autoRetryAttempts >= MAX_AUTO_RETRIES) return
|
||||
|
||||
retryPending = true
|
||||
val attempt = autoRetryAttempts
|
||||
autoRetryAttempts = attempt + 1
|
||||
val backoffMs = minOf(MAX_RETRY_BACKOFF_MS, INITIAL_RETRY_BACKOFF_MS shl attempt)
|
||||
autoRetryJob =
|
||||
viewModelScope.launch {
|
||||
delay(backoffMs)
|
||||
if (closed) return@launch
|
||||
if (_uiState.value.connection !is ConnectionUiState.Failed) return@launch
|
||||
// Drop the previous listener cleanly before starting a new
|
||||
// attempt. Reset state to Idle so the connect() guard passes.
|
||||
teardown(targetState = ConnectionUiState.Idle)
|
||||
connectInternal()
|
||||
try {
|
||||
delay(backoffMs)
|
||||
if (closed) return@launch
|
||||
if (_uiState.value.connection !is ConnectionUiState.Failed) return@launch
|
||||
// Drop the previous listener cleanly before starting a new
|
||||
// attempt. Reset state to Idle so the connect() guard passes.
|
||||
teardown(targetState = ConnectionUiState.Idle)
|
||||
connectInternal()
|
||||
} finally {
|
||||
retryPending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,17 +514,21 @@ class AudioRoomViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the per-speaker player synchronously (releases the audio device
|
||||
* immediately) and fire-and-forget the MoQ UNSUBSCRIBE on the VM scope.
|
||||
* Stop the per-speaker player + fire-and-forget UNSUBSCRIBE on the VM
|
||||
* scope. Both `AudioRoomPlayer.stop()` and `SubscribeHandle.unsubscribe()`
|
||||
* are suspend, so we route them through one coroutine instead of two.
|
||||
*/
|
||||
private fun closeSubscription(slot: ActiveSubscription) {
|
||||
val handle = slot.detach()
|
||||
val (roomPlayer, handle) = slot.detach()
|
||||
speakingExpiryJobs.remove(slot.pubkey)?.cancel()
|
||||
if (_uiState.value.speakingNow.contains(slot.pubkey)) {
|
||||
_uiState.update { it.copy(speakingNow = (it.speakingNow - slot.pubkey).toPersistentSet()) }
|
||||
}
|
||||
if (handle != null) {
|
||||
viewModelScope.launch { runCatching { handle.unsubscribe() } }
|
||||
if (roomPlayer != null || handle != null) {
|
||||
viewModelScope.launch {
|
||||
roomPlayer?.runCatching { stop() }
|
||||
handle?.runCatching { unsubscribe() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,22 +581,29 @@ class AudioRoomViewModel(
|
||||
connectJob = null
|
||||
stateObserverJob?.cancel()
|
||||
stateObserverJob = null
|
||||
// Stop players synchronously — unsubscribe happens implicitly when
|
||||
// the listener.close() below tears down the MoQ session.
|
||||
activeSubscriptions.values.forEach { it.detach() }
|
||||
// Detach + suspend-stop each player on the cleanup scope. The
|
||||
// listener.close() below tears down the MoQ session and drops
|
||||
// every active subscription, so we don't need to call
|
||||
// unsubscribe() per-handle here — but we DO need to await each
|
||||
// AudioRoomPlayer.stop() so the native MediaCodec / AudioTrack
|
||||
// is released after its decode loop has unwound.
|
||||
val scope = if (finalCleanup) cleanupScope else viewModelScope
|
||||
val players = activeSubscriptions.values.map { it.detach().first }
|
||||
activeSubscriptions.clear()
|
||||
speakingExpiryJobs.values.forEach { it.cancel() }
|
||||
speakingExpiryJobs.clear()
|
||||
if (players.isNotEmpty()) {
|
||||
scope.launch {
|
||||
players.forEach { p -> p?.runCatching { stop() } }
|
||||
}
|
||||
}
|
||||
val l = listener
|
||||
listener = null
|
||||
if (l != null) {
|
||||
// Listener.close() sends MoQ control frames (UNSUBSCRIBE etc.)
|
||||
// before the QUIC transport drops. User-driven disconnect uses
|
||||
// the still-alive viewModelScope; onCleared has already had its
|
||||
// viewModelScope cancelled, so it routes through cleanupScope
|
||||
// (a process-lived scope) so the peer sees a clean teardown
|
||||
// rather than a hard QUIC drop.
|
||||
val scope = if (finalCleanup) cleanupScope else viewModelScope
|
||||
// before the QUIC transport drops. Same scope rule as the
|
||||
// player stop above: viewModelScope when alive, cleanupScope
|
||||
// for onCleared so the wire teardown survives.
|
||||
scope.launch { runCatching { l.close() } }
|
||||
}
|
||||
_uiState.update {
|
||||
@@ -615,18 +671,20 @@ class AudioRoomViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the player + decoder synchronously and return the
|
||||
* [SubscribeHandle] (if any) so the caller can fire-and-forget
|
||||
* UNSUBSCRIBE on its own coroutine scope.
|
||||
* Hand the player + handle back to the caller's coroutine scope —
|
||||
* `AudioRoomPlayer.stop()` and `SubscribeHandle.unsubscribe()` are
|
||||
* both suspend, and the caller has the right scope to await them
|
||||
* (so native MediaCodec/AudioTrack release runs after the decode
|
||||
* loop has unwound, per audit MoQ #11/#12).
|
||||
*/
|
||||
fun detach(): SubscribeHandle? {
|
||||
fun detach(): Pair<AudioRoomPlayer?, SubscribeHandle?> {
|
||||
isPlaying = false
|
||||
roomPlayer?.let { runCatching { it.stop() } }
|
||||
val p = roomPlayer
|
||||
val h = handle
|
||||
roomPlayer = null
|
||||
handle = null
|
||||
player = null
|
||||
return h
|
||||
return p to h
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
Reference in New Issue
Block a user