diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/CoreShutdown.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/CoreShutdown.kt index 6dd6e183..c6c2daa0 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/CoreShutdown.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/CoreShutdown.kt @@ -4,14 +4,16 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.async +import kotlinx.coroutines.delay /** Один незавершённый stop на процесс. Scope принадлежит приложению, переживает onDestroy Service. * Методы вызываются на main thread. Новый Service ждёт await перед init; отмена этого ожидания не отменяет stop. - * Ошибка сохраняется для await, повторный request после завершения позволяет явно повторить очистку. */ + * Ошибка не подтверждает stop: та же задача повторяет очистку с backoff 250 мс..30 с до успеха. */ internal class CoreShutdown( private val scope: CoroutineScope, private val stop: suspend () -> Unit, private val log: (String, String) -> Unit, + private val retryWait: suspend (Long) -> Unit = { delay(it) }, ) { private var pending: Deferred? = null @@ -20,19 +22,24 @@ internal class CoreShutdown( pending?.takeUnless { it.isCompleted }?.let { return it } return scope.async { log("INFO", "core shutdown started") - try { - stop() - log("INFO", "core shutdown completed") - } catch (e: CancellationException) { - log("ERROR", "core shutdown cancelled before completion") - throw e - } catch (e: Exception) { - log("ERROR", "core shutdown failed: ${e.javaClass.simpleName}: ${e.message}") - throw e + var retryMs = 250L + while (true) { + try { + stop() + log("INFO", "core shutdown completed") + break + } catch (e: CancellationException) { + log("ERROR", "core shutdown cancelled before completion") + throw e + } catch (e: Exception) { + log("ERROR", "core shutdown failed; retry in ${retryMs}ms: ${e.javaClass.simpleName}: ${e.message}") + } + retryWait(retryMs) + retryMs = (retryMs * 2).coerceAtMost(30_000) } }.also { pending = it } } - /** Нет незавершённой остановки — сразу вернуть управление; ошибка предыдущей очистки запрещает новый init. */ + /** Нет остановки — вернуть управление; иначе новый init ждёт успешную очистку, включая все повторы. */ suspend fun await() { pending?.await() } } diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/headless/CoreShutdownTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/headless/CoreShutdownTest.kt index bbe113d2..bc27e92b 100644 --- a/tools/chatgui-android/app/src/test/java/com/utun/chat/headless/CoreShutdownTest.kt +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/headless/CoreShutdownTest.kt @@ -57,13 +57,35 @@ class CoreShutdownTest { @Test fun failedStopBlocksInitializationUntilSuccessfulRetry() = runBlocking { var attempts = 0 + val retryGate = CompletableDeferred() + val delays = mutableListOf() val retry = CoreShutdown(scope, { check(++attempts > 1) { "join failed" } }, - { level, message -> logs.add(level to message) }) - retry.request() - assertTrue(runCatching { retry.await() }.exceptionOrNull() is IllegalStateException) + { level, message -> logs.add(level to message) }, { delays.add(it); retryGate.await() }) + val operation = retry.request() + val replacement = async(start = CoroutineStart.UNDISPATCHED) { retry.await(); "init" } + assertFalse(operation.isCompleted); assertFalse(replacement.isCompleted) + assertSame(operation, retry.request()); assertEquals(1, attempts) assertTrue(logs.any { it.first == "ERROR" && it.second.contains("join failed") }) - retry.request().await() + retryGate.complete(Unit) + operation.await(); assertEquals("init", replacement.await()) retry.await() - assertEquals(2, attempts) + assertEquals(2, attempts); assertEquals(listOf(250L), delays) + } + + @Test fun cancelledSettingsWaiterCannotInterruptAutomaticShutdownRetry() = runBlocking { + val gate = CompletableDeferred(); var attempts = 0 + val retry = CoreShutdown(scope, { check(++attempts > 1) { "release pending" } }, { _, _ -> }, { gate.await() }) + val operation = retry.request() + val settings = async(start = CoroutineStart.UNDISPATCHED) { retry.await() } + settings.cancel(); assertFalse(operation.isCancelled); assertEquals(1, attempts) + gate.complete(Unit); operation.await(); assertEquals(2, attempts) + } + + @Test fun repeatedFailuresUseBoundedBackoffWithoutRequiringAnotherService() = runBlocking { + val delays = mutableListOf(); var attempts = 0 + val retry = CoreShutdown(scope, { check(++attempts > 12) { "release pending" } }, { _, _ -> }, { delays.add(it) }) + retry.request().await() + assertEquals(13, attempts); assertEquals(250L, delays.first()); assertEquals(30_000L, delays.last()) + assertTrue(delays.all { it in 250L..30_000L }) } }