diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/CoreStartup.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/CoreStartup.kt index 043976e3..41cbb434 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/CoreStartup.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/CoreStartup.kt @@ -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, 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 diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt index 76124f9d..98c547ec 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt @@ -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") diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/headless/CoreStartupTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/headless/CoreStartupTest.kt index f407b99c..645a42e3 100644 --- a/tools/chatgui-android/app/src/test/java/com/utun/chat/headless/CoreStartupTest.kt +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/headless/CoreStartupTest.kt @@ -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