From 69165753807d89bfbc7a6e7784ef4da978c90e82 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 10:02:13 +0100 Subject: [PATCH] Contain headless foreground, notification and teardown failures --- .../com/utun/chat/headless/CoreStartup.kt | 3 +- .../com/utun/chat/headless/HeadlessService.kt | 42 +++++++++++++------ .../com/utun/chat/headless/CoreStartupTest.kt | 9 ++++ 3 files changed, 41 insertions(+), 13 deletions(-) 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 41cbb4344..5110b9110 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 @@ -75,7 +75,8 @@ internal class CoreStartup( closed = true ready.completeExceptionally(error) log("ERROR", "core startup failed: ${error.javaClass.simpleName}: ${error.message}") - onFailure() + try { onFailure() } + catch (e: Exception) { log("ERROR", "startup failure cleanup callback failed: ${e.javaClass.simpleName}: ${e.message}") } } /** Сначала закрывает готовность; поздний callback и готовый конфиг уже не могут завершить запуск. */ 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 3b560f00e..33b0b089b 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 @@ -25,6 +25,7 @@ import kotlinx.coroutines.withContext /** Владеет запуском ядра/сетевых наблюдателей; готовность подтверждается событием native SERVICE_STARTED. */ class HeadlessService : Service() { + private var foregroundReady = false private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val startup = CoreStartup(startupScope, NativeLib::isReady, onReady = { LogManager.addLog("INFO", "SRV", "uTun core started") @@ -51,13 +52,18 @@ class HeadlessService : Service() { fun refreshLanguage() { currentService?.let { service -> - service.createNotificationChannel() - service.getSystemService(NotificationManager::class.java).notify(1, service.notification()) + try { + if (!service.foregroundReady) return + service.createNotificationChannel() + service.getSystemService(NotificationManager::class.java).notify(1, service.notification()) + } catch (e: Exception) { LogManager.addLog("ERROR", "SRV", "Service notification language update failed: $e") } } } fun refreshFiles() { currentService?.let { service -> - service.getSystemService(NotificationManager::class.java).notify(1, service.notification()) + try { + if (service.foregroundReady) service.getSystemService(NotificationManager::class.java).notify(1, service.notification()) + } catch (e: Exception) { LogManager.addLog("ERROR", "SRV", "Service file notification update failed: $e") } } } } @@ -65,8 +71,16 @@ class HeadlessService : Service() { override fun onCreate() { super.onCreate() currentService = this - createNotificationChannel() - startForeground(1, notification()) + try { + createNotificationChannel() + startForeground(1, notification()) + foregroundReady = true + LogManager.addLog("INFO", "SRV", "Core service foreground confirmed") + } catch (e: Exception) { + LogManager.addLog("ERROR", "SRV", "Core service foreground failed: $e") + startup.fail(e) + return + } startup.start(prepare = { shutdown.await() @@ -82,19 +96,23 @@ class HeadlessService : Service() { } override fun onDestroy() { - startup.stop() - startupScope.cancel() + foregroundReady = false if (currentService === this) currentService = null LogManager.addLog("INFO", "SRV", "uTun service stopping") - NetworkChangeMonitor.stop() - NetworkActivityMonitor.stop() - shutdown.request() - super.onDestroy() + try { startup.stop() } catch (e: Exception) { LogManager.addLog("ERROR", "SRV", "Startup cancellation failed: $e") } + try { startupScope.cancel() } catch (e: Exception) { LogManager.addLog("ERROR", "SRV", "Startup scope cancellation failed: $e") } + try { NetworkChangeMonitor.stop() } catch (e: Exception) { LogManager.addLog("ERROR", "SRV", "Network observer stop failed: $e") } + try { NetworkActivityMonitor.stop() } catch (e: Exception) { LogManager.addLog("ERROR", "SRV", "Network activity stop failed: $e") } + try { shutdown.request() } finally { super.onDestroy() } } override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (!foregroundReady) { + LogManager.addLog("ERROR", "SRV", "Service command rejected before foreground confirmation startId=$startId") + return START_NOT_STICKY + } if (intent?.action == "com.utun.chat.CANCEL_FILES") { ChatApplication.instance.memberFiles.cancel() return START_STICKY @@ -124,7 +142,7 @@ class HeadlessService : Service() { val detail = operation.status?.let { AppLanguage.text(fileStatusResource(it)) } ?: AppLanguage.text(operation.stage) builder.setContentText("${operation.name}: $detail") if (operation.active) { - val percent = if (operation.total == 0L) 0 else (operation.done.toDouble() * 100 / operation.total).toInt() + val percent = if (operation.total <= 0L) 0 else (operation.done.toDouble() * 100 / operation.total).toInt().coerceIn(0, 100) builder.setProgress(100, percent, operation.total == 0L) val cancel = PendingIntent.getService(this, 21, Intent(this, HeadlessService::class.java) .setAction("com.utun.chat.CANCEL_FILES"), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) 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 645a42e38..4c185916f 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 @@ -189,4 +189,13 @@ class CoreStartupTest { config.complete("config") assertEquals(1, waiter().await()) } + @Test fun failingStopCallbackCannotMaskTheStartupFailureOrEscapeToMain() = runBlocking { + val logs = mutableListOf() + val owner = CoreStartup(scope, { false }, {}, { error("stopSelf rejected") }, { _, message -> logs.add(message) }) + val failure = IllegalStateException("foreground denied") + owner.fail(failure) + val result = runCatching { owner.awaitReady() }.exceptionOrNull() + assertEquals("foreground denied", result?.message) + assertTrue(logs.any { "stopSelf rejected" in it }) + } }