Wait for Android core readiness before applying settings

This commit is contained in:
evgeny committed 2026-10-27 18:27:14 +01:00
1 parent 8e0090b54b
commit 11b3ceaa9b
3 files changed
+31 -62

No files matched your search

@@ -3,7 +3,6 @@ package com.utun.chat.headless
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
@@ -26,15 +25,15 @@ internal class CoreStartup(
private var requested = false
private var closed = false
/** Принимает уже поставленную в очередь подготовку; submit=true подтверждает только создание native-потока. */
fun start(config: Deferred<String>, submit: (String) -> Boolean) {
/** prepare инициализирует frontend/сеть и возвращает INI; submit=true подтверждает только создание native-потока. */
fun start(prepare: suspend () -> String, submit: (String) -> Boolean) {
if (job != null || closed) {
log("WARN", "startup ignored: requested=$requested closed=$closed")
return
}
job = scope.launch {
try {
val text = config.await()
val text = prepare()
val alreadyRunning = isReady()
requested = true
check(submit(text)) { "native start rejected" }
@@ -70,10 +69,6 @@ internal class CoreStartup(
} catch (e: Exception) { fail(e) }
}
fun coreStopped() {
if (!closed && requested && !ready.isCompleted) fail(IllegalStateException("core stopped before initial readiness"))
}
/** Также используется для ошибок init/регистрации сети до start; stopSelf вызывается один раз. */
fun fail(error: Exception) {
if (closed) return
@@ -17,30 +17,33 @@ import com.utun.chat.data.NativeLib
import com.utun.chat.data.NetworkActivityMonitor
import com.utun.chat.data.NetworkChangeMonitor
import com.utun.chat.data.fileStatusResource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** Владеет запуском ядра/сетевых наблюдателей; готовность подтверждается событием native SERVICE_STARTED. */
class HeadlessService : Service() {
private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var startupJob: Job? = null
private val startup = CoreStartup(startupScope, NativeLib::isReady, onReady = {
LogManager.addLog("INFO", "SRV", "uTun core started")
NetworkActivityMonitor.start()
NetworkChangeMonitor.onCoreStarted()
}, onFailure = ::stopSelf, log = { level, message -> LogManager.addLog(level, "SRV", message) })
companion object {
private var currentService: HeadlessService? = null
/** Main thread: изменения из Settings применяются после принятия первоначального запуска Service. */
suspend fun awaitStartup() { currentService?.startupJob?.join() }
fun onCoreStarted() {
if (currentService == null || !NativeLib.isReady()) return
LogManager.addLog("INFO", "SRV", "uTun core started")
NetworkActivityMonitor.start()
NetworkChangeMonitor.onCoreStarted()
/** Main thread: дождаться готового ядра текущего Service; ошибка/остановка прерывает вызывающую операцию.
* Без Service ожидания нет: настройки сохраняются для следующего запуска. */
suspend fun awaitStartup() {
val service = currentService ?: return
service.startup.awaitReady()
check(currentService === service && NativeLib.isReady()) { "service core stopped while awaiting startup" }
}
fun onCoreStarted() { currentService?.startup?.coreStarted() }
fun refreshLanguage() {
currentService?.let { service ->
service.createNotificationChannel()
@@ -60,41 +63,20 @@ class HeadlessService : Service() {
createNotificationChannel()
startForeground(1, notification())
val provider = ChatApplication.instance.configProvider
val dbDir = provider.getString("db.path") ?: filesDir.absolutePath
val listenPort = provider.getInt("node.listen_port")
LogManager.addLog("INFO", "SRV", "uTun service starting, port=$listenPort db=$dbDir")
if (!NativeLib.init(dbDir, 9999)) {
LogManager.addLog("ERROR", "SRV", "uTun startup rejected: native initialization failed")
stopSelf()
return
}
if (!NetworkChangeMonitor.start()) {
LogManager.addLog("ERROR", "SRV", "uTun startup rejected: network monitoring unavailable")
stopSelf()
return
}
val config = provider.buildConfigText()
startupJob = startupScope.launch {
try {
// await отменяется при onDestroy: готовый конфиг не запустит ядро для уже остановленного Service.
if (NativeLib.start(config.await())) LogManager.addLog("INFO", "SRV", "uTun startup requested")
else {
LogManager.addLog("ERROR", "SRV", "uTun start failed")
stopSelf()
}
} catch (e: CancellationException) { throw e
} catch (e: Exception) {
LogManager.addLog("ERROR", "SRV", "uTun startup failed: ${e.javaClass.simpleName}: ${e.message}")
stopSelf()
startup.start(prepare = {
val provider = ChatApplication.instance.configProvider
val (dbDir, listenPort) = withContext(Dispatchers.IO) {
(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" }
check(NetworkChangeMonitor.start()) { "network monitoring unavailable" }
provider.buildConfigText().await()
}, submit = NativeLib::start)
}
override fun onDestroy() {
startup.stop()
startupScope.cancel()
if (currentService === this) currentService = null
LogManager.addLog("INFO", "SRV", "uTun service stopping")
@@ -32,7 +32,7 @@ class CoreStartupTest {
@After fun release() { startup.stop(); scope.cancel() }
private fun start() = startup.start(config) { submitted.add(it); accepted }
private fun start() = startup.start({ config.await() }) { submitted.add(it); accepted }
private fun waiter() = scope.async(start = CoroutineStart.UNDISPATCHED) { startup.awaitReady(); readyCalls }
@Test fun acceptedNativeStartDoesNotReleaseSettingsBeforeReady() = runBlocking {
@@ -129,7 +129,7 @@ class CoreStartupTest {
@Test fun missingNativeReadySignalFailsWithBoundedTimeout() = runBlocking {
val timed = coordinator(timeout = 20)
timed.start(CompletableDeferred("config")) { true }
timed.start({ "config" }) { true }
val error = withTimeout(2_000) { runCatching { timed.awaitReady() }.exceptionOrNull() }
assertTrue(error is IllegalStateException)
assertTrue(error!!.message!!.contains("readiness timeout"))
@@ -137,18 +137,10 @@ class CoreStartupTest {
assertEquals(1, failures)
}
@Test fun nativeStopBeforeReadyFailsInitialStartup() = runBlocking {
start(); config.complete("config")
startup.coreStopped()
assertTrue(runCatching { startup.awaitReady() }.exceptionOrNull() is IllegalStateException)
assertEquals(1, failures)
}
@Test fun readyServiceContinuesResynchronizingLaterCoreInstances() = runBlocking {
nativeReady = true
start(); config.complete("config")
startup.awaitReady()
startup.coreStopped()
startup.coreStarted()
assertEquals(2, readyCalls)
assertEquals(0, failures)
@@ -189,7 +181,7 @@ class CoreStartupTest {
}
@Test fun synchronousReadySignalDuringNativeSubmissionIsRetained() = runBlocking {
startup.start(config) {
startup.start({ config.await() }) {
nativeReady = true
startup.coreStarted()
true