Reproduce file cleanup failures and verify native cancellation
This commit is contained in:
1 parent
f60390c99c
commit
4f06b4bca9
3 files changed
+170
No files matched your search
@@ -16,6 +16,8 @@ kotlin {
|
||||
"com/utun/chat/data/ProximityManager.kt", "com/utun/chat/data/PhoneStateMonitor.kt",
|
||||
"com/utun/chat/data/NetworkActivityMonitor.kt", "com/utun/chat/data/NetworkChangeMonitor.kt",
|
||||
"com/utun/chat/data/RingtonePlayer.kt", "com/utun/chat/data/CallRingback.kt", "com/utun/chat/data/NotificationSound.kt")
|
||||
kotlin.include("com/utun/chat/data/MemberFilesRepository.kt", "com/utun/chat/data/MemberFilesProtocol.kt",
|
||||
"com/utun/chat/headless/HeadlessHostEnvironment.kt")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.utun.chat.headless
|
||||
|
||||
/** Host-наблюдение обновлений уведомления: никаких Android services или native workers. */
|
||||
object HeadlessService {
|
||||
var fileRefreshes = 0
|
||||
fun refreshFiles() { fileRefreshes++ }
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Looper
|
||||
import com.utun.chat.R
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.nio.file.Files
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.test.*
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/** Настоящий repository; только URI, wake lock и JNI задают управляемые отказы/завершения. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MemberFilesFailureTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private lateinit var context: Context
|
||||
private lateinit var root: File
|
||||
private lateinit var repository: MemberFilesRepository
|
||||
private val location = FileLocation(7, 8, 1, "docs")
|
||||
private val uri = Uri.parse("content://documents/output")
|
||||
|
||||
@Before fun prepare() {
|
||||
assertSame(Looper.getMainLooper(), Looper.myLooper())
|
||||
Dispatchers.setMain(dispatcher)
|
||||
root = Files.createTempDirectory("utun-member-files-host-").toFile()
|
||||
context = Context().also { it.cacheDir = root }
|
||||
NativeLib.ready = false
|
||||
CoreActivity.visible(false); CoreActivity.call(false); CoreActivity.radioCall(false); CoreActivity.transfer(false); CoreActivity.reset()
|
||||
NativeLib.ready = true; NativeLib.failActivityRequest = false; NativeLib.acceptActivityRequest = true
|
||||
NativeLib.activityRequests.clear(); NativeLib.fileTransfers.clear(); NativeLib.fileCancels.clear()
|
||||
NativeLib.fileRequest = { _, _, _ -> true }; NativeLib.acceptFileTransfer = true
|
||||
LogManager.entries.clear()
|
||||
repository = MemberFilesRepository(context)
|
||||
}
|
||||
|
||||
@After fun release() {
|
||||
NativeLib.failActivityRequest = false
|
||||
context.powerManager!!.locks.forEach { it.failures = emptySet() }
|
||||
repository.coreStopped()
|
||||
dispatcher.scheduler.runCurrent()
|
||||
context.powerManager!!.locks.filter { it.held }.forEach { it.release() }
|
||||
CoreActivity.transfer(false)
|
||||
Dispatchers.resetMain()
|
||||
root.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun pumpUntil(done: () -> Boolean) {
|
||||
repeat(1000) {
|
||||
dispatcher.scheduler.runCurrent()
|
||||
if (done()) return
|
||||
Thread.sleep(2)
|
||||
}
|
||||
fail("repository did not finish: ${repository.operation.value}")
|
||||
}
|
||||
private fun finished() = repository.operation.value?.active == false
|
||||
private fun diagnostic(text: String) = assertTrue("missing diagnostic: $text",
|
||||
LogManager.entries.any { it.level in listOf("WARN", "ERROR") && text in it.message })
|
||||
private fun export(): FileExport {
|
||||
val file = File(root, "ready.bin").also { it.writeBytes(byteArrayOf(1, 2, 3)) }
|
||||
return FileExport(91, location, file.name, file, MemberFilesRepository.SAVE)
|
||||
}
|
||||
private fun terminal(transfer: NativeLib.FileTransfer, status: Int) = ByteBuffer.allocate(51).order(ByteOrder.LITTLE_ENDIAN).apply {
|
||||
putLong(transfer.id); putLong(transfer.group); putLong(transfer.server); putLong(transfer.owner)
|
||||
putLong(0); putLong(0); putShort(status.toShort()); put(2)
|
||||
}.array()
|
||||
|
||||
@Test fun rejectedRequestAndTimeoutAreExplicitAndCancelNativeWaiter() = runTest(dispatcher) {
|
||||
NativeLib.fileRequest = { _, _, _ -> false }
|
||||
assertTrue(runCatching { repository.request(location, MemberFilesProtocol.LIST) }.exceptionOrNull() is IOException)
|
||||
assertEquals(1, NativeLib.fileCancels.size)
|
||||
NativeLib.fileRequest = { _, _, _ -> true }
|
||||
val result = async { runCatching { repository.request(location, MemberFilesProtocol.LIST) } }
|
||||
runCurrent(); advanceTimeBy(60_000); runCurrent()
|
||||
assertEquals(13, (result.await().exceptionOrNull() as FileStatusException).status)
|
||||
assertEquals(2, NativeLib.fileCancels.size)
|
||||
diagnostic("request timed out")
|
||||
}
|
||||
|
||||
@Test fun rejectedTransferRemovesOwnedDestinationAndReleasesActivity() = runTest(dispatcher) {
|
||||
NativeLib.acceptFileTransfer = false
|
||||
repository.download(location, MemberFilesProtocol.Entry("remote.bin", 1, 3, 0, ByteArray(16) { 1 }), 0)
|
||||
pumpUntil(::finished)
|
||||
assertEquals(9, repository.operation.value?.status)
|
||||
assertFalse(File(NativeLib.fileTransfers.single().local).parentFile!!.exists())
|
||||
assertFalse(context.powerManager!!.locks.single().held)
|
||||
assertEquals(listOf(true, false), NativeLib.activityRequests)
|
||||
diagnostic("transfer enqueue failed")
|
||||
}
|
||||
|
||||
@Test fun cancellationRetainsDestinationUntilNativeClosedItsHandle() = runTest(dispatcher) {
|
||||
repository.download(location, MemberFilesProtocol.Entry("remote.bin", 1, 3, 0, ByteArray(16) { 1 }), 0)
|
||||
pumpUntil { NativeLib.fileTransfers.isNotEmpty() }
|
||||
val transfer = NativeLib.fileTransfers.single()
|
||||
val directory = File(transfer.local).parentFile!!
|
||||
repository.cancel(); runCurrent()
|
||||
assertEquals(listOf(transfer.id), NativeLib.fileCancels)
|
||||
assertTrue(directory.exists()); assertTrue(repository.operation.value!!.active)
|
||||
repository.onEvent(50, terminal(transfer, MemberFilesProtocol.CANCELLED))
|
||||
pumpUntil(::finished)
|
||||
assertFalse(directory.exists()); assertTrue(repository.operation.value!!.cancelled)
|
||||
assertFalse(context.powerManager!!.locks.single().held)
|
||||
}
|
||||
|
||||
@Test fun failedDocumentWriteDeletesPartialProviderDocumentAndKeepsSource() = runTest(dispatcher) {
|
||||
val prepared = export()
|
||||
var deleted: Uri? = null
|
||||
context.contentResolver.output = { throw IOException("provider write rejected") }
|
||||
context.contentResolver.delete = { deleted = it; true }
|
||||
repository.save(prepared, uri)
|
||||
pumpUntil(::finished)
|
||||
assertEquals(uri, deleted); assertTrue(prepared.file.isFile)
|
||||
assertEquals(9, repository.operation.value?.status)
|
||||
assertFalse(repository.operation.value!!.retryable)
|
||||
assertFalse(context.powerManager!!.locks.single().held)
|
||||
assertEquals(listOf(true, false), NativeLib.activityRequests)
|
||||
diagnostic("provider write rejected")
|
||||
}
|
||||
|
||||
@Test fun acquireFailureAfterSideEffectStillReleasesOwnedLock() = runTest(dispatcher) {
|
||||
context.powerManager!!.failures = setOf("acquire")
|
||||
repository.save(export(), uri)
|
||||
pumpUntil(::finished)
|
||||
assertEquals(9, repository.operation.value?.status)
|
||||
assertFalse("failed acquire lost an already-held lock", context.powerManager!!.locks.single().held)
|
||||
diagnostic("wake lock acquire unavailable")
|
||||
}
|
||||
|
||||
@Test fun releaseFailureDoesNotSkipStandbyCleanupAndNextOperationRetriesLock() = runTest(dispatcher) {
|
||||
context.powerManager!!.failures = setOf("release")
|
||||
repository.save(export(), uri)
|
||||
pumpUntil(::finished)
|
||||
val lock = context.powerManager!!.locks.single()
|
||||
assertTrue(lock.held)
|
||||
assertEquals(listOf(true, false), NativeLib.activityRequests)
|
||||
diagnostic("wake lock release failed")
|
||||
lock.failures = emptySet()
|
||||
repository.save(export(), uri)
|
||||
pumpUntil(::finished)
|
||||
assertEquals(1, context.powerManager!!.locks.size)
|
||||
assertFalse(lock.held)
|
||||
}
|
||||
|
||||
@Test fun standbyFailureDuringCleanupIsLoggedWithoutLosingCompletedExport() = runTest(dispatcher) {
|
||||
context.contentResolver.output = { NativeLib.failActivityRequest = true; java.io.ByteArrayOutputStream() }
|
||||
repository.save(export(), uri)
|
||||
pumpUntil(::finished)
|
||||
assertEquals(R.string.files_completed, repository.operation.value?.stage)
|
||||
assertFalse(context.powerManager!!.locks.single().held)
|
||||
diagnostic("activity release failed")
|
||||
NativeLib.failActivityRequest = false
|
||||
CoreActivity.sync()
|
||||
assertEquals(listOf(true, false), NativeLib.activityRequests)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user