From 044ff034a62e400ab3118d739e3eb38364ec9b9b Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 12:44:51 +0100 Subject: [PATCH] Await radio and shared audio release before native core teardown --- .../main/java/com/utun/chat/data/NativeLib.kt | 38 +++++++++++-------- .../com/utun/chat/data/RadioAudioEngine.kt | 4 ++ .../utun/chat/data/RouterHostEnvironment.kt | 2 + 3 files changed, 29 insertions(+), 15 deletions(-) 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 f203a028..1a549f68 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 @@ -2,7 +2,6 @@ package com.utun.chat.data import com.utun.chat.ChatApplication import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext interface ChatEventCallback { @@ -11,6 +10,8 @@ interface ChatEventCallback { object NativeLib { @Volatile private var coreStopping = false + @Volatile private var eventCallback: ChatEventCallback? = null + fun isCoreStopping(): Boolean = coreStopping init { System.loadLibrary("utun") @@ -18,6 +19,7 @@ object NativeLib { /** Save Kotlin callbacks to C globals (log, config). Must be called before start(). */ fun init(dbPath: String, controlPort: Int): Boolean { + if (coreStopping) { LogManager.addLog("ERROR", "Core", "Initialization rejected: frontend cleanup pending"); return false } val provider = ChatApplication.instance.configProvider val settings = provider.getVoiceSettings() val ok = nativeInit( @@ -29,6 +31,7 @@ object NativeLib { LogManager.addLog("ERROR", "AudioSettings", "Native initialization failed; voice settings not applied") return false } + eventCallback?.let { nativeSetEventCallback(it) } nativeVoiceSetPreset(settings.preset) nativeVoiceSetCompressor(settings.compressorEnabled) nativeVoiceSetCompressorConfig(settings.maxGainDb, settings.lookbackMs, settings.lookaheadMs, @@ -46,39 +49,43 @@ object NativeLib { /** Снимает frontend media на main, дожидается worker на IO даже до ready; ошибка запрещает следующий init. */ suspend fun stop() { - withContext(Dispatchers.Main.immediate) { prepareForCoreStop() } + prepareForCoreStop() check(withContext(Dispatchers.IO) { nativeStop() }) { "native worker stop failed" } withContext(Dispatchers.Main.immediate) { ChatApplication.instance.memberFiles.coreStopped() } coreStopping = false } fun setEventCallback(cb: ChatEventCallback) { - nativeSetEventCallback(object : ChatEventCallback { - override fun onEvent(type: Int, data: ByteArray?) { cb.onEvent(type, data) } - }) + nativeSetEventCallback(cb) + eventCallback = cb } /** Как stop, дополнительно освобождает JNI global refs только после подтверждённого join. */ suspend fun destroy() { - withContext(Dispatchers.Main.immediate) { prepareForCoreStop() } + 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 { - try { prepareForCoreStop(); return nativeRestart(configText) } + suspend fun restart(dbPath: String, controlPort: Int, configText: String): Boolean { + try { prepareForCoreStop(); return withContext(Dispatchers.IO) { nativeRestart(configText) } } finally { coreStopping = false } } /** Снять frontend-владельцев перед stop/restart/сменой ключей. Неподтверждённый CallService либо ещё * активный mediaPreparation запрещает native stop с ошибкой; Main остаётся доступен для retry. */ - private fun prepareForCoreStop() { - runBlocking(Dispatchers.Main.immediate) { + private suspend fun prepareForCoreStop() { + val radioStopped = withContext(Dispatchers.Main.immediate) { coreStopping = true + val radio = RadioAudioEngine.instance().stop() AudioRecorderManager.stopAll() RadioCallController.reset() StreamController.reset(); CallController.reset(); ChatApplication.instance.avatars.stop(); ChatApplication.instance.memberFiles.cancel() + radio + } + withContext(Dispatchers.IO) { radioStopped.get() } + withContext(Dispatchers.Main.immediate) { CallService.requireReleased() // Не останавливать native при неподтверждённой очистке его media-владельца. StreamPlayerEngine.stopForCore() StreamController.requireReleased() @@ -88,6 +95,7 @@ object NativeLib { "Call media preparation still running" } } + withContext(Dispatchers.IO) { MediaAudioController.awaitIdle() } } /** Лёгкое обновление сокетов (auto_sockets=android) без полного рестарта ядра */ @@ -154,8 +162,8 @@ object NativeLib { fun subscribeNativeLogs(enabled: Boolean) { nativeSubscribeLogs(enabled) } fun setUdpLogTarget(ip: String, port: Int) { nativeSetUdpLogTarget(ip, port) } /** Принимает полный INI для пересоздания ядра с новой сохранённой идентичностью; true подтверждает приём запроса. */ - fun regenerateKeys(configText: String): Boolean { - try { prepareForCoreStop(); return nativeRegenerateKeys(configText) } + suspend fun regenerateKeys(configText: String): Boolean { + try { prepareForCoreStop(); return withContext(Dispatchers.IO) { nativeRegenerateKeys(configText) } } finally { coreStopping = false } } @@ -339,7 +347,7 @@ object NativeLib { private external fun nativeRadioCall(id: Long, group: Long, peer: Long, action: Int): Boolean /* ── P2P аудио-звонок (медиа) ── */ - fun callAudioStart(callId: Long): Boolean = nativeCallAudioStart(callId) + fun callAudioStart(callId: Long): Boolean = !coreStopping && nativeCallAudioStart(callId) /** Настоящий raw PCM и mute кадра: зануление выполняется в core после AEC/AGC. */ fun callAudioFeed(callId: Long, samples: ShortArray, muted: Boolean, capturePtsUs: Long): Boolean = nativeCallAudioFeed(callId, samples, muted, capturePtsUs) @@ -359,11 +367,11 @@ object NativeLib { /* ── Рация (PTT walkie-talkie) ── */ fun radioSetActive(groupId: Long, on: Boolean) { nativeRadioSetActive(groupId, on) } fun radioSubscribers(groupId: Long): Int = nativeRadioSubscribers(groupId) - fun radioAudioStart(groupId: Long, channels: Int): Boolean = nativeRadioAudioStart(groupId, channels) + fun radioAudioStart(groupId: Long, channels: Int): Boolean = !coreStopping && nativeRadioAudioStart(groupId, channels) fun radioAudioFeed(groupId: Long, samples: ShortArray): Boolean = nativeRadioAudioFeed(groupId, samples) fun radioAudioPull(groupId: Long, out: ShortArray, playedFrames: Long): Int = nativeRadioAudioPull(groupId, out, playedFrames) fun radioAudioStop() { nativeRadioAudioStop() } - fun radioCaptureStart(groupId: Long, channels: Int, mode: Int): Boolean = nativeRadioCaptureStart(groupId, channels, mode) + fun radioCaptureStart(groupId: Long, channels: Int, mode: Int): Boolean = !coreStopping && nativeRadioCaptureStart(groupId, channels, mode) /** true — копия снимка поставлена живому worker; проверка и применение ещё не подтверждены. * ConfigProvider.onRadioSettingsApplied вернёт тот же requestId и результат атомарной проверки/применения. * false — запрос отклонён (включая отсутствие callback). Stop/restart может отменить ожидающий снимок без ACK; diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt index 2cee0f51..893483be 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt @@ -126,6 +126,10 @@ class RadioAudioEngine { * Ошибка очистки/запуска даёт false. Новый start/stop инвалидирует прежний результат, также возвращая false. */ fun start(group: Long, onReady: ((Boolean) -> Unit)? = null) { checkMain("start") + if (NativeLib.isCoreStopping()) { + LogManager.addLog("WARN", "radio", "start rejected during core cleanup group=$group") + onReady?.invoke(false); return + } if (group == 0L) { LogManager.addLog("ERROR", "RadioAudio", "start rejected: zero group ID") onReady?.invoke(false) diff --git a/tools/chatgui-android/router-tests/src/main/kotlin/com/utun/chat/data/RouterHostEnvironment.kt b/tools/chatgui-android/router-tests/src/main/kotlin/com/utun/chat/data/RouterHostEnvironment.kt index 6f2983a4..d433f096 100644 --- a/tools/chatgui-android/router-tests/src/main/kotlin/com/utun/chat/data/RouterHostEnvironment.kt +++ b/tools/chatgui-android/router-tests/src/main/kotlin/com/utun/chat/data/RouterHostEnvironment.kt @@ -35,6 +35,8 @@ object LogManager { /** Только host-тест: считает уведомления standby, не загружает JNI и не запускает ядро. */ object NativeLib { + @Volatile var coreStopping = false + fun isCoreStopping() = coreStopping @Volatile var callAudioFailures = emptySet() @Volatile var onCallFeed: () -> Unit = {} val callAudioCommands: MutableList> = Collections.synchronizedList(mutableListOf())