diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MemberFilesRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MemberFilesRepository.kt index 0ec4d21f..eef084e3 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MemberFilesRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/MemberFilesRepository.kt @@ -40,7 +40,7 @@ class FileStatusException(val status: Int) : IOException("member_files status=$s * URI копируется на Dispatchers.IO в стабильный upload-source. При отмене transfer ждёт terminal * native-событие (закрытый handle) до удаления своего source/destination; остановка core завершает ожидания. * Работа удерживает wake lock и CoreActivity.transfer, ожидание решения о замене снимает оба удержания. - * Ошибки cleanup логируются независимо; неосвобождённый wake lock сохраняется для следующей попытки. + * Ошибки cleanup логируются независимо; неосвобождённый wake lock сохраняется до следующей операции/остановки core. * Скачанная копия остаётся в cacheDir/member_files для внешнего приложения/повторного SAVE. */ class MemberFilesRepository(context: Context) { @@ -86,7 +86,7 @@ class MemberFilesRepository(context: Context) { private fun protect(active: Boolean) { if (active) { val lock = wakeLock ?: (app.getSystemService(Context.POWER_SERVICE) as PowerManager) - .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "utun:member_files").also { wakeLock = it } + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "utun:member_files").also { wakeLock = it; it.setReferenceCounted(false) } if (!lock.isHeld) { lock.acquire() log("DEBUG", "wake lock acquired token=${_operation.value?.token}") @@ -337,6 +337,10 @@ class MemberFilesRepository(context: Context) { val error = CancellationException("core stopped") pending.values.toList().forEach { it.result.completeExceptionally(error) } transfers.values.toList().forEach { it.result.completeExceptionally(error) } + if (task?.isCompleted != false && wakeLock != null) { + protect(false) + log("DEBUG", "core stop cleanup wakePending=${wakeLock != null}") + } CoreActivity.reset() } fun dismiss() { if (_operation.value?.active == false) _operation.value = null; HeadlessService.refreshFiles() } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/MemberFilesViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/MemberFilesViewModel.kt index 5937f6c3..5815dc8a 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/MemberFilesViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/MemberFilesViewModel.kt @@ -92,7 +92,8 @@ class MemberFilesViewModel(private val saved: SavedStateHandle) : ViewModel() { membersRepository.refresh(group) } - /** Открыть явный адрес, например из уведомления передачи, и обновить участников; server самовольно не заменяется. */ + /** Открыть явный адрес, например из уведомления передачи, и обновить участников. + * Ненулевой server сохраняется; при server=0 выбирается первый известный хранитель. */ fun openLocation(location: FileLocation) { navigate(location) val group = location.group.toULong().toString() @@ -207,7 +208,7 @@ class MemberFilesViewModel(private val saved: SavedStateHandle) : ViewModel() { } /** MKDIR использует name, RENAME — name и entry, REMOVE — entry; работает только в собственном дереве вне avatars. - * Конкурирующая мутация/активная передача отклоняет запуск. При успехе перечитывает текущий каталог; + * Конкурирующая мутация/активная операция репозитория отклоняет запуск. При успехе перечитывает текущий каталог; * ошибка задаёт status и логируется, CancellationException передаётся дальше. Отмена не откатывает серверный commit. */ fun mutate(command: Int, name: String = "", entry: MemberFilesProtocol.Entry? = null) { val location = _state.value.location ?: return diff --git a/tools/chatgui-android/router-tests/src/main/kotlin/android/os/HostPower.kt b/tools/chatgui-android/router-tests/src/main/kotlin/android/os/HostPower.kt index ec6ecf04..f03c07bc 100644 --- a/tools/chatgui-android/router-tests/src/main/kotlin/android/os/HostPower.kt +++ b/tools/chatgui-android/router-tests/src/main/kotlin/android/os/HostPower.kt @@ -13,10 +13,13 @@ class PowerManager { return WakeLock(flags, tag, failures).also { locks.add(it) } } class WakeLock(val flags: Int, val tag: String, var failures: Set) { + var counted = true + private set var timeout = 0L var releaseCalls = 0 var held = false val isHeld: Boolean get() { check("query" !in failures) { "wake lock query unavailable" }; return held } + fun setReferenceCounted(value: Boolean) { counted = value } fun acquire() = acquire(0) fun acquire(timeout: Long) { this.timeout = timeout diff --git a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/MemberFilesFailureTest.kt b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/MemberFilesFailureTest.kt index 800931f6..16dad55f 100644 --- a/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/MemberFilesFailureTest.kt +++ b/tools/chatgui-android/router-tests/src/test/kotlin/com/utun/chat/data/MemberFilesFailureTest.kt @@ -147,6 +147,7 @@ class MemberFilesFailureTest { repository.save(export(), uri) pumpUntil(::finished) val lock = context.powerManager!!.locks.single() + assertFalse("one repository owner must allow repeated release without reference-count underflow", lock.counted) assertTrue(lock.held) assertEquals(listOf(true, false), NativeLib.activityRequests) diagnostic("wake lock release failed") @@ -157,6 +158,17 @@ class MemberFilesFailureTest { assertFalse(lock.held) } + @Test fun coreStopRetriesWakeReleaseForAnAlreadyFinishedOperation() = runTest(dispatcher) { + context.powerManager!!.failures = setOf("release") + repository.save(export(), uri) + pumpUntil(::finished) + val lock = context.powerManager!!.locks.single() + assertTrue(lock.held) + lock.failures = emptySet() + repository.coreStopped() + assertFalse("core stop left a completed operation's lock held", lock.held) + } + @Test fun standbyFailureDuringCleanupIsLoggedWithoutLosingCompletedExport() = runTest(dispatcher) { context.contentResolver.output = { NativeLib.failActivityRequest = true; java.io.ByteArrayOutputStream() } repository.save(export(), uri)