Serialize native lifecycle mutations and renew readiness across restart

This commit is contained in:
evgeny committed 2026-10-29 13:35:42 +01:00
1 parent 0568d263e0
commit 665137314a
3 files changed
+86 -20

No files matched your search

@@ -3,6 +3,8 @@ package com.utun.chat.data
import com.utun.chat.ChatApplication
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
interface ChatEventCallback {
fun onEvent(type: Int, data: ByteArray?)
@@ -11,15 +13,16 @@ interface ChatEventCallback {
object NativeLib {
@Volatile private var coreStopping = false
@Volatile private var eventCallback: ChatEventCallback? = null
private val lifecycle = Mutex()
private var initialized = false // Только под lifecycle; callbacks нельзя менять у ещё живого worker.
fun isCoreStopping(): Boolean = coreStopping
init {
System.loadLibrary("utun")
}
/** 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 }
/** Только IO под lifecycle, до первого worker либо после подтверждённого nativeStop. */
private fun initCallbacks(dbPath: String, controlPort: Int): Boolean {
val provider = ChatApplication.instance.configProvider
val settings = provider.getVoiceSettings()
val ok = nativeInit(
@@ -37,20 +40,22 @@ object NativeLib {
nativeVoiceSetCompressorConfig(settings.maxGainDb, settings.lookbackMs, settings.lookaheadMs,
settings.riseRate.toFloat(), settings.targetLevelDb.toFloat())
LogManager.addLog("INFO", "AudioSettings", "Applied at initialization: $settings")
initialized = true
return true
}
/** 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)
/** Инициализация callbacks и создание worker — одна операция; существующий worker сохраняет callbacks до join. */
suspend fun start(dbPath: String, controlPort: Int, configText: String): Boolean = lifecycle.withLock {
if (coreStopping) { LogManager.addLog("ERROR", "Core", "Start rejected: previous frontend cleanup pending"); return@withLock false }
if (isReady()) return@withLock true
withContext(Dispatchers.IO) { (initialized || initCallbacks(dbPath, controlPort)) && nativeStart(configText) }
}
/** Снимает frontend media на main, дожидается worker на IO даже до ready; ошибка запрещает следующий init. */
suspend fun stop() {
suspend fun stop() = lifecycle.withLock {
prepareForCoreStop()
check(withContext(Dispatchers.IO) { nativeStop() }) { "native worker stop failed" }
initialized = false
withContext(Dispatchers.Main.immediate) { ChatApplication.instance.memberFiles.coreStopped() }
coreStopping = false
}
@@ -61,16 +66,43 @@ object NativeLib {
}
/** Как stop, дополнительно освобождает JNI global refs только после подтверждённого join. */
suspend fun destroy() {
suspend fun destroy() = lifecycle.withLock {
prepareForCoreStop()
check(withContext(Dispatchers.IO) { nativeDestroy() }) { "native worker destroy failed" }
initialized = false
withContext(Dispatchers.Main.immediate) { ChatApplication.instance.memberFiles.coreStopped() }
coreStopping = false
}
suspend fun restart(dbPath: String, controlPort: Int, configText: String): Boolean {
try { prepareForCoreStop(); return withContext(Dispatchers.IO) { nativeRestart(configText) } }
finally { coreStopping = false }
suspend fun restart(dbPath: String, controlPort: Int, configText: String): Boolean = lifecycle.withLock {
var accepted = false
try {
prepareForCoreStop()
withContext(Dispatchers.Main.immediate) { com.utun.chat.headless.HeadlessService.onCoreRestarting() }
accepted = withContext(Dispatchers.IO) { (initialized || initCallbacks(dbPath, controlPort)) && nativeRestart(configText) }
accepted
} finally { resumeAfterRejectedChange(accepted) }
}
/** Тот же mutex исключает start/stop между join, удалением БД и восстановлением worker.
* afterStop выполняется на main после join; configText=null оставляет ядро выключенным. */
suspend fun resetDatabase(dbPath: String, controlPort: Int, configText: String?, afterStop: () -> Unit): Boolean = lifecycle.withLock {
prepareForCoreStop()
if (configText != null) withContext(Dispatchers.Main.immediate) { com.utun.chat.headless.HeadlessService.onCoreRestarting() }
check(withContext(Dispatchers.IO) { nativeStop() }) { "native stop before database reset failed" }
initialized = false
withContext(Dispatchers.Main.immediate) { ChatApplication.instance.memberFiles.coreStopped(); afterStop() }
val cleared = withContext(Dispatchers.IO) { nativeClearDatabase() }
if (configText != null) check(withContext(Dispatchers.IO) {
initCallbacks(dbPath, controlPort) && nativeStart(configText)
}) { "native start after database reset failed" }
coreStopping = false
cleared
}
private fun resumeAfterRejectedChange(accepted: Boolean) {
coreStopping = false
if (!accepted && nativeIsRunning()) com.utun.chat.AppEventHandler.onEvent(12, null)
}
/** Снять frontend-владельцев перед stop/restart/сменой ключей. Неподтверждённый CallService либо ещё
@@ -84,6 +116,7 @@ object NativeLib {
StreamController.reset(); CallController.reset(); ChatApplication.instance.avatars.stop(); ChatApplication.instance.memberFiles.cancel()
radio
}
LogManager.addLog("INFO", "Core", "Waiting for radio audio cleanup")
withContext(Dispatchers.IO) { radioStopped.get() }
withContext(Dispatchers.Main.immediate) {
CallService.requireReleased() // Не останавливать native при неподтверждённой очистке его media-владельца.
@@ -95,6 +128,7 @@ object NativeLib {
"Call media preparation still running"
}
}
LogManager.addLog("INFO", "Core", "Waiting for shared media audio cleanup")
withContext(Dispatchers.IO) { MediaAudioController.awaitIdle() }
}
@@ -162,9 +196,14 @@ object NativeLib {
fun subscribeNativeLogs(enabled: Boolean) { nativeSubscribeLogs(enabled) }
fun setUdpLogTarget(ip: String, port: Int) { nativeSetUdpLogTarget(ip, port) }
/** Принимает полный INI для пересоздания ядра с новой сохранённой идентичностью; true подтверждает приём запроса. */
suspend fun regenerateKeys(configText: String): Boolean {
try { prepareForCoreStop(); return withContext(Dispatchers.IO) { nativeRegenerateKeys(configText) } }
finally { coreStopping = false }
suspend fun regenerateKeys(configText: String): Boolean = lifecycle.withLock {
var accepted = false
try {
prepareForCoreStop()
withContext(Dispatchers.Main.immediate) { com.utun.chat.headless.HeadlessService.onCoreRestarting() }
accepted = withContext(Dispatchers.IO) { nativeRegenerateKeys(configText) }
accepted
} finally { resumeAfterRejectedChange(accepted) }
}
/* ── Channel members ── */
@@ -20,13 +20,14 @@ internal class CoreStartup(
private val log: (String, String) -> Unit,
private val timeoutMillis: Long = 10_000,
) {
private val ready = CompletableDeferred<Unit>()
private var ready = CompletableDeferred<Unit>()
private var job: Job? = null
private var recovery: Job? = null
private var requested = false
private var closed = false
/** prepare инициализирует frontend/сеть и возвращает INI; submit=true подтверждает только создание native-потока. */
fun start(prepare: suspend () -> String, submit: (String) -> Boolean) {
fun start(prepare: suspend () -> String, submit: suspend (String) -> Boolean) {
if (job != null || closed) {
log("WARN", "startup ignored: requested=$requested closed=$closed")
return
@@ -54,6 +55,7 @@ internal class CoreStartup(
/** Успешный await подтверждает готовность ядра и завершённый onReady; ошибка запуска распространяется всем ожидающим. */
suspend fun awaitReady() {
if (!closed && requested && ready.isCompleted && !isReady()) restarting()
ready.await()
check(!closed) { "service startup is closed" }
}
@@ -65,14 +67,35 @@ internal class CoreStartup(
}
try {
onReady()
recovery?.cancel(); recovery = null
if (ready.complete(Unit)) log("INFO", "initial core startup completed")
} catch (e: Exception) { fail(e) }
}
/** Закрыть прежнюю готовность до async restart; тот же deferred ждёт новый/возобновлённый ready. */
fun restarting() {
if (closed || !requested) return
if (ready.isCompleted) ready = CompletableDeferred()
recovery?.cancel()
val pending = ready
recovery = scope.launch {
try { withTimeout(timeoutMillis) { pending.await() } }
catch (e: TimeoutCancellationException) { fail(IllegalStateException("core recovery timeout after ${timeoutMillis}ms", e)) }
catch (e: CancellationException) { throw e }
catch (e: Exception) { if (!closed) fail(e) }
}
log("INFO", "core readiness suspended; waiting for restart/recovery")
}
fun coreStopped() {
if (!closed && requested && ready.isCompleted) restarting()
}
/** Также используется для ошибок init/регистрации сети до start; stopSelf вызывается один раз. */
fun fail(error: Exception) {
if (closed) return
closed = true
recovery?.cancel(); recovery = null
ready.completeExceptionally(error)
log("ERROR", "core startup failed: ${error.javaClass.simpleName}: ${error.message}")
try { onFailure() }
@@ -87,5 +110,6 @@ internal class CoreStartup(
log("INFO", "service startup closed")
}
job?.cancel()
recovery?.cancel(); recovery = null
}
}
@@ -49,6 +49,8 @@ class HeadlessService : Service() {
}
fun onCoreStarted() { currentService?.startup?.coreStarted() }
fun onCoreStopped() { currentService?.startup?.coreStopped() }
fun onCoreRestarting() { currentService?.startup?.restarting() }
fun refreshLanguage() {
currentService?.let { service ->
@@ -82,6 +84,7 @@ class HeadlessService : Service() {
return
}
var startupDirectory = filesDir.absolutePath
startup.start(prepare = {
shutdown.await()
val provider = ChatApplication.instance.configProvider
@@ -89,10 +92,10 @@ class HeadlessService : Service() {
(provider.getString("db.path") ?: filesDir.absolutePath) to provider.getInt("node.listen_port")
}
LogManager.addLog("INFO", "SRV", "uTun service starting, port=$listenPort db=$dbDir")
check(NativeLib.init(dbDir, 9999)) { "native initialization failed" }
startupDirectory = dbDir
check(NetworkChangeMonitor.start()) { "network monitoring unavailable" }
provider.buildConfigText().await()
}, submit = NativeLib::start)
}, submit = { config -> NativeLib.start(startupDirectory, 9999, config) })
}
override fun onDestroy() {