Keep media audio session close retryable until router cleanup succeeds

This commit is contained in:
evgeny committed 2026-10-29 13:34:23 +01:00
1 parent 1970f895da
commit 3d2e98b02e
2 files changed
+26 -9

No files matched your search

@@ -35,7 +35,8 @@ internal object MediaAudioController : MediaAudioSession()
* refresh/select асинхронны. attachOutput/close lease/awaitIdle/close блокируют вызывающего worker,
* поэтому main и io для них запрещены. Track регистрируется до play и отсоединяется до release.
* Ошибки инвалидируют поколение; следующий refresh повторяет попытку, сохраняя действующие PCM-подписки.
* Настройка становится committed только после успешной записи. close требует отсутствия потребителей.
* Настройка становится committed только после успешной записи. close требует отсутствия потребителей,
* запрещает новых и закрывает executor лишь после подтверждения hardware/router cleanup; отказ допускает повтор close.
*/
internal open class MediaAudioSession(private val io: ScheduledExecutorService =
Executors.newSingleThreadScheduledExecutor { Thread(it, "media-audio-control").apply { isDaemon = true } }) : AutoCloseable {
@@ -44,6 +45,7 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService =
private val main = Handler(Looper.getMainLooper())
@Volatile private var controlThread: Thread? = null
@Volatile private var closed = false
@Volatile private var closing = false
private val router = CallAudioRouter()
private val mutable = MutableStateFlow(MediaAudioState())
val state: StateFlow<MediaAudioState> = mutable
@@ -110,7 +112,7 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService =
/** Не открывает микрофон из UI. Вызывается реальным владельцем захвата с RECORD_AUDIO. */
fun subscribe(name: String): MicrophoneCapture.Subscription = synchronized(inputLock) {
check(!closed) { "Media audio session closed" }
check(!closed && !closing) { "Media audio session closing/closed" }
check(!CallController.blocksMedia()) { "Media capture blocked by call" }
if (shared?.closed != false) {
val device = InputBridge()
@@ -124,9 +126,9 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService =
/** Регистрация завершается до play, удаление — до release. Не вызывается из main или io. */
fun attachOutput(track: AudioTrack): AutoCloseable {
checkBlockingCaller()
check(!closed) { "Media audio session closed" }
check(!closed && !closing) { "Media audio session closing/closed" }
val registration = io.submit<OutputRegistration> {
check(!closed) { "Media audio session closed" }
check(!closed && !closing) { "Media audio session closing/closed" }
check(!CallController.blocksMedia()) { "Media output blocked by call" }
check(track !in outputs) { "AudioTrack already registered" }
val owner = OutputRegistration()
@@ -181,6 +183,7 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService =
fun awaitIdle() {
checkBlockingCaller()
io.submit {
check(!closing) { "Media audio session cleanup incomplete" }
check(outputs.isEmpty() && synchronized(inputLock) { shared?.closed != false }) { "Media audio still owned" }
invalidateAttempt()
releaseHardware()
@@ -196,10 +199,20 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService =
io.submit {
synchronized(inputLock) {
check(outputs.isEmpty() && shared?.closed != false) { "Media audio still owned" }
closed = true
closing = true
}
invalidateAttempt()
try { releaseHardware() } finally { router.stop() }
var failure: Exception? = null
try { releaseHardware() } catch (e: Exception) {
LogManager.addLog("ERROR", "media_audio", "session hardware cleanup failed; close remains retryable: $e")
failure = e
}
try { router.stop() } catch (e: Exception) {
LogManager.addLog("ERROR", "media_audio", "session router cleanup failed; close remains retryable: $e")
if (failure == null) failure = e
}
failure?.let { throw it }
closed = true
LogManager.addLog("INFO", "media_audio", "session closed")
}.get()
} finally { if (closed) io.shutdown() }
@@ -211,9 +224,9 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService =
}
private fun enqueue(action: () -> Unit) {
if (closed) return
if (closed || closing) return
try {
io.execute { if (!closed) try { action() } catch (e: Exception) { recover(e) } }
io.execute { if (!closed && !closing) try { action() } catch (e: Exception) { recover(e) } }
} catch (e: RejectedExecutionException) {
if (!closed) LogManager.addLog("ERROR", "media_audio", "control task rejected: $e")
}
@@ -359,7 +372,7 @@ internal open class MediaAudioSession(private val io: ScheduledExecutorService =
val epoch = generation
lateinit var task: ScheduledFuture<*>
task = io.schedule({
if (closed || generation != epoch || confirmationTimeout !== task) return@schedule
if (closed || closing || generation != epoch || confirmationTimeout !== task) return@schedule
confirmationTimeout = null
try {
check(inputReady && routeAvailable && outputsReady(outputs.keys)) {
@@ -30,6 +30,8 @@ class BluetoothAdapter {
var closeAttempts = 0
var rejectRequest = false
var throwRequest = false
var closeFailures = emptySet<BluetoothProfile>()
var closeFailuresAfter = emptySet<BluetoothProfile>()
var requestEntered: CountDownLatch? = null
var requestReturn: CountDownLatch? = null
@@ -61,9 +63,11 @@ class BluetoothAdapter {
check(profile == BluetoothProfile.HEADSET)
if (proxy == null) return
++closeAttempts
check(proxy !in closeFailures) { "BT close failed before effect proxy=$proxy" }
if (!live.remove(proxy)) return // SDK close идемпотентен; запись удаляется только один раз.
++closes
(proxy as BluetoothHeadset).connected = false
LogManager.addLog("INFO", "HostBluetooth", "closed proxy=${proxy.id} live=${live.size}")
check(proxy !in closeFailuresAfter) { "BT close failed after effect proxy=$proxy" }
}
}