Reproduce LAN invitation state and multicast ownership failures

This commit is contained in:
evgeny committed 2026-10-28 12:28:28 +01:00
1 parent 0b8caaa810
commit 0be56a133b
2 files changed
+248 -1

No files matched your search

@@ -17,7 +17,7 @@ kotlin {
"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")
"com/utun/chat/headless/HeadlessHostEnvironment.kt", "com/utun/chat/data/LocalDiscovery.kt")
}
}
@@ -0,0 +1,247 @@
package com.utun.chat.data
import android.content.Context
import android.os.Looper
import com.utun.chat.ChatApplication
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.atomic.AtomicReference
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
/** Настоящий LAN state holder: бинарные события C, ACK, native-отказы и жизненный цикл MulticastLock. */
class LocalDiscoveryTest {
private lateinit var context: Context
private val device = LocalDevice(-1L, "Телефон 😀")
private val link = "utun://opaque-invitation-secret"
private fun active(value: Boolean) = LocalDiscovery.onEvent(64, byteArrayOf(if (value) 1 else 0))
private fun row(id: Long, name: ByteArray): ByteArray = ByteBuffer.allocate(9 + name.size).order(ByteOrder.BIG_ENDIAN)
.putLong(id).put(name.size.toByte()).put(name).array()
private fun devices(vararg rows: ByteArray): ByteArray =
ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(rows.size.toShort()).array() + rows.fold(byteArrayOf()) { a, b -> a + b }
private fun invite(id: Long = device.nodeId, name: ByteArray = device.name.toByteArray(), text: ByteArray = link.toByteArray()) =
row(id, name) + text
private fun result(request: Long, peer: Long = device.nodeId, status: Int = 1) =
ByteBuffer.allocate(17).order(ByteOrder.BIG_ENDIAN).putLong(request).putLong(peer).put(status.toByte()).array()
private fun diagnostic(fragment: String) = assertTrue("missing diagnostic $fragment: ${LogManager.entries.takeLast(8)}",
LogManager.entries.any { it.level in listOf("WARN", "ERROR") && fragment in it.message })
@Before fun prepare() {
assertSame(Looper.getMainLooper(), Looper.myLooper())
context = Context(); ChatApplication.instance = context
NativeLib.localInvites.clear(); NativeLib.sendLocal = { true }
LocalDiscovery.onEvent(13, null); LocalDiscovery.clearDelivery()
while (LocalDiscovery.invitations.value.isNotEmpty()) LocalDiscovery.dismissInvitation()
LogManager.entries.clear()
}
@After fun release() {
context.failWifiService = false
context.wifiManager?.locks?.forEach { it.failures = emptySet() }
LocalDiscovery.onEvent(13, null); LocalDiscovery.clearDelivery()
context.wifiManager?.locks?.filter { it.held }?.forEach { it.release() }
NativeLib.sendLocal = { true }
}
@Test fun devicesPreserveUnsignedIdsAndUtf8AndSortByLabel() {
LocalDiscovery.onEvent(61, devices(row(-1L, device.name.toByteArray()), row(2, "A".toByteArray())))
assertEquals(listOf(2L, -1L), LocalDiscovery.devices.value.map { it.nodeId })
assertEquals("Телефон 😀 [ffffff]", LocalDiscovery.devices.value.last().label)
}
@Test fun malformedDeviceFramesPreservePreviousSnapshot() {
val valid = devices(row(9, "kept".toByteArray()))
LocalDiscovery.onEvent(61, valid)
val original = LocalDiscovery.devices.value
for (bad in listOf(null, byteArrayOf(), byteArrayOf(0), byteArrayOf(0, -127), valid.dropLast(1).toByteArray(), valid + 0)) {
LocalDiscovery.onEvent(61, bad)
assertEquals(original, LocalDiscovery.devices.value)
}
diagnostic("event")
}
@Test fun invalidNamesIdsAndDuplicateDevicesCannotReplaceGoodState() {
LocalDiscovery.onEvent(61, devices(row(9, "kept".toByteArray())))
val original = LocalDiscovery.devices.value
val invalid = listOf(byteArrayOf(), byteArrayOf(0), byteArrayOf(0x1f), byteArrayOf(0x7f), byteArrayOf(-1),
byteArrayOf(0xc0.toByte(), 0xaf.toByte()), byteArrayOf(0xed.toByte(), 0xa0.toByte(), 0x80.toByte()),
"я".repeat(64).toByteArray())
for (name in invalid) {
LocalDiscovery.onEvent(61, devices(row(1, name)))
assertEquals("name=${name.toList()}", original, LocalDiscovery.devices.value)
}
LocalDiscovery.onEvent(61, devices(row(0, "zero".toByteArray())))
assertEquals(original, LocalDiscovery.devices.value)
LocalDiscovery.onEvent(61, devices(row(1, "one".toByteArray()), row(1, "again".toByteArray())))
assertEquals(original, LocalDiscovery.devices.value)
}
@Test fun receivedInvitationIsQueuedWithoutJoiningAndSecretsAreNotLogged() {
LocalDiscovery.onEvent(62, invite())
assertEquals(LocalInvitation(device, link), LocalDiscovery.invitations.value.single())
assertTrue(NativeLib.localInvites.isEmpty())
assertTrue(LogManager.entries.none { link in it.message })
}
@Test fun invalidInvitationFramesCannotEnterTheQueue() {
val invalid = listOf(byteArrayOf(), row(1, "x".toByteArray()), invite(id = 0), invite(name = byteArrayOf(-1)),
invite(text = "https://example.test".toByteArray()), invite(text = "utun://".toByteArray()),
invite(text = "utun://bad\u0000hidden".toByteArray()), invite(text = "utun://".toByteArray() + byteArrayOf(-1)),
invite(text = ("utun://" + "a".repeat(1100)).toByteArray()))
for (bad in invalid) {
LocalDiscovery.onEvent(62, bad)
assertTrue("size=${bad.size}", LocalDiscovery.invitations.value.isEmpty())
}
}
@Test fun queueIsBoundedAndDismissesOldestFirst() {
repeat(9) { LocalDiscovery.onEvent(62, invite(id = it + 1L)) }
assertEquals((1L..8L).toList(), LocalDiscovery.invitations.value.map { it.device.nodeId })
diagnostic("queue full")
LocalDiscovery.dismissInvitation()
assertEquals(2L, LocalDiscovery.invitations.value.first().device.nodeId)
}
@Test fun matchingDeliveryMapsEveryNativeStatusAndStaysUntilCleared() {
for (status in 0..2) {
LocalDiscovery.send(device, link)
val request = NativeLib.localInvites.last().request
assertNotEquals(0L, request); assertTrue(LocalDiscovery.delivery.value!!.pending)
LocalDiscovery.onEvent(63, result(request, status = status))
val value = LocalDiscovery.delivery.value!!
assertFalse(value.pending); assertEquals(status == 1, value.received); assertEquals(status == 2, value.forbidden)
LocalDiscovery.clearDelivery(); assertNull(LocalDiscovery.delivery.value)
}
}
@Test fun ackMustMatchThePendingPeerAsWellAsTheRequest() {
LocalDiscovery.send(device, link)
val pending = LocalDiscovery.delivery.value
LocalDiscovery.onEvent(63, result(pending!!.request, peer = 22))
assertEquals(pending, LocalDiscovery.delivery.value)
diagnostic("peer")
LocalDiscovery.onEvent(63, result(pending.request))
assertTrue(LocalDiscovery.delivery.value!!.received)
}
@Test fun staleAndMalformedResultsPreservePendingDelivery() {
LocalDiscovery.send(device, link)
val pending = LocalDiscovery.delivery.value!!
for (bad in listOf(result(pending.request + 1), result(pending.request, status = 3), result(pending.request).dropLast(1).toByteArray(),
result(pending.request) + 0)) {
LocalDiscovery.onEvent(63, bad)
assertEquals(pending, LocalDiscovery.delivery.value)
}
LocalDiscovery.clearDelivery(); assertEquals(pending, LocalDiscovery.delivery.value)
}
@Test fun stopClearsDevicesAndCancelsSendingWithoutRevivingOnLateAck() {
LocalDiscovery.onEvent(61, devices(row(1, "one".toByteArray())))
LocalDiscovery.onEvent(62, invite())
LocalDiscovery.send(device, link)
val request = LocalDiscovery.delivery.value!!.request
LocalDiscovery.onEvent(13, null)
assertTrue(LocalDiscovery.devices.value.isEmpty())
assertFalse(LocalDiscovery.delivery.value!!.pending)
assertEquals(1, LocalDiscovery.invitations.value.size)
LocalDiscovery.onEvent(63, result(request))
assertFalse(LocalDiscovery.delivery.value!!.received)
}
@Test fun enqueueRejectionAndExceptionLeaveAVisibleFailureWithoutHanging() {
NativeLib.sendLocal = { false }
LocalDiscovery.send(device, link)
assertFalse(LocalDiscovery.delivery.value!!.pending); diagnostic("enqueue")
NativeLib.sendLocal = { throw IllegalStateException("native unavailable") }
val exception = runCatching { LocalDiscovery.send(device, link) }.exceptionOrNull()
assertNull("send exception escaped: $exception", exception)
assertFalse(LocalDiscovery.delivery.value!!.pending); diagnostic("send failed")
}
@Test fun busySendIsReportedAndDoesNotReplacePendingRequest() {
LocalDiscovery.send(device, link)
val pending = LocalDiscovery.delivery.value
LocalDiscovery.send(LocalDevice(2, "other"), link)
assertEquals(1, NativeLib.localInvites.size); assertEquals(pending, LocalDiscovery.delivery.value)
diagnostic("pending")
assertTrue(LogManager.entries.none { link in it.message })
}
@Test fun repeatedActivityUsesOneNonCountedLockAndReleasesOnce() {
active(true); active(true)
val lock = context.wifiManager!!.locks.single()
assertTrue(lock.held); assertFalse(lock.counted); assertEquals(1, lock.acquisitions)
active(false); LocalDiscovery.onEvent(13, null)
assertFalse(lock.held); assertEquals(1, lock.releases)
}
@Test fun acquireAfterEffectFailureStillHasAnOwnerForStop() {
context.wifiManager!!.failures = setOf("acquire_after")
active(true)
val lock = context.wifiManager!!.locks.single()
assertTrue(lock.held); diagnostic("acquire")
LocalDiscovery.onEvent(13, null)
assertFalse("acquired lock lost after exception", lock.held)
}
@Test fun failedConfigureOrAcquireRetriesTheSameLock() {
for (phase in listOf("configure", "acquire_before")) {
context.wifiManager!!.failures = setOf(phase)
val before = context.wifiManager!!.locks.size
active(true)
val lock = context.wifiManager!!.locks.last()
lock.failures = emptySet(); context.wifiManager!!.failures = emptySet()
active(true)
assertEquals("phase=$phase", before + 1, context.wifiManager!!.locks.size)
assertTrue(lock.held); assertFalse(lock.counted)
active(false)
}
}
@Test fun queryAndReleaseFailuresKeepTheLockForAnotherStopAttempt() {
for (phase in listOf("query", "release_before", "release_after")) {
active(true)
val lock = context.wifiManager!!.locks.last()
lock.failures = setOf(phase)
LocalDiscovery.onEvent(13, null)
diagnostic(if (phase == "query") "query" else "release")
lock.failures = emptySet()
LocalDiscovery.onEvent(13, null)
assertFalse(lock.held)
}
}
@Test fun absentWifiServiceAndCreateFailureAreLoggedAndCanRetry() {
val wifi = context.wifiManager!!
context.wifiManager = null; active(true); diagnostic("Wi-Fi service")
context.wifiManager = wifi; wifi.failures = setOf("create"); active(true); diagnostic("create")
wifi.failures = emptySet(); active(true); assertTrue(wifi.locks.single().held)
}
@Test fun backgroundMutatorsAreRejectedBeforeChangingState() {
LocalDiscovery.onEvent(61, devices(row(1, "kept".toByteArray())))
LocalDiscovery.onEvent(62, invite()); LocalDiscovery.send(device, link)
val snapshot = LocalDiscovery.devices.value
val pending = LocalDiscovery.delivery.value
val actions = listOf<() -> Unit>({ LocalDiscovery.send(device, link) }, LocalDiscovery::clearDelivery,
LocalDiscovery::dismissInvitation, { LocalDiscovery.onEvent(61, devices()) })
for (action in actions) {
val result = AtomicReference<Throwable?>()
val thread = Thread { result.set(runCatching(action).exceptionOrNull()) }.apply { start() }
thread.join(2000); assertFalse(thread.isAlive)
assertTrue(result.get() is IllegalStateException)
assertEquals(snapshot, LocalDiscovery.devices.value); assertEquals(pending, LocalDiscovery.delivery.value)
assertEquals(1, LocalDiscovery.invitations.value.size)
}
diagnostic("main thread")
}
@Test fun malformedActivityAndUnknownEventsDoNotReleaseTheLock() {
active(true)
for (bad in listOf(null, byteArrayOf(), byteArrayOf(2), byteArrayOf(0, 1))) LocalDiscovery.onEvent(64, bad)
assertTrue(context.wifiManager!!.locks.single().held)
LocalDiscovery.onEvent(999, byteArrayOf(0)); diagnostic("999")
}
}