From a85d2cc8c7e5409dccc0521d8a0e63a07165811f Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 10:12:15 +0100 Subject: [PATCH] Retain voice resources after ViewModel disposal and block premature core teardown --- .../utun/chat/data/AudioRecorderManager.kt | 140 +++++++++++++++--- .../main/java/com/utun/chat/data/NativeLib.kt | 20 ++- .../com/utun/chat/viewmodel/ChatViewModel.kt | 7 +- 3 files changed, 139 insertions(+), 28 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt index 258e98a8..a5dc4c28 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt @@ -9,6 +9,9 @@ import android.media.AudioTrack import android.media.MediaRecorder import androidx.core.content.ContextCompat import com.utun.chat.ChatApplication +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference /** * Запись голосового сообщения/тест микрофона и отдельный плеер native Opus-файла. @@ -27,14 +30,29 @@ class AudioRecorderManager { const val FRAME_MS = 20 const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 const val MIN_DURATION_MS = 500 + private val owners = ConcurrentHashMap.newKeySet() + + /** Main: запросить независимое освобождение всех recorder/player перед звонком. Ожидать futures только на IO. */ + fun stopAll(): List> = owners.toList().map { it.requestClose() } + + /** Main: незавершённая очистка запрещает разрушать native recorder; CoreShutdown повторяет проверку. */ + fun requireReleasedForCore() { + val pending = stopAll() + check(pending.all { it.isDone && !it.isCompletedExceptionally } && owners.isEmpty()) { + LogManager.addLog("ERROR", "AudioRecorder", "Core teardown blocked: voice resource owners=${owners.size}") + "Voice resource cleanup pending" + } + } } private enum class CaptureMode { RECORD, TEST } @Volatile private var captureMode: CaptureMode? = null - private var audioRecord: AudioRecord? = null - private var recordingThread: Thread? = null + @Volatile private var audioRecord: AudioRecord? = null + @Volatile private var recordingThread: Thread? = null @Volatile private var captureRunning = false @Volatile private var captureFailed = false + private val closing = AtomicReference?>(null) + private val playbackWorkers = ConcurrentHashMap.newKeySet() val isTesting: Boolean get() = captureMode == CaptureMode.TEST && captureRunning fun hasPermission(): Boolean { @@ -45,8 +63,10 @@ class AudioRecorderManager { /** channelId — канал либо dm:conv_id; replyTo относится к каналу, requestId подтверждает commit, generation — к PM. * true — Android/native захват запущен; false — отказ с диагностикой и откатом принадлежащих ресурсов. */ - @Synchronized fun startRecording(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean = - startCapture(CaptureMode.RECORD, channelId, replyTo, requestId, generation) + fun startRecording(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean { + if (closing.get()?.isDone == false) { LogManager.addLog("WARN", "AudioRecorder", "record start rejected: cleanup pending"); return false } + return synchronized(this) { startCapture(CaptureMode.RECORD, channelId, replyTo, requestId, generation) } + } private fun startCapture(mode: CaptureMode, target: String, reply: String = "", request: Long = 0, generation: Long = 0): Boolean { if (captureMode != null) { @@ -59,8 +79,10 @@ class AudioRecorderManager { LogManager.addLog("WARN", "AudioRecorder", "microphone permission missing mode=$mode") return false } + owners.add(this) // До JNI: stopForCore видит также запуск, ещё не закончивший подготовку устройства. if (!NativeLib.voiceStart(target, reply, request, generation)) { LogManager.addLog("ERROR", "AudioRecorder", "native voiceStart rejected mode=$mode target=$target") + forgetIfIdle() return false } captureMode = mode; captureFailed = false @@ -81,6 +103,7 @@ class AudioRecorderManager { } catch (e: Exception) { LogManager.addLog("ERROR", "AudioRecorder", "capture start failed mode=$mode target=$target: $e") if (captureMode != null) { captureFailed = true; finishCapture(false) } + forgetIfIdle() return false } } @@ -119,6 +142,7 @@ class AudioRecorderManager { recordingThread?.interrupt() var failed = captureFailed var interrupted = Thread.interrupted() + var nativeReleased = false try { try { audioRecord?.stop() } catch (e: Exception) { failed = true; LogManager.addLog("WARN", "AudioRecorder", "capture stop failed mode=$mode: $e") } @@ -132,17 +156,21 @@ class AudioRecorderManager { recordingThread = null failed = failed || captureFailed if (!releaseCaptureDevice()) failed = true - val duration = if (publish && !failed) NativeLib.voiceStop() else { NativeLib.voiceCancel(); if (publish) -1 else 0 } + val duration = if (publish && !failed) NativeLib.voiceStop().also { if (it < 0) NativeLib.voiceCancel() } + else { NativeLib.voiceCancel(); if (publish) -1 else 0 } + nativeReleased = true LogManager.addLog(if (duration < 0) "ERROR" else "INFO", "AudioRecorder", "capture finished mode=$mode publish=$publish failed=$failed durationMs=$duration") return duration } catch (e: Exception) { LogManager.addLog("ERROR", "AudioRecorder", "native capture finish failed mode=$mode: $e") - try { NativeLib.voiceCancel() } + try { NativeLib.voiceCancel(); nativeReleased = true } catch (cleanup: Exception) { LogManager.addLog("ERROR", "AudioRecorder", "native capture cancel failed: $cleanup") } return -1 } finally { - captureMode = null + if (nativeReleased) captureMode = null else captureFailed = true + forgetIfIdle() + if (!nativeReleased || audioRecord != null) requestClose() if (interrupted) Thread.currentThread().interrupt() } } @@ -173,13 +201,13 @@ class AudioRecorderManager { fun isActive(): Boolean = captureMode == CaptureMode.RECORD && captureRunning /** Индикатор уровня через native recorder; успешный тест нужно завершить stopMicTest без публикации. */ - @Synchronized fun startMicTest(): Boolean { + fun startMicTest(): Boolean { + if (closing.get()?.isDone == false) { LogManager.addLog("WARN", "AudioRecorder", "microphone test rejected: cleanup pending"); return false } if (StreamController.blocksCapture()) { LogManager.addLog("WARN", "AudioRecorder", "microphone test rejected: stream owns capture") return false } - if (isTesting) return true - return startCapture(CaptureMode.TEST, "__test__") + return synchronized(this) { if (isTesting) true else startCapture(CaptureMode.TEST, "__test__") } } @Synchronized fun stopMicTest() { if (captureMode == CaptureMode.TEST) finishCapture(false) else if (captureMode == null) releaseCaptureDevice() @@ -189,6 +217,50 @@ class AudioRecorderManager { private var playThread: Thread? = null @Volatile private var playGeneration = 0 + @Synchronized private fun forgetIfIdle() { + if (captureMode == null && audioRecord == null && playbackWorkers.isEmpty()) owners.remove(this) + } + + /** Не ждёт SDK на caller: process-владелец повторяет stop/release до подтверждения и переживает ViewModel.onCleared. */ + fun requestClose(): CompletableFuture { + val result: CompletableFuture + while (true) { + val previous = closing.get() + if (previous != null && !previous.isDone) return previous + val next = CompletableFuture() + if (closing.compareAndSet(previous, next)) { result = next; break } + } + captureRunning = false; recordingThread?.interrupt() + try { + Thread({ + var retryMs = 250L + try { + while (true) { + val released = synchronized(this) { + if (captureMode != null) finishCapture(false) else releaseCaptureDevice() + stopPlayback() + captureMode == null && audioRecord == null && playbackWorkers.isEmpty() + } + if (released) { + forgetIfIdle(); result.complete(Unit) + LogManager.addLog("INFO", "AudioRecorder", "Voice resources released owner=${System.identityHashCode(this)}") + break + } + LogManager.addLog("WARN", "AudioRecorder", "Voice cleanup pending owner=${System.identityHashCode(this)} retryMs=$retryMs") + Thread.sleep(retryMs); retryMs = (retryMs * 2).coerceAtMost(30_000) + } + } catch (e: Exception) { + LogManager.addLog("ERROR", "AudioRecorder", "Voice cleanup worker failed; owner retained: $e") + result.completeExceptionally(e) + } + }, "utun-voice-cleanup").start() + } catch (e: Exception) { + LogManager.addLog("ERROR", "AudioRecorder", "Voice cleanup worker start failed; owner retained: $e") + result.completeExceptionally(e) + } + return result + } + /** Запустить с начала; callbacks приходят из worker только для текущего поколения, completion также при ошибке. */ fun playVoiceFile(filePath: String, onProgress: ((Float, Int) -> Unit)? = null, onComplete: () -> Unit = {}) { playFromPosition(filePath, 0, onProgress, onComplete) @@ -197,11 +269,12 @@ class AudioRecorderManager { /** Запустить со startMs, округлённого вниз до 20 мс; seek декодирует и отбрасывает предшествующий PCM. * Отрицательный startMs ограничивается нулём; прогресс учитывает только принятые AudioTrack сэмплы. */ @Synchronized fun playFromPosition(filePath: String, startMs: Int, onProgress: ((Float, Int) -> Unit)? = null, onComplete: () -> Unit = {}) { + if (closing.get()?.isDone == false) { LogManager.addLog("WARN", "AudioPlayer", "play rejected: cleanup pending"); return } stopPlayback() playing = true val myGen = playGeneration - playThread = Thread { + val worker = Thread { var handle = 0L var track: AudioTrack? = null try { @@ -270,22 +343,43 @@ class AudioRecorderManager { } catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "play error: ${e.message}") } finally { - track?.apply { - try { stop() } catch (e: Exception) { LogManager.addLog("WARN", "AudioPlayer", "stop failed: ${e.message}") } - try { release() } catch (e: Exception) { LogManager.addLog("WARN", "AudioPlayer", "release failed: $e") } - } - try { if (handle != 0L) NativeLib.voiceDecodeClose(handle) } - catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "decode close failed generation=$myGen: $e") } - synchronized(this@AudioRecorderManager) { - if (playGeneration == myGen) { - playing = false; playThread = null - try { onComplete() } - catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "completion callback failed generation=$myGen: $e") } + var retryMs = 250L + var notified = false + var interrupted = Thread.interrupted() + while (true) { + track?.let { output -> + try { output.stop() } catch (e: Exception) { LogManager.addLog("WARN", "AudioPlayer", "stop failed: $e") } + try { output.release(); track = null } + catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "release failed; worker retains output: $e") } } + try { if (handle != 0L) { NativeLib.voiceDecodeClose(handle); handle = 0 } } + catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "decode close failed generation=$myGen; retained: $e") } + if (!notified) { + synchronized(this@AudioRecorderManager) { + if (playGeneration == myGen) { + playing = false; playThread = null + try { onComplete() } + catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "completion callback failed generation=$myGen: $e") } + } + } + notified = true + } + if (track == null && handle == 0L) break + try { Thread.sleep(retryMs) } catch (_: InterruptedException) { interrupted = true } + retryMs = (retryMs * 2).coerceAtMost(30_000) } + playbackWorkers.remove(Thread.currentThread()); forgetIfIdle() + if (interrupted) Thread.currentThread().interrupt() LogManager.addLog("DEBUG", "AudioPlayer", "playback worker exited generation=$myGen current=$playGeneration") } - }.apply { start() } + } + playThread = worker; playbackWorkers.add(worker); owners.add(this) + try { worker.start() } + catch (e: Exception) { + playbackWorkers.remove(worker); playThread = null; playing = false; forgetIfIdle() + LogManager.addLog("ERROR", "AudioPlayer", "Playback worker start failed: $e") + onComplete() + } } /** Асинхронно остановить текущий worker; позицию для следующего playFromPosition хранит вызывающий код. */ diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 271bf050..cb72f9e9 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -10,6 +10,7 @@ interface ChatEventCallback { } object NativeLib { + @Volatile private var coreStopping = false init { System.loadLibrary("utun") @@ -38,6 +39,7 @@ object NativeLib { /** Start full uTun instance from INI config text */ fun start(configText: String): Boolean { + if (coreStopping) { LogManager.addLog("ERROR", "Core", "Start rejected: previous frontend cleanup pending"); return false } if (isReady()) return true return nativeStart(configText) } @@ -47,6 +49,7 @@ object NativeLib { withContext(Dispatchers.Main.immediate) { prepareForCoreStop() } check(withContext(Dispatchers.IO) { nativeStop() }) { "native worker stop failed" } withContext(Dispatchers.Main.immediate) { ChatApplication.instance.memberFiles.coreStopped() } + coreStopping = false } fun setEventCallback(cb: ChatEventCallback) { @@ -60,22 +63,26 @@ object NativeLib { withContext(Dispatchers.Main.immediate) { prepareForCoreStop() } check(withContext(Dispatchers.IO) { nativeDestroy() }) { "native worker destroy failed" } withContext(Dispatchers.Main.immediate) { ChatApplication.instance.memberFiles.coreStopped() } + coreStopping = false } fun restart(dbPath: String, controlPort: Int, configText: String): Boolean { prepareForCoreStop() - return nativeRestart(configText) + return nativeRestart(configText).also { coreStopping = false } } /** Снять frontend-владельцев перед stop/restart/сменой ключей. Неподтверждённый CallService либо ещё * активный mediaPreparation запрещает native stop с ошибкой; Main остаётся доступен для retry. */ private fun prepareForCoreStop() { runBlocking(Dispatchers.Main.immediate) { + coreStopping = true + AudioRecorderManager.stopAll() RadioCallController.reset() StreamController.reset(); CallController.reset(); ChatApplication.instance.avatars.stop(); ChatApplication.instance.memberFiles.cancel() CallService.requireReleased() // Не останавливать native при неподтверждённой очистке его media-владельца. StreamPlayerEngine.stopForCore() StreamController.requireReleased() + AudioRecorderManager.requireReleasedForCore() check(!CallController.blocksMedia()) { LogManager.addLog("ERROR", "Call", "Core teardown blocked: call media preparation still running") "Call media preparation still running" @@ -148,7 +155,7 @@ object NativeLib { /** Принимает полный INI для пересоздания ядра с новой сохранённой идентичностью; true подтверждает приём запроса. */ fun regenerateKeys(configText: String): Boolean { prepareForCoreStop() - return nativeRegenerateKeys(configText) + return nativeRegenerateKeys(configText).also { coreStopping = false } } /* ── Channel members ── */ @@ -197,8 +204,13 @@ object NativeLib { fun requestLocalSockets() = nativeRequestLocalSockets() /* ── Voice recording ── */ - fun voiceStart(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean = - !StreamController.blocksCapture() && nativeVoiceStart(channelId, messageIdBytes(replyTo), requestId, generation) + fun voiceStart(channelId: String, replyTo: String = "", requestId: Long = 0, generation: Long = 0): Boolean { + if (coreStopping || (channelId != "__test__" && !isReady()) || StreamController.blocksCapture() || CallController.blocksMedia()) { + LogManager.addLog("WARN", "Voice", "Capture rejected target=$channelId coreStopping=$coreStopping; core/media unavailable") + return false + } + return nativeVoiceStart(channelId, messageIdBytes(replyTo), requestId, generation) + } fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples) fun voiceStop(): Int = nativeVoiceStop() fun voiceCancel() { nativeVoiceCancel() } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index c0685d0e..e738d361 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -412,7 +412,7 @@ class ChatViewModel : ViewModel() { } viewModelScope.launch { CallController.state.collect { call -> - if (call?.preparing == true || CallController.blocksMedia()) selectStream(null) + if (call?.preparing == true || CallController.blocksMedia()) { selectStream(null); cancelVoiceRecording() } } } viewModelScope.launch { @@ -1904,6 +1904,11 @@ class ChatViewModel : ViewModel() { _channelOwner.value = false } + override fun onCleared() { + audioRecorder.requestClose() + super.onCleared() + } + class Factory : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = ChatViewModel() as T