From 7b37427d74c5cbeae0cf55f2084ac0b2f69d2d32 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:28:25 -0500 Subject: [PATCH 1/3] fix(database): make packet state updates transactional Move packet-related read-modify-write operations into Room DAO transactions so each logical state change commits as one unit. This covers contact last-read advancement, packet and reaction status updates, SFPP hash-prefix matching, and chunked packet deletion with its dependent reactions. Keeping these operations inside the DAO prevents concurrent callers from observing an intermediate read and writing back stale state. It also ensures a late failure cannot leave only half of a packet/reaction update committed. The repository continues to enter these operations through DatabaseProvider.withDb, preserving the cross-device writer-admission contract established for database association. Add deterministic regression coverage for the behavioral invariants, including monotonic concurrent last-read updates, strict SFPP prefix matching, trigger- injected mid-operation failures, packet/reaction rollback, and rollback across the SQLite bind-limit chunk boundary. --- .../data/repository/PacketRepositoryImpl.kt | 157 +-- .../meshtastic/core/database/dao/PacketDao.kt | 166 +++- .../dao/PacketDaoAtomicTransactionTest.kt | 928 ++++++++++++++++++ 3 files changed, 1101 insertions(+), 150 deletions(-) create mode 100644 core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketDaoAtomicTransactionTest.kt diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt index 0f42fef0aa..621c5b0ff5 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt @@ -31,7 +31,6 @@ import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single import org.meshtastic.core.database.DatabaseProvider import org.meshtastic.core.database.dao.NodeInfoDao -import org.meshtastic.core.database.dao.PacketDao import org.meshtastic.core.database.entity.PacketEntity import org.meshtastic.core.database.entity.toReaction import org.meshtastic.core.di.CoroutineDispatchers @@ -107,21 +106,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val override suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) { withContext(dispatchers.io + NonCancellable) { - // Read-modify-write in one withDb block so both statements land on the same DB instance. - dbManager.withDb { db -> - val dao = db.packetDao() - val current = dao.getContactSettings(contact) - val existingTimestamp = current?.lastReadMessageTimestamp ?: Long.MIN_VALUE - if (lastReadTimestamp <= existingTimestamp) { - return@withDb - } - val updated = - (current ?: ContactSettingsEntity(contact_key = contact)).copy( - lastReadMessageUuid = messageUuid, - lastReadMessageTimestamp = lastReadTimestamp, - ) - dao.upsertContactSettings(listOf(updated)) - } + dbManager.withDb { it.packetDao().updateLastReadMessage(contact, messageUuid, lastReadTimestamp) } } } @@ -310,39 +295,15 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val insertRoomPacket(packetToSave) } - override suspend fun update(packet: DataPacket, routingError: Int): Unit = withContext(dispatchers.io) { - // Read-modify-write in one withDb block so the lookup and update land on the same DB instance. - dbManager.withDb { db -> - val dao = db.packetDao() - // Match on key fields that identify the packet, rather than the entire data object - dao.findPacketsWithId(packet.id) - .find { it.data.id == packet.id && it.data.from == packet.from && it.data.to == packet.to } - ?.let { existing -> - val updated = - if (routingError >= 0) { - existing.copy(data = packet, routingError = routingError) - } else { - existing.copy(data = packet) - } - dao.update(updated) - } - } - Unit - } + override suspend fun update(packet: DataPacket, routingError: Int): Unit = + withContext(dispatchers.io) { dbManager.withDb { it.packetDao().updatePacketByKey(packet, routingError) } } override suspend fun insertReaction(reaction: Reaction, myNodeNum: Int) { withContext(dispatchers.io) { dbManager.withDb { it.packetDao().insert(reaction.toEntity(myNodeNum)) } } } override suspend fun updateReaction(reaction: Reaction) { - withContext(dispatchers.io) { - dbManager.withDb { db -> - val dao = db.packetDao() - dao.findReactionsWithId(reaction.packetId) - .find { it.userId == reaction.user.id && it.emoji == reaction.emoji } - ?.let { dao.update(reaction.toEntity(it.myNodeNum)) } - } - } + withContext(dispatchers.io) { dbManager.withDb { it.packetDao().updateReactionByKey(reaction.toEntity(0)) } } } override suspend fun getReactionByPacketId(packetId: Int): Reaction? = withContext(dispatchers.io) { @@ -366,120 +327,20 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val rxTime: Long, myNodeNum: Int?, ) = withContext(dispatchers.io) { - // One withDb block: the id lookups and the status updates all land on the same DB instance. - dbManager.withDb { db -> - val dao = db.packetDao() - val packets = dao.findPacketsWithId(packetId) - val reactions = dao.findReactionsWithId(packetId) - updateSFPPMatches(dao, packets, reactions, from, to, hash, status, rxTime, myNodeNum) + dbManager.withDb { + it.packetDao().applySFPPStatus(packetId, from, to, hash.toByteString(), status, rxTime, myNodeNum) } Unit } - @Suppress("CyclomaticComplexMethod", "LongParameterList") - private suspend fun updateSFPPMatches( - dao: PacketDao, - packets: List, - reactions: List, - from: Int, - to: Int, - hash: ByteArray, - status: MessageStatus, - rxTime: Long, - myNodeNum: Int?, - ) { - val fromId = NodeAddress.numToDefaultId(from) - val isFromLocalNode = myNodeNum != null && from == myNodeNum - val toId = - if (to == 0 || to == NodeAddress.NODENUM_BROADCAST) { - NodeAddress.ID_BROADCAST - } else { - NodeAddress.numToDefaultId(to) - } - - val hashByteString = hash.toByteString() - - packets.forEach { packet -> - // For sent messages, from is stored as ID_LOCAL, but SFPP packet has node number - val fromMatches = - packet.data.from == fromId || (isFromLocalNode && packet.data.from == NodeAddress.ID_LOCAL) - co.touchlab.kermit.Logger.d { - "SFPP match check: packetFrom=${packet.data.from} fromId=$fromId " + - "isFromLocal=$isFromLocalNode fromMatches=$fromMatches " + - "packetTo=${packet.data.to} toId=$toId toMatches=${packet.data.to == toId}" - } - if (fromMatches && packet.data.to == toId) { - // If it's already confirmed, don't downgrade it to routing - if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { - return@forEach - } - val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time - val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime) - dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime)) - } - } - - reactions.forEach { reaction -> - val reactionFrom = reaction.userId - // For sent reactions, from is stored as ID_LOCAL, but SFPP packet has node number - val fromMatches = reactionFrom == fromId || (isFromLocalNode && reactionFrom == NodeAddress.ID_LOCAL) - - val toMatches = reaction.to == toId - - co.touchlab.kermit.Logger.d { - "SFPP reaction match check: reactionFrom=$reactionFrom fromId=$fromId " + - "isFromLocal=$isFromLocalNode fromMatches=$fromMatches " + - "reactionTo=${reaction.to} toId=$toId toMatches=$toMatches" - } - - if (fromMatches && (reaction.to == null || toMatches)) { - if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { - return@forEach - } - val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp - val updatedReaction = reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime) - dao.update(updatedReaction) - } - } - } - override suspend fun updateSFPPStatusByHash(hash: ByteArray, status: MessageStatus, rxTime: Long): Unit = withContext(dispatchers.io) { - // Read-modify-write in one withDb block so the hash lookups and updates land on the same DB instance. - dbManager.withDb { db -> - val dao = db.packetDao() - val hashByteString = hash.toByteString() - dao.findPacketBySfppHash(hashByteString)?.let { packet -> - // If it's already confirmed, don't downgrade it - if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { - return@let - } - val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time - val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime) - dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime)) - } - - dao.findReactionBySfppHash(hashByteString)?.let { reaction -> - if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { - return@let - } - val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp - val updatedReaction = - reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime) - dao.update(updatedReaction) - } - } + dbManager.withDb { it.packetDao().applySFPPStatusByHash(hash.toByteString(), status, rxTime) } Unit } override suspend fun deleteMessages(uuidList: List) = withContext(dispatchers.io) { - // One withDb block: every chunk lands on the same DB instance (previously each chunk re-fetched the DAO to - // survive a mid-op switch; the drain barrier now covers the whole chunked delete instead). - dbManager.withDb { db -> - for (chunk in uuidList.chunked(DELETE_CHUNK_SIZE)) { - db.packetDao().deleteMessages(chunk) - } - } + dbManager.withDb { it.packetDao().deleteMessagesAtomic(uuidList) } Unit } @@ -628,7 +489,5 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val companion object { private const val CONTACTS_PAGE_SIZE = 30 private const val MESSAGES_PAGE_SIZE = 50 - private const val DELETE_CHUNK_SIZE = 500 - private const val MILLISECONDS_IN_SECOND = 1000L } } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index 801f20b86f..3f5d86d3cc 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt @@ -28,15 +28,17 @@ import androidx.room3.Upsert import kotlinx.coroutines.flow.Flow import okio.ByteString import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.database.DatabaseConstants.SQLITE_MAX_BIND_PARAMETERS import org.meshtastic.core.database.entity.ContactSettings import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.entity.PacketEntity import org.meshtastic.core.database.entity.ReactionEntity import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.NodeAddress import org.meshtastic.proto.ChannelSettings -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") @Dao interface PacketDao { @@ -575,6 +577,168 @@ interface PacketDao { @Query("UPDATE packet SET show_translated = :show WHERE uuid = :uuid") suspend fun setShowTranslated(uuid: Long, show: Boolean) + // region ── Atomic read-modify-write transactions ── + + /** + * Atomically updates the last-read message pointer for [contact], preserving the monotonic-timestamp guard: if + * [lastReadTimestamp] is not newer than the stored value, the method is a no-op. Creates the contact-settings row + * if absent. Uses INSERT OR IGNORE + conditional UPDATE instead of a read-then-@Upsert so the existing row's + * unrelated columns (muteUntil, filteringDisabled) are preserved without a Kotlin-side read-modify-write. + */ + @Transaction + suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) { + insertContactSettingsIgnore(listOf(ContactSettings(contact_key = contact))) + updateLastReadMessageIfNewer(contact, messageUuid, lastReadTimestamp) + } + + /** + * Conditional UPDATE that only writes newer timestamps. Callers must ensure the contact-settings row exists (e.g. + * via [insertContactSettingsIgnore]) before calling this inside the same transaction. + * + * Returns the affected row count (1 = updated, 0 = no-op) for testability; callers outside tests typically ignore + * it. + */ + @Query( + """ + UPDATE contact_settings + SET last_read_message_uuid = :messageUuid, + last_read_message_timestamp = :lastReadTimestamp + WHERE contact_key = :contact + AND ( + last_read_message_timestamp IS NULL + OR last_read_message_timestamp < :lastReadTimestamp + ) + """, + ) + suspend fun updateLastReadMessageIfNewer(contact: String, messageUuid: Long, lastReadTimestamp: Long): Int + + /** + * Atomically finds a packet by identity key (id + from + to) and updates its data, optionally stamping + * [routingError] when it is non-negative. Mirrors the existing [updateMessageStatus] / [updateMessageId] pattern. + */ + @Transaction + suspend fun updatePacketByKey(data: DataPacket, routingError: Int) { + findPacketsWithId(data.id) + .find { it.data.id == data.id && it.data.from == data.from && it.data.to == data.to } + ?.let { existing -> + val updated = + if (routingError >= 0) { + existing.copy(data = data, routingError = routingError) + } else { + existing.copy(data = data) + } + update(updated) + } + } + + /** + * Atomically finds a reaction by [replacement]'s packetId + userId + emoji and updates it, borrowing + * [myNodeNum][ReactionEntity.myNodeNum] from the existing row. No-op if no match is found. + */ + @Transaction + suspend fun updateReactionByKey(replacement: ReactionEntity) { + val existing = + findReactionsWithId(replacement.packetId).find { + it.userId == replacement.userId && it.emoji == replacement.emoji + } ?: return + // myNodeNum is part of the composite PK and must come from the existing row. + update(replacement.copy(myNodeNum = existing.myNodeNum)) + } + + /** + * Atomically applies an SFPP delivery-status transition to every packet and reaction matching [packetId] + address + * ([from]/[to]). Preserves the no-downgrade invariant: an item already + * [SFPP_CONFIRMED][MessageStatus.SFPP_CONFIRMED] is not regressed to [SFPP_ROUTING][MessageStatus.SFPP_ROUTING]. + * All updates land in one transaction so a packet and its reactions cannot end up in inconsistent delivery states. + */ + @Suppress("CyclomaticComplexMethod", "LongParameterList") + @Transaction + suspend fun applySFPPStatus( + packetId: Int, + from: Int, + to: Int, + hash: ByteString, + status: MessageStatus, + rxTime: Long, + myNodeNum: Int?, + ) { + val packets = findPacketsWithId(packetId) + val reactions = findReactionsWithId(packetId) + val fromId = NodeAddress.numToDefaultId(from) + val isFromLocalNode = myNodeNum != null && from == myNodeNum + val toId = + if (to == 0 || to == NodeAddress.NODENUM_BROADCAST) { + NodeAddress.ID_BROADCAST + } else { + NodeAddress.numToDefaultId(to) + } + + packets.forEach { packet -> + val fromMatches = + packet.data.from == fromId || (isFromLocalNode && packet.data.from == NodeAddress.ID_LOCAL) + if (fromMatches && packet.data.to == toId) { + if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { + return@forEach + } + val newTime = if (rxTime > 0) rxTime * MILLIS_PER_SECOND else packet.received_time + val updatedData = packet.data.copy(status = status, sfppHash = hash, time = newTime) + update(packet.copy(data = updatedData, sfpp_hash = hash, received_time = newTime)) + } + } + + reactions.forEach { reaction -> + val fromMatches = reaction.userId == fromId || (isFromLocalNode && reaction.userId == NodeAddress.ID_LOCAL) + if (fromMatches && (reaction.to == null || reaction.to == toId)) { + if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { + return@forEach + } + val newTime = if (rxTime > 0) rxTime * MILLIS_PER_SECOND else reaction.timestamp + update(reaction.copy(status = status, sfpp_hash = hash, timestamp = newTime)) + } + } + } + + /** + * Atomically applies an SFPP delivery-status transition to the single packet and single reaction matching [hash] + * (8-byte prefix). Same no-downgrade invariant as [applySFPPStatus]. Both updates land in one transaction. + */ + @Transaction + suspend fun applySFPPStatusByHash(hash: ByteString, status: MessageStatus, rxTime: Long) { + findPacketBySfppHash(hash)?.let { packet -> + if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { + return@let + } + val newTime = if (rxTime > 0) rxTime * MILLIS_PER_SECOND else packet.received_time + val updatedData = packet.data.copy(status = status, sfppHash = hash, time = newTime) + update(packet.copy(data = updatedData, sfpp_hash = hash, received_time = newTime)) + } + findReactionBySfppHash(hash)?.let { reaction -> + if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { + return@let + } + val newTime = if (rxTime > 0) rxTime * MILLIS_PER_SECOND else reaction.timestamp + update(reaction.copy(status = status, sfpp_hash = hash, timestamp = newTime)) + } + } + + /** + * Atomically deletes all messages (and their reactions) for the given [uuidList], chunking internally to stay under + * SQLite's bind-parameter limit. The entire batch is all-or-nothing: a failure rolls back every chunk. + */ + @Transaction + suspend fun deleteMessagesAtomic(uuidList: List) { + if (uuidList.isEmpty()) return + for (chunk in uuidList.chunked(SQLITE_MAX_BIND_PARAMETERS)) { + deleteMessages(chunk) + } + } + + // endregion + + companion object { + private const val MILLIS_PER_SECOND = 1000L + } + // region ── FTS5 Search ── @Query( diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketDaoAtomicTransactionTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketDaoAtomicTransactionTest.kt new file mode 100644 index 0000000000..fdce3d0f13 --- /dev/null +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketDaoAtomicTransactionTest.kt @@ -0,0 +1,928 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database.dao + +import androidx.room3.executeSQL +import androidx.room3.useWriterConnection +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.database.DatabaseConstants.SQLITE_MAX_BIND_PARAMETERS +import org.meshtastic.core.database.MeshtasticDatabase +import org.meshtastic.core.database.entity.ContactSettings +import org.meshtastic.core.database.entity.MyNodeEntity +import org.meshtastic.core.database.entity.Packet +import org.meshtastic.core.database.entity.ReactionEntity +import org.meshtastic.core.database.getInMemoryDatabaseBuilder +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.NodeAddress +import org.meshtastic.proto.PortNum +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Verifies the read-modify-write helpers in [PacketDao]. Targeted failure injection proves rollback for insert/update, + * cross-table status, and multi-chunk delete transactions; a barrier-started contention case verifies monotonic + * convergence, while the remaining cases cover identity-key matching and no-downgrade rules. + */ +class PacketDaoAtomicTransactionTest { + private lateinit var database: MeshtasticDatabase + private lateinit var packetDao: PacketDao + + private val myNodeNum = 42424242 + + private val sfppHash: ByteString = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8).toByteString() + private val otherHash: ByteString = byteArrayOf(9, 10, 11, 12, 13, 14, 15, 16).toByteString() + + private val myNodeEntity = + MyNodeEntity( + myNodeNum = myNodeNum, + model = null, + firmwareVersion = null, + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 1L, + messageTimeoutMsec = 5 * 60 * 1000, + minAppVersion = 1, + maxChannels = 8, + hasWifi = false, + ) + + @BeforeTest + fun setUp() { + database = getInMemoryDatabaseBuilder().build() + packetDao = database.packetDao() + } + + /** Seeds the MyNodeInfo row required by packet queries. Must be called inside each test's runTest block. */ + private suspend fun seedMyNodeInfo() { + database.nodeInfoDao().setMyNodeInfo(myNodeEntity) + } + + @AfterTest + fun tearDown() { + database.close() + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private fun textPacket( + contact: String, + text: String, + time: Long, + packetId: Int = 0, + from: String? = NodeAddress.ID_LOCAL, + to: String? = NodeAddress.ID_BROADCAST, + status: MessageStatus = MessageStatus.UNKNOWN, + routingError: Int = -1, + sfppHash: ByteString? = null, + ) = Packet( + uuid = 0L, + myNodeNum = myNodeNum, + port_num = PortNum.TEXT_MESSAGE_APP.value, + contact_key = contact, + received_time = time, + read = true, + data = + DataPacket( + to = to, + bytes = text.encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, + from = from, + id = packetId, + status = status, + sfppHash = sfppHash, + ), + packetId = packetId, + routingError = routingError, + sfpp_hash = sfppHash, + messageText = text, + ) + + private fun reaction( + replyId: Int, + userId: String, + emoji: String, + timestamp: Long, + packetId: Int = replyId, + to: String? = null, + status: MessageStatus = MessageStatus.UNKNOWN, + sfppHash: ByteString? = null, + ) = ReactionEntity( + myNodeNum = myNodeNum, + replyId = replyId, + userId = userId, + emoji = emoji, + timestamp = timestamp, + packetId = packetId, + to = to, + status = status, + sfpp_hash = sfppHash, + ) + + /** Places [first] in a full bind-param chunk and [second] in the next chunk without inserting filler rows. */ + private fun crossChunkUuids(first: Long, second: Long): List = buildList { + add(first) + repeat(SQLITE_MAX_BIND_PARAMETERS - 1) { add(-(it + 1L)) } + add(second) + } + + // ── updateLastReadMessage ──────────────────────────────────────────────────── + + @Test + fun updateLastReadMessageCreatesSettingsWhenAbsent() = runTest { + seedMyNodeInfo() + val contact = "0${NodeAddress.ID_BROADCAST}" + packetDao.updateLastReadMessage(contact, messageUuid = 42L, lastReadTimestamp = 1000L) + + val settings = packetDao.getContactSettings(contact) + assertNotNull(settings) + assertEquals(42L, settings!!.lastReadMessageUuid) + assertEquals(1000L, settings.lastReadMessageTimestamp) + } + + @Test + fun updateLastReadMessageRollsBackInsertedSettingsWhenUpdateFails() = runTest { + seedMyNodeInfo() + val contact = "rollback-contact" + database.useWriterConnection { + it.executeSQL( + """ + CREATE TRIGGER fail_last_read_update + BEFORE UPDATE ON contact_settings + WHEN OLD.contact_key = '$contact' + BEGIN + SELECT RAISE(ABORT, 'forced last-read update failure'); + END + """ + .trimIndent(), + ) + } + + assertFails { packetDao.updateLastReadMessage(contact, messageUuid = 42L, lastReadTimestamp = 1000L) } + + assertNull(packetDao.getContactSettings(contact), "the preceding insert must roll back with the failed update") + } + + @Test + fun updateLastReadMessageAcceptsNewerTimestamp() = runTest { + seedMyNodeInfo() + val contact = "0${NodeAddress.ID_BROADCAST}" + packetDao.updateLastReadMessage(contact, messageUuid = 42L, lastReadTimestamp = 2000L) + packetDao.updateLastReadMessage(contact, messageUuid = 99L, lastReadTimestamp = 3000L) + + val settings = packetDao.getContactSettings(contact) + assertNotNull(settings) + assertEquals(99L, settings!!.lastReadMessageUuid) + assertEquals(3000L, settings.lastReadMessageTimestamp) + } + + @Test + fun updateLastReadMessageRejectsOlderTimestamp() = runTest { + seedMyNodeInfo() + val contact = "0${NodeAddress.ID_BROADCAST}" + packetDao.updateLastReadMessage(contact, messageUuid = 42L, lastReadTimestamp = 2000L) + packetDao.updateLastReadMessage(contact, messageUuid = 99L, lastReadTimestamp = 1000L) + + val settings = packetDao.getContactSettings(contact) + assertNotNull(settings) + assertEquals(42L, settings!!.lastReadMessageUuid) + assertEquals(2000L, settings.lastReadMessageTimestamp) + } + + @Test + fun concurrentLastReadUpdatesConvergeOnNewestTimestamp() = runTest { + seedMyNodeInfo() + val contact = "concurrent-last-read" + val start = CompletableDeferred() + val updates = + listOf( + async(Dispatchers.Default, start = CoroutineStart.UNDISPATCHED) { + start.await() + packetDao.updateLastReadMessage(contact, messageUuid = 42L, lastReadTimestamp = 2000L) + }, + async(Dispatchers.Default, start = CoroutineStart.UNDISPATCHED) { + start.await() + packetDao.updateLastReadMessage(contact, messageUuid = 99L, lastReadTimestamp = 3000L) + }, + ) + + start.complete(Unit) + updates.awaitAll() + + val settings = assertNotNull(packetDao.getContactSettings(contact)) + assertEquals(99L, settings.lastReadMessageUuid) + assertEquals(3000L, settings.lastReadMessageTimestamp) + } + + /** + * Verifies that [PacketDao.updateLastReadMessage] preserves unrelated contact-settings columns (muteUntil, + * filteringDisabled) that were set before the last-read update. The INSERT-IGNORE + conditional-UPDATE pattern must + * never overwrite these fields with defaults. + */ + @Test + fun updateLastReadMessagePreservesUnrelatedFields() = runTest { + seedMyNodeInfo() + val contact = "0${NodeAddress.ID_BROADCAST}" + + // Seed with non-default muteUntil and filteringDisabled + packetDao.insertContactSettingsIgnore( + listOf(ContactSettings(contact_key = contact, muteUntil = 999_999_999L, filteringDisabled = true)), + ) + + // Perform the last-read update (newer timestamp) + packetDao.updateLastReadMessage(contact, messageUuid = 42L, lastReadTimestamp = 3000L) + + val settings = packetDao.getContactSettings(contact) + assertNotNull(settings) + assertEquals(42L, settings!!.lastReadMessageUuid, "lastReadMessageUuid should be updated") + assertEquals(3000L, settings.lastReadMessageTimestamp, "lastReadMessageTimestamp should be updated") + assertEquals(999_999_999L, settings.muteUntil, "muteUntil must be preserved") + assertTrue(settings.filteringDisabled, "filteringDisabled must be preserved") + } + + /** Verifies that updating contact A's last-read position does not alter contact B. */ + @Test + fun updateLastReadMessageIsContactScoped() = runTest { + seedMyNodeInfo() + val contactA = "0${NodeAddress.ID_BROADCAST}" + val contactB = "0!abcdef01" + + packetDao.updateLastReadMessage(contactA, messageUuid = 10L, lastReadTimestamp = 1000L) + packetDao.updateLastReadMessage(contactB, messageUuid = 20L, lastReadTimestamp = 2000L) + packetDao.updateLastReadMessage(contactA, messageUuid = 30L, lastReadTimestamp = 3000L) + + val settingsA = packetDao.getContactSettings(contactA) + assertNotNull(settingsA) + assertEquals(30L, settingsA!!.lastReadMessageUuid, "contact A updated to 30") + assertEquals(3000L, settingsA.lastReadMessageTimestamp) + + val settingsB = packetDao.getContactSettings(contactB) + assertNotNull(settingsB) + assertEquals(20L, settingsB!!.lastReadMessageUuid, "contact B unchanged at 20") + assertEquals(2000L, settingsB.lastReadMessageTimestamp) + } + + @Test + fun updateLastReadMessageEqualTimestampIsNoOp() = runTest { + seedMyNodeInfo() + val contact = "0${NodeAddress.ID_BROADCAST}" + packetDao.updateLastReadMessage(contact, messageUuid = 42L, lastReadTimestamp = 2000L) + packetDao.updateLastReadMessage(contact, messageUuid = 99L, lastReadTimestamp = 2000L) + + val settings = packetDao.getContactSettings(contact) + assertNotNull(settings) + assertEquals(42L, settings!!.lastReadMessageUuid) + assertEquals(2000L, settings.lastReadMessageTimestamp) + } + + // ── updatePacketByKey ──────────────────────────────────────────────────────── + + @Test + fun updatePacketByKeyUpdatesOnlyMatchingIdentity() = runTest { + seedMyNodeInfo() + val matching = textPacket("contact", "match", time = 100, packetId = 50, from = "!aa", to = "^all") + val sameIdDiffFrom = textPacket("contact", "other", time = 200, packetId = 50, from = "!bb", to = "^all") + val sameIdFromDiffTo = textPacket("contact", "other-to", time = 300, packetId = 50, from = "!aa", to = "!cc") + packetDao.insert(matching) + packetDao.insert(sameIdDiffFrom) + packetDao.insert(sameIdFromDiffTo) + + val newData = matching.data.copy(status = MessageStatus.DELIVERED) + packetDao.updatePacketByKey(newData, routingError = -1) + + val packets = packetDao.findPacketsWithId(50) + val updated = packets.find { it.data.from == "!aa" && it.data.to == "^all" } + val untouchedFrom = packets.find { it.data.from == "!bb" } + val untouchedTo = packets.find { it.data.from == "!aa" && it.data.to == "!cc" } + assertEquals(MessageStatus.DELIVERED, updated?.data?.status) + assertEquals(MessageStatus.UNKNOWN, untouchedFrom?.data?.status) + assertEquals(MessageStatus.UNKNOWN, untouchedTo?.data?.status) + } + + @Test + fun updatePacketByKeyAppliesRoutingErrorWhenNonNegative() = runTest { + seedMyNodeInfo() + val packet = textPacket("contact", "msg", time = 100, packetId = 60, from = "!aa", to = "^all") + packetDao.insert(packet) + + packetDao.updatePacketByKey(packet.data, routingError = 7) + + val updated = packetDao.findPacketsWithId(60).first() + assertEquals(7, updated.routingError) + } + + @Test + fun updatePacketByKeyLeavesRoutingErrorUnchangedWhenMinusOne() = runTest { + seedMyNodeInfo() + val packet = + textPacket("contact", "msg", time = 100, packetId = 70, from = "!aa", to = "^all", routingError = 3) + packetDao.insert(packet) + + val newData = packet.data.copy(status = MessageStatus.DELIVERED) + packetDao.updatePacketByKey(newData, routingError = -1) + + val updated = packetDao.findPacketsWithId(70).first() + assertEquals(3, updated.routingError, "routingError should be preserved, not reset to -1") + assertEquals(MessageStatus.DELIVERED, updated.data.status) + } + + // ── updateReactionByKey ────────────────────────────────────────────────────── + + @Test + fun updateReactionByKeyUpdatesMatchingReaction() = runTest { + seedMyNodeInfo() + packetDao.insert(textPacket("contact", "msg", time = 100, packetId = 80)) + packetDao.insert(reaction(replyId = 80, userId = "!u", emoji = "👍", timestamp = 1, packetId = 80)) + + val replacement = + reaction( + replyId = 80, + userId = "!u", + emoji = "👍", + timestamp = 999, + packetId = 80, + status = MessageStatus.SFPP_CONFIRMED, + sfppHash = sfppHash, + ) + .copy(myNodeNum = 0) // placeholder — DAO must borrow from existing + packetDao.updateReactionByKey(replacement) + + val updated = packetDao.findReactionsWithId(80).find { it.userId == "!u" && it.emoji == "👍" } + assertNotNull(updated) + assertEquals(999, updated!!.timestamp) + assertEquals(MessageStatus.SFPP_CONFIRMED, updated.status) + assertEquals(sfppHash, updated.sfpp_hash) + } + + @Test + fun updateReactionByKeyPreservesExistingMyNodeNum() = runTest { + seedMyNodeInfo() + packetDao.insert(textPacket("contact", "msg", time = 100, packetId = 81)) + packetDao.insert(reaction(replyId = 81, userId = "!u", emoji = "❤️", timestamp = 1, packetId = 81)) + + val replacement = + ReactionEntity( + myNodeNum = 0, // placeholder — DAO must overwrite with existing + replyId = 81, + userId = "!u", + emoji = "❤️", + timestamp = 555, + packetId = 81, + ) + packetDao.updateReactionByKey(replacement) + + val updated = packetDao.findReactionsWithId(81).find { it.userId == "!u" && it.emoji == "❤️" } + assertNotNull(updated) + assertEquals(myNodeNum, updated!!.myNodeNum, "myNodeNum must be borrowed from existing row, not 0") + assertEquals(555, updated.timestamp) + } + + @Test + fun updateReactionByKeyLeavesNonMatchingEmojiUntouched() = runTest { + seedMyNodeInfo() + packetDao.insert(textPacket("contact", "msg", time = 100, packetId = 82)) + packetDao.insert(reaction(replyId = 82, userId = "!u", emoji = "👍", timestamp = 1, packetId = 82)) + + val replacement = + ReactionEntity( + myNodeNum = myNodeNum, + replyId = 82, + userId = "!u", + emoji = "❤️", // different emoji — no match + timestamp = 999, + packetId = 82, + ) + packetDao.updateReactionByKey(replacement) + + val reactions = packetDao.findReactionsWithId(82) + assertEquals(1, reactions.size) + assertEquals("👍", reactions.first().emoji) + assertEquals(1, reactions.first().timestamp, "original reaction untouched") + } + + @Test + fun updateReactionByKeyIsNoOpWhenNoMatch() = runTest { + seedMyNodeInfo() + packetDao.insert(reaction(replyId = 83, userId = "!u", emoji = "👍", timestamp = 1, packetId = 83)) + + val replacement = + ReactionEntity( + myNodeNum = myNodeNum, + replyId = 83, + userId = "!other", // different user — no match + emoji = "👍", + timestamp = 999, + packetId = 83, + ) + packetDao.updateReactionByKey(replacement) + + val reactions = packetDao.findReactionsWithId(83) + assertEquals(1, reactions.size) + assertEquals(1, reactions.first().timestamp, "no reaction updated") + } + + // ── applySFPPStatus ────────────────────────────────────────────────────────── + + @Test + fun applySFPPStatusUpdatesMatchingPacketAndReactionInOneTransaction() = runTest { + seedMyNodeInfo() + val packetId = 100 + packetDao.insert( + textPacket( + "contact", + "msg", + time = 1000, + packetId = packetId, + from = NodeAddress.ID_LOCAL, + to = NodeAddress.ID_BROADCAST, + ), + ) + packetDao.insert( + reaction( + replyId = packetId, + userId = NodeAddress.ID_LOCAL, + emoji = "👍", + timestamp = 1, + packetId = packetId, + to = NodeAddress.ID_BROADCAST, + ), + ) + + packetDao.applySFPPStatus( + packetId = packetId, + from = myNodeNum, + to = 0, + hash = sfppHash, + status = MessageStatus.SFPP_ROUTING, + rxTime = 5L, + myNodeNum = myNodeNum, + ) + + val packet = packetDao.findPacketsWithId(packetId).first() + assertEquals(MessageStatus.SFPP_ROUTING, packet.data.status) + assertEquals(sfppHash, packet.data.sfppHash) + assertEquals(sfppHash, packet.sfpp_hash) + assertEquals(5_000L, packet.data.time, "rxTime should be converted to millis") + assertEquals(5_000L, packet.received_time) + + val reaction = packetDao.findReactionsWithId(packetId).first() + assertEquals(MessageStatus.SFPP_ROUTING, reaction.status) + assertEquals(sfppHash, reaction.sfpp_hash) + } + + @Test + fun applySFPPStatusRollsBackPacketWhenReactionUpdateFails() = runTest { + seedMyNodeInfo() + val packetId = 101 + packetDao.insert( + textPacket( + "rollback-status", + "msg", + time = 1000, + packetId = packetId, + from = NodeAddress.ID_LOCAL, + to = NodeAddress.ID_BROADCAST, + ), + ) + packetDao.insert( + reaction( + replyId = packetId, + userId = NodeAddress.ID_LOCAL, + emoji = "👍", + timestamp = 1, + packetId = packetId, + to = NodeAddress.ID_BROADCAST, + ), + ) + database.useWriterConnection { + it.executeSQL( + """ + CREATE TRIGGER fail_reaction_status_update + BEFORE UPDATE ON reactions + WHEN OLD.packet_id = $packetId + BEGIN + SELECT RAISE(ABORT, 'forced reaction update failure'); + END + """ + .trimIndent(), + ) + } + + assertFails { + packetDao.applySFPPStatus( + packetId = packetId, + from = myNodeNum, + to = 0, + hash = sfppHash, + status = MessageStatus.SFPP_ROUTING, + rxTime = 5L, + myNodeNum = myNodeNum, + ) + } + + val packet = packetDao.findPacketsWithId(packetId).single() + assertEquals(MessageStatus.UNKNOWN, packet.data.status) + assertNull(packet.data.sfppHash) + assertNull(packet.sfpp_hash) + assertEquals(1000L, packet.received_time) + val reaction = packetDao.findReactionsWithId(packetId).single() + assertEquals(MessageStatus.UNKNOWN, reaction.status) + assertNull(reaction.sfpp_hash) + assertEquals(1L, reaction.timestamp) + } + + @Test + fun applySFPPStatusMatchesLocalNodeWithIdLocal() = runTest { + seedMyNodeInfo() + val packetId = 110 + // Packet stored with from = "^local" — local node sends with ID_LOCAL. + packetDao.insert( + textPacket( + "contact", + "msg", + time = 1000, + packetId = packetId, + from = NodeAddress.ID_LOCAL, + to = NodeAddress.ID_BROADCAST, + ), + ) + + // from = myNodeNum triggers isFromLocalNode = true, which matches data.from == ID_LOCAL. + packetDao.applySFPPStatus( + packetId = packetId, + from = myNodeNum, + to = 0, + hash = sfppHash, + status = MessageStatus.SFPP_ROUTING, + rxTime = 0L, + myNodeNum = myNodeNum, + ) + + val packet = packetDao.findPacketsWithId(packetId).first() + assertEquals(MessageStatus.SFPP_ROUTING, packet.data.status) + } + + @Test + fun applySFPPStatusBroadcastDestinationMatchesIdBroadcast() = runTest { + seedMyNodeInfo() + val packetId = 120 + // to = 0 (broadcast) should match data.to == "^all" (ID_BROADCAST). + packetDao.insert( + textPacket( + "contact", + "msg", + time = 1000, + packetId = packetId, + from = NodeAddress.ID_LOCAL, + to = NodeAddress.ID_BROADCAST, + ), + ) + + packetDao.applySFPPStatus( + packetId = packetId, + from = myNodeNum, + to = 0, + hash = sfppHash, + status = MessageStatus.SFPP_ROUTING, + rxTime = 0L, + myNodeNum = myNodeNum, + ) + + val packet = packetDao.findPacketsWithId(packetId).first() + assertEquals(MessageStatus.SFPP_ROUTING, packet.data.status) + } + + @Test + fun applySFPPStatusMatchesNumericUnicastAddresses() = runTest { + seedMyNodeInfo() + val packetId = 125 + packetDao.insert( + textPacket("contact", "msg", time = 1000, packetId = packetId, from = "!0000007b", to = "!000001c8"), + ) + + packetDao.applySFPPStatus( + packetId = packetId, + from = 123, + to = 456, + hash = sfppHash, + status = MessageStatus.SFPP_ROUTING, + rxTime = 0L, + myNodeNum = myNodeNum, + ) + + val packet = packetDao.findPacketsWithId(packetId).single() + assertEquals(MessageStatus.SFPP_ROUTING, packet.data.status) + assertEquals(sfppHash, packet.sfpp_hash) + } + + @Test + fun applySFPPStatusDoesNotDowngradeConfirmedToRouting() = runTest { + seedMyNodeInfo() + val packetId = 130 + packetDao.insert( + textPacket( + "contact", + "msg", + time = 1000, + packetId = packetId, + from = NodeAddress.ID_LOCAL, + to = NodeAddress.ID_BROADCAST, + status = MessageStatus.SFPP_CONFIRMED, + ), + ) + packetDao.insert( + reaction( + replyId = packetId, + userId = NodeAddress.ID_LOCAL, + emoji = "👍", + timestamp = 1, + packetId = packetId, + to = NodeAddress.ID_BROADCAST, + status = MessageStatus.SFPP_CONFIRMED, + ), + ) + + packetDao.applySFPPStatus( + packetId = packetId, + from = myNodeNum, + to = 0, + hash = sfppHash, + status = MessageStatus.SFPP_ROUTING, + rxTime = 5L, + myNodeNum = myNodeNum, + ) + + val packet = packetDao.findPacketsWithId(packetId).first() + assertEquals(MessageStatus.SFPP_CONFIRMED, packet.data.status, "packet must not be downgraded") + + val reaction = packetDao.findReactionsWithId(packetId).first() + assertEquals(MessageStatus.SFPP_CONFIRMED, reaction.status, "reaction must not be downgraded") + } + + @Test + fun applySFPPStatusLeavesNonMatchingAddressesUntouched() = runTest { + seedMyNodeInfo() + val packetId = 140 + // Packet from node 123 (!0000007b), not from local node. + packetDao.insert( + textPacket( + "contact", + "msg", + time = 1000, + packetId = packetId, + from = "!0000007b", + to = NodeAddress.ID_BROADCAST, + ), + ) + + // Call with from = 456 (!000001c8) — should NOT match. + packetDao.applySFPPStatus( + packetId = packetId, + from = 456, + to = 0, + hash = sfppHash, + status = MessageStatus.SFPP_ROUTING, + rxTime = 0L, + myNodeNum = myNodeNum, + ) + + val packet = packetDao.findPacketsWithId(packetId).first() + assertEquals(MessageStatus.UNKNOWN, packet.data.status, "non-matching packet should be untouched") + assertNull(packet.sfpp_hash) + } + + // ── applySFPPStatusByHash ──────────────────────────────────────────────────── + + @Test + fun applySFPPStatusByHashMatchesByHashPrefix() = runTest { + seedMyNodeInfo() + val packetId = 200 + val fullHash = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12).toByteString() + val hashPrefix = fullHash.substring(0, 8) + packetDao.insert(textPacket("contact", "msg", time = 1000, packetId = packetId, sfppHash = fullHash)) + packetDao.insert( + reaction( + replyId = packetId, + userId = "!u", + emoji = "👍", + timestamp = 1, + packetId = packetId, + sfppHash = fullHash, + ), + ) + + packetDao.applySFPPStatusByHash(hashPrefix, status = MessageStatus.SFPP_ROUTING, rxTime = 5L) + + val packet = packetDao.findPacketBySfppHash(fullHash) + assertNotNull(packet) + assertEquals(MessageStatus.SFPP_ROUTING, packet!!.data.status) + + val reaction = packetDao.findReactionBySfppHash(fullHash) + assertNotNull(reaction) + assertEquals(MessageStatus.SFPP_ROUTING, reaction!!.status) + } + + @Test + fun applySFPPStatusByHashRollsBackPacketWhenReactionUpdateFails() = runTest { + seedMyNodeInfo() + val packetId = 205 + val fullHash = byteArrayOf(9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 1, 2).toByteString() + val hashPrefix = fullHash.substring(0, 8) + packetDao.insert(textPacket("contact", "msg", time = 1000, packetId = packetId, sfppHash = fullHash)) + packetDao.insert( + reaction( + replyId = packetId, + userId = "!u", + emoji = "👍", + timestamp = 1, + packetId = packetId, + sfppHash = fullHash, + ), + ) + database.useWriterConnection { + it.executeSQL( + """ + CREATE TRIGGER fail_reaction_hash_status_update + BEFORE UPDATE ON reactions + WHEN OLD.packet_id = $packetId + BEGIN + SELECT RAISE(ABORT, 'forced reaction hash update failure'); + END + """ + .trimIndent(), + ) + } + + assertFails { packetDao.applySFPPStatusByHash(hashPrefix, status = MessageStatus.SFPP_ROUTING, rxTime = 5L) } + + val packet = assertNotNull(packetDao.findPacketBySfppHash(fullHash)) + assertEquals(MessageStatus.UNKNOWN, packet.data.status) + assertEquals(1000L, packet.received_time) + val reaction = assertNotNull(packetDao.findReactionBySfppHash(fullHash)) + assertEquals(MessageStatus.UNKNOWN, reaction.status) + assertEquals(1L, reaction.timestamp) + } + + @Test + fun applySFPPStatusByHashDoesNotDowngradeConfirmedToRouting() = runTest { + seedMyNodeInfo() + val packetId = 210 + packetDao.insert( + textPacket( + "contact", + "msg", + time = 1000, + packetId = packetId, + status = MessageStatus.SFPP_CONFIRMED, + sfppHash = sfppHash, + ), + ) + + packetDao.applySFPPStatusByHash(sfppHash, status = MessageStatus.SFPP_ROUTING, rxTime = 5L) + + val packet = packetDao.findPacketBySfppHash(sfppHash) + assertNotNull(packet) + assertEquals(MessageStatus.SFPP_CONFIRMED, packet!!.data.status, "confirmed packet must not be downgraded") + } + + @Test + fun applySFPPStatusByHashLeavesUnrelatedHashUntouched() = runTest { + seedMyNodeInfo() + val packetId = 220 + packetDao.insert(textPacket("contact", "msg", time = 1000, packetId = packetId, sfppHash = sfppHash)) + + // Apply with a completely different hash prefix — should not touch the packet. + packetDao.applySFPPStatusByHash(otherHash, status = MessageStatus.SFPP_ROUTING, rxTime = 5L) + + val packet = packetDao.findPacketBySfppHash(sfppHash) + assertNotNull(packet) + assertEquals(MessageStatus.UNKNOWN, packet!!.data.status, "unrelated hash should be untouched") + } + + // ── deleteMessagesAtomic ───────────────────────────────────────────────────── + + @Test + fun deleteMessagesAtomicDeletesPacketsAndReactions() = runTest { + seedMyNodeInfo() + val packetId = 300 + packetDao.insert(textPacket("contact", "msg", time = 100, packetId = packetId)) + packetDao.insert(reaction(replyId = packetId, userId = "!u", emoji = "👍", timestamp = 1, packetId = packetId)) + + val uuids = packetDao.getMessagesFrom("contact").first().map { it.packet.uuid } + assertTrue(uuids.isNotEmpty()) + + packetDao.deleteMessagesAtomic(uuids) + + assertEquals(0, packetDao.getMessageCount("contact")) + assertTrue(packetDao.findReactionsWithId(packetId).isEmpty(), "reactions should be deleted with their packets") + } + + @Test + fun deleteMessagesAtomicEmptyListIsNoOp() = runTest { + seedMyNodeInfo() + val contact = "empty-test" + packetDao.insert(textPacket(contact, "msg", time = 100, packetId = 310)) + val before = packetDao.getMessageCount(contact) + + packetDao.deleteMessagesAtomic(emptyList()) + + assertEquals(before, packetDao.getMessageCount(contact)) + } + + @Test + fun deleteMessagesAtomicCrossesChunkBoundaryAndDeletesAll() = runTest { + seedMyNodeInfo() + val contact = "chunk-boundary" + val firstPacketId = 311 + val secondPacketId = 312 + packetDao.insert(textPacket(contact, "first", time = 100, packetId = firstPacketId)) + packetDao.insert(textPacket(contact, "second", time = 200, packetId = secondPacketId)) + packetDao.insert( + reaction( + replyId = firstPacketId, + userId = "!rollback", + emoji = "👍", + timestamp = 1, + packetId = firstPacketId, + ), + ) + // Also insert a packet that should survive the delete. + val survivorContact = "survivor" + packetDao.insert(textPacket(survivorContact, "keep", time = 999_999, packetId = 9_999)) + + val firstUuid = packetDao.findPacketsWithId(firstPacketId).single().uuid + val secondUuid = packetDao.findPacketsWithId(secondPacketId).single().uuid + + packetDao.deleteMessagesAtomic(crossChunkUuids(firstUuid, secondUuid)) + + assertEquals(0, packetDao.getMessageCount(contact), "all chunked packets deleted") + assertEquals(1, packetDao.getMessageCount(survivorContact), "non-selected packet untouched") + } + + @Test + fun deleteMessagesAtomicRollsBackEarlierChunkWhenLaterChunkFails() = runTest { + seedMyNodeInfo() + val contact = "chunk-rollback" + val firstPacketId = 320 + val secondPacketId = 321 + packetDao.insert(textPacket(contact, "first", time = 100, packetId = firstPacketId)) + packetDao.insert(textPacket(contact, "second", time = 200, packetId = secondPacketId)) + packetDao.insert( + reaction( + replyId = firstPacketId, + userId = "!rollback", + emoji = "👍", + timestamp = 1, + packetId = firstPacketId, + ), + ) + val firstUuid = packetDao.findPacketsWithId(firstPacketId).single().uuid + val secondUuid = packetDao.findPacketsWithId(secondPacketId).single().uuid + database.useWriterConnection { + it.executeSQL( + """ + CREATE TRIGGER fail_second_delete_chunk + BEFORE DELETE ON packet + WHEN OLD.packet_id = $secondPacketId + BEGIN + SELECT RAISE(ABORT, 'forced later-chunk delete failure'); + END + """ + .trimIndent(), + ) + } + assertFails { packetDao.deleteMessagesAtomic(crossChunkUuids(firstUuid, secondUuid)) } + + assertEquals(1, packetDao.findPacketsWithId(firstPacketId).size, "the first chunk must roll back") + assertEquals(1, packetDao.findPacketsWithId(secondPacketId).size, "the failing row must remain") + assertEquals(1, packetDao.findReactionsWithId(firstPacketId).size, "first-chunk reactions must roll back") + } +} From 7071d4d1fd16f90dce95959cdf4010a13e3afc09 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:28:33 -0500 Subject: [PATCH 2/3] fix(database): make mesh-log cleanup and traceroute replacement transactional Consolidate mesh-log UUID cleanup and traceroute snapshot replacement into Room transactions. Mesh-log selection still parses the Kotlin protobuf snapshot to identify local-stats telemetry, while the selected UUID deletions now commit atomically across SQLite-safe chunks. Traceroute replacement validates snapshot ownership before mutation, deletes the previous rows, and inserts the new rows inside one transaction. Keep mesh-log selection and deletion under one DatabaseProvider.withDb admission and one captured Room instance. Move only protobuf conversion and parsing onto the injected compute dispatcher so large telemetry snapshots do not occupy the temporary single-lane database dispatcher. A device switch therefore cannot select UUIDs from one database and delete matching UUIDs from another, and an empty selection avoids opening a write transaction entirely. Preserve the existing authoritative-snapshot behavior: an empty traceroute map removes the stored snapshot, and local-stats selection remains outside SQL where the protobuf payload can be decoded safely. Coroutine cancellation propagates through the dispatcher handoff and the surrounding database admission. Add cross-chunk success and trigger-injected rollback coverage. A failure in a later mesh-log chunk restores earlier deletions, and a traceroute insert failure after deletion restores the original node positions. --- .../data/repository/MeshLogRepositoryImpl.kt | 40 ++--- .../TracerouteSnapshotRepositoryImpl.kt | 29 ++-- .../core/database/dao/MeshLogDao.kt | 41 ++++- .../database/dao/TracerouteNodePositionDao.kt | 15 ++ .../core/database/dao/MeshLogDaoTest.kt | 160 ++++++++++++++++++ .../dao/TracerouteNodePositionDaoTest.kt | 148 ++++++++++++++++ 6 files changed, 389 insertions(+), 44 deletions(-) create mode 100644 core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/MeshLogDaoTest.kt create mode 100644 core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDaoTest.kt diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt index 52698b5264..f7b7584de1 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt @@ -180,28 +180,31 @@ open class MeshLogRepositoryImpl( Unit } - /** Deletes only local stats telemetry logs for [nodeNum], preserving other telemetry types. */ + /** + * Deletes only local stats telemetry logs for [nodeNum], preserving other telemetry types. Selection and deletion + * retain one admitted database while protobuf parsing runs on the compute dispatcher; deletion of the selected UUID + * set is atomic. + */ override suspend fun deleteLocalStatsLogs(nodeNum: Int) = withContext(dispatchers.io) { val myNodeNum = nodeInfoReadDataSource.myNodeInfoFlow().firstOrNull()?.myNodeNum val logId = if (nodeNum == myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum - // One withDb block: the query and the chunked deletes all land on the same DB instance (previously each - // chunk - // re-fetched the DAO to survive a mid-op switch; capturing one instance is the more correct behavior, and - // the - // drain barrier now covers the whole read-modify-delete against a concurrent merge). dbManager.withDb { db -> val dao = db.meshLogDao() - val localStatsLogIds = - dao.getLogsFrom(logId, PortNum.TELEMETRY_APP.value, Int.MAX_VALUE) - .firstOrNull() - .orEmpty() - .map { it.asExternalModel() } - .filter { parseTelemetryLog(it)?.local_stats != null } - .map { it.uuid } - - // Chunk to stay under SQLite's bind-variable limit. - for (chunk in localStatsLogIds.chunked(DELETE_CHUNK_SIZE)) { - dao.deleteLogsByUuid(chunk) + // Snapshot read outside the transaction — the selection requires Kotlin protobuf parsing + // (parseTelemetryLog), so it cannot be a single SQL operation. The delete targets stable UUIDs, + // so a concurrent insert between snapshot and delete is simply not in the delete set. + val logs = dao.getLogsSnapshot(logId, PortNum.TELEMETRY_APP.value, Int.MAX_VALUE) + val uuidsToDelete = + withContext(dispatchers.default) { + logs + .map { it.asExternalModel() } + .filter { parseTelemetryLog(it)?.local_stats != null } + .map { it.uuid } + } + // The chunked delete runs inside one Room transaction — all-or-nothing. Avoid opening an empty write + // transaction when the snapshot contains no local-stats telemetry. + if (uuidsToDelete.isNotEmpty()) { + dao.deleteLogsByUuidAtomic(uuidsToDelete) } } Unit @@ -217,8 +220,5 @@ open class MeshLogRepositoryImpl( companion object { private const val MILLIS_PER_SEC = 1000L - - /** Max UUIDs per DELETE IN-clause; keeps us under SQLite's bind-variable limit. */ - private const val DELETE_CHUNK_SIZE = 500 } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepositoryImpl.kt index 769d184795..1e95527353 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepositoryImpl.kt @@ -43,25 +43,20 @@ class TracerouteSnapshotRepositoryImpl( .flowOn(dispatchers.io) .conflate() - // The delete+insert pair runs in one withDb block so both land on the same DB instance and stay visible to the - // cross-transport merge drain barrier (see DatabaseProvider). override suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map) { withContext(dispatchers.io) { - dbManager.withDb { - val dao = it.tracerouteNodePositionDao() - dao.deleteByLogUuid(logUuid) - if (positions.isEmpty()) return@withDb - val entities = - positions.map { (nodeNum, position) -> - TracerouteNodePositionEntity( - logUuid = logUuid, - requestId = requestId, - nodeNum = nodeNum, - position = position, - ) - } - dao.insertAll(entities) - } + val entities = + positions.map { (nodeNum, position) -> + TracerouteNodePositionEntity( + logUuid = logUuid, + requestId = requestId, + nodeNum = nodeNum, + position = position, + ) + } + // Single transactional DAO call — delete + insert is atomic. An authoritative empty result deliberately + // deletes the previous snapshot instead of retaining stale positions. + dbManager.withDb { it.tracerouteNodePositionDao().replaceByLogUuid(logUuid, entities) } } } } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt index 9369bbf0e7..066132219c 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt @@ -20,12 +20,25 @@ import androidx.room3.Dao import androidx.room3.Insert import androidx.room3.OnConflictStrategy import androidx.room3.Query +import androidx.room3.Transaction import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.database.DatabaseConstants.SQLITE_MAX_BIND_PARAMETERS import org.meshtastic.core.database.entity.MeshLog @Dao +@Suppress("TooManyFunctions") interface MeshLogDao { + companion object { + /** Shared SQL for querying logs by from_num and port_num, used by both Flow and snapshot variants. */ + private const val LOGS_FROM_QUERY = + """ + SELECT * FROM log + WHERE from_num = :fromNum AND (:portNum = -1 OR port_num = :portNum) + ORDER BY received_date DESC LIMIT :maxItem + """ + } + @Query("SELECT * FROM log ORDER BY received_date DESC LIMIT :maxItem") fun getAllLogs(maxItem: Int): Flow> @@ -37,13 +50,7 @@ interface MeshLogDao { * * @param portNum If -1, returns all logs regardless of port. If 0, returns logs with port 0. */ - @Query( - """ - SELECT * FROM log - WHERE from_num = :fromNum AND (:portNum = -1 OR port_num = :portNum) - ORDER BY received_date DESC LIMIT :maxItem - """, - ) + @Query(LOGS_FROM_QUERY) fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int): Flow> @Insert suspend fun insert(log: MeshLog) @@ -72,4 +79,24 @@ interface MeshLogDao { @Query("DELETE FROM log WHERE received_date < :cutoffTimestamp") suspend fun deleteOlderThan(cutoffTimestamp: Long) + + /** + * Suspend snapshot variant of [getLogsFrom] for one-shot reads (no Flow observer overhead). Used when a caller + * needs to read-then-process-then-delete in two steps — the selection requires Kotlin parsing so it stays outside + * the delete transaction, but the delete itself is atomic (see [deleteLogsByUuidAtomic]). + */ + @Query(LOGS_FROM_QUERY) + suspend fun getLogsSnapshot(fromNum: Int, portNum: Int, maxItem: Int): List + + /** + * Atomically deletes all logs matching [uuids], chunking internally to stay under SQLite's bind-parameter limit. + * The entire batch is all-or-nothing: a failure rolls back every chunk. + */ + @Transaction + suspend fun deleteLogsByUuidAtomic(uuids: List) { + if (uuids.isEmpty()) return + for (chunk in uuids.chunked(SQLITE_MAX_BIND_PARAMETERS)) { + deleteLogsByUuid(chunk) + } + } } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt index 85988f781b..445ae6af96 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt @@ -18,6 +18,7 @@ package org.meshtastic.core.database.dao import androidx.room3.Dao import androidx.room3.Query +import androidx.room3.Transaction import androidx.room3.Upsert import kotlinx.coroutines.flow.Flow import org.meshtastic.core.database.entity.TracerouteNodePositionEntity @@ -35,4 +36,18 @@ interface TracerouteNodePositionDao { suspend fun getAllSnapshot(): List @Upsert suspend fun insertAll(entities: List) + + /** + * Atomically replaces all positions for [logUuid]: deletes the old snapshot and inserts [entities] in one + * transaction. If insertion fails, the old snapshot is preserved (rollback). Observers never see an empty + * intermediate state. + */ + @Transaction + suspend fun replaceByLogUuid(logUuid: String, entities: List) { + require(entities.all { it.logUuid == logUuid }) { "All traceroute positions must belong to $logUuid" } + deleteByLogUuid(logUuid) + if (entities.isNotEmpty()) { + insertAll(entities) + } + } } diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/MeshLogDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/MeshLogDaoTest.kt new file mode 100644 index 0000000000..7cd6b7038d --- /dev/null +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/MeshLogDaoTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database.dao + +import androidx.room3.executeSQL +import androidx.room3.useWriterConnection +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.database.DatabaseConstants.SQLITE_MAX_BIND_PARAMETERS +import org.meshtastic.core.database.MeshtasticDatabase +import org.meshtastic.core.database.entity.MeshLog +import org.meshtastic.core.database.getInMemoryDatabaseBuilder +import org.meshtastic.proto.PortNum +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertTrue + +class MeshLogDaoTest { + private lateinit var database: MeshtasticDatabase + private lateinit var meshLogDao: MeshLogDao + + private val testFromNum = 42 + + private fun logEntry(uuid: String, portNum: Int = PortNum.TELEMETRY_APP.value, time: Long) = MeshLog( + uuid = uuid, + message_type = "Packet", + received_date = time, + raw_message = "", + fromNum = testFromNum, + portNum = portNum, + ) + + /** Places [first] in a full bind-param chunk and [second] in the next chunk without inserting filler rows. */ + private fun crossChunkUuids(first: String, second: String): List = buildList { + add(first) + repeat(SQLITE_MAX_BIND_PARAMETERS - 1) { add("missing-$it") } + add(second) + } + + @BeforeTest + fun setUp() { + database = getInMemoryDatabaseBuilder().build() + meshLogDao = database.meshLogDao() + } + + @AfterTest + fun closeDb() { + database.close() + } + + @Test + fun testDeleteLogsByUuidAtomicRemovesAll() = runTest { + meshLogDao.insert(logEntry("log-1", time = 100)) + meshLogDao.insert(logEntry("log-2", time = 200)) + meshLogDao.insert(logEntry("log-3", time = 300)) + + val uuids = + meshLogDao.getLogsFrom(testFromNum, PortNum.TELEMETRY_APP.value, Int.MAX_VALUE).first().map { it.uuid } + assertEquals(3, uuids.size) + + meshLogDao.deleteLogsByUuidAtomic(uuids) + + val remaining = meshLogDao.getLogsFrom(testFromNum, PortNum.TELEMETRY_APP.value, Int.MAX_VALUE).first() + assertTrue(remaining.isEmpty()) + } + + @Test + fun testDeleteLogsByUuidAtomicEmptyListIsNoOp() = runTest { + meshLogDao.insert(logEntry("log-1", time = 100)) + meshLogDao.insert(logEntry("log-2", time = 200)) + + val before = meshLogDao.getLogsFrom(testFromNum, PortNum.TELEMETRY_APP.value, Int.MAX_VALUE).first() + assertEquals(2, before.size) + + meshLogDao.deleteLogsByUuidAtomic(emptyList()) + + val after = meshLogDao.getLogsFrom(testFromNum, PortNum.TELEMETRY_APP.value, Int.MAX_VALUE).first() + assertEquals(2, after.size) + } + + @Test + fun testDeleteLogsByUuidAtomicCrossesChunkBoundary() = runTest { + val firstUuid = "first-chunk" + val secondUuid = "second-chunk" + meshLogDao.insert(logEntry(firstUuid, time = 100)) + meshLogDao.insert(logEntry(secondUuid, time = 200)) + // Insert one log from a different fromNum that should survive the delete. + val survivor = + MeshLog( + uuid = "survivor", + message_type = "Packet", + received_date = 0L, + raw_message = "", + fromNum = 999, + portNum = PortNum.TELEMETRY_APP.value, + ) + meshLogDao.insert(survivor) + + meshLogDao.deleteLogsByUuidAtomic(crossChunkUuids(firstUuid, secondUuid)) + + val remaining = meshLogDao.getLogsFrom(testFromNum, PortNum.TELEMETRY_APP.value, Int.MAX_VALUE).first() + assertTrue(remaining.isEmpty(), "all selected logs should be deleted across the chunk boundary") + + val survivorStillPresent = meshLogDao.getLogsFrom(999, PortNum.TELEMETRY_APP.value, Int.MAX_VALUE).first() + assertEquals(1, survivorStillPresent.size, "unselected log from a different fromNum should remain") + } + + @Test + fun testDeleteLogsByUuidAtomicRollsBackEarlierChunkWhenLaterChunkFails() = runTest { + val firstUuid = "first-chunk" + val secondUuid = "failing-second-chunk" + meshLogDao.insert(logEntry(firstUuid, time = 100)) + meshLogDao.insert(logEntry(secondUuid, time = 200)) + database.useWriterConnection { + it.executeSQL( + """ + CREATE TRIGGER fail_second_log_delete_chunk + BEFORE DELETE ON log + WHEN OLD.uuid = '$secondUuid' + BEGIN + SELECT RAISE(ABORT, 'forced later-chunk delete failure'); + END + """ + .trimIndent(), + ) + } + assertFails { meshLogDao.deleteLogsByUuidAtomic(crossChunkUuids(firstUuid, secondUuid)) } + + val remaining = meshLogDao.getLogsFrom(testFromNum, PortNum.TELEMETRY_APP.value, Int.MAX_VALUE).first() + assertEquals(setOf(firstUuid, secondUuid), remaining.map { it.uuid }.toSet()) + } + + @Test + fun testGetLogsSnapshotReturnsMatchingLogs() = runTest { + meshLogDao.insert(logEntry("log-1", portNum = PortNum.TELEMETRY_APP.value, time = 300)) + meshLogDao.insert(logEntry("log-2", portNum = PortNum.TELEMETRY_APP.value, time = 100)) + meshLogDao.insert(logEntry("log-3", portNum = PortNum.POSITION_APP.value, time = 200)) + + val snapshot = meshLogDao.getLogsSnapshot(testFromNum, PortNum.TELEMETRY_APP.value, Int.MAX_VALUE) + assertEquals(2, snapshot.size, "only telemetry logs should match") + assertEquals(listOf("log-1", "log-2"), snapshot.map { it.uuid }, "telemetry logs in DESC order") + } +} diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDaoTest.kt new file mode 100644 index 0000000000..199f56cf23 --- /dev/null +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDaoTest.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database.dao + +import androidx.room3.executeSQL +import androidx.room3.useWriterConnection +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.database.MeshtasticDatabase +import org.meshtastic.core.database.entity.MeshLog +import org.meshtastic.core.database.entity.TracerouteNodePositionEntity +import org.meshtastic.core.database.getInMemoryDatabaseBuilder +import org.meshtastic.proto.Position +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class TracerouteNodePositionDaoTest { + + private lateinit var database: MeshtasticDatabase + private lateinit var tracerouteDao: TracerouteNodePositionDao + private lateinit var meshLogDao: MeshLogDao + + private val logUuid = "traceroute-log-1" + + @BeforeTest + fun setUp() { + database = getInMemoryDatabaseBuilder().build() + tracerouteDao = database.tracerouteNodePositionDao() + meshLogDao = database.meshLogDao() + } + + @AfterTest + fun tearDown() { + database.close() + } + + private suspend fun seedParentLog() { + meshLogDao.insert( + MeshLog( + uuid = logUuid, + message_type = "Packet", + received_date = 1000L, + raw_message = "", + fromNum = 0, + portNum = 0, + ), + ) + } + + private fun position(nodeNum: Int) = + TracerouteNodePositionEntity(logUuid = logUuid, requestId = 1, nodeNum = nodeNum, position = Position()) + + @Test + fun testReplaceByLogUuidIsAtomic() = runTest { + seedParentLog() + tracerouteDao.insertAll(listOf(position(10), position(20))) + + val before = tracerouteDao.getByLogUuid(logUuid).first() + assertEquals(2, before.size) + + tracerouteDao.replaceByLogUuid(logUuid, listOf(position(30), position(40), position(50))) + + val after = tracerouteDao.getByLogUuid(logUuid).first() + assertEquals(3, after.size) + assertEquals(setOf(30, 40, 50), after.map { it.nodeNum }.toSet()) + } + + @Test + fun testReplaceByLogUuidRollsBackDeletionWhenInsertFails() = runTest { + seedParentLog() + tracerouteDao.insertAll(listOf(position(10), position(20))) + database.useWriterConnection { + it.executeSQL( + """ + CREATE TRIGGER fail_traceroute_replacement_insert + BEFORE INSERT ON traceroute_node_position + WHEN NEW.node_num = 30 + BEGIN + SELECT RAISE(ABORT, 'forced traceroute replacement failure'); + END + """ + .trimIndent(), + ) + } + + assertFailsWith { tracerouteDao.replaceByLogUuid(logUuid, listOf(position(30))) } + + val after = tracerouteDao.getByLogUuid(logUuid).first() + assertEquals(setOf(10, 20), after.map { it.nodeNum }.toSet()) + } + + @Test + fun testReplaceByLogUuidEmptyEntitiesDeletesAll() = runTest { + seedParentLog() + tracerouteDao.insertAll(listOf(position(10), position(20))) + + val before = tracerouteDao.getByLogUuid(logUuid).first() + assertEquals(2, before.size) + + tracerouteDao.replaceByLogUuid(logUuid, emptyList()) + + val after = tracerouteDao.getByLogUuid(logUuid).first() + assertTrue(after.isEmpty()) + } + + @Test + fun testReplaceByLogUuidRejectsMismatchedSnapshotBeforeMutation() = runTest { + seedParentLog() + tracerouteDao.insertAll(listOf(position(10), position(20))) + + val before = tracerouteDao.getByLogUuid(logUuid).first() + assertEquals(2, before.size) + + val badEntity = + TracerouteNodePositionEntity( + logUuid = "nonexistent-log", + requestId = 1, + nodeNum = 99, + position = Position(), + ) + assertFailsWith { + tracerouteDao.replaceByLogUuid(logUuid, listOf(position(30), badEntity)) + } + + val after = tracerouteDao.getByLogUuid(logUuid).first() + assertEquals(2, after.size, "invalid replacement must not mutate the existing snapshot") + assertEquals(setOf(10, 20), after.map { it.nodeNum }.toSet()) + assertTrue(tracerouteDao.getByLogUuid("nonexistent-log").first().isEmpty()) + } +} From 9c85d585304386a81390003a88f406f8eee8bcb6 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:28:43 -0500 Subject: [PATCH 3/3] fix(database): make database association abort-safe Harden cross-transport database association as one protocol spanning transport admission, configuration persistence, Room merge ownership, cache retirement, device switching, and orderly shutdown. Bind every received payload to the real transport address and monotonically increasing session generation at callback admission, copying bytes into an immutable ByteString envelope before they enter the FIFO. Carry that provenance through decoding and configuration dispatch instead of reconstructing it from mutable address or generation flows when a delayed packet is handled. Publish generation, address, node number, and optional factory hardware ID as one coherent ConnectionIdentity. Retain node-number fallback for radios without a usable device ID. Reconcile identities with an atomic StateFlow update so a stale identity is removed without erasing one already published for a replacement generation. Preserve structured cancellation, log recoverable association failures, and allow fatal Errors to propagate. Model transport authority with explicit lifecycle leases. Session teardown closes callback and new-work admission immediately, while a private lifecycle token stays published until every operation admitted for that generation returns. Count those leases under the callback lock so independent packet side effects can acquire a nested lease before their parent packet operation releases its own. Teardown waits for the count to drain before clearing the public lifecycle token, closing the old transport, or publishing a replacement generation. An admitted lease therefore remains authoritative through a destination transaction commit even after late work has been rejected. Retain a serialized session-operation lane alongside counted leases. Handshake reset and configuration persistence use that lane to preserve FIFO ordering: the MyNodeInfo reset queues undispatched before the frame consumer returns, so later device, module, channel, UI, region-preset, file-manifest, metadata, or node-list persistence cannot overtake it. Independently deferred packet persistence, notifications, audit insertion, and service emission use their own counted leases instead, avoiding self-deadlock while ensuring teardown still drains them. Make transport teardown cancellation-safe once revocation begins. Complete lease drain, transport state reset, transport close, service-scope replacement, and the final disconnect publication under NonCancellable ownership. Use finally blocks so a cancellation or fatal failure from the polite-disconnect path cannot strand a revoked transport in the service. Keep replacement admission serialized by the transport mutex and reject stale close-time callbacks through the closed session gate. Propagate the owning session through the complete configuration handshake: MyNodeInfo, metadata, device and module config, channels, UI config, regional presets, file manifests, node snapshots, stage completion, and reboot-driven configuration requests. Synchronous state mutation shares callback admission; asynchronous repository work enters the ordered session lane. Await status and Stage-2 NodeInfo persistence before publication, recheck revocation between operations, and publish NodeDB readiness and Connected state only for the current session. Stale completions cannot advance progress, lockdown state, or post-handshake side effects. Authorize DatabaseManager association from the immutable handshake session and require every caller to provide explicit authority. Recheck authority before claim writes, database construction, pending-route publication, merge finalization, and routing repair. Hold the transport lifecycle lease through DatabaseMerger's Room transaction return, making session rollover and transaction commit mutually exclusive. Keep first and final transaction checks as defensive rollback guards for direct or future callers. Coordinate association with the writer-admission gate, bounded source-writer drain, marker-backed recovery, synchronous canonical publication, and logical retirement. A pre-commit rollover rolls back every copied table and releases blocked writers back to the source. A committed merge releases them to the destination, persists address routing and retirement intent, and remains canonical even if later metadata finalization requires recovery. Persisted retirement is reclaimed at the next safe manager startup; published and detached Room pools stay alive until orderly shutdown proves consumers have stopped. Retain session provenance for the entire inbound packet pipeline, not only admin configuration. Bind text and reaction persistence, routing ACK updates, SFPP, telemetry, traceroute snapshots, Store & Forward history, node updates, geofence evaluation, discovery callbacks, audit records, packet emission, client state, and notifications to the generation that admitted the packet. Require explicit, non-null authority at the telemetry and traceroute interfaces so neither can silently enter a trusted sessionless persistence path. Deferred jobs enter their nested lease undispatched. Early packets retain their session in the buffer, flush serially, discard superseded generations, and return the current packet to the queue if NodeDB readiness regresses. Keep node transforms side-effect-free because CAS contention may evaluate them more than once. Publish effects only after a successful state transition. Serialize persistence through fixed per-node stripes, read the latest in-memory snapshot inside the lane, honor the write-disable gate, and route radio-originated scheduled writes through the originating session lease. This prevents an earlier suspended upsert from overwriting a newer node state or resuming against a replacement device database. Fail device switches closed when the previous database cannot be restored. Never restore a preference or transport for the old device while the new database may remain active. Disable node writes, clear identity, node state, buffered packets, and notifications, then independently attempt persisted and transport deselection so one best-effort cleanup failure cannot skip the safety-critical transport reset. When database restoration succeeds, restore ancillary state without masking the original switch failure. Treat a matching saved preference as an already-active no-op only when transport acceptance or the synchronous transport snapshot proves the requested address is selected; otherwise throw before persisting or reporting success. Make transport construction failure-safe. If the factory throws after session publication, revoke admission, drain any synchronously admitted work, restore the prior connection state, clear partial transport fields, and rethrow the original failure. Keep the consumed generation monotonic so retry callbacks cannot impersonate the failed session. Align the fake transport with production: address selection alone creates no session, active restart advances the generation, and disconnect drains admitted work before lifecycle completion. Protect deferred cache enforcement against intervening switches by resolving the active database only under the manager mutex. Exclude active-writer and detached pools from eviction, retry enforcement after writers drain, and remove cached ownership only after a successful close. Never delete files after a close failure, and retain retirement intent whenever cleanup must be retried. Let cancellation stop best-effort destructive eviction rather than forcing it through NonCancellable execution. Never replay an arbitrary withDb callback after it starts. A callback may have completed one operation before a closed-pool or acquisition-timeout failure, so a retry could duplicate a side effect or split one logical write across databases. Recover the wedged active pool only for later admissions, preserve the original callback failure, and attach reopen failure as suppressed context. Keep default-pool creation, current-flow publication, and shutdown acquisition under construction-safe synchronization so lazy initialization cannot race cache mutation or teardown. Track manager operations, admitted writers, detached pools, and lazy manager jobs explicitly. Treat CLOSING as retryable ownership: reject new work, cancel manager jobs before waiting for their operation tokens, bound drains, close each distinct Room pool once, and retain state for a later close attempt when cleanup cannot finish. Application teardown cancels consumers before invoking the manager's suspending close bridge. Keep runtime diagnostics privacy-safe: raw transport addresses and factory identifiers are used only for authority and routing checks, while session, identity, handshake, and association logs redact them. Leave the established persistent audit-record payload contract unchanged. Add deterministic barrier-based coverage for stale and same-address generations, queued and mid-admission MyNodeInfo, ordered complete config clearing, serialized handshake work alongside concurrent nested leases, revocation during Stage-2 installation, stale publication, transaction-time association rollover, routing repair, writer admission, failed switch rollback, rejected same-address selection, factory retry, fake lifecycle boundaries, deferred packet rejection, node persistence ordering, cancellation-safe teardown, bounded and retryable shutdown, concurrent close, detached pools, lazy initialization, startup retirement, real merge-marker recovery, early-buffer readiness regression, explicit telemetry authority, and session-bound audit and notification work. --- .../org/meshtastic/app/MeshUtilApplication.kt | 14 +- .../core/common/database/DatabaseManager.kt | 16 +- .../data/manager/AdminPacketHandlerImpl.kt | 19 +- .../manager/FromRadioPacketHandlerImpl.kt | 147 +- .../core/data/manager/GeofenceMonitor.kt | 22 +- .../data/manager/MeshConfigFlowManagerImpl.kt | 379 +++-- .../data/manager/MeshConfigHandlerImpl.kt | 134 +- .../core/data/manager/MeshDataHandlerImpl.kt | 296 ++-- .../data/manager/MeshMessageProcessorImpl.kt | 316 ++-- .../core/data/manager/NodeManagerImpl.kt | 245 ++- .../core/data/manager/PacketHandlerImpl.kt | 4 +- .../core/data/manager/SessionWork.kt | 43 + .../manager/StoreForwardPacketHandlerImpl.kt | 48 +- .../manager/TelemetryPacketHandlerImpl.kt | 109 +- .../data/manager/TracerouteHandlerImpl.kt | 25 +- .../data/repository/MeshLogRepositoryImpl.kt | 49 +- .../data/repository/PacketRepositoryImpl.kt | 23 +- .../manager/AdminPacketHandlerImplTest.kt | 49 +- .../manager/FromRadioPacketHandlerImplTest.kt | 165 +- .../core/data/manager/GeofenceMonitorTest.kt | 2 + .../manager/MeshConfigFlowManagerImplTest.kt | 304 +++- .../data/manager/MeshConfigHandlerImplTest.kt | 168 +- .../core/data/manager/MeshDataHandlerTest.kt | 108 +- .../manager/MeshMessageProcessorImplTest.kt | 302 +++- .../NodeManagerConnectionIdentityTest.kt | 258 +++ .../core/data/manager/NodeManagerImplTest.kt | 111 +- .../core/data/manager/SessionWorkTest.kt | 69 + .../manager/TelemetryPacketHandlerImplTest.kt | 52 +- .../StoreForwardPacketHandlerImplTest.kt | 28 + .../repository/AirQualityChartReproTest.kt | 32 +- core/database/build.gradle.kts | 1 + .../core/database/DatabaseConstants.kt | 17 +- .../core/database/DatabaseManager.kt | 1413 +++++++++++++---- .../core/database/DatabaseMerger.kt | 24 +- .../core/database/DatabaseProvider.kt | 9 +- .../core/database/dao/MeshLogDao.kt | 26 + .../meshtastic/core/database/dao/PacketDao.kt | 51 +- .../database/dao/SwitchingDiscoveryDao.kt | 4 +- .../DatabaseManagerAssociationRecoveryTest.kt | 515 ++++++ .../database/DatabaseManagerBackfillTest.kt | 110 ++ .../database/DatabaseManagerEvictionTest.kt | 33 + .../database/DatabaseManagerRetirementTest.kt | 140 ++ .../database/DatabaseManagerShutdownTest.kt | 503 ++++++ .../database/DatabaseManagerTestFixture.kt | 364 +++++ .../DatabaseManagerWriterAdmissionTest.kt | 286 ++++ .../core/database/DatabaseMergerTest.kt | 38 + .../core/database/dao/MeshLogDaoTest.kt | 28 + .../dao/PacketDaoAtomicTransactionTest.kt | 69 +- ...abaseManagerPendingRouteRecoveryJvmTest.kt | 275 ++++ .../core/repository/AdminPacketHandler.kt | 2 +- .../core/repository/FromRadioPacketHandler.kt | 4 +- .../core/repository/MeshConfigFlowManager.kt | 22 +- .../core/repository/MeshConfigHandler.kt | 37 +- .../core/repository/MeshDataHandler.kt | 16 +- .../core/repository/MeshMessageProcessor.kt | 17 +- .../meshtastic/core/repository/NodeManager.kt | 95 +- .../core/repository/PacketHandler.kt | 4 +- .../core/repository/RadioInterfaceService.kt | 63 +- .../core/repository/ReceivedRadioFrame.kt | 37 + .../repository/StoreForwardPacketHandler.kt | 9 +- .../core/repository/TelemetryPacketHandler.kt | 3 +- .../core/repository/TracerouteHandler.kt | 3 +- .../core/service/MeshServiceOrchestrator.kt | 11 +- .../core/service/RadioControllerImpl.kt | 201 ++- .../service/SharedRadioInterfaceService.kt | 329 +++- .../service/MeshServiceOrchestratorTest.kt | 99 +- .../core/service/RadioControllerImplTest.kt | 459 +++++- ...SharedRadioInterfaceServiceLivenessTest.kt | 277 +++- .../core/takserver/TAKMeshIntegrationTest.kt | 11 +- .../core/testing/FakeDatabaseManager.kt | 11 +- .../core/testing/FakeRadioInterfaceService.kt | 141 +- .../FakeRadioInterfaceServiceSessionTest.kt | 99 ++ .../core/testing/RepositoryFakesTest.kt | 21 + .../org/meshtastic/desktop/stub/NoopStubs.kt | 17 +- 74 files changed, 8104 insertions(+), 1327 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/SessionWork.kt create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SessionWorkTest.kt create mode 100644 core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerAssociationRecoveryTest.kt create mode 100644 core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerBackfillTest.kt create mode 100644 core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerRetirementTest.kt create mode 100644 core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerShutdownTest.kt create mode 100644 core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerTestFixture.kt create mode 100644 core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerWriterAdmissionTest.kt create mode 100644 core/database/src/jvmTest/kotlin/org/meshtastic/core/database/DatabaseManagerPendingRouteRecoveryJvmTest.kt create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ReceivedRadioFrame.kt create mode 100644 core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceServiceSessionTest.kt diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt index 531deab93c..2c5501c8e2 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext @@ -135,11 +136,16 @@ open class MeshUtilApplication : } override fun onTerminate() { - // Shutdown managers (useful for Robolectric tests) - get().close() + // Shutdown managers (useful for Robolectric tests). + // Non-blocking: cancelAndJoin inside runBlocking on the main thread can deadlock + // if any active coroutine is dispatching to Dispatchers.Main. applicationScope.cancel() - super.onTerminate() - org.koin.core.context.stopKoin() + try { + runBlocking { get().close() } + } finally { + super.onTerminate() + org.koin.core.context.stopKoin() + } } private fun scheduleMeshLogCleanup() { diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt index f2ef17e5c0..e99769a023 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt @@ -33,17 +33,23 @@ interface DatabaseManager { suspend fun switchActiveDatabase(address: String?) /** - * Associates the currently-active connection with the physical device learned from the radio handshake, so the same - * device reached over a different transport (BLE / TCP / USB) shares one database. The first transport to report - * the device claims the active DB for it; a later transport folds its own data in and switches to the claimed DB. - * Idempotent once a device/transport pair has been unified. + * Associates the connection identified by [address] with the physical device learned from its radio handshake, so + * the same device reached over a different transport (BLE / TCP / USB) shares one database. Implementations must + * reject an address that is no longer active, preventing a stale identity emission from claiming or merging the + * newly selected transport's database. The first transport to report the device claims its active DB; a later + * transport folds its own data in and switches to the claimed DB. Idempotent once a device/transport pair has been + * unified. * * [deviceId] is the factory-burned hardware id from MyNodeInfo (null/blank when the platform or firmware doesn't * report one, or when lockdown zeroes it). It is the preferred claim key because it survives the node-number * changes firmware 2.8 introduced (`my_node_num = crc32(public_key)` renumbers on upgrade, erase, and re-key); * [nodeNum] remains the fallback claim for hardware without a device id and for claims made by older app versions. + * + * The caller must hold a transport-session lifecycle lease for the full call. [isSessionActive] must represent that + * lease, not merely whether new work may still be admitted, so association rollover cannot overlap the destination + * transaction commit. */ - suspend fun associateDevice(nodeNum: Int, deviceId: String?) + suspend fun associateDevice(address: String, nodeNum: Int, deviceId: String?, isSessionActive: () -> Boolean) /** Returns true if a database exists for the given device address. */ fun hasDatabaseFor(address: String?): Boolean diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt index 5f1f42c4f9..03ac5d1100 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt @@ -22,6 +22,7 @@ import org.meshtastic.core.repository.AdminPacketHandler import org.meshtastic.core.repository.MeshConfigFlowManager import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.repository.SessionManager import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.MeshPacket @@ -38,7 +39,7 @@ class AdminPacketHandlerImpl( private val sessionManager: SessionManager, ) : AdminPacketHandler { - override fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) { + override fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int, session: RadioSessionContext) { val payload = packet.decoded?.payload ?: return val u = AdminMessage.ADAPTER.decode(payload) Logger.d { "Admin message from=${packet.from} fields=${u.summarize()}" } @@ -52,22 +53,26 @@ class AdminPacketHandlerImpl( val fromNum = packet.from u.get_module_config_response?.let { if (fromNum == myNodeNum) { - configHandler.value.handleModuleConfig(it) + configHandler.value.handleModuleConfig(it, session) } else { - it.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) } + it.statusmessage?.node_status?.let { status -> + nodeManager.updateNodeForSession(fromNum, session) { node -> + node.copy(nodeStatus = status.takeIf(String::isNotEmpty)) + } + } } } if (fromNum == myNodeNum) { - u.get_config_response?.let { configHandler.value.handleDeviceConfig(it) } - u.get_channel_response?.let { configHandler.value.handleChannel(it) } + u.get_config_response?.let { configHandler.value.handleDeviceConfig(it, session) } + u.get_channel_response?.let { configHandler.value.handleChannel(it, session) } } u.get_device_metadata_response?.let { if (fromNum == myNodeNum) { - configFlowManager.value.handleLocalMetadata(it) + configFlowManager.value.handleLocalMetadata(it, session) } else { - nodeManager.insertMetadata(fromNum, it) + nodeManager.insertMetadata(fromNum, it, session) } } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt index 4a13b7b12e..d46b39a48c 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt @@ -20,7 +20,6 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.util.isOtaStatusNotification import org.meshtastic.core.repository.FirmwareUpdateStatusRepository @@ -32,6 +31,8 @@ import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.repository.ServiceStateWriter import org.meshtastic.core.repository.XModemManager import org.meshtastic.core.resources.Res @@ -46,6 +47,7 @@ import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.FromRadio /** Implementation of [FromRadioPacketHandler] that dispatches [FromRadio] variants to specialized handlers. */ +@Suppress("LongParameterList") @Single class FromRadioPacketHandlerImpl( private val serviceStateWriter: ServiceStateWriter, @@ -57,6 +59,7 @@ class FromRadioPacketHandlerImpl( private val notificationManager: NotificationManager, private val lockdownCoordinator: LockdownCoordinator, private val firmwareUpdateStatusRepository: FirmwareUpdateStatusRepository, + private val radioInterfaceService: RadioInterfaceService, ) : FromRadioPacketHandler { // Application-scoped coroutine context for suspend work (e.g. getStringSuspend). @@ -64,7 +67,7 @@ class FromRadioPacketHandlerImpl( private val scope = CoroutineScope(ioDispatcher + SupervisorJob()) @Suppress("CyclomaticComplexMethod") - override fun handleFromRadio(proto: FromRadio) { + override fun handleFromRadio(proto: FromRadio, session: RadioSessionContext) { val myInfo = proto.my_info val metadata = proto.metadata val nodeInfo = proto.node_info @@ -82,100 +85,130 @@ class FromRadioPacketHandlerImpl( val lockdownStatus = proto.lockdown_status when { - myInfo != null -> configFlowManager.value.handleMyInfo(myInfo) + myInfo != null -> configFlowManager.value.handleMyInfo(myInfo, session) // deviceuiConfig arrives immediately after my_info (STATE_SEND_UIDATA). It carries // the device's display, theme, node-filter, and other UI preferences. - deviceUIConfig != null -> configHandler.value.handleDeviceUIConfig(deviceUIConfig) + deviceUIConfig != null -> configHandler.value.handleDeviceUIConfig(deviceUIConfig, session) - metadata != null -> configFlowManager.value.handleLocalMetadata(metadata) + metadata != null -> configFlowManager.value.handleLocalMetadata(metadata, session) nodeInfo != null -> { - configFlowManager.value.handleNodeInfo(nodeInfo) - serviceStateWriter.setConnectionProgress("Nodes (${configFlowManager.value.newNodeCount})") + if (configFlowManager.value.handleNodeInfo(nodeInfo, session)) { + runIfSessionActive(session, "node-list progress") { + serviceStateWriter.setConnectionProgress("Nodes (${configFlowManager.value.newNodeCount})") + } + } } configCompleteId != null -> { - configFlowManager.value.handleConfigComplete(configCompleteId) - lockdownCoordinator.onConfigComplete() + if (configFlowManager.value.handleConfigComplete(configCompleteId, session)) { + runIfSessionActive(session, "lockdown config completion") { lockdownCoordinator.onConfigComplete() } + } } - mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage) + mqttProxyMessage != null -> + runIfSessionActive(session, "MQTT proxy message") { + mqttManager.handleMqttProxyMessage(mqttProxyMessage) + } - queueStatus != null -> packetHandler.handleQueueStatus(queueStatus) + queueStatus != null -> + runIfSessionActive(session, "queue status") { packetHandler.handleQueueStatus(queueStatus) } - config != null -> configHandler.value.handleDeviceConfig(config) + config != null -> configHandler.value.handleDeviceConfig(config, session) - moduleConfig != null -> configHandler.value.handleModuleConfig(moduleConfig) + moduleConfig != null -> configHandler.value.handleModuleConfig(moduleConfig, session) - channel != null -> configHandler.value.handleChannel(channel) + channel != null -> configHandler.value.handleChannel(channel, session) - fileInfo != null -> configFlowManager.value.handleFileInfo(fileInfo) + fileInfo != null -> configFlowManager.value.handleFileInfo(fileInfo, session) // region_presets arrives during the handshake (after metadata, before channels). It tells the client // which modem presets are legal per LoRa region. Absent on firmware < 2.8. - regionPresets != null -> configHandler.value.handleRegionPresets(regionPresets) + regionPresets != null -> configHandler.value.handleRegionPresets(regionPresets, session) - xmodemPacket != null -> xmodemManager.value.handleIncomingXModem(xmodemPacket) + xmodemPacket != null -> + runIfSessionActive(session, "XModem packet") { xmodemManager.value.handleIncomingXModem(xmodemPacket) } - lockdownStatus != null -> lockdownCoordinator.handleLockdownStatus(lockdownStatus) + lockdownStatus != null -> + runIfSessionActive(session, "lockdown status") { + lockdownCoordinator.handleLockdownStatus(lockdownStatus) + } - clientNotification != null -> handleClientNotification(clientNotification) + clientNotification != null -> handleClientNotification(clientNotification, session) // Firmware rebooted without a transport-level disconnect (common on serial/TCP). // Re-handshake immediately rather than waiting for the 30s stall guard. proto.rebooted != null -> { Logger.w { "Firmware rebooted (rebooted=${proto.rebooted}), re-initiating handshake" } - configFlowManager.value.triggerWantConfig() + configFlowManager.value.triggerWantConfig(session) } } } - private fun handleClientNotification(cn: ClientNotification) { - serviceStateWriter.setClientNotification(cn) + private fun runIfSessionActive(session: RadioSessionContext, operation: String, block: () -> Unit) { + if (!radioInterfaceService.runIfSessionActive(session, block)) { + Logger.d { "Discarding $operation from stale transport session" } + } + } - scope.handledLaunch { - if (cn.isOtaStatusNotification() && firmwareUpdateStatusRepository.status.value.isOtaUpdateActive) { - Logger.i { "OTA status ClientNotification received; skipping duplicate generic alert" } - return@handledLaunch - } + private fun handleClientNotification(cn: ClientNotification, session: RadioSessionContext) { + val admitted = + radioInterfaceService.runIfSessionActive(session) { serviceStateWriter.setClientNotification(cn) } + if (!admitted) { + Logger.d { "Discarding client notification from stale transport session" } + return + } + radioInterfaceService.launchSessionWork( + scope = scope, + session = session, + onRejected = { Logger.d { "Skipping client alert from stale transport session" } }, + ) { + dispatchClientNotification(cn) + } + } - val inform = cn.key_verification_number_inform - val request = cn.key_verification_number_request - val verificationFinal = cn.key_verification_final - val (title, type) = - when { - inform != null -> { - Logger.i { "Key verification inform from ${inform.remote_longname}" } - Pair(getStringSuspend(Res.string.key_verification_title), Notification.Type.Info) - } + private suspend fun dispatchClientNotification(cn: ClientNotification) { + if (cn.isOtaStatusNotification() && firmwareUpdateStatusRepository.status.value.isOtaUpdateActive) { + Logger.i { "OTA status ClientNotification received; skipping duplicate generic alert" } + return + } - request != null -> { - Logger.i { "Key verification request from ${request.remote_longname}" } - Pair(getStringSuspend(Res.string.key_verification_request_title), Notification.Type.Info) - } + val inform = cn.key_verification_number_inform + val request = cn.key_verification_number_request + val verificationFinal = cn.key_verification_final + val (title, type) = + when { + inform != null -> { + Logger.i { "Key verification inform received" } + Pair(getStringSuspend(Res.string.key_verification_title), Notification.Type.Info) + } - verificationFinal != null -> { - Logger.i { "Key verification final from ${verificationFinal.remote_longname}" } - Pair(getStringSuspend(Res.string.key_verification_final_title), Notification.Type.Info) - } + request != null -> { + Logger.i { "Key verification request received" } + Pair(getStringSuspend(Res.string.key_verification_request_title), Notification.Type.Info) + } - cn.duplicated_public_key != null -> { - Logger.w { "Duplicated public key notification received" } - Pair(getStringSuspend(Res.string.duplicated_public_key_title), Notification.Type.Warning) - } + verificationFinal != null -> { + Logger.i { "Key verification final received" } + Pair(getStringSuspend(Res.string.key_verification_final_title), Notification.Type.Info) + } - cn.low_entropy_key != null -> { - Logger.w { "Low entropy key notification received" } - Pair(getStringSuspend(Res.string.low_entropy_key_title), Notification.Type.Warning) - } + cn.duplicated_public_key != null -> { + Logger.w { "Duplicated public key notification received" } + Pair(getStringSuspend(Res.string.duplicated_public_key_title), Notification.Type.Warning) + } - else -> Pair(getStringSuspend(Res.string.client_notification), Notification.Type.Info) + cn.low_entropy_key != null -> { + Logger.w { "Low entropy key notification received" } + Pair(getStringSuspend(Res.string.low_entropy_key_title), Notification.Type.Warning) } - notificationManager.dispatch( - Notification(title = title, type = type, message = cn.message, category = Notification.Category.Alert), - ) - } + else -> Pair(getStringSuspend(Res.string.client_notification), Notification.Type.Info) + } + + notificationManager.dispatch( + Notification(title = title, type = type, message = cn.message, category = Notification.Category.Alert), + ) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt index 3be4260f5b..2952dcba20 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt @@ -33,6 +33,8 @@ import org.meshtastic.core.repository.MeshNotificationManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NotificationPrefs import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.geofence import org.meshtastic.core.resources.geofence_entered_body @@ -67,10 +69,16 @@ class GeofenceMonitor( private val serviceNotifications: MeshNotificationManager, private val crossingStore: GeofenceCrossingStore, private val notificationPrefs: NotificationPrefs, + private val radioInterfaceService: RadioInterfaceService, @Named("ServiceScope") private val scope: CoroutineScope, ) { - private data class PositionSample(val nodeNum: Int, val lat: Double, val lon: Double) + private data class PositionSample( + val nodeNum: Int, + val lat: Double, + val lon: Double, + val session: RadioSessionContext?, + ) @Volatile private var activeGeofences: List = emptyList() @@ -83,7 +91,13 @@ class GeofenceMonitor( scope.launch { for (sample in samples) { try { - evaluate(sample.nodeNum, sample.lat, sample.lon) + if (sample.session == null) { + evaluate(sample.nodeNum, sample.lat, sample.lon) + } else { + radioInterfaceService.runWhileSessionActive(sample.session) { + evaluate(sample.nodeNum, sample.lat, sample.lon) + } + } } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { // Isolate per-sample failures: an unexpected throw must not kill the sole consumer and silently // stop geofence tracking for the rest of the session. @@ -111,7 +125,7 @@ class GeofenceMonitor( } /** Evaluate a received node position against every active geofence. [nodeNum] is the position's sender. */ - fun onPositionReceived(nodeNum: Int, myNodeNum: Int, position: Position) { + fun onPositionReceived(nodeNum: Int, myNodeNum: Int, position: Position, session: RadioSessionContext? = null) { val latI = position.latitude_i ?: 0 val lonI = position.longitude_i ?: 0 val lat = latI * DEG_D @@ -125,7 +139,7 @@ class GeofenceMonitor( lon !in MIN_LONGITUDE..MAX_LONGITUDE || activeGeofences.isEmpty() if (skip) return - samples.trySend(PositionSample(nodeNum, lat, lon)) + samples.trySend(PositionSample(nodeNum, lat, lon, session)) } private suspend fun evaluate(nodeNum: Int, lat: Double, lon: Double) { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt index c029ac7179..e4f8e752ba 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt @@ -20,6 +20,7 @@ import co.touchlab.kermit.Logger import kotlinx.atomicfu.atomic import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.delay import org.koin.core.annotation.Named import org.koin.core.annotation.Single @@ -27,6 +28,7 @@ import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceVersion +import org.meshtastic.core.model.Node import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.HandshakeConstants import org.meshtastic.core.repository.MeshConfigFlowManager @@ -36,6 +38,8 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationPrefs import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.repository.ServiceStateWriter import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.FileInfo @@ -57,6 +61,7 @@ class MeshConfigFlowManagerImpl( private val commandSender: CommandSender, private val heartbeatSender: DataLayerHeartbeatSender, private val notificationPrefs: NotificationPrefs, + private val radioInterfaceService: RadioInterfaceService, @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshConfigFlowManager { private val wantConfigDelay = 100L @@ -83,6 +88,7 @@ class MeshConfigFlowManagerImpl( * the Stage 2 request; keep those packets so the later node-list phase can still make progress. */ data class ReceivingConfig( + val session: RadioSessionContext, val rawMyNodeInfo: ProtoMyNodeInfo, val metadata: DeviceMetadata? = null, val earlyNodes: List = emptyList(), @@ -94,15 +100,41 @@ class MeshConfigFlowManagerImpl( * [myNodeInfo] was committed at the Stage 1→2 transition. [nodes] accumulates [NodeInfo] packets until * `config_complete_id` arrives. */ - data class ReceivingNodeInfo(val myNodeInfo: SharedMyNodeInfo, val nodes: List = emptyList()) : - HandshakeState() + data class ReceivingNodeInfo( + val session: RadioSessionContext, + val myNodeInfo: SharedMyNodeInfo, + val nodes: List = emptyList(), + ) : HandshakeState() /** Both stages finished. The app is fully connected. */ - data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState() + data class Complete(val session: RadioSessionContext, val myNodeInfo: SharedMyNodeInfo) : HandshakeState() } private val handshakeState = atomic(HandshakeState.Idle) + private fun runForSession(session: RadioSessionContext, block: () -> Unit): Boolean = + radioInterfaceService.runIfSessionActive(session, block) + + private suspend fun runWhileForSession(session: RadioSessionContext, block: suspend () -> Unit): Boolean = + radioInterfaceService.runWhileSessionActive(session, block) + + private fun isActiveSession(session: RadioSessionContext): Boolean = radioInterfaceService.isSessionActive(session) + + private fun HandshakeState.belongsTo(session: RadioSessionContext): Boolean = when (this) { + HandshakeState.Idle -> false + is HandshakeState.ReceivingConfig -> this.session == session + is HandshakeState.ReceivingNodeInfo -> this.session == session + is HandshakeState.Complete -> this.session == session + } + + /** Privacy-safe state label for diagnostics; handshake payloads contain transport and device identifiers. */ + private fun HandshakeState.diagnosticName(): String = when (this) { + HandshakeState.Idle -> "Idle" + is HandshakeState.ReceivingConfig -> "ReceivingConfig" + is HandshakeState.ReceivingNodeInfo -> "ReceivingNodeInfo" + is HandshakeState.Complete -> "Complete" + } + override val newNodeCount: Int get() = when (val state = handshakeState.value) { @@ -111,30 +143,47 @@ class MeshConfigFlowManagerImpl( else -> 0 } - override fun handleConfigComplete(configCompleteId: Int) { + override fun handleConfigComplete(configCompleteId: Int, session: RadioSessionContext): Boolean { + var handled = false + val admitted = runForSession(session) { handled = handleConfigCompleteActive(configCompleteId, session) } + if (!admitted) { + Logger.d { "Discarding config_complete from stale transport session gen=${session.generation}" } + } + return admitted && handled + } + + private fun handleConfigCompleteActive(configCompleteId: Int, session: RadioSessionContext): Boolean { val state = handshakeState.value - when (configCompleteId) { + return when (configCompleteId) { HandshakeConstants.CONFIG_NONCE -> { - if (state !is HandshakeState.ReceivingConfig) { - Logger.w { "Ignoring Stage 1 config_complete in state=$state" } - return + if (state !is HandshakeState.ReceivingConfig || !state.belongsTo(session)) { + Logger.w { "Ignoring Stage 1 config_complete in state=${state.diagnosticName()}" } + false + } else { + handleConfigOnlyComplete(state) + true } - handleConfigOnlyComplete(state) } HandshakeConstants.NODE_INFO_NONCE -> { - if (state !is HandshakeState.ReceivingNodeInfo) { - Logger.w { "Ignoring Stage 2 config_complete in state=$state" } - return + if (state !is HandshakeState.ReceivingNodeInfo || !state.belongsTo(session)) { + Logger.w { "Ignoring Stage 2 config_complete in state=${state.diagnosticName()}" } + false + } else { + handleNodeInfoComplete(state) + true } - handleNodeInfoComplete(state) } - else -> Logger.w { "Config complete id mismatch: $configCompleteId" } + else -> { + Logger.w { "Config complete id mismatch: $configCompleteId" } + false + } } } private fun handleConfigOnlyComplete(state: HandshakeState.ReceivingConfig) { + val session = state.session Logger.i { "Config-only complete (Stage 1)" } val finalizedInfo = buildMyNodeInfo(state.rawMyNodeInfo, state.metadata) @@ -143,7 +192,7 @@ class MeshConfigFlowManagerImpl( handshakeState.value = HandshakeState.Idle scope.handledLaunch { delay(wantConfigDelay) - connectionManager.value.startConfigOnly() + runForSession(session) { connectionManager.value.startConfigOnly() } } return } @@ -160,31 +209,40 @@ class MeshConfigFlowManagerImpl( } } - handshakeState.value = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo, nodes = state.earlyNodes) + handshakeState.value = + HandshakeState.ReceivingNodeInfo( + session = state.session, + myNodeInfo = finalizedInfo, + nodes = state.earlyNodes, + ) Logger.i { "myNodeInfo committed" } connectionManager.value.onRadioConfigLoaded() serviceStateWriter.setConnectionProgress("Loading node list") scope.handledLaunch { delay(wantConfigDelay) - heartbeatSender.sendHeartbeat("inter-stage") + val heartbeatSent = runWhileForSession(session) { heartbeatSender.sendHeartbeat("inter-stage") } + if (!heartbeatSent) return@handledLaunch delay(wantConfigDelay) - Logger.i { "Requesting NodeInfo (Stage 2)" } - connectionManager.value.startNodeInfoOnly() + runForSession(session) { + Logger.i { "Requesting NodeInfo (Stage 2)" } + connectionManager.value.startNodeInfoOnly() + } } connectionManager.value.onHandshakeProgress() } private fun handleNodeInfoComplete(state: HandshakeState.ReceivingNodeInfo) { + val session = state.session Logger.i { "NodeInfo complete (Stage 2)" } val info = state.myNodeInfo // Transition state immediately (synchronously) to prevent duplicate handling. - // The async work below (DB writes, broadcasts) proceeds without the guard. + // The async work below rechecks the originating transport session before publishing results. // Because nodes is now immutable, no snapshot is needed — state.nodes IS the snapshot. // Any stall-guard retry that re-enters handleNodeInfo will see Complete state and be ignored. - handshakeState.value = HandshakeState.Complete(myNodeInfo = info) + handshakeState.value = HandshakeState.Complete(session = state.session, myNodeInfo = info) // Cancel the transport-aware fast-recovery watchdog SYNCHRONOUSLY, before the async DB // install work below is launched. The firmware handshake has already completed at this @@ -194,44 +252,7 @@ class MeshConfigFlowManagerImpl( // NodeDB side-effect set, but it runs only after the DB install block finishes. connectionManager.value.onHandshakeComplete() - val entities = - state.nodes.mapNotNull { nodeInfo -> - nodeManager.installNodeInfo(nodeInfo) - nodeManager.nodeDBbyNodeNum[nodeInfo.num] - ?: run { - Logger.w { "Node ${nodeInfo.num} missing from DB after installNodeInfo; skipping" } - null - } - } - - scope.handledLaunch { - try { - val removedNums = nodeRepository.installConfig(info, entities) - if (removedNums.isNotEmpty()) { - // Identity migration dropped stale rows (e.g. the device renumbered after a firmware - // 2.8 upgrade); evict them from the in-memory index so lookups can't resurrect them. - Logger.i { "Config install migrated ${removedNums.size} stale node identit(y/ies)" } - removedNums.forEach(nodeManager::removeByNodenum) - } - } catch (e: CancellationException) { - throw e - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.e(e) { "Post-handshake NodeDB install failed; restarting transport to recover" } - nodeManager.setNodeDbReady(false) - nodeManager.setAllowNodeDbWrites(false) - connectionManager.value.recoverPostHandshakeFailure() - return@handledLaunch - } - - nodeManager.setNodeDbReady(true) - nodeManager.setAllowNodeDbWrites(true) - serviceStateWriter.setConnectionState(ConnectionState.Connected) - - safeCatching { analytics.setDeviceAttributes(info.firmwareVersion ?: "unknown", info.model ?: "unknown") } - .onFailure { e -> Logger.w(e) { "Failed to set post-handshake analytics attributes" } } - safeCatching { connectionManager.value.onNodeDbReady() } - .onFailure { e -> Logger.e(e) { "Post-connected onNodeDbReady side effects failed" } } - } + scope.handledLaunch { finishNodeInfoInstall(state) } // Note: onHandshakeProgress() is intentionally NOT called here. By this point the // handshake has reached HandshakeState.Complete and the synchronous onHandshakeComplete() // call above has already cancelled the watchdog. Re-arming via onHandshakeProgress() @@ -239,84 +260,200 @@ class MeshConfigFlowManagerImpl( // sites cover all genuine progress. } - override fun handleMyInfo(myInfo: ProtoMyNodeInfo) { - Logger.i { "MyNodeInfo received" } - - // Transition to Stage 1, discarding any stale data from a prior interrupted handshake. - handshakeState.value = HandshakeState.ReceivingConfig(rawMyNodeInfo = myInfo) - // Device id before node num: RadioControllerImpl gates its DB association on a non-null num, - // so ordering this way guarantees the association never fires with a stale device id. - // Hex, not utf8: device_id is raw hardware bytes, and a lossy decode could collapse two - // distinct devices into the same id. - nodeManager.setMyDeviceId(myInfo.device_id.hex().takeIf { it.isNotBlank() }) - nodeManager.setMyNodeNum(myInfo.my_node_num) - nodeManager.setFirmwareEdition(myInfo.firmware_edition) - applyEventFirmwareNotificationDefaults(myInfo.firmware_edition) - - // Bump the generation so that a pending clear from a prior (interrupted) handshake - // will see a stale snapshot and skip its writes, preventing it from wiping config - // that was saved by this (newer) handshake's incoming packets. - val gen = handshakeGeneration.incrementAndGet() - - // Clear persisted radio config so the new handshake starts from a clean slate. - // DataStore serializes its own writes, so the clear will precede subsequent - // setLocalConfig / updateChannelSettings calls dispatched by later packets in this - // session (handleFromRadio processes packets sequentially, so later dispatches always - // occur after this one returns). - scope.handledLaunch { - if (handshakeGeneration.value != gen) return@handledLaunch // Stale handshake; skip. - radioConfigRepository.clearChannelSet() - radioConfigRepository.clearLocalConfig() - radioConfigRepository.clearLocalModuleConfig() - radioConfigRepository.clearDeviceUIConfig() - radioConfigRepository.clearFileManifest() - radioConfigRepository.clearLoraRegionPresetMap() + private suspend fun finishNodeInfoInstall(state: HandshakeState.ReceivingNodeInfo) { + val session = state.session + try { + val admitted = runWhileForSession(session) { installAndPublishNodeDatabase(state) } + if (!admitted) Logger.d { "Discarding stale post-handshake install and publication" } + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + val recovered = + runForSession(session) { + Logger.e(e) { "Post-handshake NodeDB install failed; restarting transport to recover" } + nodeManager.setNodeDbReady(false) + nodeManager.setAllowNodeDbWrites(false) + connectionManager.value.recoverPostHandshakeFailure() + } + if (!recovered) Logger.d { "Discarding stale post-handshake recovery" } } - connectionManager.value.onHandshakeProgress() } - override fun handleLocalMetadata(metadata: DeviceMetadata) { - Logger.i { "Local Metadata received: ${metadata.firmware_version}" } - val state = handshakeState.value - if (state is HandshakeState.ReceivingConfig) { - handshakeState.value = state.copy(metadata = metadata) - // Persist the metadata immediately — buildMyNodeInfo() reads it at Stage 1 complete, - // but the DB write does not need to wait until then. - if (metadata != DeviceMetadata()) { - scope.handledLaunch { nodeRepository.insertMetadata(state.rawMyNodeInfo.my_node_num, metadata) } - } - connectionManager.value.onHandshakeProgress() - } else { - Logger.w { "Ignoring metadata outside Stage 1 (state=$state)" } + @Suppress("ReturnCount") + private suspend fun installAndPublishNodeDatabase(state: HandshakeState.ReceivingNodeInfo) { + val session = state.session + val info = state.myNodeInfo + val entities = mutableListOf() + state.nodes.forEach { nodeInfo -> + nodeManager.installNodeInfo(nodeInfo) + if (!isActiveSession(session)) return + nodeManager.nodeDBbyNodeNum[nodeInfo.num]?.let(entities::add) + ?: Logger.w { "Node ${nodeInfo.num} missing after installNodeInfo; skipping" } } + if (!isActiveSession(session)) return + + val removedNums = nodeRepository.installConfig(info, entities) + if (!isActiveSession(session)) return + if (removedNums.isNotEmpty()) { + Logger.i { "Config install migrated ${removedNums.size} stale node identit(y/ies)" } + removedNums.forEach(nodeManager::removeByNodenum) + } + + val published = + runForSession(session) { + nodeManager.setNodeDbReady(true) + nodeManager.setAllowNodeDbWrites(true) + serviceStateWriter.setConnectionState(ConnectionState.Connected) + } + if (!published) return + + safeCatching { analytics.setDeviceAttributes(info.firmwareVersion ?: "unknown", info.model ?: "unknown") } + .onFailure { e -> Logger.w(e) { "Failed to set post-handshake analytics attributes" } } + if (!isActiveSession(session)) return + safeCatching { connectionManager.value.onNodeDbReady() } + .onFailure { e -> Logger.e(e) { "Post-connected onNodeDbReady side effects failed" } } } - override fun handleNodeInfo(info: NodeInfo) { - val state = handshakeState.value - when (state) { - is HandshakeState.ReceivingConfig -> { - Logger.d { "Buffering NodeInfo received during Stage 1" } - handshakeState.value = state.copy(earlyNodes = state.earlyNodes.withNodeInfo(info)) + override fun handleMyInfo(myInfo: ProtoMyNodeInfo, session: RadioSessionContext) { + // Hex, not utf8: device_id is raw hardware bytes, and a lossy decode could collapse two + // distinct devices into the same id. Decode before admission, then publish every synchronous handshake + // mutation under the transport's revocation lock. + val deviceId = myInfo.device_id.hex().takeIf { it.isNotBlank() } + var clearGeneration: Long? = null + val admitted = + radioInterfaceService.runIfSessionActive(session) { + Logger.i { "MyNodeInfo received" } + handshakeState.value = HandshakeState.ReceivingConfig(session = session, rawMyNodeInfo = myInfo) + nodeManager.setMyDeviceId(deviceId) + nodeManager.setMyNodeNum(myInfo.my_node_num) + nodeManager.publishConnectionIdentity( + sessionGeneration = session.generation, + address = session.address, + nodeNum = myInfo.my_node_num, + deviceId = deviceId, + ) + Logger.d { + "[DeviceAssociation] fresh-identity node=${myInfo.my_node_num} " + + "deviceIdPresent=${deviceId != null}" + } + nodeManager.setFirmwareEdition(myInfo.firmware_edition) + applyEventFirmwareNotificationDefaults(myInfo.firmware_edition) + + // Bump the generation so a pending clear from an interrupted handshake cannot wipe config saved by + // this newer session. The async clear also rechecks transport authority before touching persistence. + clearGeneration = handshakeGeneration.incrementAndGet() connectionManager.value.onHandshakeProgress() } + if (!admitted) { + Logger.d { "[DeviceAssociation] discard stale MyNodeInfo gen=${session.generation}" } + return + } - is HandshakeState.ReceivingNodeInfo -> { - handshakeState.value = state.copy(nodes = state.nodes.withNodeInfo(info)) - connectionManager.value.onHandshakeProgress() + val gen = checkNotNull(clearGeneration) + // Queue on the serialized session-operation lane before returning to the FIFO frame consumer. Without + // UNDISPATCHED, a later config frame can queue its persistence first and then be erased by this reset. + scope.handledLaunch(start = CoroutineStart.UNDISPATCHED) { + runWhileForSession(session) { + if (handshakeGeneration.value != gen) return@runWhileForSession + radioConfigRepository.clearChannelSet() + if (handshakeGeneration.value != gen) return@runWhileForSession + radioConfigRepository.clearLocalConfig() + if (handshakeGeneration.value != gen) return@runWhileForSession + radioConfigRepository.clearLocalModuleConfig() + if (handshakeGeneration.value != gen) return@runWhileForSession + radioConfigRepository.clearDeviceUIConfig() + if (handshakeGeneration.value != gen) return@runWhileForSession + radioConfigRepository.clearFileManifest() + if (handshakeGeneration.value != gen) return@runWhileForSession + radioConfigRepository.clearLoraRegionPresetMap() } + } + } - else -> Logger.w { "Ignoring NodeInfo outside active handshake (state=$state)" } + override fun handleLocalMetadata(metadata: DeviceMetadata, session: RadioSessionContext): Boolean { + var handled = false + var metadataNodeNum: Int? = null + val admitted = + runForSession(session) { + Logger.i { "Local Metadata received: ${metadata.firmware_version}" } + val state = handshakeState.value + if (state is HandshakeState.ReceivingConfig && state.belongsTo(session)) { + handled = true + handshakeState.value = state.copy(metadata = metadata) + // Persist the metadata immediately, but never let a queued old-session write target the next + // session's selected database. + if (metadata != DeviceMetadata()) { + metadataNodeNum = state.rawMyNodeInfo.my_node_num + } + connectionManager.value.onHandshakeProgress() + } else { + Logger.w { + "Ignoring metadata outside the owning Stage 1 session (state=${state.diagnosticName()})" + } + } + } + metadataNodeNum?.let { nodeNum -> + scope.handledLaunch(start = CoroutineStart.UNDISPATCHED) { + runWhileForSession(session) { nodeRepository.insertMetadata(nodeNum, metadata) } + } } + if (!admitted) Logger.d { "Discarding metadata from stale transport session" } + return admitted && handled } - override fun handleFileInfo(info: FileInfo) { - Logger.d { "FileInfo received: ${info.file_name} (${info.size_bytes} bytes)" } - scope.handledLaunch { radioConfigRepository.addFileInfo(info) } - connectionManager.value.onHandshakeProgress() + override fun handleNodeInfo(info: NodeInfo, session: RadioSessionContext): Boolean { + var handled = false + val admitted = + runForSession(session) { + val state = handshakeState.value + when (state) { + is HandshakeState.ReceivingConfig -> { + if (state.belongsTo(session)) { + handled = true + Logger.d { "Buffering NodeInfo received during Stage 1" } + handshakeState.value = state.copy(earlyNodes = state.earlyNodes.withNodeInfo(info)) + connectionManager.value.onHandshakeProgress() + } else { + Logger.w { "Ignoring NodeInfo from a session that does not own Stage 1" } + } + } + + is HandshakeState.ReceivingNodeInfo -> { + if (state.belongsTo(session)) { + handled = true + handshakeState.value = state.copy(nodes = state.nodes.withNodeInfo(info)) + connectionManager.value.onHandshakeProgress() + } else { + Logger.w { "Ignoring NodeInfo from a session that does not own Stage 2" } + } + } + + else -> Logger.w { "Ignoring NodeInfo outside active handshake (state=${state.diagnosticName()})" } + } + } + if (!admitted) Logger.d { "Discarding NodeInfo from stale transport session" } + return admitted && handled + } + + override fun handleFileInfo(info: FileInfo, session: RadioSessionContext): Boolean { + val admitted = + runForSession(session) { + Logger.d { "FileInfo received: ${info.file_name} (${info.size_bytes} bytes)" } + connectionManager.value.onHandshakeProgress() + } + if (admitted) { + scope.handledLaunch(start = CoroutineStart.UNDISPATCHED) { + runWhileForSession(session) { radioConfigRepository.addFileInfo(info) } + } + } + if (!admitted) Logger.d { "Discarding FileInfo from stale transport session" } + return admitted } - override fun triggerWantConfig() { - connectionManager.value.startConfigOnly() + override fun triggerWantConfig(session: RadioSessionContext): Boolean { + val admitted = runForSession(session) { connectionManager.value.startConfigOnly() } + if (!admitted) Logger.d { "Discarding reboot handshake trigger from stale transport session" } + return admitted } /** diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt index f46d5beabe..b830ab69bc 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt @@ -17,7 +17,9 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn @@ -29,6 +31,8 @@ import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.repository.ServiceStateWriter import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -44,6 +48,7 @@ class MeshConfigHandlerImpl( private val serviceStateWriter: ServiceStateWriter, private val nodeManager: NodeManager, private val connectionManager: Lazy, + private val radioInterfaceService: RadioInterfaceService, @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshConfigHandler { @@ -58,51 +63,112 @@ class MeshConfigHandlerImpl( radioConfigRepository.moduleConfigFlow.onEach { _moduleConfig.value = it }.launchIn(scope) } - override fun handleDeviceConfig(config: Config) { - Logger.d { "Device config received: ${config.summarize()}" } - scope.handledLaunch { radioConfigRepository.setLocalConfig(config) } - serviceStateWriter.setConnectionProgress("Device config received") - connectionManager.value.onHandshakeProgress() - } + private fun runForSession(session: RadioSessionContext, block: () -> Unit): Boolean = + radioInterfaceService.runIfSessionActive(session, block) - override fun handleModuleConfig(config: ModuleConfig) { - Logger.d { "Module config received: ${config.summarize()}" } - scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) } - serviceStateWriter.setConnectionProgress("Module config received") + private suspend fun runWhileForSession(session: RadioSessionContext, block: suspend () -> Unit): Boolean = + radioInterfaceService.runWhileSessionActive(session, block) - config.statusmessage?.let { sm -> - nodeManager.myNodeNum.value?.let { num -> nodeManager.updateNodeStatus(num, sm.node_status) } + override fun handleDeviceConfig(config: Config, session: RadioSessionContext): Boolean { + val admitted = + runForSession(session) { + Logger.d { "Device config received: ${config.summarize()}" } + serviceStateWriter.setConnectionProgress("Device config received") + connectionManager.value.onHandshakeProgress() + } + if (admitted) { + launchPersistenceForSession(session) { radioConfigRepository.setLocalConfig(config) } } - connectionManager.value.onHandshakeProgress() + if (!admitted) Logger.d { "Discarding device config from stale transport session" } + return admitted } - override fun handleChannel(channel: Channel) { - // We always want to save channel settings we receive from the radio - scope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) } + override fun handleModuleConfig(config: ModuleConfig, session: RadioSessionContext): Boolean { + var statusUpdate: Pair? = null + val admitted = + runForSession(session) { + statusUpdate = + config.statusmessage?.let { status -> + nodeManager.myNodeNum.value?.let { nodeNum -> nodeNum to status.node_status } + } + Logger.d { "Module config received: ${config.summarize()}" } + serviceStateWriter.setConnectionProgress("Module config received") + connectionManager.value.onHandshakeProgress() + } + if (admitted) { + launchPersistenceForSession(session) { + radioConfigRepository.setLocalModuleConfig(config) + statusUpdate?.let { (nodeNum, status) -> + try { + nodeManager.updateNodeStatusAndPersist(nodeNum, status) + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.w(e) { "Node status persistence failed after module config was persisted" } + } + } + } + } + if (!admitted) Logger.d { "Discarding module config from stale transport session" } + return admitted + } - // Update status message if we have node info, otherwise use a generic one - val mi = nodeManager.getMyNodeInfo() - val index = channel.index - if (mi != null) { - serviceStateWriter.setConnectionProgress("Channels (${index + 1} / ${mi.maxChannels})") - } else { - serviceStateWriter.setConnectionProgress("Channels (${index + 1})") + override fun handleChannel(channel: Channel, session: RadioSessionContext): Boolean { + val admitted = + runForSession(session) { + // Update status message if we have node info, otherwise use a generic one. + val mi = nodeManager.getMyNodeInfo() + val index = channel.index + if (mi != null) { + serviceStateWriter.setConnectionProgress("Channels (${index + 1} / ${mi.maxChannels})") + } else { + serviceStateWriter.setConnectionProgress("Channels (${index + 1})") + } + connectionManager.value.onHandshakeProgress() + } + if (admitted) { + // We always want to save channel settings we receive from the radio. + launchPersistenceForSession(session) { radioConfigRepository.updateChannelSettings(channel) } } - connectionManager.value.onHandshakeProgress() + if (!admitted) Logger.d { "Discarding channel config from stale transport session" } + return admitted } - override fun handleDeviceUIConfig(config: DeviceUIConfig) { - Logger.d { "DeviceUI config received" } - scope.handledLaunch { radioConfigRepository.setDeviceUIConfig(config) } - // deviceuiConfig arrives during Stage 1 immediately after my_info. It proves the transport - // is alive, so surface it as handshake progress — without this, a long gap before the next - // meaningful packet could falsely trip the fast-path watchdog on TCP/USB. - connectionManager.value.onHandshakeProgress() + override fun handleDeviceUIConfig(config: DeviceUIConfig, session: RadioSessionContext): Boolean { + val admitted = + runForSession(session) { + Logger.d { "DeviceUI config received" } + // deviceuiConfig arrives during Stage 1 immediately after my_info. It proves the transport + // is alive, so surface it as handshake progress — without this, a long gap before the next + // meaningful packet could falsely trip the fast-path watchdog on TCP/USB. + connectionManager.value.onHandshakeProgress() + } + if (admitted) { + launchPersistenceForSession(session) { radioConfigRepository.setDeviceUIConfig(config) } + } + if (!admitted) Logger.d { "Discarding DeviceUI config from stale transport session" } + return admitted + } + + override fun handleRegionPresets(map: LoRaRegionPresetMap, session: RadioSessionContext): Boolean { + val admitted = + runForSession(session) { + Logger.d { "Region presets received (${map.region_groups.size} regions, ${map.groups.size} groups)" } + connectionManager.value.onHandshakeProgress() + } + if (admitted) { + launchPersistenceForSession(session) { radioConfigRepository.setLoraRegionPresetMap(map) } + } + if (!admitted) Logger.d { "Discarding region presets from stale transport session" } + return admitted } - override fun handleRegionPresets(map: LoRaRegionPresetMap) { - Logger.d { "Region presets received (${map.region_groups.size} regions, ${map.groups.size} groups)" } - scope.handledLaunch { radioConfigRepository.setLoraRegionPresetMap(map) } + /** + * Queues handshake persistence on the serialized session-operation lane before the FIFO consumer admits the next + * packet. + */ + private fun launchPersistenceForSession(session: RadioSessionContext, block: suspend () -> Unit) { + scope.handledLaunch(start = CoroutineStart.UNDISPATCHED) { runWhileForSession(session, block) } } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index 9b42e70b35..986a6abb43 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -21,11 +21,9 @@ import co.touchlab.kermit.Severity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import okio.ByteString import org.koin.core.annotation.Named import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.DataPacket @@ -57,6 +55,8 @@ import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.repository.ServiceStateWriter import org.meshtastic.core.repository.StoreForwardPacketHandler import org.meshtastic.core.repository.TelemetryPacketHandler @@ -109,6 +109,7 @@ class MeshDataHandlerImpl( private val collectorRegistry: DiscoveryPacketCollectorRegistry, private val geofenceMonitor: GeofenceMonitor, private val meshBeaconRepository: MeshBeaconRepository, + private val radioInterfaceService: RadioInterfaceService, @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshDataHandler { @@ -120,19 +121,27 @@ class MeshDataHandlerImpl( PortNum.NODE_STATUS_APP.value, ) - override fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String?, logInsertJob: Job?) { + override fun handleReceivedData( + packet: MeshPacket, + myNodeNum: Int, + session: RadioSessionContext, + logUuid: String?, + logInsertJob: Job?, + ) { val dataPacket = dataMapper.toDataPacket(packet) ?: return val fromUs = myNodeNum == packet.from dataPacket.status = MessageStatus.RECEIVED - handleDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob) + handleDataPacket(packet, dataPacket, myNodeNum, fromUs, session, logUuid, logInsertJob) analytics.track("num_data_receive", DataPair("num_data_receive", 1)) // Forward to discovery scan collector if active collectorRegistry.collector?.let { collector -> if (collector.isActive) { - scope.handledLaunch { collector.onPacketReceived(packet, dataPacket) } + radioInterfaceService.launchSessionWork(scope, session) { + collector.onPacketReceived(packet, dataPacket) + } } } } @@ -142,19 +151,20 @@ class MeshDataHandlerImpl( dataPacket: DataPacket, myNodeNum: Int, fromUs: Boolean, + session: RadioSessionContext, logUuid: String?, logInsertJob: Job?, ) { val decoded = packet.decoded ?: return when (decoded.portnum) { - PortNum.TEXT_MESSAGE_APP -> handleTextMessage(packet, dataPacket, myNodeNum) - PortNum.NODE_STATUS_APP -> handleNodeStatus(packet, dataPacket, myNodeNum) - PortNum.ALERT_APP -> rememberDataPacket(dataPacket, myNodeNum) - PortNum.WAYPOINT_APP -> handleWaypoint(packet, dataPacket, myNodeNum) - PortNum.POSITION_APP -> handlePosition(packet, dataPacket, myNodeNum) - PortNum.NODEINFO_APP -> if (!fromUs) handleNodeInfo(packet) - PortNum.TELEMETRY_APP -> telemetryHandler.handleTelemetry(packet, dataPacket, myNodeNum) - else -> handleSpecializedDataPacket(packet, dataPacket, myNodeNum, logUuid, logInsertJob) + PortNum.TEXT_MESSAGE_APP -> handleTextMessage(packet, dataPacket, myNodeNum, session) + PortNum.NODE_STATUS_APP -> handleNodeStatus(packet, dataPacket, myNodeNum, session) + PortNum.ALERT_APP -> rememberDataPacket(dataPacket, myNodeNum, session = session) + PortNum.WAYPOINT_APP -> handleWaypoint(packet, dataPacket, myNodeNum, session) + PortNum.POSITION_APP -> handlePosition(packet, dataPacket, myNodeNum, session) + PortNum.NODEINFO_APP -> if (!fromUs) handleNodeInfo(packet, session) + PortNum.TELEMETRY_APP -> telemetryHandler.handleTelemetry(packet, dataPacket, myNodeNum, session) + else -> handleSpecializedDataPacket(packet, dataPacket, myNodeNum, session, logUuid, logInsertJob) } } @@ -162,33 +172,34 @@ class MeshDataHandlerImpl( packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int, + session: RadioSessionContext, logUuid: String?, logInsertJob: Job?, ) { val decoded = packet.decoded ?: return when (decoded.portnum) { PortNum.TRACEROUTE_APP -> { - tracerouteHandler.handleTraceroute(packet, logUuid, logInsertJob) + tracerouteHandler.handleTraceroute(packet, logUuid, logInsertJob, session) } PortNum.ROUTING_APP -> { - handleRouting(packet, dataPacket) + handleRouting(packet, dataPacket, session) } PortNum.PAXCOUNTER_APP -> { - handlePaxCounter(packet) + handlePaxCounter(packet, session) } PortNum.STORE_FORWARD_APP -> { - storeForwardHandler.handleStoreAndForward(packet, dataPacket, myNodeNum) + storeForwardHandler.handleStoreAndForward(packet, dataPacket, myNodeNum, session) } PortNum.STORE_FORWARD_PLUSPLUS_APP -> { - storeForwardHandler.handleStoreForwardPlusPlus(packet) + storeForwardHandler.handleStoreForwardPlusPlus(packet, session) } PortNum.ADMIN_APP -> { - adminPacketHandler.handleAdminMessage(packet, myNodeNum) + adminPacketHandler.handleAdminMessage(packet, myNodeNum, session) } PortNum.NEIGHBORINFO_APP -> { @@ -203,20 +214,20 @@ class MeshDataHandlerImpl( PortNum.RANGE_TEST_APP, PortNum.DETECTION_SENSOR_APP, -> { - handleRangeTest(dataPacket, myNodeNum) + handleRangeTest(dataPacket, myNodeNum, session) } PortNum.MESH_BEACON_APP -> { - handleMeshBeacon(packet, myNodeNum) + handleMeshBeacon(packet, myNodeNum, session) } else -> {} } } - private fun handleRangeTest(dataPacket: DataPacket, myNodeNum: Int) { + private fun handleRangeTest(dataPacket: DataPacket, myNodeNum: Int, session: RadioSessionContext) { val u = dataPacket.copy(dataType = PortNum.TEXT_MESSAGE_APP.value) - rememberDataPacket(u, myNodeNum) + rememberDataPacket(u, myNodeNum, session = session) } /** @@ -226,7 +237,7 @@ class MeshDataHandlerImpl( * carrying a join offer (a channel) are actionable; message-only beacons are ignored. */ @Suppress("ReturnCount") - private fun handleMeshBeacon(packet: MeshPacket, myNodeNum: Int) { + private fun handleMeshBeacon(packet: MeshPacket, myNodeNum: Int, session: RadioSessionContext) { // Ignore our own beacons (spec FR-001) — once broadcast is enabled a node that also listens would self-notify. if (packet.from == myNodeNum) return val payload = packet.decoded?.payload ?: return @@ -236,7 +247,7 @@ class MeshDataHandlerImpl( val offer = MeshBeaconOffer(fromNodeNum = packet.from, beacon = beacon, snr = packet.rx_snr, rssi = packet.rx_rssi) if (meshBeaconRepository.add(offer)) { - scope.launch { + radioInterfaceService.launchSessionWork(scope, session) { notificationManager.dispatch( Notification( title = getStringSuspend(Res.string.mesh_beacon_notification_title), @@ -250,38 +261,53 @@ class MeshDataHandlerImpl( } } - private fun handlePaxCounter(packet: MeshPacket) { + private fun handlePaxCounter(packet: MeshPacket, session: RadioSessionContext) { val payload = packet.decoded?.payload ?: return val p = Paxcount.ADAPTER.decodeOrNull(payload, Logger) ?: return - nodeManager.handleReceivedPaxcounter(packet.from, p) + nodeManager.handleReceivedPaxcounter(packet.from, p, session) } - private fun handlePosition(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { + private fun handlePosition( + packet: MeshPacket, + dataPacket: DataPacket, + myNodeNum: Int, + session: RadioSessionContext, + ) { val payload = packet.decoded?.payload ?: return val p = Position.ADAPTER.decodeOrNull(payload, Logger) ?: return Logger.d { "Position from ${packet.from}: ${Position.ADAPTER.toOneLiner(p)}" } - nodeManager.handleReceivedPosition(packet.from, myNodeNum, p, dataPacket.time) - geofenceMonitor.onPositionReceived(packet.from, myNodeNum, p) + nodeManager.handleReceivedPosition(packet.from, myNodeNum, p, dataPacket.time, session) + geofenceMonitor.onPositionReceived(packet.from, myNodeNum, p, session) } - private fun handleWaypoint(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { + private fun handleWaypoint( + packet: MeshPacket, + dataPacket: DataPacket, + myNodeNum: Int, + session: RadioSessionContext, + ) { val payload = packet.decoded?.payload ?: return val u = Waypoint.ADAPTER.decode(payload) if (u.locked_to != 0 && u.locked_to != packet.from) return val currentSecond = nowSeconds.toInt() - rememberDataPacket(dataPacket, myNodeNum, updateNotification = u.expire > currentSecond) + rememberDataPacket(dataPacket, myNodeNum, updateNotification = u.expire > currentSecond, session = session) } - private fun handleTextMessage(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { + private fun handleTextMessage( + packet: MeshPacket, + dataPacket: DataPacket, + myNodeNum: Int, + session: RadioSessionContext, + ) { val decoded = packet.decoded ?: return if (decoded.reply_id != 0 && decoded.emoji != 0) { - rememberReaction(packet) + rememberReaction(packet, session) } else { - rememberDataPacket(dataPacket, myNodeNum) + rememberDataPacket(dataPacket, myNodeNum, session = session) } } - private fun handleNodeInfo(packet: MeshPacket) { + private fun handleNodeInfo(packet: MeshPacket, session: RadioSessionContext) { val payload = packet.decoded?.payload ?: return val u = User.ADAPTER.decode(payload) @@ -293,21 +319,26 @@ class MeshDataHandlerImpl( it } } - nodeManager.handleReceivedUser(packet.from, u, packet.channel) + nodeManager.handleReceivedUser(packet.from, u, packet.channel, session = session) } - private fun handleNodeStatus(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { + private fun handleNodeStatus( + packet: MeshPacket, + dataPacket: DataPacket, + myNodeNum: Int, + session: RadioSessionContext, + ) { val payload = packet.decoded?.payload ?: return val s = StatusMessage.ADAPTER.decodeOrNull(payload, Logger) ?: return - nodeManager.handleReceivedNodeStatus(packet.from, s) - rememberDataPacket(dataPacket, myNodeNum) + nodeManager.handleReceivedNodeStatus(packet.from, s, session) + rememberDataPacket(dataPacket, myNodeNum, session = session) } - private fun handleRouting(packet: MeshPacket, dataPacket: DataPacket) { + private fun handleRouting(packet: MeshPacket, dataPacket: DataPacket, session: RadioSessionContext) { val payload = packet.decoded?.payload ?: return val r = Routing.ADAPTER.decodeOrNull(payload, Logger) ?: return if (r.error_reason == Routing.Error.DUTY_CYCLE_LIMIT) { - scope.launch { + radioInterfaceService.launchSessionWork(scope, session) { serviceStateWriter.setErrorMessage(getStringSuspend(Res.string.error_duty_cycle), Severity.Warn) } } @@ -316,13 +347,19 @@ class MeshDataHandlerImpl( nodeManager.toNodeID(packet.from), r.error_reason?.value ?: 0, dataPacket.relayNode, + session, ) - packet.decoded?.request_id?.let { packetHandler.removeResponse(it, complete = true) } } @Suppress("CyclomaticComplexMethod", "LongMethod") - private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int?) { - scope.handledLaunch { + private fun handleAckNak( + requestId: Int, + fromId: String, + routingError: Int, + relayNode: Int?, + session: RadioSessionContext, + ) { + radioInterfaceService.launchSessionWork(scope, session) { val isAck = routingError == Routing.Error.NONE.value val p = packetRepository.value.getPacketByPacketId(requestId) val reaction = packetRepository.value.getReactionByPacketId(requestId) @@ -355,10 +392,16 @@ class MeshDataHandlerImpl( packetRepository.value.updateReaction(updated) } } + packetHandler.removeResponse(requestId, complete = true) } } - override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) { + override fun rememberDataPacket( + dataPacket: DataPacket, + myNodeNum: Int, + updateNotification: Boolean, + session: RadioSessionContext?, + ) { if (dataPacket.dataType !in rememberDataType) return val fromLocal = dataPacket.isFromLocal(myNodeNum) val toBroadcast = dataPacket.isBroadcast @@ -367,7 +410,7 @@ class MeshDataHandlerImpl( // contactKey: unique contact key filter (channel)+(nodeId) val contactKey = "${dataPacket.channel}$contactId" - scope.handledLaunch { + radioInterfaceService.launchSessionWork(scope, session) { packetRepository.value.apply { // Check for duplicates before inserting val existingPackets = findPacketsWithId(dataPacket.id) @@ -377,7 +420,7 @@ class MeshDataHandlerImpl( "to=${dataPacket.to} contactKey=$contactKey" + " (already have ${existingPackets.size} packet(s))" } - return@handledLaunch + return@launchSessionWork } // Check if message should be filtered @@ -423,18 +466,16 @@ class MeshDataHandlerImpl( textMentionsNode(dataPacket.text, nodeManager.getMyId()) val isSilent = nodeMuted || (conversationMuted && !mentionsMe) if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) { - scope.launch { - notificationManager.dispatch( - Notification( - title = getSenderName(dataPacket), - message = dataPacket.alert ?: getStringSuspend(Res.string.critical_alert), - category = Notification.Category.Alert, - contactKey = contactKey, - ), - ) - } + notificationManager.dispatch( + Notification( + title = getSenderName(dataPacket), + message = dataPacket.alert ?: getStringSuspend(Res.string.critical_alert), + category = Notification.Category.Alert, + contactKey = contactKey, + ), + ) } else if (updateNotification && !isSilent) { - scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) } + updateNotification(contactKey, dataPacket, isSilent) } } @@ -486,79 +527,80 @@ class MeshDataHandlerImpl( } @Suppress("LongMethod", "KotlinConstantConditions") - private fun rememberReaction(packet: MeshPacket) = scope.handledLaunch { - val decoded = packet.decoded ?: return@handledLaunch - val emoji = decoded.payload.toByteArray().decodeToString() - val fromId = nodeManager.toNodeID(packet.from) - - val fromNode = nodeManager.nodeDBbyNodeNum[packet.from] ?: Node(num = packet.from) - val toNode = nodeManager.nodeDBbyNodeNum[packet.to] ?: Node(num = packet.to) - - val reaction = - Reaction( - replyId = decoded.reply_id, - user = fromNode.user, - emoji = emoji, - timestamp = nowMillis, - snr = packet.rx_snr, - rssi = packet.rx_rssi, - hopsAway = - if (packet.hop_start == 0 || packet.hop_limit > packet.hop_start) { - HOPS_AWAY_UNAVAILABLE - } else { - packet.hop_start - packet.hop_limit - }, - packetId = packet.id, - status = MessageStatus.RECEIVED, - to = toNode.user.id, - channel = packet.channel, - ) + private fun rememberReaction(packet: MeshPacket, session: RadioSessionContext) = + radioInterfaceService.launchSessionWork(scope, session) { + val decoded = packet.decoded ?: return@launchSessionWork + val emoji = decoded.payload.toByteArray().decodeToString() + val fromId = nodeManager.toNodeID(packet.from) + + val fromNode = nodeManager.nodeDBbyNodeNum[packet.from] ?: Node(num = packet.from) + val toNode = nodeManager.nodeDBbyNodeNum[packet.to] ?: Node(num = packet.to) + + val reaction = + Reaction( + replyId = decoded.reply_id, + user = fromNode.user, + emoji = emoji, + timestamp = nowMillis, + snr = packet.rx_snr, + rssi = packet.rx_rssi, + hopsAway = + if (packet.hop_start == 0 || packet.hop_limit > packet.hop_start) { + HOPS_AWAY_UNAVAILABLE + } else { + packet.hop_start - packet.hop_limit + }, + packetId = packet.id, + status = MessageStatus.RECEIVED, + to = toNode.user.id, + channel = packet.channel, + ) - // Check for duplicates before inserting - val existingReactions = packetRepository.value.findReactionsWithId(packet.id) - if (existingReactions.isNotEmpty()) { - Logger.d { - "Skipping duplicate reaction: packetId=${packet.id} replyId=${decoded.reply_id} " + - "from=$fromId emoji=$emoji (already have ${existingReactions.size} reaction(s))" + // Check for duplicates before inserting + val existingReactions = packetRepository.value.findReactionsWithId(packet.id) + if (existingReactions.isNotEmpty()) { + Logger.d { + "Skipping duplicate reaction: packetId=${packet.id} replyId=${decoded.reply_id} " + + "from=$fromId emoji=$emoji (already have ${existingReactions.size} reaction(s))" + } + return@launchSessionWork } - return@handledLaunch - } - - packetRepository.value.insertReaction(reaction, nodeManager.myNodeNum.value ?: 0) - // Find the original packet to get the contactKey - packetRepository.value.getPacketByPacketId(decoded.reply_id)?.let { originalPacket -> - // Skip notification if the original message was filtered - val targetId = - if (originalPacket.source is NodeAddress.Local) originalPacket.to else originalPacket.from - val contactKey = "${originalPacket.channel}$targetId" - val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted - val nodeMuted = nodeManager.getNodeById(fromId)?.isMuted == true - val isSilent = conversationMuted || nodeMuted - - if (!isSilent) { - val isBroadcast = originalPacket.destination is NodeAddress.Broadcast - val channelName = - if (isBroadcast) { - radioConfigRepository.channelSetFlow - .first() - .settings - .getOrNull(originalPacket.channel) - ?.name - } else { - null - } - serviceNotifications.updateReactionNotification( - contactKey, - getSenderName(dataMapper.toDataPacket(packet)!!), - emoji, - isBroadcast, - channelName, - isSilent, - ) + packetRepository.value.insertReaction(reaction, nodeManager.myNodeNum.value ?: 0) + + // Find the original packet to get the contactKey + packetRepository.value.getPacketByPacketId(decoded.reply_id)?.let { originalPacket -> + // Skip notification if the original message was filtered + val targetId = + if (originalPacket.source is NodeAddress.Local) originalPacket.to else originalPacket.from + val contactKey = "${originalPacket.channel}$targetId" + val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted + val nodeMuted = nodeManager.getNodeById(fromId)?.isMuted == true + val isSilent = conversationMuted || nodeMuted + + if (!isSilent) { + val isBroadcast = originalPacket.destination is NodeAddress.Broadcast + val channelName = + if (isBroadcast) { + radioConfigRepository.channelSetFlow + .first() + .settings + .getOrNull(originalPacket.channel) + ?.name + } else { + null + } + serviceNotifications.updateReactionNotification( + contactKey, + getSenderName(dataMapper.toDataPacket(packet)!!), + emoji, + isBroadcast, + channelName, + isSilent, + ) + } } } - } companion object { private const val HOPS_AWAY_UNAVAILABLE = -1 diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt index 568cf31174..05692b5ab6 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt @@ -18,6 +18,7 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -43,6 +44,9 @@ import org.meshtastic.core.repository.MeshDataHandler import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.MeshMessageProcessor import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.core.repository.ReceivedRadioFrame import org.meshtastic.core.repository.ServiceStateWriter import org.meshtastic.proto.FromRadio import org.meshtastic.proto.LogRecord @@ -60,13 +64,10 @@ class MeshMessageProcessorImpl( private val meshLogRepository: Lazy, private val dataHandler: Lazy, private val fromRadioDispatcher: FromRadioPacketHandler, + private val radioInterfaceService: RadioInterfaceService, @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshMessageProcessor { - private val mapsMutex = Mutex() - private val logUuidByPacketId = mutableMapOf() - private val logInsertJobByPacketId = mutableMapOf() - /** * Epoch-millisecond timestamp of the last local-node `lastHeard` DB write. Used to throttle updates to at most once * per [LOCAL_NODE_REFRESH_INTERVAL_MS] so that high-frequency FromRadio variants (log records, queue status) don't @@ -74,12 +75,17 @@ class MeshMessageProcessorImpl( */ @Volatile private var lastLocalNodeRefreshMs = 0L + @Volatile private var lastLocalNodeRefreshGeneration = Long.MIN_VALUE + + private data class BufferedMeshPacket(val packet: MeshPacket, val session: RadioSessionContext) + private val earlyMutex = Mutex() - private val earlyReceivedPackets = ArrayDeque() + private val earlyFlushMutex = Mutex() + private val earlyReceivedPackets = ArrayDeque() private val maxEarlyPacketBuffer = 10240 - override fun clearEarlyPackets() { - scope.launch { earlyMutex.withLock { earlyReceivedPackets.clear() } } + override suspend fun clearEarlyPackets() { + earlyFlushMutex.withLock { earlyMutex.withLock { earlyReceivedPackets.clear() } } } init { @@ -99,45 +105,53 @@ class MeshMessageProcessorImpl( .launchIn(scope) } - override fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) { - runCatching { FromRadio.ADAPTER.decode(bytes) } - .onSuccess { proto -> processFromRadio(proto, myNodeNum) } - .onFailure { primaryException -> - runCatching { - val logRecord = LogRecord.ADAPTER.decode(bytes) - processFromRadio(FromRadio(log_record = logRecord), myNodeNum) - } - .onFailure { _ -> - Logger.e(primaryException) { - "Failed to parse radio packet (len=${bytes.size}). Not a valid FromRadio or LogRecord." + override suspend fun handleFromRadio(frame: ReceivedRadioFrame, myNodeNum: Int?) { + if (!radioInterfaceService.isSessionActive(frame.session)) { + Logger.d { "Dropping decoded work from stale transport session gen=${frame.session.generation}" } + return + } + val bytes = frame.payload.toByteArray() + val proto = + safeCatching { FromRadio.ADAPTER.decode(bytes) } + .getOrElse { primaryException -> + safeCatching { FromRadio(log_record = LogRecord.ADAPTER.decode(bytes)) } + .getOrElse { + Logger.e(primaryException) { + "Failed to parse radio packet (len=${bytes.size}). Not a valid FromRadio or LogRecord." + } + return } - } - } + } + processFromRadio(proto, myNodeNum, frame.session) } - private fun processFromRadio(proto: FromRadio, myNodeNum: Int?) { - // The FromRadio *decode* boundary is guarded by the caller, but the per-variant handlers below are not. A - // single malformed-but-decodable packet from any peer must not throw out to the receivedData collector and - // cancel it — that would silently deafen the radio (a remote DoS). Contain handler failures here: log and drop - // the packet, then carry on. (safeCatching still re-throws CancellationException, preserving cancellation.) - safeCatching { - // Any decoded FromRadio proves the radio link is alive — keep the local node fresh. - refreshLocalNodeLastHeard() - - // Audit log every incoming variant - logVariant(proto) - - val packet = proto.packet - if (packet != null) { - handleReceivedMeshPacket(packet, myNodeNum) - } else { - fromRadioDispatcher.handleFromRadio(proto) + private suspend fun processFromRadio(proto: FromRadio, myNodeNum: Int?, session: RadioSessionContext) { + val admitted = + radioInterfaceService.runWhileSessionActive(session) { + safeCatching { + // Audit log every incoming variant without allowing delayed work to cross a session boundary. + logVariant(proto, session) + + val packet = proto.packet + if (packet != null) { + handleReceivedMeshPacket(packet, myNodeNum, session) + } else { + // Packets refresh the local node in processReceivedMeshPacket; other variants need the + // heartbeat. + refreshLocalNodeLastHeard(session) + fromRadioDispatcher.handleFromRadio(proto, session) + } + } + .onFailure { + Logger.e(it) { "Dropped a FromRadio after a handler error; receive pipeline kept alive" } + } } + if (!admitted) { + Logger.d { "Dropping FromRadio from stale transport session gen=${session.generation}" } } - .onFailure { Logger.e(it) { "Dropped a FromRadio after a handler error; receive pipeline kept alive" } } } - private fun logVariant(proto: FromRadio) { + private fun logVariant(proto: FromRadio, session: RadioSessionContext) { val (type, message) = when { proto.log_record != null -> "LogRecord" to proto.log_record.toString() @@ -162,10 +176,12 @@ class MeshMessageProcessorImpl( raw_message = message, fromRadio = proto, ), + session, ) } - override fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) { + /** Test seam for packet-only fixtures with explicit transport authority. */ + internal suspend fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?, session: RadioSessionContext) { val rxTime = if (packet.rx_time == 0) { nowSeconds.toInt() @@ -179,55 +195,79 @@ class MeshMessageProcessorImpl( // MyNodeInfo resolves), a local packet would be stored under its raw from_num and become invisible to // per-node chart queries while still appearing in the unfiltered Debug log. Buffer until both are ready; // the init combine flushes the buffer once myNodeNum resolves. + if (!radioInterfaceService.isSessionActive(session)) { + Logger.d { "Dropping mesh packet from stale transport session gen=${session.generation}" } + return + } + if (nodeManager.isNodeDbReady.value && myNodeNum != null) { - processReceivedMeshPacket(preparedPacket, myNodeNum) + processReceivedMeshPacket(preparedPacket, myNodeNum, session) } else { - scope.launch { - earlyMutex.withLock { - val queueSize = earlyReceivedPackets.size - if (queueSize >= maxEarlyPacketBuffer) { - Logger.w { "Early packet buffer full ($queueSize), dropping oldest packet" } - earlyReceivedPackets.removeFirstOrNull() - } - earlyReceivedPackets.addLast(preparedPacket) - } + // Production callers already hold the session lease in processFromRadio. Enqueue directly so packet FIFO + // does not depend on the ordering of separately launched mutex waiters. + enqueueBufferedPacket(BufferedMeshPacket(preparedPacket, session)) + if (nodeManager.isNodeDbReady.value && nodeManager.myNodeNum.value != null) { + flushEarlyReceivedPackets("enqueue-ready") } } } + private suspend fun enqueueBufferedPacket(buffered: BufferedMeshPacket) = earlyMutex.withLock { + discardBufferedPacketsFromOtherSessions(buffered.session) + val queueSize = earlyReceivedPackets.size + if (queueSize >= maxEarlyPacketBuffer) { + Logger.w { "Early packet buffer full ($queueSize), dropping oldest packet" } + earlyReceivedPackets.removeFirstOrNull() + } + earlyReceivedPackets.addLast(buffered) + } + + private fun discardBufferedPacketsFromOtherSessions(activeSession: RadioSessionContext) { + val removed = earlyReceivedPackets.removeAll { queued -> queued.session != activeSession } + if (removed) Logger.d { "Discarded buffered packets from an earlier transport generation" } + } + private fun flushEarlyReceivedPackets(reason: String) { - scope.launch { - // Resolve and null-check myNodeNum BEFORE draining the buffer: if it regressed to null between the flush - // trigger and here, leave the packets buffered for the next resolution rather than draining and keying - // them under their raw from_num. The captured non-null value is used for the whole batch. - val myNodeNum = nodeManager.myNodeNum.value ?: return@launch - val packets = - earlyMutex.withLock { - if (earlyReceivedPackets.isEmpty()) return@withLock emptyList() - val list = earlyReceivedPackets.toList() - earlyReceivedPackets.clear() - list - } - if (packets.isEmpty()) return@launch - - Logger.d { "replayEarlyPackets reason=$reason count=${packets.size}" } - // Each buffered packet is processed independently, wrapped the same way processFromRadio - // guards the live path: a single malformed/adversarial packet (e.g. a corrupted NodeInfo - // that fails proto decode deep inside handleNodeInfo) must not throw out of this - // scope.launch coroutine and crash the process -- that would turn one bad packet - // received early in a (re)connect into a full app crash / remote DoS. Log and skip it, - // then keep draining the rest of the batch. - packets.forEach { packet -> - safeCatching { processReceivedMeshPacket(packet, myNodeNum) } + scope.launch { earlyFlushMutex.withLock { replayEarlyReceivedPackets(reason) } } + } + + private suspend fun replayEarlyReceivedPackets(reason: String) { + var replayed = 0 + while (nodeManager.isNodeDbReady.value) { + val myNodeNum = nodeManager.myNodeNum.value ?: return + val buffered = earlyMutex.withLock { earlyReceivedPackets.removeFirstOrNull() } ?: break + + // Readiness can regress while a queued flush waits behind an earlier replay. Put the packet back at the + // front instead of keying it against a database that is being cleared or an unresolved local node number. + if (!isReplayReady(myNodeNum)) { + earlyMutex.withLock { earlyReceivedPackets.addFirst(buffered) } + return + } + + if (processBufferedPacket(buffered, myNodeNum)) replayed += 1 + } + if (replayed > 0) Logger.d { "replayEarlyPackets reason=$reason count=$replayed" } + } + + private fun isReplayReady(myNodeNum: Int): Boolean = + nodeManager.isNodeDbReady.value && nodeManager.myNodeNum.value == myNodeNum + + private suspend fun processBufferedPacket(buffered: BufferedMeshPacket, myNodeNum: Int): Boolean { + val admitted = + radioInterfaceService.runWhileSessionActive(buffered.session) { + safeCatching { processReceivedMeshPacket(buffered.packet, myNodeNum, buffered.session) } .onFailure { Logger.e(it) { "Dropped a buffered early packet after a handler error; replay continued" } } } + if (!admitted) { + Logger.d { "Dropping buffered packet from stale transport session gen=${buffered.session.generation}" } } + return admitted } @Suppress("LongMethod") - private fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) { + private suspend fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int, session: RadioSessionContext) { val decoded = packet.decoded ?: return val log = MeshLog( @@ -239,66 +279,51 @@ class MeshMessageProcessorImpl( portNum = decoded.portnum.value, fromRadio = FromRadio(packet = packet), ) - val logJob = insertMeshLog(log) + val logJob = insertMeshLog(log, session) + + launchSessionBound(session, "mesh-packet emission") { serviceStateWriter.emitMeshPacket(packet) } - scope.launch { - mapsMutex.withLock { - logInsertJobByPacketId[packet.id] = logJob - logUuidByPacketId[packet.id] = log.uuid + val from = packet.from + if (from == myNodeNum) { + persistNodeUpdate( + myNodeNum, + channel = packet.channel, + operation = "local sender-node packet update", + ) { node -> + applySenderPacketUpdate(node, packet, decoded).copy(lastHeard = nowSeconds.toInt()) + } + } else { + persistNodeUpdate(myNodeNum, operation = "local-node packet refresh") { node: Node -> + node.copy(lastHeard = nowSeconds.toInt()) + } + persistNodeUpdate(from, channel = packet.channel, operation = "sender-node packet update") { node -> + applySenderPacketUpdate(node, packet, decoded) } } - scope.handledLaunch { serviceStateWriter.emitMeshPacket(packet) } - - myNodeNum?.let { myNum -> - val from = packet.from - val isOtherNode = myNum != from - nodeManager.updateNode(myNum) { node: Node -> node.copy(lastHeard = nowSeconds.toInt()) } - nodeManager.updateNode(from, channel = packet.channel) { node: Node -> - val viaMqtt = packet.via_mqtt == true - val isDirect = packet.hop_start == packet.hop_limit - - var snr = node.snr - var rssi = node.rssi - if (isDirect && packet.isLora() && !viaMqtt) { - snr = packet.rx_snr - rssi = packet.rx_rssi - } - - val hopsAway = - if (decoded.portnum == PortNum.RANGE_TEST_APP) { - 0 - } else if (viaMqtt) { - -1 - } else if (packet.hop_start == 0 && (decoded.bitfield ?: 0) == 0) { - -1 - } else if (packet.hop_limit > packet.hop_start) { - -1 - } else { - packet.hop_start - packet.hop_limit - } - - node.copy( - lastHeard = clampTimestampToNow(packet.rx_time), - viaMqtt = viaMqtt, - lastTransport = packet.transport_mechanism.value, - snr = snr, - rssi = rssi, - hopsAway = hopsAway, - ) - } + dataHandler.value.handleReceivedData(packet, myNodeNum, session, log.uuid, logJob) + } - try { - dataHandler.value.handleReceivedData(packet, myNum, log.uuid, logJob) - } finally { - scope.launch { - mapsMutex.withLock { - logUuidByPacketId.remove(packet.id) - logInsertJobByPacketId.remove(packet.id) - } - } + private fun applySenderPacketUpdate(node: Node, packet: MeshPacket, decoded: org.meshtastic.proto.Data): Node { + val viaMqtt = packet.via_mqtt == true + val isDirect = packet.hop_start == packet.hop_limit + val updateRadioMetrics = isDirect && packet.isLora() && !viaMqtt + val hopsAway = + when { + decoded.portnum == PortNum.RANGE_TEST_APP -> 0 + viaMqtt -> -1 + packet.hop_start == 0 && (decoded.bitfield ?: 0) == 0 -> -1 + packet.hop_limit > packet.hop_start -> -1 + else -> packet.hop_start - packet.hop_limit } - } + return node.copy( + lastHeard = clampTimestampToNow(packet.rx_time), + viaMqtt = viaMqtt, + lastTransport = packet.transport_mechanism.value, + snr = if (updateRadioMetrics) packet.rx_snr else node.snr, + rssi = if (updateRadioMetrics) packet.rx_rssi else node.rssi, + hopsAway = hopsAway, + ) } /** @@ -312,16 +337,41 @@ class MeshMessageProcessorImpl( * To avoid flooding the DB on high-frequency variants (log records arrive many times per second when debug logging * is enabled), writes are throttled to at most once per [LOCAL_NODE_REFRESH_INTERVAL_MS]. */ - private fun refreshLocalNodeLastHeard() { + private suspend fun refreshLocalNodeLastHeard(session: RadioSessionContext) { val now = nowMillis - if (now - lastLocalNodeRefreshMs < LOCAL_NODE_REFRESH_INTERVAL_MS) return - lastLocalNodeRefreshMs = now + val sameGeneration = lastLocalNodeRefreshGeneration == session.generation + if (sameGeneration && now - lastLocalNodeRefreshMs < LOCAL_NODE_REFRESH_INTERVAL_MS) return val myNum = nodeManager.myNodeNum.value ?: return - nodeManager.updateNode(myNum) { node: Node -> node.copy(lastHeard = nowSeconds.toInt()) } + val persisted = + persistNodeUpdate(myNum, operation = "local-node link refresh") { node: Node -> + node.copy(lastHeard = nowSeconds.toInt()) + } + if (persisted) { + lastLocalNodeRefreshGeneration = session.generation + lastLocalNodeRefreshMs = now + } } - private fun insertMeshLog(log: MeshLog): Job = scope.handledLaunch { meshLogRepository.value.insert(log) } + private suspend fun persistNodeUpdate( + nodeNum: Int, + channel: Int = 0, + operation: String, + transform: (Node) -> Node, + ): Boolean = safeCatching { nodeManager.updateNodeAndPersist(nodeNum, channel, transform) } + .onFailure { Logger.e(it) { "Failed $operation; packet processing continued" } } + .isSuccess + + private fun insertMeshLog(log: MeshLog, session: RadioSessionContext): Job = + launchSessionBound(session, "mesh-log insert") { meshLogRepository.value.insert(log) } + + private fun launchSessionBound(session: RadioSessionContext, operation: String, block: suspend () -> Unit): Job = + scope.handledLaunch(start = CoroutineStart.UNDISPATCHED) { + val admitted = radioInterfaceService.runWithSessionLease(session) { block() } + if (!admitted) { + Logger.d { "Skipping $operation from stale transport session gen=${session.generation}" } + } + } companion object { /** diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt index ced03a830c..78a9b09f57 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -23,7 +23,10 @@ import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import okio.ByteString import org.koin.core.annotation.Named import org.koin.core.annotation.Single @@ -33,10 +36,13 @@ import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.util.NodeIdLookup +import org.meshtastic.core.repository.ConnectionIdentity import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.getStringSuspend import org.meshtastic.core.resources.new_node_seen @@ -47,6 +53,7 @@ import org.meshtastic.proto.Paxcount import org.meshtastic.proto.StatusMessage import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User +import kotlinx.coroutines.flow.update as updateStateFlow import org.meshtastic.proto.NodeInfo as ProtoNodeInfo import org.meshtastic.proto.Position as ProtoPosition @@ -56,9 +63,16 @@ import org.meshtastic.proto.Position as ProtoPosition class NodeManagerImpl( private val nodeRepository: NodeRepository, private val notificationManager: NotificationManager, + private val radioInterfaceService: RadioInterfaceService, @Named("ServiceScope") private val scope: CoroutineScope, ) : NodeManager { + // Fixed stripes bound mutex lifetime while preserving same-node ordering; hash collisions only reduce parallelism. + private val nodePersistenceLanes = List(NODE_PERSISTENCE_LANE_COUNT) { Mutex() } + + private fun persistenceLane(nodeNum: Int): Mutex = + nodePersistenceLanes[(nodeNum.toLong() and Int.MAX_VALUE.toLong()).toInt() % nodePersistenceLanes.size] + // Two indices over the same node set: byNum is the canonical store (mesh-level identifier), byId is a secondary // O(1) lookup for the user-facing hex string. Both are held in a single atomic ref so updates are observed // consistently — concurrent readers never see an entry present in one index but not the other. @@ -130,6 +144,23 @@ class NodeManagerImpl( myDeviceId.value = id } + private val _connectionIdentity = MutableStateFlow(null) + override val connectionIdentity: StateFlow = _connectionIdentity + + override fun clearConnectionIdentity() { + _connectionIdentity.value = null + } + + override fun clearStaleConnectionIdentity(activeSessionGeneration: Long) { + _connectionIdentity.updateStateFlow { identity -> + identity?.takeIf { it.sessionGeneration == activeSessionGeneration } + } + } + + override fun publishConnectionIdentity(sessionGeneration: Long, address: String, nodeNum: Int, deviceId: String?) { + _connectionIdentity.value = ConnectionIdentity(sessionGeneration, address, nodeNum, deviceId) + } + override val firmwareEdition = MutableStateFlow(null) override fun setFirmwareEdition(edition: FirmwareEdition?) { @@ -137,10 +168,15 @@ class NodeManagerImpl( } companion object { + private const val NODE_PERSISTENCE_LANE_COUNT = 64 + private const val TIME_MS_TO_S = 1000L } override fun loadCachedNodeDB() { + // NOTE: this method intentionally does NOT touch _connectionIdentity. The connection-session identity is + // only populated by a fresh MyNodeInfo handshake (see publishConnectionIdentity); restoring it from cache + // would re-introduce the stale-identity association bug across transport switches. scope.handledLaunch { val nodes = nodeRepository.nodeDBbyNum.first() nodeIndex.value = NodeIndex.fromByNum(nodes) @@ -157,6 +193,7 @@ class NodeManagerImpl( myNodeNum.value = null myDeviceId.value = null firmwareEdition.value = null + _connectionIdentity.value = null } override fun getMyNodeInfo(): MyNodeInfo? { @@ -203,30 +240,72 @@ class NodeManagerImpl( Node(num = n, user = defaultUser, channel = channel) } - override fun updateNode(nodeNum: Int, channel: Int, transform: (Node) -> Node) { - // Perform read + transform inside update{} to ensure atomicity. - // Without this, concurrent calls for the same nodeNum could read the same snapshot - // and the last writer would silently overwrite the other's changes. - var next: Node? = null - nodeIndex.update { index -> + private data class NodeStateChange(val previous: Node, val next: Node) + + private fun updateNodeState(nodeNum: Int, channel: Int, transform: (Node) -> Node): NodeStateChange { + // Compute the transform against one immutable snapshot and publish it with CAS. The transform may be retried, + // so it must remain side-effect free; callers perform notifications or other effects only after this returns. + while (true) { + val index = nodeIndex.value val current = index.byNum[nodeNum] ?: getOrCreateNode(nodeNum, channel) - val transformed = transform(current) - next = transformed - index.put(nodeNum, transformed) + val next = transform(current) + if (nodeIndex.compareAndSet(index, index.put(nodeNum, next))) { + return NodeStateChange(previous = current, next = next) + } } - val result = next ?: return + } - if (result.user.id.isNotEmpty() && isNodeDbReady.value) { - scope.handledLaunch { nodeRepository.upsert(result) } + private fun shouldPersist(node: Node): Boolean = + node.user.id.isNotEmpty() && isNodeDbReady.value && allowNodeDbWrites.value + + private fun updateNodeAndSchedulePersistence( + nodeNum: Int, + channel: Int, + session: RadioSessionContext? = null, + transform: (Node) -> Node, + ): NodeStateChange = updateNodeState(nodeNum, channel, transform).also { change -> + if (shouldPersist(change.next)) { + radioInterfaceService.launchSessionWork(scope, session) { persistLatestNode(nodeNum) } } } - override fun handleReceivedUser(fromNum: Int, p: User, channel: Int, manuallyVerified: Boolean) { - updateNode(fromNum) { node -> - val newNode = (node.isUnknownUser && p.hw_model != HardwareModel.UNSET) - val shouldPreserve = shouldPreserveExistingUser(node.user, p) + override fun updateNode(nodeNum: Int, channel: Int, transform: (Node) -> Node) { + updateNodeAndSchedulePersistence(nodeNum, channel, transform = transform) + } + + override fun updateNodeForSession( + nodeNum: Int, + session: RadioSessionContext, + channel: Int, + transform: (Node) -> Node, + ) { + updateNodeAndSchedulePersistence(nodeNum, channel, session, transform) + } + + override suspend fun updateNodeAndPersist(nodeNum: Int, channel: Int, transform: (Node) -> Node) { + val result = updateNodeState(nodeNum, channel, transform).next + if (shouldPersist(result)) persistLatestNode(nodeNum) + } - val next = + /** + * Serializes persistence per node and reads the latest in-memory value inside that lane. A delayed earlier update + * therefore cannot overwrite a newer state after its repository write resumes. + */ + private suspend fun persistLatestNode(nodeNum: Int) = persistenceLane(nodeNum).withLock { + val latest = nodeIndex.value.byNum[nodeNum] ?: return@withLock + if (shouldPersist(latest)) nodeRepository.upsert(latest) + } + + override fun handleReceivedUser( + fromNum: Int, + p: User, + channel: Int, + manuallyVerified: Boolean, + session: RadioSessionContext?, + ) { + val change = + updateNodeAndSchedulePersistence(fromNum, channel, session) { node -> + val shouldPreserve = shouldPreserveExistingUser(node.user, p) if (shouldPreserve) { node.copy(channel = channel, manuallyVerified = manuallyVerified) } else { @@ -239,27 +318,37 @@ class NodeManagerImpl( manuallyVerified = manuallyVerified, ) } - if (newNode && !shouldPreserve) { - scope.handledLaunch { - notificationManager.dispatch( - Notification( - title = getStringSuspend(Res.string.new_node_seen, next.user.short_name), - message = next.user.long_name, - category = Notification.Category.NodeEvent, - id = next.num, - // Path format must stay in sync with DEEP_LINK_BASE_URI + DeepLinkRouter - // in core/navigation (avoided as a Gradle dep here to keep core/data free - // of Compose Navigation libs). - deepLinkUri = "meshtastic://meshtastic/nodes/${next.num}", - ), - ) - } } - next + val shouldNotify = + change.previous.isUnknownUser && + p.hw_model != HardwareModel.UNSET && + !shouldPreserveExistingUser(change.previous.user, p) + if (shouldNotify) { + val node = change.next + radioInterfaceService.launchSessionWork(scope, session) { + notificationManager.dispatch( + Notification( + title = getStringSuspend(Res.string.new_node_seen, node.user.short_name), + message = node.user.long_name, + category = Notification.Category.NodeEvent, + id = node.num, + // Path format must stay in sync with DEEP_LINK_BASE_URI + DeepLinkRouter + // in core/navigation (avoided as a Gradle dep here to keep core/data free + // of Compose Navigation libs). + deepLinkUri = "meshtastic://meshtastic/nodes/${node.num}", + ), + ) + } } } - override fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) { + override fun handleReceivedPosition( + fromNum: Int, + myNodeNum: Int, + p: ProtoPosition, + defaultTime: Long, + session: RadioSessionContext?, + ) { val isZeroPos = (p.latitude_i ?: 0) == 0 && (p.longitude_i ?: 0) == 0 @Suppress("ComplexCondition") if (myNodeNum == fromNum && isZeroPos && p.sats_in_view == 0 && p.time == 0) { @@ -267,7 +356,7 @@ class NodeManagerImpl( return } - updateNode(fromNum) { node -> + updateNodeAndSchedulePersistence(fromNum, channel = 0, session = session) { node -> val rawPosTime = if (p.time != 0) p.time else (defaultTime / TIME_MS_TO_S).toInt() val posTime = clampTimestampToNow(rawPosTime) val newLastHeard = maxOf(node.lastHeard, posTime) @@ -302,57 +391,65 @@ class NodeManagerImpl( } } - override fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount) { - updateNode(fromNum) { it.copy(paxcounter = p) } + override fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount, session: RadioSessionContext?) { + updateNodeAndSchedulePersistence(fromNum, channel = 0, session = session) { it.copy(paxcounter = p) } } - override fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage) { - updateNodeStatus(fromNum, s.status) + override fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage, session: RadioSessionContext?) { + updateNodeAndSchedulePersistence(fromNum, channel = 0, session = session) { node -> + applyNodeStatus(node, s.status) + } } override fun updateNodeStatus(nodeNum: Int, status: String?) { - updateNode(nodeNum) { it.copy(nodeStatus = status?.takeIf { s -> s.isNotEmpty() }) } + updateNode(nodeNum) { node -> applyNodeStatus(node, status) } + } + + override suspend fun updateNodeStatusAndPersist(nodeNum: Int, status: String?) { + updateNodeAndPersist(nodeNum) { node -> applyNodeStatus(node, status) } } + private fun applyNodeStatus(node: Node, status: String?): Node = + node.copy(nodeStatus = status?.takeIf { it.isNotEmpty() }) + override fun installNodeInfo(info: ProtoNodeInfo) { - updateNode(info.num) { node -> - var next = node - val user = info.user - if (user != null) { - if (shouldPreserveExistingUser(node.user, user)) { - // keep existing names - } else { - var newUser = - user.let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it } - if (info.via_mqtt && !newUser.long_name.endsWith(" (MQTT)")) { - newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)") - } - next = next.copy(user = newUser, publicKey = newUser.public_key) - } - } - val position = info.position - if (position != null) { - val clampedPos = position.copy(time = clampTimestampToNow(position.time)) - next = next.copy(position = clampedPos) + // Stage-2 configuration installation persists the full node snapshot through NodeRepository.installConfig. + // Keep this primitive in-memory so that handshake installation does not issue one redundant write per node. + updateNodeState(info.num, channel = 0) { node -> applyNodeInfo(node, info) } + } + + override suspend fun installNodeInfoAndPersist(info: ProtoNodeInfo) { + updateNodeAndPersist(info.num) { node -> applyNodeInfo(node, info) } + } + + private fun applyNodeInfo(node: Node, info: ProtoNodeInfo): Node { + var next = node + val user = info.user + if (user != null && !shouldPreserveExistingUser(node.user, user)) { + var newUser = user.let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it } + if (info.via_mqtt && !newUser.long_name.endsWith(" (MQTT)")) { + newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)") } - next = - next.copy( - lastHeard = clampTimestampToNow(info.last_heard), - deviceMetrics = info.device_metrics ?: next.deviceMetrics, - channel = info.channel, - viaMqtt = info.via_mqtt, - hopsAway = info.hops_away ?: -1, - isFavorite = info.is_favorite, - isIgnored = info.is_ignored, - isMuted = info.is_muted, - signsPackets = info.has_xeddsa_signed, - ) - next + next = next.copy(user = newUser, publicKey = newUser.public_key) } + info.position?.let { position -> + next = next.copy(position = position.copy(time = clampTimestampToNow(position.time))) + } + return next.copy( + lastHeard = clampTimestampToNow(info.last_heard), + deviceMetrics = info.device_metrics ?: next.deviceMetrics, + channel = info.channel, + viaMqtt = info.via_mqtt, + hopsAway = info.hops_away ?: -1, + isFavorite = info.is_favorite, + isIgnored = info.is_ignored, + isMuted = info.is_muted, + signsPackets = info.has_xeddsa_signed, + ) } - override fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) { - scope.handledLaunch { nodeRepository.insertMetadata(nodeNum, metadata) } + override fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata, session: RadioSessionContext?) { + radioInterfaceService.launchSessionWork(scope, session) { nodeRepository.insertMetadata(nodeNum, metadata) } } private fun shouldPreserveExistingUser(existing: User, incoming: User): Boolean { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt index cc77ba0def..a2423a8e6a 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt @@ -177,8 +177,8 @@ class PacketHandlerImpl( } } - override fun removeResponse(dataRequestId: Int, complete: Boolean) { - scope.launch { responseMutex.withLock { queueResponse.remove(dataRequestId)?.complete(complete) } } + override suspend fun removeResponse(dataRequestId: Int, complete: Boolean) { + responseMutex.withLock { queueResponse.remove(dataRequestId)?.complete(complete) } } /** diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/SessionWork.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/SessionWork.kt new file mode 100644 index 0000000000..1f4d2a453c --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/SessionWork.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.data.manager + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext + +/** + * Launches deferred [block] without a lease for explicitly trusted work, or acquires a nested lifecycle lease before + * the caller can release its packet lease. The undispatched leased path prevents teardown from racing the coroutine's + * first dispatcher turn. [onRejected] runs only when a non-null [session] can no longer admit work. + */ +internal fun RadioInterfaceService.launchSessionWork( + scope: CoroutineScope, + session: RadioSessionContext?, + onRejected: () -> Unit = {}, + block: suspend () -> Unit, +): Job = if (session == null) { + scope.handledLaunch { block() } +} else { + scope.handledLaunch(start = CoroutineStart.UNDISPATCHED) { + val admitted = runWithSessionLease(session) { block() } + if (!admitted) onRejected() + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt index 98eed9752b..6c4c747dd5 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt @@ -22,7 +22,6 @@ import okio.ByteString.Companion.toByteString import okio.IOException import org.koin.core.annotation.Named import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.NodeAddress @@ -31,6 +30,8 @@ import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshDataHandler import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.repository.StoreForwardPacketHandler import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum @@ -45,10 +46,16 @@ class StoreForwardPacketHandlerImpl( private val packetRepository: Lazy, private val historyManager: HistoryManager, private val dataHandler: Lazy, + private val radioInterfaceService: RadioInterfaceService, @Named("ServiceScope") private val scope: CoroutineScope, ) : StoreForwardPacketHandler { - override fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { + override fun handleStoreAndForward( + packet: MeshPacket, + dataPacket: DataPacket, + myNodeNum: Int, + session: RadioSessionContext?, + ) { val payload = packet.decoded?.payload ?: return val u = try { @@ -57,11 +64,11 @@ class StoreForwardPacketHandlerImpl( Logger.e(e) { "Failed to parse StoreAndForward packet" } return } - handleReceivedStoreAndForward(dataPacket, u, myNodeNum) + handleReceivedStoreAndForward(dataPacket, u, myNodeNum, session) } @Suppress("LongMethod", "ReturnCount") - override fun handleStoreForwardPlusPlus(packet: MeshPacket) { + override fun handleStoreForwardPlusPlus(packet: MeshPacket, session: RadioSessionContext?) { val payload = packet.decoded?.payload ?: return val sfpp = try { @@ -76,9 +83,9 @@ class StoreForwardPacketHandlerImpl( StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF, StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF, - -> handleLinkProvide(sfpp) + -> handleLinkProvide(sfpp, session) - StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> handleCanonAnnounce(sfpp) + StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> handleCanonAnnounce(sfpp, session) StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> { Logger.i { "SF++: Node ${packet.from} is querying chain status" } @@ -90,7 +97,7 @@ class StoreForwardPacketHandlerImpl( } } - private fun handleLinkProvide(sfpp: StoreForwardPlusPlus) { + private fun handleLinkProvide(sfpp: StoreForwardPlusPlus, session: RadioSessionContext?) { val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE val status = if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED @@ -120,7 +127,11 @@ class StoreForwardPacketHandlerImpl( "SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " + "to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum.value} status=$status" } - scope.handledLaunch { + radioInterfaceService.launchSessionWork( + scope = scope, + session = session, + onRejected = { Logger.d { "Dropped SF++ work from a retired transport session" } }, + ) { packetRepository.value.updateSFPPStatus( packetId = sfpp.encapsulated_id, from = sfpp.encapsulated_from, @@ -133,8 +144,12 @@ class StoreForwardPacketHandlerImpl( } } - private fun handleCanonAnnounce(sfpp: StoreForwardPlusPlus) { - scope.handledLaunch { + private fun handleCanonAnnounce(sfpp: StoreForwardPlusPlus, session: RadioSessionContext?) { + radioInterfaceService.launchSessionWork( + scope = scope, + session = session, + onRejected = { Logger.d { "Dropped SF++ work from a retired transport session" } }, + ) { sfpp.message_hash.let { packetRepository.value.updateSFPPStatusByHash( hash = it.toByteArray(), @@ -145,7 +160,12 @@ class StoreForwardPacketHandlerImpl( } } - private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) { + private fun handleReceivedStoreAndForward( + dataPacket: DataPacket, + s: StoreAndForward, + myNodeNum: Int, + session: RadioSessionContext?, + ) { val lastRequest = s.history?.last_request ?: 0 Logger.d { "StoreAndForward from=${dataPacket.from} lastRequest=$lastRequest" } when { @@ -156,7 +176,7 @@ class StoreForwardPacketHandlerImpl( bytes = text.encodeToByteArray().toByteString(), dataType = PortNum.TEXT_MESSAGE_APP.value, ) - dataHandler.value.rememberDataPacket(u, myNodeNum) + dataHandler.value.rememberDataPacket(u, myNodeNum, session = session) } s.history != null -> { @@ -170,7 +190,7 @@ class StoreForwardPacketHandlerImpl( bytes = text.encodeToByteArray().toByteString(), dataType = PortNum.TEXT_MESSAGE_APP.value, ) - dataHandler.value.rememberDataPacket(u, myNodeNum) + dataHandler.value.rememberDataPacket(u, myNodeNum, session = session) historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown") } @@ -184,7 +204,7 @@ class StoreForwardPacketHandlerImpl( dataPacket.to = NodeAddress.ID_BROADCAST } val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value) - dataHandler.value.rememberDataPacket(u, myNodeNum) + dataHandler.value.rememberDataPacket(u, myNodeNum, session = session) } else -> {} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt index c3bcbab97a..b7f66bb3dc 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt @@ -18,7 +18,6 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koin.core.annotation.Named @@ -32,6 +31,8 @@ import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.repository.TelemetryPacketHandler import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.getStringSuspend @@ -50,81 +51,71 @@ class TelemetryPacketHandlerImpl( private val nodeManager: NodeManager, private val connectionManager: Lazy, private val notificationManager: NotificationManager, + private val radioInterfaceService: RadioInterfaceService, @Named("ServiceScope") private val scope: CoroutineScope, ) : TelemetryPacketHandler { private val batteryMutex = Mutex() private val notifiedNodes = mutableSetOf() - @Suppress("LongMethod", "CyclomaticComplexMethod") - override fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { + @Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") + override fun handleTelemetry( + packet: MeshPacket, + dataPacket: DataPacket, + myNodeNum: Int, + session: RadioSessionContext, + ) { val payload = packet.decoded?.payload ?: return - val t = + val telemetry = (Telemetry.ADAPTER.decodeOrNull(payload, Logger) ?: return).let { if (it.time == 0) it.copy(time = (dataPacket.time.milliseconds.inWholeSeconds).toInt()) else it } - Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(t)}" } + Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(telemetry)}" } val fromNum = packet.from - val isRemote = (fromNum != myNodeNum) - if (!isRemote) { - connectionManager.value.updateTelemetry(t) - } - - nodeManager.updateNode(fromNum) { node: Node -> - val metrics = t.device_metrics - val environment = t.environment_metrics - val power = t.power_metrics + val isRemote = fromNum != myNodeNum + if (!isRemote) connectionManager.value.updateTelemetry(telemetry) + val transform: (Node) -> Node = { node -> var nextNode = node - when { - metrics != null -> { - nextNode = nextNode.copy(deviceMetrics = metrics) - if (fromNum == myNodeNum || (isRemote && node.isFavorite)) { - if ( - (metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED && - (metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD - ) { - scope.launch { - if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) { - notificationManager.dispatch( - Notification( - title = - getStringSuspend( - Res.string.low_battery_title, - nextNode.user.short_name, - ), - message = - getStringSuspend( - Res.string.low_battery_message, - nextNode.user.long_name, - nextNode.deviceMetrics.battery_level ?: 0, - ), - category = Notification.Category.Battery, - ), - ) - } - } - } else { - scope.launch { - batteryMutex.withLock { notifiedNodes.remove(fromNum) } - notificationManager.cancel(nextNode.num) - } - } - } - } - - environment != null -> nextNode = nextNode.copy(environmentMetrics = environment) + telemetry.device_metrics?.let { nextNode = nextNode.copy(deviceMetrics = it) } + telemetry.environment_metrics?.let { nextNode = nextNode.copy(environmentMetrics = it) } + telemetry.power_metrics?.let { nextNode = nextNode.copy(powerMetrics = it) } + telemetry.air_quality_metrics?.let { nextNode = nextNode.copy(airQualityMetrics = it) } + val telemetryTime = if (telemetry.time != 0) telemetry.time else nextNode.lastHeard + val newLastHeard = clampTimestampToNow(maxOf(nextNode.lastHeard, telemetryTime)) + nextNode.copy(lastHeard = newLastHeard) + } + nodeManager.updateNodeForSession(fromNum, session, transform = transform) - power != null -> nextNode = nextNode.copy(powerMetrics = power) + val metrics = telemetry.device_metrics ?: return + val updatedNode = nodeManager.nodeDBbyNodeNum[fromNum] ?: return + if (fromNum != myNodeNum && !updatedNode.isFavorite) return - t.air_quality_metrics != null -> { - t.air_quality_metrics?.let { aq -> nextNode = nextNode.copy(airQualityMetrics = aq) } + if ( + (metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED && + (metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD + ) { + radioInterfaceService.launchSessionWork(scope, session) { + if (shouldBatteryNotificationShow(fromNum, telemetry, myNodeNum)) { + notificationManager.dispatch( + Notification( + title = getStringSuspend(Res.string.low_battery_title, updatedNode.user.short_name), + message = + getStringSuspend( + Res.string.low_battery_message, + updatedNode.user.long_name, + updatedNode.deviceMetrics.battery_level ?: 0, + ), + category = Notification.Category.Battery, + ), + ) } } - - val telemetryTime = if (t.time != 0) t.time else nextNode.lastHeard - val newLastHeard = clampTimestampToNow(maxOf(nextNode.lastHeard, telemetryTime)) - nextNode.copy(lastHeard = newLastHeard) + } else { + radioInterfaceService.launchSessionWork(scope, session) { + batteryMutex.withLock { notifiedNodes.remove(fromNum) } + notificationManager.cancel(updatedNode.num) + } } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt index bbf91dbdc2..ecd1fff240 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt @@ -16,15 +16,17 @@ */ package org.meshtastic.core.data.manager +import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import org.koin.core.annotation.Named import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.model.getTracerouteResponse import org.meshtastic.core.model.service.TracerouteResponse import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.repository.ServiceStateWriter import org.meshtastic.core.repository.TracerouteHandler import org.meshtastic.core.repository.TracerouteSnapshotRepository @@ -39,6 +41,7 @@ class TracerouteHandlerImpl( private val serviceStateWriter: ServiceStateWriter, private val tracerouteSnapshotRepository: TracerouteSnapshotRepository, private val nodeRepository: NodeRepository, + private val radioInterfaceService: RadioInterfaceService, @Named("ServiceScope") private val scope: CoroutineScope, ) : TracerouteHandler { @@ -46,16 +49,23 @@ class TracerouteHandlerImpl( override fun recordStartTime(requestId: Int) = requestTimer.start(requestId) - override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: Job?) { - // Decode the route discovery once — avoids triple protobuf decode + override fun handleTraceroute( + packet: MeshPacket, + logUuid: String?, + logInsertJob: Job?, + session: RadioSessionContext, + ) { + // Decode the route discovery once — avoids triple protobuf decode. val routeDiscovery = packet.fullRouteDiscovery ?: return val forwardRoute = routeDiscovery.route val returnRoute = routeDiscovery.route_back - - // Require both directions for a "full" traceroute response if (forwardRoute.isEmpty() || returnRoute.isEmpty()) return - scope.handledLaunch { + radioInterfaceService.launchSessionWork( + scope = scope, + session = session, + onRejected = { Logger.d { "Dropped traceroute work from a retired transport session" } }, + ) { val full = routeDiscovery.getTracerouteResponse( getUser = { num -> @@ -65,7 +75,6 @@ class TracerouteHandlerImpl( headerTowards = getStringSuspend(Res.string.traceroute_route_towards_dest), headerBack = getStringSuspend(Res.string.traceroute_route_back_to_us), ) - val requestId = packet.decoded?.request_id ?: 0 if (logUuid != null) { @@ -78,9 +87,7 @@ class TracerouteHandlerImpl( } val responseText = requestTimer.appendDuration(requestId, full, "Traceroute") - val destination = forwardRoute.firstOrNull() ?: returnRoute.lastOrNull() ?: 0 - serviceStateWriter.setTracerouteResponse( TracerouteResponse( message = responseText, diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt index f7b7584de1..d870207d68 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt @@ -181,28 +181,42 @@ open class MeshLogRepositoryImpl( } /** - * Deletes only local stats telemetry logs for [nodeNum], preserving other telemetry types. Selection and deletion - * retain one admitted database while protobuf parsing runs on the compute dispatcher; deletion of the selected UUID - * set is atomic. + * Deletes only local stats telemetry logs for [nodeNum], preserving other telemetry types. The bounded keyset scan + * and atomic deletion remain under one manager-tracked database lease, while protobuf parsing runs on the compute + * dispatcher. */ override suspend fun deleteLocalStatsLogs(nodeNum: Int) = withContext(dispatchers.io) { val myNodeNum = nodeInfoReadDataSource.myNodeInfoFlow().firstOrNull()?.myNodeNum val logId = if (nodeNum == myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum - dbManager.withDb { db -> - val dao = db.meshLogDao() - // Snapshot read outside the transaction — the selection requires Kotlin protobuf parsing - // (parseTelemetryLog), so it cannot be a single SQL operation. The delete targets stable UUIDs, - // so a concurrent insert between snapshot and delete is simply not in the delete set. - val logs = dao.getLogsSnapshot(logId, PortNum.TELEMETRY_APP.value, Int.MAX_VALUE) - val uuidsToDelete = - withContext(dispatchers.default) { - logs - .map { it.asExternalModel() } - .filter { parseTelemetryLog(it)?.local_stats != null } - .map { it.uuid } + dbManager.withDb { selectedDb -> + val dao = selectedDb.meshLogDao() + val uuidsToDelete = mutableListOf() + var beforeReceivedDate: Long? = null + var beforeUuid: String? = null + do { + val page = + dao.getLogsSnapshotPage( + fromNum = logId, + portNum = PortNum.TELEMETRY_APP.value, + beforeReceivedDate = beforeReceivedDate, + beforeUuid = beforeUuid, + pageSize = TELEMETRY_SNAPSHOT_PAGE_SIZE, + ) + uuidsToDelete += + withContext(dispatchers.default) { + page + .asSequence() + .map { it.asExternalModel() } + .filter { parseTelemetryLog(it)?.local_stats != null } + .map { it.uuid } + .toList() + } + page.lastOrNull()?.let { last -> + beforeReceivedDate = last.received_date + beforeUuid = last.uuid } - // The chunked delete runs inside one Room transaction — all-or-nothing. Avoid opening an empty write - // transaction when the snapshot contains no local-stats telemetry. + } while (page.size == TELEMETRY_SNAPSHOT_PAGE_SIZE) + if (uuidsToDelete.isNotEmpty()) { dao.deleteLogsByUuidAtomic(uuidsToDelete) } @@ -220,5 +234,6 @@ open class MeshLogRepositoryImpl( companion object { private const val MILLIS_PER_SEC = 1000L + private const val TELEMETRY_SNAPSHOT_PAGE_SIZE = 512 } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt index 621c5b0ff5..b3e6448606 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt @@ -91,8 +91,9 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val override fun getUnreadCountTotal(): Flow = dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountTotal() } - // One-shot writes go through withDb so they register with the cross-transport merge drain barrier and pick up - // its closed-pool retry (see DatabaseProvider). Reads and Flow/Paging factories stay on currentDb by design. + // One-shot writes go through withDb so they register with the cross-transport merge drain barrier. The callback + // is never replayed after it starts; callers needing retries must make that policy explicit where idempotency is + // known. Reads and Flow/Paging factories stay on currentDb by design. override suspend fun clearUnreadCount(contact: String, timestamp: Long) { withContext(dispatchers.io + NonCancellable) { @@ -326,22 +327,22 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val status: MessageStatus, rxTime: Long, myNodeNum: Int?, - ) = withContext(dispatchers.io) { - dbManager.withDb { - it.packetDao().applySFPPStatus(packetId, from, to, hash.toByteString(), status, rxTime, myNodeNum) + ) { + withContext(dispatchers.io) { + dbManager.withDb { + it.packetDao().applySFPPStatus(packetId, from, to, hash.toByteString(), status, rxTime, myNodeNum) + } } - Unit } - override suspend fun updateSFPPStatusByHash(hash: ByteArray, status: MessageStatus, rxTime: Long): Unit = + override suspend fun updateSFPPStatusByHash(hash: ByteArray, status: MessageStatus, rxTime: Long) { withContext(dispatchers.io) { dbManager.withDb { it.packetDao().applySFPPStatusByHash(hash.toByteString(), status, rxTime) } - Unit } + } - override suspend fun deleteMessages(uuidList: List) = withContext(dispatchers.io) { - dbManager.withDb { it.packetDao().deleteMessagesAtomic(uuidList) } - Unit + override suspend fun deleteMessages(uuidList: List) { + withContext(dispatchers.io) { dbManager.withDb { it.packetDao().deleteMessagesAtomic(uuidList) } } } override suspend fun deleteContacts(contactList: List) { diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt index 547f7f62c5..bfa8ed42e6 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.data.manager import dev.mokkery.MockMode +import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify import okio.ByteString @@ -24,6 +25,7 @@ import okio.ByteString.Companion.toByteString import org.meshtastic.core.repository.MeshConfigFlowManager import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.repository.SessionManager import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel @@ -47,6 +49,7 @@ class AdminPacketHandlerImplTest { private lateinit var handler: AdminPacketHandlerImpl private val myNodeNum = 12345 + private val session = RadioSessionContext(generation = 7L, address = "tcp:test") @BeforeTest fun setUp() { @@ -64,6 +67,8 @@ class AdminPacketHandlerImplTest { return MeshPacket(from = from, decoded = Data(portnum = PortNum.ADMIN_APP, payload = payload)) } + private fun handle(packet: MeshPacket) = handler.handleAdminMessage(packet, myNodeNum, session) + // ---------- Session passkey ---------- @Test @@ -72,7 +77,7 @@ class AdminPacketHandlerImplTest { val adminMsg = AdminMessage(session_passkey = passkey) val packet = makePacket(myNodeNum, adminMsg) - handler.handleAdminMessage(packet, myNodeNum) + handle(packet) verify { sessionManager.recordSession(myNodeNum, passkey) } } @@ -82,7 +87,7 @@ class AdminPacketHandlerImplTest { val adminMsg = AdminMessage(session_passkey = ByteString.EMPTY) val packet = makePacket(myNodeNum, adminMsg) - handler.handleAdminMessage(packet, myNodeNum) + handle(packet) // recordSession should NOT be called for empty passkey } @@ -94,9 +99,9 @@ class AdminPacketHandlerImplTest { val adminMsg = AdminMessage(get_config_response = config) val packet = makePacket(myNodeNum, adminMsg) - handler.handleAdminMessage(packet, myNodeNum) + handle(packet) - verify { configHandler.handleDeviceConfig(config) } + verify { configHandler.handleDeviceConfig(config, session) } } @Test @@ -105,7 +110,7 @@ class AdminPacketHandlerImplTest { val adminMsg = AdminMessage(get_config_response = config) val packet = makePacket(99999, adminMsg) - handler.handleAdminMessage(packet, myNodeNum) + handle(packet) // configHandler.handleDeviceConfig should NOT be called } @@ -117,9 +122,9 @@ class AdminPacketHandlerImplTest { val adminMsg = AdminMessage(get_module_config_response = moduleConfig) val packet = makePacket(myNodeNum, adminMsg) - handler.handleAdminMessage(packet, myNodeNum) + handle(packet) - verify { configHandler.handleModuleConfig(moduleConfig) } + verify { configHandler.handleModuleConfig(moduleConfig, session) } } @Test @@ -129,9 +134,9 @@ class AdminPacketHandlerImplTest { val remoteNode = 99999 val packet = makePacket(remoteNode, adminMsg) - handler.handleAdminMessage(packet, myNodeNum) + handle(packet) - verify { nodeManager.updateNodeStatus(remoteNode, "Battery Low") } + verify { nodeManager.updateNodeForSession(remoteNode, session, channel = 0, transform = any()) } } @Test @@ -140,8 +145,8 @@ class AdminPacketHandlerImplTest { val adminMsg = AdminMessage(get_module_config_response = moduleConfig) val packet = makePacket(99999, adminMsg) - handler.handleAdminMessage(packet, myNodeNum) - // No crash, no updateNodeStatus call + handle(packet) + // No crash, no session-bound node update } // ---------- get_channel_response ---------- @@ -152,9 +157,9 @@ class AdminPacketHandlerImplTest { val adminMsg = AdminMessage(get_channel_response = channel) val packet = makePacket(myNodeNum, adminMsg) - handler.handleAdminMessage(packet, myNodeNum) + handle(packet) - verify { configHandler.handleChannel(channel) } + verify { configHandler.handleChannel(channel, session) } } @Test @@ -163,7 +168,7 @@ class AdminPacketHandlerImplTest { val adminMsg = AdminMessage(get_channel_response = channel) val packet = makePacket(99999, adminMsg) - handler.handleAdminMessage(packet, myNodeNum) + handle(packet) // configHandler.handleChannel should NOT be called } @@ -175,9 +180,9 @@ class AdminPacketHandlerImplTest { val adminMsg = AdminMessage(get_device_metadata_response = metadata) val packet = makePacket(myNodeNum, adminMsg) - handler.handleAdminMessage(packet, myNodeNum) + handle(packet) - verify { configFlowManager.handleLocalMetadata(metadata) } + verify { configFlowManager.handleLocalMetadata(metadata, session) } } @Test @@ -187,9 +192,9 @@ class AdminPacketHandlerImplTest { val remoteNode = 99999 val packet = makePacket(remoteNode, adminMsg) - handler.handleAdminMessage(packet, myNodeNum) + handle(packet) - verify { nodeManager.insertMetadata(remoteNode, metadata) } + verify { nodeManager.insertMetadata(remoteNode, metadata, session) } } // ---------- Edge cases ---------- @@ -197,7 +202,7 @@ class AdminPacketHandlerImplTest { @Test fun `packet with null decoded payload is ignored`() { val packet = MeshPacket(from = myNodeNum, decoded = null) - handler.handleAdminMessage(packet, myNodeNum) + handle(packet) // No crash } @@ -205,7 +210,7 @@ class AdminPacketHandlerImplTest { fun `packet with empty payload bytes is ignored`() { val packet = MeshPacket(from = myNodeNum, decoded = Data(portnum = PortNum.ADMIN_APP, payload = ByteString.EMPTY)) - handler.handleAdminMessage(packet, myNodeNum) + handle(packet) // No crash — decodes as default AdminMessage with no fields set } @@ -216,9 +221,9 @@ class AdminPacketHandlerImplTest { val adminMsg = AdminMessage(session_passkey = passkey, get_config_response = config) val packet = makePacket(myNodeNum, adminMsg) - handler.handleAdminMessage(packet, myNodeNum) + handle(packet) verify { sessionManager.recordSession(myNodeNum, passkey) } - verify { configHandler.handleDeviceConfig(config) } + verify { configHandler.handleDeviceConfig(config, session) } } } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt index 22cb8c04a3..910fe08567 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt @@ -17,10 +17,15 @@ package org.meshtastic.core.data.manager import dev.mokkery.MockMode +import dev.mokkery.answering.calls import dev.mokkery.answering.returns import dev.mokkery.every +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode +import dev.mokkery.verifySuspend import org.meshtastic.core.model.util.isOtaStatusNotification import org.meshtastic.core.repository.FirmwareUpdateStatusRepository import org.meshtastic.core.repository.MeshConfigFlowManager @@ -28,6 +33,9 @@ import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.core.repository.RadioSessionLease import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.XModemManager import org.meshtastic.core.testing.FakeLockdownCoordinator @@ -42,6 +50,7 @@ import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.MqttClientProxyMessage import org.meshtastic.proto.MyNodeInfo import org.meshtastic.proto.QueueStatus +import org.meshtastic.proto.XModem import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -60,11 +69,35 @@ class FromRadioPacketHandlerImplTest { private val xmodemManager: XModemManager = mock(MockMode.autofill) private val lockdownCoordinator = FakeLockdownCoordinator() private val firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository() + private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill) + private val session = RadioSessionContext(generation = 7L, address = "tcp:test") + private val sessionLease = + object : RadioSessionLease { + override val session: RadioSessionContext = this@FromRadioPacketHandlerImplTest.session + + override fun isCurrent(): Boolean = true + } private lateinit var handler: FromRadioPacketHandlerImpl @BeforeTest fun setup() { + every { radioInterfaceService.runIfSessionActive(session, any()) } calls + { + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as () -> Unit + block() + true + } + everySuspend { radioInterfaceService.runWithSessionLease(session, any()) } calls + { + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as suspend (RadioSessionLease) -> Unit + block(sessionLease) + true + } + every { configFlowManager.handleNodeInfo(any(), any()) } returns true + every { configFlowManager.handleConfigComplete(any(), any()) } returns true handler = FromRadioPacketHandlerImpl( serviceRepository, @@ -76,17 +109,20 @@ class FromRadioPacketHandlerImplTest { notificationManager, lockdownCoordinator, firmwareUpdateStatusRepository, + radioInterfaceService, ) } + private fun handle(proto: FromRadio) = handler.handleFromRadio(proto, session) + @Test fun `handleFromRadio routes MY_INFO to configFlowManager`() { val myInfo = MyNodeInfo(my_node_num = 1234) val proto = FromRadio(my_info = myInfo) - handler.handleFromRadio(proto) + handle(proto) - verify { configFlowManager.handleMyInfo(myInfo) } + verify { configFlowManager.handleMyInfo(myInfo, session) } } @Test @@ -94,9 +130,9 @@ class FromRadioPacketHandlerImplTest { val metadata = DeviceMetadata(firmware_version = "v1.0") val proto = FromRadio(metadata = metadata) - handler.handleFromRadio(proto) + handle(proto) - verify { configFlowManager.handleLocalMetadata(metadata) } + verify { configFlowManager.handleLocalMetadata(metadata, session) } } @Test @@ -106,9 +142,9 @@ class FromRadioPacketHandlerImplTest { every { configFlowManager.newNodeCount } returns 1 - handler.handleFromRadio(proto) + handle(proto) - verify { configFlowManager.handleNodeInfo(nodeInfo) } + verify { configFlowManager.handleNodeInfo(nodeInfo, session) } verify { serviceRepository.setConnectionProgress("Nodes (1)") } } @@ -117,18 +153,85 @@ class FromRadioPacketHandlerImplTest { val nonce = 69420 val proto = FromRadio(config_complete_id = nonce) - handler.handleFromRadio(proto) + handle(proto) - verify { configFlowManager.handleConfigComplete(nonce) } + verify { configFlowManager.handleConfigComplete(nonce, session) } assertTrue(lockdownCoordinator.configCompleteCalled) } + @Test + fun `stale NODE_INFO does not update connection progress`() { + val nodeInfo = ProtoNodeInfo(num = 1234) + every { configFlowManager.handleNodeInfo(nodeInfo, session) } returns false + + handle(FromRadio(node_info = nodeInfo)) + + verify { configFlowManager.handleNodeInfo(nodeInfo, session) } + verify(mode = VerifyMode.exactly(0)) { serviceRepository.setConnectionProgress(any()) } + } + + @Test + fun `stale CONFIG_COMPLETE does not notify lockdown coordinator`() { + val nonce = 69420 + every { configFlowManager.handleConfigComplete(nonce, session) } returns false + + handle(FromRadio(config_complete_id = nonce)) + + verify { configFlowManager.handleConfigComplete(nonce, session) } + assertFalse(lockdownCoordinator.configCompleteCalled) + } + + @Test + fun `revoked session skips node progress after handler returns`() { + val nodeInfo = ProtoNodeInfo(num = 1234) + var active = true + every { configFlowManager.handleNodeInfo(nodeInfo, session) } calls + { + active = false + true + } + every { radioInterfaceService.runIfSessionActive(session, any()) } calls + { + if (!active) { + false + } else { + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as () -> Unit + block() + true + } + } + + handle(FromRadio(node_info = nodeInfo)) + + verify(mode = VerifyMode.exactly(0)) { serviceRepository.setConnectionProgress(any()) } + } + + @Test + fun `revoked session skips direct packet-dispatch branches`() { + every { radioInterfaceService.runIfSessionActive(session, any()) } returns false + val proxyMessage = MqttClientProxyMessage(topic = "test/topic") + val queueStatus = QueueStatus(free = 10) + val xmodemPacket = XModem() + val lockdownStatus = LockdownStatus(state = LockdownStatus.State.LOCKED) + + handle(FromRadio(mqttClientProxyMessage = proxyMessage)) + handle(FromRadio(queueStatus = queueStatus)) + handle(FromRadio(xmodemPacket = xmodemPacket)) + handle(FromRadio(lockdown_status = lockdownStatus)) + + verify(mode = VerifyMode.exactly(0)) { mqttManager.handleMqttProxyMessage(any()) } + verify(mode = VerifyMode.exactly(0)) { packetHandler.handleQueueStatus(any()) } + verify(mode = VerifyMode.exactly(0)) { xmodemManager.handleIncomingXModem(any()) } + assertEquals(null, lockdownCoordinator.lastStatus) + } + @Test fun `handleFromRadio routes LOCKDOWN_STATUS to lockdownCoordinator`() { val lockdownStatus = LockdownStatus(state = LockdownStatus.State.LOCKED, lock_reason = "token_missing") val proto = FromRadio(lockdown_status = lockdownStatus) - handler.handleFromRadio(proto) + handle(proto) assertEquals(lockdownStatus, lockdownCoordinator.lastStatus) } @@ -138,7 +241,7 @@ class FromRadioPacketHandlerImplTest { val queueStatus = QueueStatus(free = 10) val proto = FromRadio(queueStatus = queueStatus) - handler.handleFromRadio(proto) + handle(proto) verify { packetHandler.handleQueueStatus(queueStatus) } } @@ -148,9 +251,9 @@ class FromRadioPacketHandlerImplTest { val config = Config(lora = Config.LoRaConfig(use_preset = true)) val proto = FromRadio(config = config) - handler.handleFromRadio(proto) + handle(proto) - verify { configHandler.handleDeviceConfig(config) } + verify { configHandler.handleDeviceConfig(config, session) } } @Test @@ -158,9 +261,9 @@ class FromRadioPacketHandlerImplTest { val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) val proto = FromRadio(moduleConfig = moduleConfig) - handler.handleFromRadio(proto) + handle(proto) - verify { configHandler.handleModuleConfig(moduleConfig) } + verify { configHandler.handleModuleConfig(moduleConfig, session) } } @Test @@ -168,9 +271,9 @@ class FromRadioPacketHandlerImplTest { val channel = Channel(index = 0) val proto = FromRadio(channel = channel) - handler.handleFromRadio(proto) + handle(proto) - verify { configHandler.handleChannel(channel) } + verify { configHandler.handleChannel(channel, session) } } @Test @@ -178,9 +281,9 @@ class FromRadioPacketHandlerImplTest { val map = LoRaRegionPresetMap() val proto = FromRadio(region_presets = map) - handler.handleFromRadio(proto) + handle(proto) - verify { configHandler.handleRegionPresets(map) } + verify { configHandler.handleRegionPresets(map, session) } } @Test @@ -188,7 +291,7 @@ class FromRadioPacketHandlerImplTest { val proxyMsg = MqttClientProxyMessage(topic = "test/topic") val proto = FromRadio(mqttClientProxyMessage = proxyMsg) - handler.handleFromRadio(proto) + handle(proto) verify { mqttManager.handleMqttProxyMessage(proxyMsg) } } @@ -201,7 +304,7 @@ class FromRadioPacketHandlerImplTest { // Note: getString() from Compose Resources requires Skiko native lib which // is not available in headless JVM tests. We test the parts that don't trigger it. try { - handler.handleFromRadio(proto) + handle(proto) } catch (_: Throwable) { // Expected: Skiko can't load in headless JVM/native } @@ -209,6 +312,28 @@ class FromRadioPacketHandlerImplTest { verify { serviceRepository.setClientNotification(notification) } } + @Test + fun `stale client notification is discarded before publication`() { + val notification = ClientNotification(message = "stale") + every { radioInterfaceService.runIfSessionActive(session, any()) } returns false + + handle(FromRadio(clientNotification = notification)) + + verify(mode = VerifyMode.exactly(0)) { serviceRepository.setClientNotification(any()) } + verifySuspend(mode = VerifyMode.exactly(0)) { notificationManager.dispatch(any()) } + } + + @Test + fun `client alert is skipped when the session retires after publication`() { + val notification = ClientNotification(message = "retired") + everySuspend { radioInterfaceService.runWithSessionLease(session, any()) } returns false + + handle(FromRadio(clientNotification = notification)) + + verify { serviceRepository.setClientNotification(notification) } + verifySuspend(mode = VerifyMode.exactly(0)) { notificationManager.dispatch(any()) } + } + @Test fun `OTA status client notifications are identified`() { assertTrue(ClientNotification(message = "Rebooting to WiFi OTA").isOtaStatusNotification()) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/GeofenceMonitorTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/GeofenceMonitorTest.kt index 5997c33cf1..9f9c68c54a 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/GeofenceMonitorTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/GeofenceMonitorTest.kt @@ -37,6 +37,7 @@ import org.meshtastic.core.repository.MeshNotificationManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NotificationPrefs import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.proto.Position import org.meshtastic.proto.Waypoint import kotlin.test.Test @@ -119,6 +120,7 @@ class GeofenceMonitorTest { m.notifications, GeofenceCrossingStore(), m.notificationPrefs, + mock(MockMode.autofill), scope, ) scope.advanceUntilIdle() // collect the active-geofence snapshot diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt index 070e55381b..c79bf87f95 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt @@ -26,8 +26,12 @@ import dev.mokkery.mock import dev.mokkery.verify import dev.mokkery.verify.VerifyMode import dev.mokkery.verifySuspend +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy @@ -46,6 +50,8 @@ import org.meshtastic.core.repository.NotificationPrefs import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.FileInfo @@ -55,6 +61,8 @@ import org.meshtastic.proto.NodeInfo import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo @OptIn(ExperimentalCoroutinesApi::class) @@ -76,6 +84,7 @@ class MeshConfigFlowManagerImplTest { private val commandSender = mock(MockMode.autofill) private val packetHandler = mock(MockMode.autofill) private val notificationPrefs = mock(MockMode.autofill) + private val radioInterfaceService = mock(MockMode.autofill) private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) @@ -83,6 +92,8 @@ class MeshConfigFlowManagerImplTest { private lateinit var manager: MeshConfigFlowManagerImpl private val myNodeNum = 12345 + private val activeSession = RadioSessionContext(generation = 0L, address = "tcp:test-node") + private val activeSessionFlow = MutableStateFlow(activeSession) private val protoMyNodeInfo = ProtoMyNodeInfo( @@ -101,6 +112,38 @@ class MeshConfigFlowManagerImplTest { every { packetHandler.sendToRadio(any()) } returns Unit every { nodeManager.nodeDBbyNodeNum } returns emptyMap() every { nodeManager.myNodeNum } returns MutableStateFlow(null) + activeSessionFlow.value = activeSession + every { radioInterfaceService.activeSession } returns activeSessionFlow + every { radioInterfaceService.isSessionActive(any()) } calls + { + activeSessionFlow.value == it.args[0] as RadioSessionContext + } + every { radioInterfaceService.runIfSessionActive(any(), any()) } calls + { + val session = it.args[0] as RadioSessionContext + + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as () -> Unit + if (activeSessionFlow.value == session) { + block() + true + } else { + false + } + } + everySuspend { radioInterfaceService.runWhileSessionActive(any(), any()) } calls + { + val session = it.args[0] as RadioSessionContext + + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as (suspend () -> Unit) + if (activeSessionFlow.value == session) { + block() + true + } else { + false + } + } every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false) every { notificationPrefs.nodeEventsEnabled } returns MutableStateFlow(true) // autofill returns null for List, which would NPE the non-null return; individual @@ -118,18 +161,41 @@ class MeshConfigFlowManagerImplTest { commandSender = commandSender, heartbeatSender = DataLayerHeartbeatSender(packetHandler), notificationPrefs = notificationPrefs, + radioInterfaceService = radioInterfaceService, scope = testScope, ) } + private fun handleMyInfo(myInfo: ProtoMyNodeInfo) = manager.handleMyInfo(myInfo, activeSession) + + private fun MeshConfigFlowManagerImpl.handleLocalMetadata(metadata: DeviceMetadata): Boolean = + handleLocalMetadata(metadata, activeSession) + + private fun MeshConfigFlowManagerImpl.handleNodeInfo(info: NodeInfo): Boolean = handleNodeInfo(info, activeSession) + + private fun MeshConfigFlowManagerImpl.handleFileInfo(info: FileInfo): Boolean = handleFileInfo(info, activeSession) + + private fun MeshConfigFlowManagerImpl.handleConfigComplete(configCompleteId: Int): Boolean = + handleConfigComplete(configCompleteId, activeSession) + + private fun MeshConfigFlowManagerImpl.triggerWantConfig(): Boolean = triggerWantConfig(activeSession) + // ---------- handleMyInfo ---------- @Test fun `handleMyInfo transitions to ReceivingConfig and sets myNodeNum`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() verify { nodeManager.setMyNodeNum(myNodeNum) } + verify { + nodeManager.publishConnectionIdentity( + sessionGeneration = 0L, + address = "tcp:test-node", + nodeNum = myNodeNum, + deviceId = "746573742d646576696365", + ) + } } @Test @@ -137,7 +203,7 @@ class MeshConfigFlowManagerImplTest { // device_id is raw hardware bytes, not text: a lossy utf8 decode would collapse distinct // ids into the same replacement-character string. 0xFF bytes are invalid UTF-8 on purpose. val rawId = byteArrayOf(0xFF.toByte(), 0x00, 0xA1.toByte(), 0xB2.toByte()).toByteString() - manager.handleMyInfo(protoMyNodeInfo.copy(device_id = rawId)) + handleMyInfo(protoMyNodeInfo.copy(device_id = rawId)) advanceUntilIdle() verify { nodeManager.setMyDeviceId("ff00a1b2") } @@ -145,15 +211,69 @@ class MeshConfigFlowManagerImplTest { @Test fun `handleMyInfo reports an absent device_id as null`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo.copy(device_id = okio.ByteString.EMPTY)) + handleMyInfo(protoMyNodeInfo.copy(device_id = okio.ByteString.EMPTY)) advanceUntilIdle() verify { nodeManager.setMyDeviceId(null) } } + @Test + fun `handleMyInfo without a selected address does not publish session identity`() = testScope.runTest { + activeSessionFlow.value = null + + handleMyInfo(protoMyNodeInfo) + advanceUntilIdle() + + verify(mode = VerifyMode.exactly(0)) { nodeManager.setMyDeviceId(any()) } + verify(mode = VerifyMode.exactly(0)) { nodeManager.setMyNodeNum(any()) } + verify(mode = VerifyMode.exactly(0)) { nodeManager.publishConnectionIdentity(any(), any(), any(), any()) } + } + + @Test + fun `queued MyNodeInfo from an old generation is discarded`() = testScope.runTest { + activeSessionFlow.value = activeSession.copy(generation = activeSession.generation + 1) + + handleMyInfo(protoMyNodeInfo) + advanceUntilIdle() + + verify(mode = VerifyMode.exactly(0)) { nodeManager.setMyNodeNum(any()) } + verify(mode = VerifyMode.exactly(0)) { nodeManager.publishConnectionIdentity(any(), any(), any(), any()) } + } + + @Test + fun `stale session cannot mutate handshake identity or metadata`() = testScope.runTest { + activeSessionFlow.value = activeSession.copy(generation = activeSession.generation + 1) + + handleMyInfo(protoMyNodeInfo) + manager.handleLocalMetadata(metadata, activeSession) + advanceUntilIdle() + + verify(mode = VerifyMode.exactly(0)) { nodeManager.setMyDeviceId(any()) } + verify(mode = VerifyMode.exactly(0)) { nodeManager.setMyNodeNum(any()) } + verify(mode = VerifyMode.exactly(0)) { nodeManager.publishConnectionIdentity(any(), any(), any(), any()) } + verifySuspend(mode = VerifyMode.exactly(0)) { nodeRepository.insertMetadata(any(), any()) } + } + + @Test + fun `new active session cannot advance handshake state owned by prior session`() = testScope.runTest { + handleMyInfo(protoMyNodeInfo) + advanceUntilIdle() + val nextSession = activeSession.copy(generation = activeSession.generation + 1) + activeSessionFlow.value = nextSession + + assertFalse(manager.handleLocalMetadata(metadata, nextSession)) + assertFalse(manager.handleNodeInfo(NodeInfo(num = 100), nextSession)) + assertFalse(manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE, nextSession)) + advanceUntilIdle() + + verifySuspend(mode = VerifyMode.exactly(0)) { nodeRepository.insertMetadata(any(), any()) } + verify(mode = VerifyMode.exactly(0)) { connectionManager.onRadioConfigLoaded() } + verify(mode = VerifyMode.exactly(0)) { nodeManager.installNodeInfo(any()) } + } + @Test fun `handleMyInfo clears persisted radio config`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() verifySuspend { radioConfigRepository.clearChannelSet() } @@ -164,11 +284,75 @@ class MeshConfigFlowManagerImplTest { verifySuspend { radioConfigRepository.clearLoraRegionPresetMap() } } + @Test + fun `handleMyInfo admits config clearing before returning to the frame consumer`() = testScope.runTest { + handleMyInfo(protoMyNodeInfo) + + verifySuspend { radioConfigRepository.clearChannelSet() } + } + + @Test + fun `config reset holds the session lane until every clear completes`() = testScope.runTest { + val sessionLane = Mutex() + everySuspend { radioInterfaceService.runWhileSessionActive(activeSession, any()) } calls + { + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as (suspend () -> Unit) + sessionLane.withLock { block() } + true + } + val firstClearStarted = CompletableDeferred() + val releaseFirstClear = CompletableDeferred() + everySuspend { radioConfigRepository.clearChannelSet() } calls + { + firstClearStarted.complete(Unit) + releaseFirstClear.await() + } + + handleMyInfo(protoMyNodeInfo) + firstClearStarted.await() + + val completedClears = mutableSetOf() + everySuspend { radioConfigRepository.clearLocalConfig() } calls { completedClears += "config" } + everySuspend { radioConfigRepository.clearLocalModuleConfig() } calls { completedClears += "module" } + everySuspend { radioConfigRepository.clearDeviceUIConfig() } calls { completedClears += "device-ui" } + everySuspend { radioConfigRepository.clearFileManifest() } calls { completedClears += "manifest" } + everySuspend { radioConfigRepository.clearLoraRegionPresetMap() } calls + { + completedClears += "lora-presets" + } + + val laterPersistenceStarted = CompletableDeferred() + val laterPersistence = launch { + radioInterfaceService.runWhileSessionActive(activeSession) { + assertEquals( + setOf("config", "module", "device-ui", "manifest", "lora-presets"), + completedClears, + "Later persistence must not enter until every config clear finishes", + ) + laterPersistenceStarted.complete(Unit) + } + } + runCurrent() + assertFalse(laterPersistenceStarted.isCompleted) + + releaseFirstClear.complete(Unit) + advanceUntilIdle() + laterPersistence.join() + + assertTrue(laterPersistenceStarted.isCompleted) + verifySuspend { radioConfigRepository.clearLocalConfig() } + verifySuspend { radioConfigRepository.clearLocalModuleConfig() } + verifySuspend { radioConfigRepository.clearDeviceUIConfig() } + verifySuspend { radioConfigRepository.clearFileManifest() } + verifySuspend { radioConfigRepository.clearLoraRegionPresetMap() } + } + // ---------- handleLocalMetadata ---------- @Test fun `handleLocalMetadata persists metadata when in ReceivingConfig state`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) @@ -179,7 +363,7 @@ class MeshConfigFlowManagerImplTest { @Test fun `handleLocalMetadata skips empty metadata`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() // Default/empty DeviceMetadata should not trigger insertMetadata @@ -202,7 +386,7 @@ class MeshConfigFlowManagerImplTest { @Test fun `Stage 1 complete builds MyNodeInfo and transitions to ReceivingNodeInfo`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() @@ -223,7 +407,7 @@ class MeshConfigFlowManagerImplTest { sentPackets.add(call.arg(0)) } - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() @@ -246,7 +430,7 @@ class MeshConfigFlowManagerImplTest { fun `Stage 1 complete with old firmware logs warning but continues handshake`() = testScope.runTest { val oldMetadata = DeviceMetadata(firmware_version = "2.3.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false) - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(oldMetadata) advanceUntilIdle() @@ -262,7 +446,7 @@ class MeshConfigFlowManagerImplTest { @Test fun `Stage 1 complete without metadata still succeeds with null firmware version`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() // No metadata provided @@ -274,7 +458,7 @@ class MeshConfigFlowManagerImplTest { @Test fun `Stage 1 complete updates progress for node list loading`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() @@ -296,7 +480,7 @@ class MeshConfigFlowManagerImplTest { @Test fun `Duplicate Stage 1 config_complete does not re-trigger`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() @@ -316,7 +500,7 @@ class MeshConfigFlowManagerImplTest { @Test fun `handleNodeInfo accumulates nodes during Stage 2`() = testScope.runTest { // Transition to Stage 2 - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() @@ -336,7 +520,7 @@ class MeshConfigFlowManagerImplTest { val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100) every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode) - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleNodeInfo(NodeInfo(num = 100)) @@ -364,13 +548,67 @@ class MeshConfigFlowManagerImplTest { // ---------- handleConfigComplete Stage 2 ---------- + @Test + fun `active session completes metadata node info and both handshake stages`() = testScope.runTest { + val nodeInfo = NodeInfo(num = 100) + val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = nodeInfo.num) + every { nodeManager.nodeDBbyNodeNum } returns mapOf(nodeInfo.num to testNode) + + handleMyInfo(protoMyNodeInfo) + advanceUntilIdle() + assertTrue(manager.handleLocalMetadata(metadata, activeSession)) + advanceUntilIdle() + assertTrue(manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE, activeSession)) + advanceTimeBy(STAGE_TRANSITION_ADVANCE_MS) + runCurrent() + assertTrue(manager.handleNodeInfo(nodeInfo, activeSession)) + assertTrue(manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE, activeSession)) + advanceUntilIdle() + + verifySuspend { nodeRepository.insertMetadata(myNodeNum, metadata) } + verify { nodeManager.installNodeInfo(nodeInfo) } + verifySuspend(mode = VerifyMode.exactly(0)) { nodeManager.installNodeInfoAndPersist(any()) } + verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.installConfig(any(), any()) } + verify { nodeManager.setNodeDbReady(true) } + verify { nodeManager.setAllowNodeDbWrites(true) } + verifySuspend { connectionManager.onNodeDbReady() } + } + + @Test + fun `session revocation during Stage 2 stops remaining persistence and publication`() = testScope.runTest { + val firstNode = NodeInfo(num = 100) + val secondNode = NodeInfo(num = 200) + val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = firstNode.num) + every { nodeManager.nodeDBbyNodeNum } returns mapOf(firstNode.num to testNode) + every { nodeManager.installNodeInfo(any()) } calls { activeSessionFlow.value = null } + + handleMyInfo(protoMyNodeInfo) + advanceUntilIdle() + assertTrue(manager.handleLocalMetadata(metadata, activeSession)) + advanceUntilIdle() + assertTrue(manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE, activeSession)) + advanceTimeBy(STAGE_TRANSITION_ADVANCE_MS) + runCurrent() + assertTrue(manager.handleNodeInfo(firstNode, activeSession)) + assertTrue(manager.handleNodeInfo(secondNode, activeSession)) + assertTrue(manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE, activeSession)) + advanceUntilIdle() + + verify(mode = VerifyMode.exactly(1)) { nodeManager.installNodeInfo(any()) } + verifySuspend(mode = VerifyMode.exactly(0)) { nodeRepository.installConfig(any(), any()) } + verify(mode = VerifyMode.exactly(0)) { nodeManager.setNodeDbReady(true) } + verify(mode = VerifyMode.exactly(0)) { nodeManager.setAllowNodeDbWrites(true) } + verify(mode = VerifyMode.exactly(0)) { serviceRepository.setConnectionState(ConnectionState.Connected) } + verifySuspend(mode = VerifyMode.exactly(0)) { connectionManager.onNodeDbReady() } + } + @Test fun `Stage 2 complete processes nodes and sets Connected state`() = testScope.runTest { val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100) every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode) // Full handshake: MyInfo -> metadata -> Stage 1 complete -> nodes -> Stage 2 complete - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() @@ -397,7 +635,7 @@ class MeshConfigFlowManagerImplTest { @Test fun `Stage 2 complete with no nodes still transitions to Connected`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() @@ -418,7 +656,7 @@ class MeshConfigFlowManagerImplTest { every { analytics.setDeviceAttributes(any(), any()) } calls { throw IllegalStateException("analytics") } everySuspend { connectionManager.onNodeDbReady() } calls { throw IllegalStateException("side effects") } - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() @@ -442,7 +680,7 @@ class MeshConfigFlowManagerImplTest { fun `Stage 2 complete disconnects when NodeDB install fails`() = testScope.runTest { everySuspend { nodeRepository.installConfig(any(), any()) } calls { throw IllegalStateException("room") } - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() @@ -504,7 +742,7 @@ class MeshConfigFlowManagerImplTest { every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode) // Stage 0: Idle -> handleMyInfo - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() verify { nodeManager.setMyNodeNum(myNodeNum) } @@ -538,14 +776,14 @@ class MeshConfigFlowManagerImplTest { @Test fun `handleMyInfo resets stale handshake state`() = testScope.runTest { // Start first handshake - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() // Before Stage 1 completes, a new handleMyInfo arrives (device rebooted) val newMyInfo = protoMyNodeInfo.copy(my_node_num = 99999) - manager.handleMyInfo(newMyInfo) + handleMyInfo(newMyInfo) advanceUntilIdle() verify { nodeManager.setMyNodeNum(99999) } @@ -558,7 +796,7 @@ class MeshConfigFlowManagerImplTest { every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false) val eventMyInfo = protoMyNodeInfo.copy(firmware_edition = FirmwareEdition.DEFCON) - manager.handleMyInfo(eventMyInfo) + handleMyInfo(eventMyInfo) advanceUntilIdle() verify { notificationPrefs.setNodeEventsEnabled(false) } @@ -570,7 +808,7 @@ class MeshConfigFlowManagerImplTest { every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(true) val eventMyInfo = protoMyNodeInfo.copy(firmware_edition = FirmwareEdition.DEFCON) - manager.handleMyInfo(eventMyInfo) + handleMyInfo(eventMyInfo) advanceUntilIdle() verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsEnabled(any()) } @@ -580,7 +818,7 @@ class MeshConfigFlowManagerImplTest { fun `handleMyInfo re-enables node notifications when vanilla firmware reconnects`() = testScope.runTest { every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(true) - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() verify { notificationPrefs.setNodeEventsEnabled(true) } @@ -591,7 +829,7 @@ class MeshConfigFlowManagerImplTest { fun `handleMyInfo does not touch prefs for vanilla when not previously auto-disabled`() = testScope.runTest { every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false) - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsEnabled(any()) } @@ -602,7 +840,7 @@ class MeshConfigFlowManagerImplTest { @Test fun `handleMyInfo calls onHandshakeProgress`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() verify { connectionManager.onHandshakeProgress() } @@ -610,7 +848,7 @@ class MeshConfigFlowManagerImplTest { @Test fun `handleLocalMetadata in ReceivingConfig calls onHandshakeProgress`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() @@ -639,7 +877,7 @@ class MeshConfigFlowManagerImplTest { @Test fun `handleNodeInfo during Stage 1 calls onHandshakeProgress`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleNodeInfo(NodeInfo(num = 100)) @@ -649,7 +887,7 @@ class MeshConfigFlowManagerImplTest { @Test fun `handleNodeInfo during Stage 2 calls onHandshakeProgress`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() @@ -667,7 +905,7 @@ class MeshConfigFlowManagerImplTest { @Test fun `Stage 1 complete calls onHandshakeProgress`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() @@ -699,7 +937,7 @@ class MeshConfigFlowManagerImplTest { val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100) every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode) - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() @@ -727,7 +965,7 @@ class MeshConfigFlowManagerImplTest { val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100) every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode) - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() @@ -766,7 +1004,7 @@ class MeshConfigFlowManagerImplTest { emptyList() } - manager.handleMyInfo(protoMyNodeInfo) + handleMyInfo(protoMyNodeInfo) advanceUntilIdle() manager.handleLocalMetadata(metadata) advanceUntilIdle() diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt index fe9ef25fce..6e02bfb1a8 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt @@ -17,14 +17,20 @@ package org.meshtastic.core.data.manager import dev.mokkery.MockMode +import dev.mokkery.answering.calls import dev.mokkery.answering.returns import dev.mokkery.every +import dev.mokkery.everySuspend import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode import dev.mokkery.verifySuspend +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -33,6 +39,8 @@ import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.Channel import org.meshtastic.proto.Config @@ -44,6 +52,8 @@ import org.meshtastic.proto.ModuleConfig import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class MeshConfigHandlerImplTest { @@ -52,11 +62,13 @@ class MeshConfigHandlerImplTest { private val serviceRepository = mock(MockMode.autofill) private val nodeManager = mock(MockMode.autofill) private val connectionManager = mock(MockMode.autofill) + private val radioInterfaceService = mock(MockMode.autofill) private val localConfigFlow = MutableStateFlow(LocalConfig()) private val moduleConfigFlow = MutableStateFlow(LocalModuleConfig()) private val testDispatcher = UnconfinedTestDispatcher() + private val session = RadioSessionContext(generation = 1L, address = "tcp:test") private lateinit var handler: MeshConfigHandlerImpl @@ -64,6 +76,20 @@ class MeshConfigHandlerImplTest { fun setUp() { every { radioConfigRepository.localConfigFlow } returns localConfigFlow every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow + every { radioInterfaceService.runIfSessionActive(session, any()) } calls + { + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as () -> Unit + block() + true + } + everySuspend { radioInterfaceService.runWhileSessionActive(session, any()) } calls + { + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as (suspend () -> Unit) + block() + true + } } private fun createHandler(scope: CoroutineScope): MeshConfigHandlerImpl = MeshConfigHandlerImpl( @@ -71,6 +97,7 @@ class MeshConfigHandlerImplTest { serviceStateWriter = serviceRepository, nodeManager = nodeManager, connectionManager = lazy { this.connectionManager }, + radioInterfaceService = radioInterfaceService, scope = scope, ) @@ -102,13 +129,75 @@ class MeshConfigHandlerImplTest { fun `handleDeviceConfig persists config and updates progress`() = runTest(testDispatcher) { handler = createHandler(backgroundScope) val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) - handler.handleDeviceConfig(config) + handler.handleDeviceConfig(config, session) advanceUntilIdle() verifySuspend { radioConfigRepository.setLocalConfig(config) } verify { serviceRepository.setConnectionProgress("Device config received") } } + @Test + fun `stale session cannot persist config or advance handshake`() = runTest(testDispatcher) { + handler = createHandler(backgroundScope) + val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) + every { radioInterfaceService.runIfSessionActive(session, any()) } returns false + + assertFalse(handler.handleDeviceConfig(config, session)) + advanceUntilIdle() + + verifySuspend(mode = VerifyMode.exactly(0)) { radioConfigRepository.setLocalConfig(any()) } + verify(mode = VerifyMode.exactly(0)) { serviceRepository.setConnectionProgress(any()) } + verify(mode = VerifyMode.exactly(0)) { connectionManager.onHandshakeProgress() } + } + + @Test + fun `active session persists device config and advances handshake`() = runTest(testDispatcher) { + handler = createHandler(backgroundScope) + val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) + every { radioInterfaceService.runIfSessionActive(session, any()) } calls + { + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as () -> Unit + block() + true + } + everySuspend { radioInterfaceService.runWhileSessionActive(session, any()) } calls + { + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as (suspend () -> Unit) + block() + true + } + + assertTrue(handler.handleDeviceConfig(config, session)) + advanceUntilIdle() + + verifySuspend { radioConfigRepository.setLocalConfig(config) } + verify { serviceRepository.setConnectionProgress("Device config received") } + verify { connectionManager.onHandshakeProgress() } + } + + @Test + fun `revoked session cannot complete queued config persistence`() = runTest(testDispatcher) { + handler = createHandler(backgroundScope) + val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) + every { radioInterfaceService.runIfSessionActive(session, any()) } calls + { + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as () -> Unit + block() + true + } + everySuspend { radioInterfaceService.runWhileSessionActive(session, any()) } returns false + + handler.handleDeviceConfig(config, session) + advanceUntilIdle() + + verifySuspend(mode = VerifyMode.exactly(0)) { radioConfigRepository.setLocalConfig(any()) } + verify { serviceRepository.setConnectionProgress("Device config received") } + verify { connectionManager.onHandshakeProgress() } + } + @Test fun `handleDeviceConfig handles all config variants`() = runTest(testDispatcher) { handler = createHandler(backgroundScope) @@ -124,7 +213,7 @@ class MeshConfigHandlerImplTest { ) for (config in configs) { - handler.handleDeviceConfig(config) + handler.handleDeviceConfig(config, session) advanceUntilIdle() } @@ -138,7 +227,7 @@ class MeshConfigHandlerImplTest { fun `handleModuleConfig persists config and updates progress`() = runTest(testDispatcher) { handler = createHandler(backgroundScope) val config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) - handler.handleModuleConfig(config) + handler.handleModuleConfig(config, session) advanceUntilIdle() verifySuspend { radioConfigRepository.setLocalModuleConfig(config) } @@ -152,10 +241,10 @@ class MeshConfigHandlerImplTest { every { nodeManager.myNodeNum } returns MutableStateFlow(myNum) val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active")) - handler.handleModuleConfig(config) + handler.handleModuleConfig(config, session) advanceUntilIdle() - verify { nodeManager.updateNodeStatus(myNum, "Active") } + verifySuspend { nodeManager.updateNodeStatusAndPersist(myNum, "Active") } } @Test @@ -164,18 +253,66 @@ class MeshConfigHandlerImplTest { every { nodeManager.myNodeNum } returns MutableStateFlow(null) val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active")) - handler.handleModuleConfig(config) + handler.handleModuleConfig(config, session) advanceUntilIdle() // No crash — updateNodeStatus should not be called } + @Test + fun `module config remains persisted when node status persistence fails`() = runTest(testDispatcher) { + handler = createHandler(backgroundScope) + val myNum = 123 + val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active")) + every { nodeManager.myNodeNum } returns MutableStateFlow(myNum) + everySuspend { nodeManager.updateNodeStatusAndPersist(myNum, "Active") } calls + { + throw IllegalStateException("forced node-status failure") + } + + handler.handleModuleConfig(config, session) + advanceUntilIdle() + + verifySuspend(mode = VerifyMode.exactly(1)) { radioConfigRepository.setLocalModuleConfig(config) } + verifySuspend(mode = VerifyMode.exactly(1)) { nodeManager.updateNodeStatusAndPersist(myNum, "Active") } + } + + @Test + fun `module config cancellation cancels node status persistence job`() = runTest(testDispatcher) { + val parentJob = Job() + val handlerScope = CoroutineScope(parentJob + testDispatcher) + handler = createHandler(handlerScope) + val existingJobs = parentJob.children.toSet() + val statusStarted = CompletableDeferred() + val releaseStatus = CompletableDeferred() + val myNum = 123 + val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active")) + every { nodeManager.myNodeNum } returns MutableStateFlow(myNum) + everySuspend { nodeManager.updateNodeStatusAndPersist(myNum, "Active") } calls + { + statusStarted.complete(Unit) + releaseStatus.await() + throw CancellationException("forced node-status cancellation") + } + + handler.handleModuleConfig(config, session) + statusStarted.await() + val persistenceJob = parentJob.children.first { it !in existingJobs } + releaseStatus.complete(Unit) + advanceUntilIdle() + + assertTrue(persistenceJob.isCancelled, "node-status cancellation must remain cancellation") + verifySuspend(mode = VerifyMode.exactly(1)) { radioConfigRepository.setLocalModuleConfig(config) } + verifySuspend(mode = VerifyMode.exactly(1)) { nodeManager.updateNodeStatusAndPersist(myNum, "Active") } + parentJob.cancel() + } + // ---------- handleChannel ---------- @Test fun `handleChannel persists channel settings`() = runTest(testDispatcher) { handler = createHandler(backgroundScope) val channel = Channel(index = 0) - handler.handleChannel(channel) + handler.handleChannel(channel, session) advanceUntilIdle() verifySuspend { radioConfigRepository.updateChannelSettings(channel) } @@ -203,7 +340,7 @@ class MeshConfigHandlerImplTest { ) val channel = Channel(index = 2) - handler.handleChannel(channel) + handler.handleChannel(channel, session) advanceUntilIdle() verify { serviceRepository.setConnectionProgress("Channels (3 / 8)") } @@ -215,7 +352,7 @@ class MeshConfigHandlerImplTest { every { nodeManager.getMyNodeInfo() } returns null val channel = Channel(index = 0) - handler.handleChannel(channel) + handler.handleChannel(channel, session) advanceUntilIdle() verify { serviceRepository.setConnectionProgress("Channels (1)") } @@ -227,7 +364,7 @@ class MeshConfigHandlerImplTest { fun `handleDeviceUIConfig persists config`() = runTest(testDispatcher) { handler = createHandler(backgroundScope) val config = DeviceUIConfig() - handler.handleDeviceUIConfig(config) + handler.handleDeviceUIConfig(config, session) advanceUntilIdle() verifySuspend { radioConfigRepository.setDeviceUIConfig(config) } @@ -239,7 +376,7 @@ class MeshConfigHandlerImplTest { fun `handleDeviceConfig calls onHandshakeProgress`() = runTest(testDispatcher) { handler = createHandler(backgroundScope) val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) - handler.handleDeviceConfig(config) + handler.handleDeviceConfig(config, session) advanceUntilIdle() verify { connectionManager.onHandshakeProgress() } @@ -249,7 +386,7 @@ class MeshConfigHandlerImplTest { fun `handleModuleConfig calls onHandshakeProgress`() = runTest(testDispatcher) { handler = createHandler(backgroundScope) val config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) - handler.handleModuleConfig(config) + handler.handleModuleConfig(config, session) advanceUntilIdle() verify { connectionManager.onHandshakeProgress() } @@ -261,7 +398,7 @@ class MeshConfigHandlerImplTest { every { nodeManager.getMyNodeInfo() } returns null val channel = Channel(index = 0) - handler.handleChannel(channel) + handler.handleChannel(channel, session) advanceUntilIdle() verify { connectionManager.onHandshakeProgress() } @@ -270,7 +407,7 @@ class MeshConfigHandlerImplTest { @Test fun `handleDeviceUIConfig calls onHandshakeProgress`() = runTest(testDispatcher) { handler = createHandler(backgroundScope) - handler.handleDeviceUIConfig(DeviceUIConfig()) + handler.handleDeviceUIConfig(DeviceUIConfig(), session) advanceUntilIdle() verify { connectionManager.onHandshakeProgress() } @@ -282,9 +419,10 @@ class MeshConfigHandlerImplTest { fun `handleRegionPresets persists map`() = runTest(testDispatcher) { handler = createHandler(backgroundScope) val map = LoRaRegionPresetMap() - handler.handleRegionPresets(map) + handler.handleRegionPresets(map, session) advanceUntilIdle() verifySuspend { radioConfigRepository.setLoraRegionPresetMap(map) } + verify { connectionManager.onHandshakeProgress() } } } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt index 333b7bdff2..80c181a832 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt @@ -17,12 +17,14 @@ package org.meshtastic.core.data.manager import dev.mokkery.MockMode +import dev.mokkery.answering.calls import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.everySuspend import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode.Companion.exactly import dev.mokkery.verifySuspend import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -52,6 +54,9 @@ import org.meshtastic.core.repository.PacketHandler import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.core.repository.RadioSessionLease import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.StoreForwardPacketHandler import org.meshtastic.core.repository.TelemetryPacketHandler @@ -94,6 +99,18 @@ class MeshDataHandlerTest { private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill) private val telemetryHandler: TelemetryPacketHandler = mock(MockMode.autofill) private val adminPacketHandler: AdminPacketHandler = mock(MockMode.autofill) + private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill) + private val session = RadioSessionContext(generation = 7L, address = "tcp:test") + + private fun testLease(session: RadioSessionContext): RadioSessionLease = object : RadioSessionLease { + override val session: RadioSessionContext = session + + override fun isCurrent(): Boolean = true + } + + private fun MeshDataHandlerImpl.handleReceivedData(packet: MeshPacket, myNodeNum: Int) { + handleReceivedData(packet, myNodeNum, session) + } private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) @@ -149,12 +166,23 @@ class MeshDataHandlerTest { serviceNotifications = serviceNotifications, crossingStore = GeofenceCrossingStore(), notificationPrefs = FakeNotificationPrefs(), + radioInterfaceService = radioInterfaceService, scope = geofenceScope, ), meshBeaconRepository = meshBeaconRepository, + radioInterfaceService = radioInterfaceService, scope = testScope, ) + everySuspend { radioInterfaceService.runWithSessionLease(any(), any()) } calls + { + val requestedSession = it.args[0] as RadioSessionContext + + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as (suspend (RadioSessionLease) -> Unit) + block(testLease(requestedSession)) + true + } // Default: mapper returns null for empty packets, which is the safe default every { dataMapper.toDataPacket(any()) } returns null // Stub commonly accessed properties to avoid NPE from autofill @@ -262,7 +290,7 @@ class MeshDataHandlerTest { handler.handleReceivedData(packet, myNodeNum) - verify { nodeManager.handleReceivedPosition(remoteNum, myNodeNum, any(), 1000L) } + verify { nodeManager.handleReceivedPosition(remoteNum, myNodeNum, any(), 1000L, session) } } // --- NodeInfo handling --- @@ -288,7 +316,7 @@ class MeshDataHandlerTest { handler.handleReceivedData(packet, myNodeNum) - verify { nodeManager.handleReceivedUser(remoteNum, any(), any(), any()) } + verify { nodeManager.handleReceivedUser(remoteNum, any(), any(), any(), session) } } @Test @@ -311,7 +339,9 @@ class MeshDataHandlerTest { handler.handleReceivedData(packet, myNodeNum) - verify(mode = dev.mokkery.verify.VerifyMode.not) { nodeManager.handleReceivedUser(any(), any(), any(), any()) } + verify(mode = dev.mokkery.verify.VerifyMode.not) { + nodeManager.handleReceivedUser(any(), any(), any(), any(), any()) + } } // --- Paxcounter handling --- @@ -336,7 +366,7 @@ class MeshDataHandlerTest { handler.handleReceivedData(packet, 123) - verify { nodeManager.handleReceivedPaxcounter(remoteNum, any()) } + verify { nodeManager.handleReceivedPaxcounter(remoteNum, any(), session) } } // --- Traceroute handling --- @@ -359,7 +389,7 @@ class MeshDataHandlerTest { handler.handleReceivedData(packet, 123) - verify { tracerouteHandler.handleTraceroute(packet, any(), any()) } + verify { tracerouteHandler.handleTraceroute(packet, any(), any(), session) } } // --- NeighborInfo handling --- @@ -468,13 +498,13 @@ class MeshDataHandlerTest { handler.handleReceivedData(packet, 123) - verify { storeForwardHandler.handleStoreAndForward(packet, any(), 123) } + verify { storeForwardHandler.handleStoreAndForward(packet, any(), 123, session) } } // --- Routing/ACK-NAK handling --- @Test - fun `routing packet with successful ack broadcasts and removes response`() { + fun `routing packet with successful ack broadcasts and removes response`() = testScope.runTest { val routing = Routing(error_reason = Routing.Error.NONE) val packet = MeshPacket( @@ -493,8 +523,37 @@ class MeshDataHandlerTest { every { nodeManager.toNodeID(456) } returns "!remote" handler.handleReceivedData(packet, 123) + advanceUntilIdle() - verify { packetHandler.removeResponse(99, complete = true) } + verifySuspend { packetHandler.removeResponse(99, complete = true) } + } + + @Test + fun `routing ack from a retired generation cannot update the replacement database`() = testScope.runTest { + val routing = Routing(error_reason = Routing.Error.NONE) + val packet = + MeshPacket( + from = 456, + decoded = + Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99), + ) + val dataPacket = + DataPacket( + from = "!remote", + to = NodeAddress.ID_BROADCAST, + bytes = routing.encode().toByteString(), + dataType = PortNum.ROUTING_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + every { nodeManager.toNodeID(456) } returns "!remote" + everySuspend { radioInterfaceService.runWithSessionLease(session, any()) } returns false + + handler.handleReceivedData(packet, 123) + advanceUntilIdle() + + verifySuspend(exactly(0)) { packetRepository.getPacketByPacketId(any()) } + verifySuspend(exactly(0)) { packetRepository.update(any(), any()) } + verifySuspend(exactly(0)) { packetHandler.removeResponse(any(), any()) } } @Test @@ -545,7 +604,7 @@ class MeshDataHandlerTest { handler.handleReceivedData(packet, 123) - verify { telemetryHandler.handleTelemetry(packet, any(), 123) } + verify { telemetryHandler.handleTelemetry(packet, any(), 123, session) } } @Test @@ -573,7 +632,7 @@ class MeshDataHandlerTest { handler.handleReceivedData(packet, myNodeNum) - verify { telemetryHandler.handleTelemetry(packet, any(), myNodeNum) } + verify { telemetryHandler.handleTelemetry(packet, any(), myNodeNum, session) } } // --- Text message handling --- @@ -609,6 +668,33 @@ class MeshDataHandlerTest { verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) } } + @Test + fun `text persistence from a retired session is rejected before database access`() = testScope.runTest { + val packet = + MeshPacket( + id = 45, + from = 456, + decoded = + Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "late".encodeToByteArray().toByteString()), + ) + val dataPacket = + DataPacket( + id = 45, + from = "!remote", + to = NodeAddress.ID_BROADCAST, + bytes = "late".encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, + ) + every { dataMapper.toDataPacket(packet) } returns dataPacket + everySuspend { radioInterfaceService.runWithSessionLease(session, any()) } returns false + + handler.handleReceivedData(packet, 123) + advanceUntilIdle() + + verifySuspend(exactly(0)) { packetRepository.findPacketsWithId(any()) } + verifySuspend(exactly(0)) { packetRepository.insert(any(), any(), any(), any(), any(), any()) } + } + @Test fun `duplicate text message is not inserted again`() = testScope.runTest { val packet = @@ -731,7 +817,7 @@ class MeshDataHandlerTest { handler.handleReceivedData(packet, 123) - verify { adminPacketHandler.handleAdminMessage(packet, 123) } + verify { adminPacketHandler.handleAdminMessage(packet, 123, session) } } // --- Message filtering --- diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt index 64c0a62571..32f0c552bc 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt @@ -17,25 +17,34 @@ package org.meshtastic.core.data.manager import dev.mokkery.MockMode +import dev.mokkery.answering.calls import dev.mokkery.answering.returns import dev.mokkery.answering.throws import dev.mokkery.every +import dev.mokkery.everySuspend import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify import dev.mokkery.verify.VerifyMode import dev.mokkery.verifySuspend +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.core.repository.FromRadioPacketHandler import org.meshtastic.core.repository.MeshDataHandler import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.core.repository.RadioSessionLease +import org.meshtastic.core.repository.ReceivedRadioFrame import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.Data import org.meshtastic.proto.FromRadio @@ -44,6 +53,9 @@ import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class MeshMessageProcessorImplTest { @@ -53,18 +65,73 @@ class MeshMessageProcessorImplTest { private val meshLogRepository = mock(MockMode.autofill) private val fromRadioDispatcher = mock(MockMode.autofill) private val dataHandler = mock(MockMode.autofill) + private val radioInterfaceService = mock(MockMode.autofill) private val testDispatcher = UnconfinedTestDispatcher() private lateinit var processor: MeshMessageProcessorImpl private val myNodeNum = 12345 + private val session = RadioSessionContext(generation = 3L, address = "tcp:test") + private val activeSession = MutableStateFlow(session) private val isNodeDbReady = MutableStateFlow(false) + private fun testLease(session: RadioSessionContext): RadioSessionLease = object : RadioSessionLease { + override val session: RadioSessionContext = session + + override fun isCurrent(): Boolean = activeSession.value == session + } + @BeforeTest fun setUp() { + activeSession.value = session + isNodeDbReady.value = false every { nodeManager.isNodeDbReady } returns isNodeDbReady every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) + every { radioInterfaceService.activeSession } returns activeSession + every { radioInterfaceService.isSessionActive(any()) } calls + { + activeSession.value == (it.args[0] as RadioSessionContext) + } + every { radioInterfaceService.runIfSessionActive(any(), any()) } calls + { + val requestedSession = it.args[0] as RadioSessionContext + + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as () -> Unit + if (activeSession.value == requestedSession) { + block() + true + } else { + false + } + } + everySuspend { radioInterfaceService.runWhileSessionActive(any(), any()) } calls + { + val requestedSession = it.args[0] as RadioSessionContext + + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as (suspend () -> Unit) + if (activeSession.value == requestedSession) { + block() + true + } else { + false + } + } + everySuspend { radioInterfaceService.runWithSessionLease(any(), any()) } calls + { + val requestedSession = it.args[0] as RadioSessionContext + + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as (suspend (RadioSessionLease) -> Unit) + if (activeSession.value == requestedSession) { + block(testLease(requestedSession)) + true + } else { + false + } + } } private fun createProcessor(scope: CoroutineScope): MeshMessageProcessorImpl = MeshMessageProcessorImpl( @@ -73,9 +140,17 @@ class MeshMessageProcessorImplTest { meshLogRepository = lazy { meshLogRepository }, dataHandler = lazy { dataHandler }, fromRadioDispatcher = fromRadioDispatcher, + radioInterfaceService = radioInterfaceService, scope = scope, ) + private fun frame(bytes: ByteArray, frameSession: RadioSessionContext = session) = + ReceivedRadioFrame(bytes.toByteString(), frameSession) + + private suspend fun MeshMessageProcessorImpl.handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) { + handleReceivedMeshPacket(packet, myNodeNum, session) + } + // ---------- handleFromRadio: non-packet variants ---------- @Test @@ -85,10 +160,10 @@ class MeshMessageProcessorImplTest { val fromRadio = FromRadio(log_record = logRecord) val bytes = fromRadio.encode() - processor.handleFromRadio(bytes, myNodeNum) + processor.handleFromRadio(frame(bytes), myNodeNum) advanceUntilIdle() - verify { fromRadioDispatcher.handleFromRadio(any()) } + verify { fromRadioDispatcher.handleFromRadio(any(), session) } } @Test @@ -99,11 +174,11 @@ class MeshMessageProcessorImplTest { val logRecord = LogRecord(message = "fallback log") val bytes = logRecord.encode() - processor.handleFromRadio(bytes, myNodeNum) + processor.handleFromRadio(frame(bytes), myNodeNum) advanceUntilIdle() // Should have been dispatched as a FromRadio with log_record set - verify { fromRadioDispatcher.handleFromRadio(any()) } + verify { fromRadioDispatcher.handleFromRadio(any(), session) } } @Test @@ -112,24 +187,131 @@ class MeshMessageProcessorImplTest { // Invalid protobuf bytes — both parses should fail val garbage = byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0xFD.toByte()) - processor.handleFromRadio(garbage, myNodeNum) + processor.handleFromRadio(frame(garbage), myNodeNum) advanceUntilIdle() // No crash } + @Test + fun `stale session frame is dropped before dispatch or persistence`() = runTest(testDispatcher) { + processor = createProcessor(backgroundScope) + activeSession.value = RadioSessionContext(generation = 4L, address = session.address) + val fromRadio = FromRadio(log_record = LogRecord(message = "stale")) + + processor.handleFromRadio(frame(fromRadio.encode()), myNodeNum) + advanceUntilIdle() + + verify(mode = VerifyMode.exactly(0)) { fromRadioDispatcher.handleFromRadio(any(), any()) } + verifySuspend(mode = VerifyMode.exactly(0)) { nodeManager.updateNodeAndPersist(any(), any(), any()) } + verifySuspend(mode = VerifyMode.exactly(0)) { meshLogRepository.insert(any()) } + } + + @Test + fun `session revoked after decode cannot reach packet handlers`() = runTest(testDispatcher) { + processor = createProcessor(backgroundScope) + val fromRadio = FromRadio(log_record = LogRecord(message = "revoked")) + everySuspend { radioInterfaceService.runWhileSessionActive(session, any()) } returns false + + processor.handleFromRadio(frame(fromRadio.encode()), myNodeNum) + advanceUntilIdle() + + verify(mode = VerifyMode.exactly(0)) { fromRadioDispatcher.handleFromRadio(any(), any()) } + verifySuspend(mode = VerifyMode.exactly(0)) { nodeManager.updateNodeAndPersist(any(), any(), any()) } + verifySuspend(mode = VerifyMode.exactly(0)) { meshLogRepository.insert(any()) } + } + + @Test + fun `packet processing awaits node persistence before releasing session authority`() = runTest(testDispatcher) { + isNodeDbReady.value = true + every { nodeManager.myNodeNum } returns MutableStateFlow(null) + var authorityDepth = 0 + everySuspend { radioInterfaceService.runWhileSessionActive(session, any()) } calls + { + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as (suspend () -> Unit) + authorityDepth += 1 + try { + block() + true + } finally { + authorityDepth -= 1 + } + } + processor = createProcessor(backgroundScope) + val persistenceStarted = CompletableDeferred() + val releasePersistence = CompletableDeferred() + everySuspend { nodeManager.updateNodeAndPersist(any(), any(), any()) } calls + { + assertTrue(authorityDepth > 0, "node persistence must begin while session authority is held") + persistenceStarted.complete(Unit) + releasePersistence.await() + } + val packet = + MeshPacket( + id = 9, + from = 999, + decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY), + rx_time = 1000, + ) + + val processing = async { processor.handleFromRadio(frame(FromRadio(packet = packet).encode()), myNodeNum) } + persistenceStarted.await() + + assertFalse(processing.isCompleted, "session authority must remain held until node persistence finishes") + releasePersistence.complete(Unit) + processing.await() + } + + @Test + fun `local sender packet persists one combined node update`() = runTest(testDispatcher) { + isNodeDbReady.value = true + every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) + processor = createProcessor(backgroundScope) + val packet = + MeshPacket( + id = 10, + from = myNodeNum, + decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY), + rx_time = 1, + ) + + processor.handleFromRadio(frame(FromRadio(packet = packet).encode()), myNodeNum) + advanceUntilIdle() + + verifySuspend(mode = VerifyMode.exactly(1)) { nodeManager.updateNodeAndPersist(myNodeNum, any(), any()) } + } + + @Test + fun `replacement generation refreshes the local node without waiting for the prior throttle`() = + runTest(testDispatcher) { + processor = createProcessor(backgroundScope) + val fromRadio = FromRadio(log_record = LogRecord(message = "alive")) + + processor.handleFromRadio(frame(fromRadio.encode()), myNodeNum) + advanceUntilIdle() + val replacementSession = RadioSessionContext(generation = 4L, address = session.address) + activeSession.value = replacementSession + processor.handleFromRadio(frame(fromRadio.encode(), replacementSession), myNodeNum) + advanceUntilIdle() + + verifySuspend(mode = VerifyMode.exactly(2)) { nodeManager.updateNodeAndPersist(myNodeNum, any(), any()) } + } + @Test fun `a throwing handler does not propagate out of handleFromRadio`() = runTest(testDispatcher) { processor = createProcessor(backgroundScope) // A malformed-but-decodable packet can make a downstream handler throw. That must NOT escape to the // receivedData collector, which would cancel the collection and silently deafen the radio (a remote DoS). // Regression guard for the safeCatching in processFromRadio: before that fix this call rethrew and failed. - every { fromRadioDispatcher.handleFromRadio(any()) } throws RuntimeException("hostile packet") + every { fromRadioDispatcher.handleFromRadio(any(), any()) } throws RuntimeException("hostile packet") val fromRadio = FromRadio(log_record = LogRecord(message = "boom")) // routes to fromRadioDispatcher - processor.handleFromRadio(fromRadio.encode(), myNodeNum) // must return normally, not throw + processor.handleFromRadio(frame(fromRadio.encode()), myNodeNum) // must return normally, not throw advanceUntilIdle() - verify { fromRadioDispatcher.handleFromRadio(any()) } // handler ran and threw, yet we are still here + verify { + fromRadioDispatcher.handleFromRadio(any(), session) + } // handler ran and threw, yet we are still here } // ---------- handleReceivedMeshPacket: early buffering ---------- @@ -178,6 +360,96 @@ class MeshMessageProcessorImplTest { verifySuspend { serviceRepository.emitMeshPacket(any()) } } + @Test + fun `buffer replay pauses without losing order when node DB readiness regresses`() = runTest(testDispatcher) { + processor = createProcessor(backgroundScope) + isNodeDbReady.value = false + val handledIds = mutableListOf() + every { dataHandler.handleReceivedData(any(), any(), any(), any(), any()) } calls + { + val packet = it.args[0] as MeshPacket + handledIds += packet.id + if (packet.id == 1) isNodeDbReady.value = false + } + + listOf(1, 2).forEach { id -> + processor.handleReceivedMeshPacket( + MeshPacket( + id = id, + from = 999, + decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY), + rx_time = 1000 + id, + ), + myNodeNum, + ) + } + advanceUntilIdle() + + isNodeDbReady.value = true + advanceUntilIdle() + assertEquals(listOf(1), handledIds) + + // Handling packet 1 regressed readiness to false, so this true transition rearms the replay collector. + isNodeDbReady.value = true + advanceUntilIdle() + assertEquals(listOf(1, 2), handledIds) + } + + @Test + fun `packet audit work is rejected when session lease admission closes`() = runTest(testDispatcher) { + processor = createProcessor(backgroundScope) + isNodeDbReady.value = true + everySuspend { radioInterfaceService.runWithSessionLease(session, any()) } returns false + val packet = + MeshPacket( + id = 6, + from = 999, + decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY), + rx_time = 1000, + ) + + processor.handleFromRadio(frame(FromRadio(packet = packet).encode()), myNodeNum) + advanceUntilIdle() + + verifySuspend(mode = VerifyMode.exactly(0)) { meshLogRepository.insert(any()) } + verifySuspend(mode = VerifyMode.exactly(0)) { serviceRepository.emitMeshPacket(any()) } + } + + @Test + fun `buffered packet from retired session is not replayed into replacement session`() = runTest(testDispatcher) { + processor = createProcessor(backgroundScope) + isNodeDbReady.value = false + val processedIds = mutableListOf() + every { dataHandler.handleReceivedData(any(), any(), any(), any(), any()) } calls + { + processedIds += (it.args[0] as MeshPacket).id + } + val oldPacket = + MeshPacket( + id = 7, + from = 999, + decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY), + rx_time = 1000, + ) + + processor.handleFromRadio(frame(FromRadio(packet = oldPacket).encode()), myNodeNum) + advanceUntilIdle() + + val replacementSession = RadioSessionContext(generation = 4L, address = session.address) + activeSession.value = replacementSession + val replacementPacket = oldPacket.copy(id = 8, rx_time = 1001) + processor.handleFromRadio( + frame(FromRadio(packet = replacementPacket).encode(), replacementSession), + myNodeNum, + ) + advanceUntilIdle() + + isNodeDbReady.value = true + advanceUntilIdle() + + assertEquals(listOf(8), processedIds) + } + @Test fun `early buffer overflow drops oldest packet`() = runTest(testDispatcher) { processor = createProcessor(backgroundScope) @@ -220,7 +492,7 @@ class MeshMessageProcessorImplTest { // escaped the bare `scope.launch` in flushEarlyReceivedPackets and crashed the whole app process // (observed live: "FATAL EXCEPTION" at MeshMessageProcessorImpl.kt:214, PID killed, "keeps // stopping" dialog on device). Fixed by wrapping each buffered packet's processing in safeCatching. - every { dataHandler.handleReceivedData(any(), any(), any(), any()) } throws + every { dataHandler.handleReceivedData(any(), any(), any(), any(), any()) } throws RuntimeException("corrupted NodeInfo") val poisoned = @@ -246,8 +518,8 @@ class MeshMessageProcessorImplTest { advanceUntilIdle() // must not throw -- this is the crash repro point // Both packets were attempted -- the poisoned packet's throw did not stop the rest of the batch. - verify { nodeManager.updateNode(999, any(), any()) } - verify { nodeManager.updateNode(998, any(), any()) } + verifySuspend { nodeManager.updateNodeAndPersist(999, any(), any()) } + verifySuspend { nodeManager.updateNodeAndPersist(998, any(), any()) } } // ---------- handleReceivedMeshPacket: rx_time normalization ---------- @@ -309,7 +581,7 @@ class MeshMessageProcessorImplTest { advanceUntilIdle() // Should have called updateNode for myNodeNum (lastHeard update) - verify { nodeManager.updateNode(myNodeNum, any(), any()) } + verifySuspend { nodeManager.updateNodeAndPersist(myNodeNum, any(), any()) } } @Test @@ -331,7 +603,7 @@ class MeshMessageProcessorImplTest { advanceUntilIdle() // Should have called updateNode for the sender - verify { nodeManager.updateNode(senderNode, any(), any()) } + verifySuspend { nodeManager.updateNodeAndPersist(senderNode, any(), any()) } } // ---------- handleReceivedMeshPacket: null decoded ---------- @@ -438,7 +710,7 @@ class MeshMessageProcessorImplTest { isNodeDbReady.value = true advanceUntilIdle() - // emitMeshPacket should NOT have been called (buffer was cleared) + verifySuspend(mode = VerifyMode.exactly(0)) { serviceRepository.emitMeshPacket(any()) } } // ---------- logVariant ---------- @@ -450,7 +722,7 @@ class MeshMessageProcessorImplTest { val fromRadio = FromRadio(log_record = logRecord) val bytes = fromRadio.encode() - processor.handleFromRadio(bytes, myNodeNum) + processor.handleFromRadio(frame(bytes), myNodeNum) advanceUntilIdle() verifySuspend { meshLogRepository.insert(any()) } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt new file mode 100644 index 0000000000..8140f24a5f --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.data.manager + +import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.mock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.repository.ConnectionIdentity +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.NotificationManager +import org.meshtastic.core.repository.RadioInterfaceService +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Verifies [NodeManagerImpl.connectionIdentity] — the fresh-handshake identity source that stays separate from the + * general [NodeManagerImpl.myNodeNum]/[NodeManagerImpl.myDeviceId] StateFlows so a stale cached identity cannot + * associate a new transport address with a previous device. Covers publish/clear semantics, loadCachedNodeDB + * non-population, full-reset behavior, StateFlow distinctness, null-deviceId handling, and address binding. + */ +class NodeManagerConnectionIdentityTest { + + private val nodeRepository: NodeRepository = mock(MockMode.autofill) + private val notificationManager: NotificationManager = mock(MockMode.autofill) + private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill) + private val testScope = TestScope() + + private lateinit var nodeManager: NodeManagerImpl + + @BeforeTest + fun setUp() { + nodeManager = NodeManagerImpl(nodeRepository, notificationManager, radioInterfaceService, testScope) + } + + @Test + fun `publish then clear sets and nulls connectionIdentity`() = testScope.runTest { + assertNull(nodeManager.connectionIdentity.value, "identity should start null") + + nodeManager.publishConnectionIdentity( + sessionGeneration = 1L, + address = "tcp:one", + nodeNum = 42, + deviceId = "deadbeef", + ) + assertEquals( + ConnectionIdentity(sessionGeneration = 1L, address = "tcp:one", nodeNum = 42, deviceId = "deadbeef"), + nodeManager.connectionIdentity.value, + ) + + nodeManager.clearConnectionIdentity() + assertNull(nodeManager.connectionIdentity.value, "identity should be null after clear") + } + + @Test + fun `connectionIdentity diagnostic text redacts persistent identifiers`() = testScope.runTest { + val identity = ConnectionIdentity(7L, "ble:AA:BB:CC:DD:EE:FF", 42, "factory-burned-id") + + val diagnostic = identity.toString() + + assertFalse("AA:BB:CC:DD:EE:FF" in diagnostic) + assertFalse("factory-burned-id" in diagnostic) + assertTrue("sessionGeneration=7" in diagnostic) + assertTrue("nodeNum=42" in diagnostic) + assertTrue("deviceIdPresent=true" in diagnostic) + } + + @Test + fun `generation-aware clear preserves fresh identity and removes stale identity`() = testScope.runTest { + val freshIdentity = ConnectionIdentity(2L, "tcp:one", 42, "deadbeef") + nodeManager.publishConnectionIdentity( + sessionGeneration = freshIdentity.sessionGeneration, + address = freshIdentity.address, + nodeNum = freshIdentity.nodeNum, + deviceId = freshIdentity.deviceId, + ) + + nodeManager.clearStaleConnectionIdentity(activeSessionGeneration = 2L) + assertEquals(freshIdentity, nodeManager.connectionIdentity.value, "the active generation must be preserved") + + nodeManager.clearStaleConnectionIdentity(activeSessionGeneration = 3L) + assertNull(nodeManager.connectionIdentity.value, "an identity from the prior generation must be cleared") + } + + @Test + fun `loadCachedNodeDB does not populate connectionIdentity`() = testScope.runTest { + // Even with a non-null myNodeInfo in the repo cache, connectionIdentity must remain null — only a fresh + // MyNodeInfo handshake publishes the identity (see publishConnectionIdentity). + every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap()) + every { nodeRepository.myNodeInfo } returns MutableStateFlow(cachedMyNodeInfo(myNodeNum = 777)) + + nodeManager.loadCachedNodeDB() + runCurrent() + + // myNodeNum may have been restored from cache, but connectionIdentity must NOT. + assertEquals(777, nodeManager.myNodeNum.value, "myNodeNum should be restored from cache") + assertNull(nodeManager.connectionIdentity.value, "connectionIdentity must not be populated by cache reload") + } + + @Test + fun `clear resets connectionIdentity alongside other state`() = testScope.runTest { + nodeManager.publishConnectionIdentity( + sessionGeneration = 1L, + address = "tcp:one", + nodeNum = 42, + deviceId = "deadbeef", + ) + assertEquals(ConnectionIdentity(1L, "tcp:one", 42, "deadbeef"), nodeManager.connectionIdentity.value) + + nodeManager.clear() + + assertNull(nodeManager.connectionIdentity.value, "clear() must reset connectionIdentity") + assertNull(nodeManager.myNodeNum.value, "clear() must reset myNodeNum") + assertNull(nodeManager.myDeviceId.value, "clear() must reset myDeviceId") + } + + @Test + fun `connectionIdentity StateFlow emits only distinct values`() = testScope.runTest { + val emissions = mutableListOf() + val collector = testScope.launch { nodeManager.connectionIdentity.collect { emissions += it } } + runCurrent() + // Initial replay: the starting null value. + assertEquals( + listOf(null), + emissions, + "only the initial null should be present before any publish", + ) + + // First publish — produces one new emission. + nodeManager.publishConnectionIdentity( + sessionGeneration = 1L, + address = "tcp:one", + nodeNum = 42, + deviceId = "deadbeef", + ) + runCurrent() + assertEquals( + listOf(null, ConnectionIdentity(1L, "tcp:one", 42, "deadbeef")), + emissions, + "first publish should emit once", + ) + + // Re-publishing an equal value must NOT emit again — StateFlow conflates equal values, so any downstream + // distinctUntilChanged (e.g. RadioControllerImpl's combine collector) skips it. + nodeManager.publishConnectionIdentity( + sessionGeneration = 1L, + address = "tcp:one", + nodeNum = 42, + deviceId = "deadbeef", + ) + runCurrent() + assertEquals( + listOf(null, ConnectionIdentity(1L, "tcp:one", 42, "deadbeef")), + emissions, + "equal re-publication must not re-emit", + ) + + // A genuinely different value does emit. + nodeManager.publishConnectionIdentity( + sessionGeneration = 1L, + address = "tcp:two", + nodeNum = 43, + deviceId = "deadbeef", + ) + runCurrent() + assertEquals( + listOf( + null, + ConnectionIdentity(1L, "tcp:one", 42, "deadbeef"), + ConnectionIdentity(1L, "tcp:two", 43, "deadbeef"), + ), + emissions, + "different value must emit", + ) + collector.cancel() + } + + @Test + fun `publish with null deviceId keeps address and nodeNum set`() = testScope.runTest { + nodeManager.publishConnectionIdentity( + sessionGeneration = 1L, + address = "ble:one", + nodeNum = 99, + deviceId = null, + ) + + val identity = nodeManager.connectionIdentity.value + assertNotEquals(null, identity, "identity itself must not be null") + assertEquals("ble:one", identity?.address, "address must identify the handshake transport") + assertEquals(99, identity?.nodeNum, "nodeNum must be set even when deviceId is null") + assertNull(identity?.deviceId, "deviceId must remain null") + } + + @Test + fun `clearing a session removes its address-bound identity`() = testScope.runTest { + // Simulate the previous transport publishing its handshake identity. + nodeManager.publishConnectionIdentity( + sessionGeneration = 1L, + address = "ble:old", + nodeNum = 42, + deviceId = "deadbeef", + ) + assertEquals(ConnectionIdentity(1L, "ble:old", 42, "deadbeef"), nodeManager.connectionIdentity.value) + + nodeManager.clearConnectionIdentity() + + assertNull(nodeManager.connectionIdentity.value, "identity must be null after the session is cleared") + + // Even after subsequent loadCachedNodeDB-style state restoration, identity stays null. + every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap()) + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) + nodeManager.loadCachedNodeDB() + runCurrent() + assertNull(nodeManager.connectionIdentity.value, "identity must stay null across cache reload") + } + + private fun cachedMyNodeInfo(myNodeNum: Int) = MyNodeInfo( + myNodeNum = myNodeNum, + hasGPS = false, + model = null, + firmwareVersion = null, + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 0L, + messageTimeoutMsec = 0, + minAppVersion = 0, + maxChannels = 0, + hasWifi = false, + channelUtilization = 0f, + airUtilTx = 0f, + deviceId = null, + ) +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt index 67227fd149..fb5f9621ec 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt @@ -17,11 +17,21 @@ package org.meshtastic.core.data.manager import dev.mokkery.MockMode +import dev.mokkery.answering.calls import dev.mokkery.answering.returns import dev.mokkery.every +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any import dev.mokkery.mock +import dev.mokkery.verify.VerifyMode.Companion.exactly +import dev.mokkery.verifySuspend +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.MyNodeInfo @@ -29,6 +39,8 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationManager +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.EnvironmentMetrics import org.meshtastic.proto.HardwareModel @@ -37,6 +49,7 @@ import org.meshtastic.proto.User import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -47,13 +60,14 @@ class NodeManagerImplTest { private val nodeRepository: NodeRepository = mock(MockMode.autofill) private val notificationManager: NotificationManager = mock(MockMode.autofill) + private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill) private val testScope = TestScope() private lateinit var nodeManager: NodeManagerImpl @BeforeTest fun setUp() { - nodeManager = NodeManagerImpl(nodeRepository, notificationManager, testScope) + nodeManager = NodeManagerImpl(nodeRepository, notificationManager, radioInterfaceService, testScope) } @Test @@ -67,6 +81,101 @@ class NodeManagerImplTest { assertEquals(NodeAddress.numToDefaultId(nodeNum), result.user.id) } + @Test + fun `updateNodeAndPersist awaits the repository write`() = testScope.runTest { + val nodeNum = 1234 + nodeManager.setNodeDbReady(true) + nodeManager.setAllowNodeDbWrites(true) + val writeStarted = CompletableDeferred() + val releaseWrite = CompletableDeferred() + everySuspend { nodeRepository.upsert(any()) } calls + { + writeStarted.complete(Unit) + releaseWrite.await() + } + + val update = async { nodeManager.updateNodeAndPersist(nodeNum) { node -> node.copy(lastHeard = 42) } } + writeStarted.await() + assertFalse(update.isCompleted) + releaseWrite.complete(Unit) + update.await() + + verifySuspend { nodeRepository.upsert(any()) } + assertEquals(42, nodeManager.nodeDBbyNodeNum[nodeNum]?.lastHeard) + } + + @Test + fun `same-node persistence cannot finish with an older snapshot`() = testScope.runTest { + val nodeNum = 1234 + nodeManager.setNodeDbReady(true) + nodeManager.setAllowNodeDbWrites(true) + val firstWriteStarted = CompletableDeferred() + val secondWriteStarted = CompletableDeferred() + val releaseFirstWrite = CompletableDeferred() + val persisted = mutableListOf() + var invocation = 0 + everySuspend { nodeRepository.upsert(any()) } calls + { + val node = it.arg(0) + invocation++ + when (invocation) { + 1 -> { + firstWriteStarted.complete(Unit) + releaseFirstWrite.await() + } + + 2 -> secondWriteStarted.complete(Unit) + } + persisted += node + } + + val first = async { nodeManager.updateNodeAndPersist(nodeNum) { node -> node.copy(lastHeard = 1) } } + firstWriteStarted.await() + val second = + async(start = CoroutineStart.UNDISPATCHED) { + nodeManager.updateNodeAndPersist(nodeNum) { node -> node.copy(lastHeard = 2) } + } + runCurrent() + assertFalse( + secondWriteStarted.isCompleted, + "the second upsert must not enter while the first owns its lane", + ) + + releaseFirstWrite.complete(Unit) + first.await() + second.await() + + assertEquals(listOf(1, 2), persisted.map(Node::lastHeard)) + assertEquals(2, nodeManager.nodeDBbyNodeNum[nodeNum]?.lastHeard) + } + + @Test + fun `session-bound node persistence from a retired generation is rejected`() = testScope.runTest { + val nodeNum = 1234 + val oldSession = RadioSessionContext(generation = 7L, address = "ble:same") + nodeManager.setNodeDbReady(true) + nodeManager.setAllowNodeDbWrites(true) + everySuspend { radioInterfaceService.runWithSessionLease(oldSession, any()) } returns false + + nodeManager.updateNodeForSession(nodeNum, oldSession) { node -> node.copy(lastHeard = 42) } + runCurrent() + + assertEquals(42, nodeManager.nodeDBbyNodeNum[nodeNum]?.lastHeard) + verifySuspend(exactly(0)) { nodeRepository.upsert(any()) } + } + + @Test + fun `node persistence is suppressed while database writes are disabled`() = testScope.runTest { + val nodeNum = 1234 + nodeManager.setNodeDbReady(true) + nodeManager.setAllowNodeDbWrites(false) + + nodeManager.updateNodeAndPersist(nodeNum) { node -> node.copy(lastHeard = 42) } + + verifySuspend(exactly(0)) { nodeRepository.upsert(any()) } + assertEquals(42, nodeManager.nodeDBbyNodeNum[nodeNum]?.lastHeard) + } + @Test fun `handleReceivedUser preserves existing user if incoming is default`() { val nodeNum = 1234 diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SessionWorkTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SessionWorkTest.kt new file mode 100644 index 0000000000..730d27997c --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SessionWorkTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.data.manager + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.core.testing.FakeRadioInterfaceService +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionWorkTest { + + @Test + fun `trusted work without a session executes`() = runTest { + val service = FakeRadioInterfaceService(backgroundScope) + var executed = false + + service.launchSessionWork(backgroundScope, session = null) { executed = true }.join() + + assertTrue(executed) + } + + @Test + fun `retired session invokes rejection without executing work`() = runTest { + val service = FakeRadioInterfaceService(backgroundScope) + val retiredSession = RadioSessionContext(generation = 1L, address = "ble:retired") + var executed = false + var rejected = false + + service + .launchSessionWork(scope = backgroundScope, session = retiredSession, onRejected = { rejected = true }) { + executed = true + } + .join() + + assertFalse(executed) + assertTrue(rejected) + } + + @Test + fun `active session executes leased work`() = runTest { + val service = FakeRadioInterfaceService(backgroundScope) + service.setDeviceAddress("ble:active") + service.connect() + val activeSession = requireNotNull(service.activeSession.value) + var executed = false + + service.launchSessionWork(backgroundScope, activeSession) { executed = true }.join() + + assertTrue(executed) + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt index 05739aca07..4bc0543050 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt @@ -17,6 +17,10 @@ package org.meshtastic.core.data.manager import dev.mokkery.MockMode +import dev.mokkery.answering.calls +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.everySuspend import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify @@ -27,10 +31,14 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NotificationManager +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.core.repository.RadioSessionLease import org.meshtastic.proto.Data import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.EnvironmentMetrics @@ -47,6 +55,7 @@ class TelemetryPacketHandlerImplTest { private val nodeManager = mock(MockMode.autofill) private val connectionManager = mock(MockMode.autofill) private val notificationManager = mock(MockMode.autofill) + private val radioInterfaceService = mock(MockMode.autofill) private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) @@ -55,14 +64,31 @@ class TelemetryPacketHandlerImplTest { private val myNodeNum = 12345 private val remoteNodeNum = 99999 + private val radioSession = RadioSessionContext(generation = 1L, address = "test:telemetry") + private val sessionLease = + object : RadioSessionLease { + override val session: RadioSessionContext = radioSession + + override fun isCurrent(): Boolean = true + } @BeforeTest fun setUp() { + every { nodeManager.nodeDBbyNodeNum } returns + mapOf(myNodeNum to Node(num = myNodeNum), remoteNodeNum to Node(num = remoteNodeNum)) + everySuspend { radioInterfaceService.runWithSessionLease(radioSession, any()) } calls + { + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as suspend (RadioSessionLease) -> Unit + block(sessionLease) + true + } handler = TelemetryPacketHandlerImpl( nodeManager = nodeManager, connectionManager = lazy { connectionManager }, notificationManager = notificationManager, + radioInterfaceService = radioInterfaceService, scope = testScope, ) } @@ -94,11 +120,11 @@ class TelemetryPacketHandlerImplTest { val packet = makeTelemetryPacket(myNodeNum, telemetry) val dataPacket = makeDataPacket(myNodeNum) - handler.handleTelemetry(packet, dataPacket, myNodeNum) + handler.handleTelemetry(packet, dataPacket, myNodeNum, radioSession) advanceUntilIdle() verify { connectionManager.updateTelemetry(any()) } - verify { nodeManager.updateNode(myNodeNum, any(), any()) } + verify { nodeManager.updateNodeForSession(myNodeNum, radioSession, any(), any()) } } // ---------- Device metrics from remote node ---------- @@ -110,10 +136,10 @@ class TelemetryPacketHandlerImplTest { val packet = makeTelemetryPacket(remoteNodeNum, telemetry) val dataPacket = makeDataPacket(remoteNodeNum) - handler.handleTelemetry(packet, dataPacket, myNodeNum) + handler.handleTelemetry(packet, dataPacket, myNodeNum, radioSession) advanceUntilIdle() - verify { nodeManager.updateNode(remoteNodeNum, any(), any()) } + verify { nodeManager.updateNodeForSession(remoteNodeNum, radioSession, any(), any()) } } // ---------- Environment metrics ---------- @@ -128,10 +154,10 @@ class TelemetryPacketHandlerImplTest { val packet = makeTelemetryPacket(remoteNodeNum, telemetry) val dataPacket = makeDataPacket(remoteNodeNum) - handler.handleTelemetry(packet, dataPacket, myNodeNum) + handler.handleTelemetry(packet, dataPacket, myNodeNum, radioSession) advanceUntilIdle() - verify { nodeManager.updateNode(remoteNodeNum, any(), any()) } + verify { nodeManager.updateNodeForSession(remoteNodeNum, radioSession, any(), any()) } } // ---------- Power metrics ---------- @@ -142,10 +168,10 @@ class TelemetryPacketHandlerImplTest { val packet = makeTelemetryPacket(remoteNodeNum, telemetry) val dataPacket = makeDataPacket(remoteNodeNum) - handler.handleTelemetry(packet, dataPacket, myNodeNum) + handler.handleTelemetry(packet, dataPacket, myNodeNum, radioSession) advanceUntilIdle() - verify { nodeManager.updateNode(remoteNodeNum, any(), any()) } + verify { nodeManager.updateNodeForSession(remoteNodeNum, radioSession, any(), any()) } } // ---------- Telemetry time handling ---------- @@ -156,10 +182,10 @@ class TelemetryPacketHandlerImplTest { val packet = makeTelemetryPacket(myNodeNum, telemetry) val dataPacket = makeDataPacket(myNodeNum) - handler.handleTelemetry(packet, dataPacket, myNodeNum) + handler.handleTelemetry(packet, dataPacket, myNodeNum, radioSession) advanceUntilIdle() - verify { nodeManager.updateNode(myNodeNum, any(), any()) } + verify { nodeManager.updateNodeForSession(myNodeNum, radioSession, any(), any()) } } // ---------- Null payload ---------- @@ -169,7 +195,7 @@ class TelemetryPacketHandlerImplTest { val packet = MeshPacket(from = myNodeNum, decoded = null) val dataPacket = makeDataPacket(myNodeNum) - handler.handleTelemetry(packet, dataPacket, myNodeNum) + handler.handleTelemetry(packet, dataPacket, myNodeNum, radioSession) advanceUntilIdle() // No crash } @@ -183,7 +209,7 @@ class TelemetryPacketHandlerImplTest { ) val dataPacket = makeDataPacket(myNodeNum) - handler.handleTelemetry(packet, dataPacket, myNodeNum) + handler.handleTelemetry(packet, dataPacket, myNodeNum, radioSession) advanceUntilIdle() // No crash — decodeOrNull returns null for empty payload } @@ -197,7 +223,7 @@ class TelemetryPacketHandlerImplTest { val packet = makeTelemetryPacket(myNodeNum, telemetry) val dataPacket = makeDataPacket(myNodeNum) - handler.handleTelemetry(packet, dataPacket, myNodeNum) + handler.handleTelemetry(packet, dataPacket, myNodeNum, radioSession) advanceUntilIdle() // No dispatch call — battery is healthy diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt index e366bd103f..9985a25b8f 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt @@ -19,9 +19,11 @@ package org.meshtastic.core.data.manager import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every +import dev.mokkery.everySuspend import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify +import dev.mokkery.verify.VerifyMode import dev.mokkery.verifySuspend import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -37,6 +39,8 @@ import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshDataHandler import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum @@ -52,6 +56,7 @@ class StoreForwardPacketHandlerImplTest { private val packetRepository = mock(MockMode.autofill) private val historyManager = mock(MockMode.autofill) private val dataHandler = mock(MockMode.autofill) + private val radioInterfaceService = mock(MockMode.autofill) private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) @@ -70,6 +75,7 @@ class StoreForwardPacketHandlerImplTest { packetRepository = lazy { packetRepository }, historyManager = historyManager, dataHandler = lazy { dataHandler }, + radioInterfaceService = radioInterfaceService, scope = testScope, ) } @@ -222,6 +228,28 @@ class StoreForwardPacketHandlerImplTest { verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) } } + @Test + fun `SFPP update from a retired same-address generation is rejected`() = testScope.runTest { + val oldSession = RadioSessionContext(generation = 4L, address = "ble:same") + val sfpp = + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, + encapsulated_id = 42, + encapsulated_from = 1000, + encapsulated_to = 2000, + message_hash = ByteString.of(0x01, 0x02, 0x03, 0x04), + ) + val packet = makeSfppPacket(999, sfpp) + everySuspend { radioInterfaceService.runWithSessionLease(oldSession, any()) } returns false + + handler.handleStoreForwardPlusPlus(packet, oldSession) + advanceUntilIdle() + + verifySuspend(mode = VerifyMode.exactly(0)) { + packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) + } + } + // ---------- SF++: CANON_ANNOUNCE ---------- @Test diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/AirQualityChartReproTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/AirQualityChartReproTest.kt index c02dab230a..9dd638f411 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/AirQualityChartReproTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/AirQualityChartReproTest.kt @@ -17,8 +17,11 @@ package org.meshtastic.core.data.repository import dev.mokkery.MockMode +import dev.mokkery.answering.calls import dev.mokkery.answering.returns import dev.mokkery.every +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any import dev.mokkery.mock import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first @@ -34,6 +37,9 @@ import org.meshtastic.core.model.MeshLog import org.meshtastic.core.repository.FromRadioPacketHandler import org.meshtastic.core.repository.MeshDataHandler import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.core.repository.RadioSessionLease import org.meshtastic.core.repository.ServiceStateWriter import org.meshtastic.core.testing.FakeDatabaseProvider import org.meshtastic.core.testing.FakeMeshLogPrefs @@ -197,8 +203,31 @@ class AirQualityChartReproTest { val myNodeNumFlow = MutableStateFlow(null) // not yet resolved val nodeManager = mock(MockMode.autofill) + val radioInterfaceService = mock(MockMode.autofill) + val session = RadioSessionContext(generation = 1L, address = "test:air-quality") every { nodeManager.isNodeDbReady } returns MutableStateFlow(true) every { nodeManager.myNodeNum } returns myNodeNumFlow + every { radioInterfaceService.isSessionActive(session) } returns true + everySuspend { radioInterfaceService.runWhileSessionActive(session, any()) } calls + { + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as (suspend () -> Unit) + block() + true + } + everySuspend { radioInterfaceService.runWithSessionLease(session, any()) } calls + { + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as (suspend (RadioSessionLease) -> Unit) + block( + object : RadioSessionLease { + override val session: RadioSessionContext = session + + override fun isCurrent(): Boolean = true + }, + ) + true + } val processor = MeshMessageProcessorImpl( @@ -207,11 +236,12 @@ class AirQualityChartReproTest { meshLogRepository = lazy { repository }, dataHandler = lazy { mock(MockMode.autofill) }, fromRadioDispatcher = mock(MockMode.autofill), + radioInterfaceService = radioInterfaceService, scope = backgroundScope, ) // Arrives before MyNodeInfo resolves -> buffered, NOT written to the log table (so not orphaned in the DB). - processor.handleReceivedMeshPacket(airQualityPacket(), myNodeNum = null) + processor.handleReceivedMeshPacket(airQualityPacket(), myNodeNum = null, session = session) advanceUntilIdle() assertEquals(0, repository.getAllLogsUnbounded().first().size, "packet should be buffered, not yet stored") diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index cc24a098e2..f408eb620a 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -32,6 +32,7 @@ kotlin { commonMain.dependencies { implementation(libs.androidx.sqlite.bundled) implementation(libs.androidx.datastore.preferences) + implementation(libs.kotlinx.atomicfu) implementation(libs.okio) api(projects.core.common) diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt index bcc498495e..e9d10b09fb 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt @@ -39,6 +39,9 @@ object DatabaseConstants { const val DEVICE_DB_FOR_PREFIX: String = "device_db_for:" const val NODE_DB_FOR_PREFIX: String = "node_db_for:" const val ADDR_DB_FOR_PREFIX: String = "addr_db_for:" + const val PENDING_SOURCE_DB_FOR_PREFIX: String = "pending_source_db_for:" + const val PENDING_DESTINATION_DB_FOR_PREFIX: String = "pending_destination_db_for:" + const val RETIRED_DB_NAMES_KEY: String = "retired_db_names" // Display/truncation and hash sizing for DB names const val DB_NAME_HASH_LEN: Int = 10 @@ -48,6 +51,12 @@ object DatabaseConstants { // Address anonymization sizing const val ADDRESS_ANON_SHORT_LEN: Int = 4 const val ADDRESS_ANON_EDGE_LEN: Int = 2 + + /** + * SQLite's default maximum number of host parameters (bind variables) per statement. Used to chunk IN-clause + * queries. + */ + const val SQLITE_MAX_BIND_PARAMETERS: Int = 999 } fun shortSha1(s: String): String = s.encodeUtf8().sha1().hex().take(DatabaseConstants.DB_NAME_HASH_LEN) @@ -84,12 +93,16 @@ fun anonymizeDbName(name: String): String = ) + "…" } -/** Compute which DBs to evict using LRU policy. */ +/** + * Computes which databases to evict using LRU policy. [protectedDbNames] remain counted toward [limit] but can never be + * selected, so the returned list may intentionally leave the cache over limit while recovery evidence is retained. + */ internal fun selectEvictionVictims( dbNames: List, activeDbName: String, limit: Int, lastUsedMsByDb: Map, + protectedDbNames: Set = emptySet(), ): List { val deviceDbNames = dbNames.filterNot { it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME } @@ -97,7 +110,7 @@ internal fun selectEvictionVictims( if (limit < 1 || deviceDbNames.size <= limit) { emptyList() } else { - val candidates = deviceDbNames.filter { it != activeDbName } + val candidates = deviceDbNames.filter { it != activeDbName && it !in protectedDbNames } if (candidates.isEmpty()) { emptyList() } else { diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt index 885458a8c9..7eb67c0d37 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt @@ -23,22 +23,26 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey import co.touchlab.kermit.Logger +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -55,9 +59,20 @@ import org.meshtastic.core.di.CoroutineDispatchers import kotlin.concurrent.Volatile import org.meshtastic.core.common.database.DatabaseManager as SharedDatabaseManager +/** Returns database names that form either side of an unfinished, crash-recoverable association route. */ +internal fun pendingRouteDbNames(preferences: Preferences): Set = preferences + .asMap() + .asSequence() + .filter { (key, _) -> + key.name.startsWith(DatabaseConstants.PENDING_SOURCE_DB_FOR_PREFIX) || + key.name.startsWith(DatabaseConstants.PENDING_DESTINATION_DB_FOR_PREFIX) + } + .mapNotNull { (_, value) -> value as? String } + .toSet() + /** Manages per-device Room database instances for node data, with LRU eviction. */ @Single(binds = [DatabaseProvider::class, SharedDatabaseManager::class]) -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") @OptIn(ExperimentalCoroutinesApi::class) open class DatabaseManager( @Named("DatabaseDataStore") private val datastore: DataStore, @@ -66,28 +81,96 @@ open class DatabaseManager( SharedDatabaseManager { private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default) + private val managerJobLock = SynchronizedObject() + private val activeManagerJobs = mutableSetOf() + private var managerJobDrain: CompletableDeferred? = null private val mutex = Mutex() + private val closeMutex = Mutex() + + private enum class LifecycleState { + OPEN, + CLOSING, + CLOSED, + } + + @Volatile private var lifecycleState = LifecycleState.OPEN // Per-source write barrier for merges. `withDb` deliberately does NOT take [mutex] (hot path), so a merge under // [mutex] must still drain any in-flight writer that captured the source DB before folding it away — otherwise a // late-committing write is lost when the source is retired. This dedicated lock (never held across a drain await, - // so it can't deadlock the merge) tracks live `withDb` blocks per captured DB instance. It also guards the merge's - // active-DB swap so a writer either registers against `source` before the swap and is drained, or captures `dest` - // after it and is safe — it can never slip through the gap between the two. + // so it can't deadlock the merge) tracks live `withDb` blocks per captured DB instance and the writer-admission + // gate. The gate is armed at the start of an association attempt: while it is pending, [beginWrite] blocks new + // writers instead of letting them capture a DB, so a new `withDb` can never write to `source` once it is being + // retired, nor land on `dest` before the merge commits. The gate completes with `source` if the attempt aborts + // (drain timeout, cancellation, or pre-commit merge failure) and with `dest` once the merge commits — source is + // never restored after the merge commits. The lock is released before any suspend (drain await, gate await, Room + // work, merge work, or DataStore work), so it can't deadlock any of them. private val writerTrackerMutex = Mutex() private val activeWriters = mutableMapOf() private val drainWaiters = mutableMapOf>>() + private val deferredEvictions = mutableSetOf() + private var shutdownWriterDrain: CompletableDeferred? = null + + // Admitted manager-operation tokens (one per serialized associateDevice/switch/recovery/eviction/cleanup that + // takes the manager [mutex]). Guarded by [writerTrackerMutex]. [close] arms [managerOperationDrain] and bound-waits + // for this set to empty BEFORE acquiring [mutex] for its ownership snapshot — otherwise an admitted operation that + // holds [mutex] indefinitely (e.g. a stalled merge) pins shutdown forever despite the writer-drain bound already + // in place. New operations are rejected at admission once lifecycleState != OPEN. + private val activeManagerOperations = mutableSetOf() + private var managerOperationDrain: CompletableDeferred? = null + + // Armed at the start of an association attempt; null otherwise. A non-null gate blocks [beginWrite] until the + // attempt resolves. It completes with the canonical DB (source on abort, dest on commit) so blocked writers resume + // against the right instance. Never read or written outside [writerTrackerMutex]. + private var writerGate: CompletableDeferred? = null private val cacheLimitKey = intPreferencesKey(DatabaseConstants.CACHE_LIMIT_KEY) private val legacyCleanedKey = booleanPreferencesKey(DatabaseConstants.LEGACY_DB_CLEANED_KEY) + private val retiredDbNamesKey = stringSetPreferencesKey(DatabaseConstants.RETIRED_DB_NAMES_KEY) private fun lastUsedKey(dbName: String) = longPreferencesKey("db_last_used:$dbName") private fun addrDbKey(address: String?) = stringPreferencesKey("${DatabaseConstants.ADDR_DB_FOR_PREFIX}${normalizeAddress(address)}") + private fun pendingSourceDbKey(address: String) = + stringPreferencesKey("${DatabaseConstants.PENDING_SOURCE_DB_FOR_PREFIX}${normalizeAddress(address)}") + + private fun pendingDestinationDbKey(address: String) = + stringPreferencesKey("${DatabaseConstants.PENDING_DESTINATION_DB_FOR_PREFIX}${normalizeAddress(address)}") + private var backfillJob: Job? = null + /** Launches and tracks manager-owned work so shutdown waits only for jobs that can still touch owned resources. */ + protected fun launchManagerWork( + dispatcher: CoroutineDispatcher = dispatchers.default, + block: suspend CoroutineScope.() -> Unit, + ): Job { + lateinit var job: Job + job = managerScope.launch(dispatcher, start = CoroutineStart.LAZY, block = block) + val accepted = + synchronized(managerJobLock) { + if (lifecycleState == LifecycleState.OPEN) { + activeManagerJobs.add(job) + // A LAZY coroutine can be cancelled after registration but before its body starts. Completion + // handlers run in both that case and the normal completion path, so every accepted job releases + // its shutdown-drain registration exactly once. + job.invokeOnCompletion { + synchronized(managerJobLock) { + if (activeManagerJobs.remove(job) && activeManagerJobs.isEmpty()) { + managerJobDrain?.complete(Unit) + } + } + } + true + } else { + false + } + } + if (accepted) job.start() else job.cancel() + return job + } + @Volatile private var hasDelayedFirstDeviceBackfill = false override val cacheLimit: StateFlow = @@ -99,25 +182,97 @@ open class DatabaseManager( override fun setCacheLimit(limit: Int) { val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT) - managerScope.launch { + launchManagerWork { datastore.edit { it[cacheLimitKey] = clamped } - // Enforce asynchronously with current active DB protected - enforceCacheLimit(activeDbName = currentDbName) + // Resolve the protected DB only once this deferred work owns the manager mutex. A switch may complete + // between scheduling and execution, so capturing currentDbName here could evict the newly active pool. + enforceCacheLimit() } } private val dbCache = mutableMapOf() - private val _currentDb = MutableStateFlow(null) + /** Replaced pools that remain live for consumers of an earlier [currentDb] emission until orderly shutdown. */ + private val detachedDatabases = mutableListOf() + + /** Databases merged and logically retired but kept open — app-wide consumers may still hold references. */ + private val logicallyRetired = mutableSetOf() + + /** Guarded by [mutex]; cleanup is safe only once, before this manager opens a device database. */ + private var attemptedStartupRetirementCleanup = false + + /** Guarded by [mutex]; keeps crash-route recovery from deleting a source after device DBs have been published. */ + private var hasOpenedDeviceDatabase = false + + private data class NamedDatabase(val dbName: String, val database: MeshtasticDatabase) + + private data class ShutdownDatabase(val database: MeshtasticDatabase, val dbNames: MutableSet) + + private data class ShutdownSnapshot(val databases: List, val retiredNames: List) + + /** + * Covers default-pool creation, current-flow publication, and shutdown ownership transfer as one state machine. + * Unlike two independent Kotlin lazy delegates, this lock leaves no gap where shutdown can close a newly built + * default pool before the flow that owns it becomes visible. + */ + private val initializationLock = SynchronizedObject() + + /** Guarded by [initializationLock]. The pool may exist before it is inserted into [dbCache] or published. */ + private var initializedDefaultDb: MeshtasticDatabase? = null + + /** Guarded by [initializationLock]. Cleared only after shutdown has acquired its contained pool. */ + private var currentDbState: MutableStateFlow? = null + + /** Caller must hold [initializationLock]. */ + private fun getOrCreateDefaultDatabaseLocked(): MeshtasticDatabase { + initializedDefaultDb?.let { + return it + } + checkOpen() + val database = buildDatabase(DatabaseConstants.DEFAULT_DB_NAME) + if (lifecycleState != LifecycleState.OPEN) { + runCatching { closeDatabase(database) } + .onFailure { + // Retain ownership so the shutdown snapshot can retry instead of losing an open pool. + initializedDefaultDb = database + Logger.w(it) { "Failed to close default database initialized during shutdown" } + } + checkOpen() + } + initializedDefaultDb = database + return database + } + + /** Builds the default pool once without touching [dbCache]; cache insertion remains manager-mutex-only. */ + private fun getOrCreateDefaultDatabase(): MeshtasticDatabase = + synchronized(initializationLock) { getOrCreateDefaultDatabaseLocked() } + + /** Lazily builds and publishes the default pool as one [initializationLock]-guarded ownership transfer. */ + private fun getOrCreateCurrentDbState(): MutableStateFlow = synchronized(initializationLock) { + currentDbState?.let { + return@synchronized it + } + checkOpen() + MutableStateFlow(getOrCreateDefaultDatabaseLocked()).also { currentDbState = it } + } /** - * The currently active database, built lazily on first access. Room's `onOpen` callback is itself lazy (not invoked - * until the first query), so construction only allocates the builder and connection pool — actual I/O is deferred. + * The currently active database. The default DB is opened lazily on first access and every internal publication + * ([switchActiveDatabase], association rollback/release, active-DB reopen recovery) writes [_currentDb] directly, + * so [currentDb].value reflects the new instance on the same program step — no `stateIn`/`filterNotNull` derivation + * that would delay visibility to a coroutine dispatch. + * + * Initialization is deferred until first use so the overridable [buildDatabase] runs only after subclass properties + * are set. It is construction-safe and does not mutate [dbCache]; switch and association paths insert the default + * instance while holding [mutex]. Default creation, flow publication, and shutdown acquisition share + * [initializationLock], so shutdown cannot miss or prematurely close an in-progress publication. Room's `onOpen` + * callback remains lazy until the first query. */ - override val currentDb: StateFlow = - _currentDb - .filterNotNull() - .stateIn(managerScope, SharingStarted.Eagerly, getOrOpenDatabase(DatabaseConstants.DEFAULT_DB_NAME)) + private val _currentDb: MutableStateFlow + get() = getOrCreateCurrentDbState() + + override val currentDb: StateFlow + get() = _currentDb private val _currentAddress = MutableStateFlow(null) val currentAddress: StateFlow = _currentAddress @@ -134,13 +289,48 @@ open class DatabaseManager( switchActiveDatabase(address) } + /** Returns a cached database or builds one. Every caller must hold [mutex]. */ + private fun getOrOpenDatabase(dbName: String): MeshtasticDatabase = dbCache.getOrPut(dbName) { + if (dbName == DatabaseConstants.DEFAULT_DB_NAME) getOrCreateDefaultDatabase() else buildDatabase(dbName) + } + + private fun checkOpen() { + check(lifecycleState == LifecycleState.OPEN) { "DatabaseManager is closing or closed" } + } + /** - * Returns a cached [MeshtasticDatabase] or builds a new one for [dbName]. The caller must hold [mutex] when - * modifying [dbCache] concurrently; however, this helper is also used from [currentDb]'s `initialValue` where the - * mutex is not yet relevant (single-threaded construction). + * Admits one serialized manager operation (anything that takes [mutex]) and drains it on completion. + * + * Registration happens BEFORE the operation waits on [mutex] (after the lifecycle check); the token is removed in + * `finally` so cancellation, merge failure, and timeout all release admission. A [close] that observes a non-empty + * admission set arms [managerOperationDrain] and bound-waits on it before taking [mutex] for its snapshot, so a + * wedged admitted operation cannot pin shutdown past [WRITER_DRAIN_TIMEOUT_MS]. */ - private fun getOrOpenDatabase(dbName: String): MeshtasticDatabase = - dbCache.getOrPut(dbName) { getDatabaseBuilder(dbName).build() } + private suspend fun withManagerOperation(block: suspend () -> T): T { + val token = Any() + writerTrackerMutex.withLock { + checkOpen() + activeManagerOperations.add(token) + } + try { + return block() + } finally { + withContext(NonCancellable) { + writerTrackerMutex.withLock { + activeManagerOperations.remove(token) + if (activeManagerOperations.isEmpty()) { + managerOperationDrain?.complete(Unit) + } + } + } + } + } + + /** + * Builds a new [MeshtasticDatabase] for [dbName]. Tests override this to control file placement (temp directory + * instead of the platform data dir). Production delegates to the platform-specific [getDatabaseBuilder]. + */ + protected open fun buildDatabase(dbName: String): MeshtasticDatabase = getDatabaseBuilder(dbName).build() /** * Resolves the DB name to use for [address], honoring a cross-transport alias when one exists. A secondary @@ -148,51 +338,127 @@ open class DatabaseManager( * without an alias this falls back to the address-hashed name — today's default — for a first-time or primary * connection. See [associateDevice]. */ - private suspend fun resolveDbName(address: String?): String { + @Suppress("ReturnCount") + private suspend fun resolveDbName(address: String?, canReclaimRecoveredSource: Boolean): String { val fallback = buildDbName(address) if (fallback == DatabaseConstants.DEFAULT_DB_NAME) return fallback - return datastore.data.first()[addrDbKey(address)] ?: fallback + val transportAddress = address ?: return fallback + val aliasKey = addrDbKey(transportAddress) + val pendingSourceKey = pendingSourceDbKey(transportAddress) + val pendingDestinationKey = pendingDestinationDbKey(transportAddress) + val prefs = datastore.data.first() + + val pendingSource = prefs[pendingSourceKey] + val pendingDestination = prefs[pendingDestinationKey] + if (pendingSource == null && pendingDestination == null) return prefs[aliasKey] ?: fallback + if (pendingSource == null || pendingDestination == null) { + datastore.edit { + it.remove(pendingSourceKey) + it.remove(pendingDestinationKey) + } + return prefs[aliasKey] ?: fallback + } + + // A pending route is only intent. The destination's merge marker is the durable proof that the data copy + // committed. Verify it before publishing either database so no caller can write to a merged-away fallback. + val destinationDb = withContext(dispatchers.io) { getOrOpenDatabase(pendingDestination) } + val mergeCommitted = verifyMergeMarker(destinationDb, pendingSource) + if (!mergeCommitted) { + datastore.edit { + it.remove(pendingSourceKey) + it.remove(pendingDestinationKey) + } + return prefs[aliasKey] ?: fallback + } + + // If this process may already have published the source pool, its durable retirement and in-memory + // protection are one cancellation-atomic transition. Otherwise cancellation after DataStore commits but + // before logicallyRetired is updated could let cache eviction close/delete a pool still held by consumers. + withContext(NonCancellable) { + datastore.edit { + it[aliasKey] = pendingDestination + it.remove(pendingSourceKey) + it.remove(pendingDestinationKey) + it[lastUsedKey(pendingDestination)] = nowMillis + it[retiredDbNamesKey] = it[retiredDbNamesKey].orEmpty() + pendingSource + } + if (!canReclaimRecoveredSource) logicallyRetired.add(pendingSource) + } + if (canReclaimRecoveredSource) { + physicallyRetireDatabase(pendingSource) + currentCoroutineContext().ensureActive() + } + Logger.i { + "Repaired pending route from ${anonymizeDbName(pendingSource)} to " + anonymizeDbName(pendingDestination) + } + return pendingDestination } + /** Reads the destination merge marker used as commit proof for pending-route recovery. */ + protected open suspend fun verifyMergeMarker(destination: MeshtasticDatabase, sourceName: String): Boolean = + destination.mergeMarkerDao().isMerged(sourceName) + /** Switch active database to the one associated with [address]. Serialized via mutex. */ - override suspend fun switchActiveDatabase(address: String?) = mutex.withLock { - val dbName = resolveDbName(address) + override suspend fun switchActiveDatabase(address: String?) = withManagerOperation { + mutex.withLock { + checkOpen() + cleanupPersistedRetirementsAtStartup() + val dbName = resolveDbName(address, canReclaimRecoveredSource = !hasOpenedDeviceDatabase) + + // Remember the previously active DB name (any) so we can record its last-used time as well. + val previousDbName = currentDbName + + // Fast path: no-op only when both the selected address and its resolved database are already active. + // resolveDbName() may repair a committed pending route to a different destination for the same address. + if (_currentAddress.value == address && currentDbName == dbName) { + markLastUsed(dbName) + return@withLock + } - // Remember the previously active DB name (any) so we can record its last-used time as well. - val previousDbName = if (_currentDb.value != null) currentDbName else null + // Build/open Room DB off the main thread + val db = withContext(dispatchers.io) { getOrOpenDatabase(dbName) } + if (dbName != DatabaseConstants.DEFAULT_DB_NAME) hasOpenedDeviceDatabase = true - // Fast path: no-op if already on this address - if (_currentAddress.value == address && _currentDb.value != null) { + // Emit the new DB BEFORE closing the old ones. flatMapLatest collectors on + // currentDb will cancel their in-flight queries on the previous database once + // the new value is emitted. Closing the old pool first would race with those + // collectors, causing "Connection pool is closed" crashes. + writerTrackerMutex.withLock { + _currentDb.value = db + currentDbName = dbName + } + _currentAddress.value = address markLastUsed(dbName) - return@withLock - } + // Also mark the previous DB as used "just now" so LRU has an accurate, recent timestamp + markLastUsed(previousDbName) + + // Do NOT close the previous DB synchronously here. Even though _currentDb has been + // updated, in-flight `withDb` calls may still hold a reference to the old database + // (captured before the emission). Closing the connection pool while those queries are + // executing causes "Connection pool is closed" crashes. Instead, let LRU eviction + // (enforceCacheLimit) handle cleanup — it only runs on databases that are not the + // active target and have not been used recently. + + schedulePostSwitchMaintenance(dbName = dbName, db = db) - // Build/open Room DB off the main thread - val db = withContext(dispatchers.io) { getOrOpenDatabase(dbName) } - - // Emit the new DB BEFORE closing the old one. flatMapLatest collectors on - // currentDb will cancel their in-flight queries on the previous database once - // the new value is emitted. Closing the old pool first would race with those - // collectors, causing "Connection pool is closed" crashes. - _currentDb.value = db - _currentAddress.value = address - currentDbName = dbName - markLastUsed(dbName) - // Also mark the previous DB as used "just now" so LRU has an accurate, recent timestamp - previousDbName?.let { markLastUsed(it) } - - // Do NOT close the previous DB synchronously here. Even though _currentDb has been - // updated, in-flight `withDb` calls may still hold a reference to the old database - // (captured before the emission). Closing the connection pool while those queries are - // executing causes "Connection pool is closed" crashes. Instead, let LRU eviction - // (enforceCacheLimit) handle cleanup — it only runs on databases that are not the - // active target and have not been used recently. + Logger.i { "Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}" } + } + } + /** + * Schedules deferred maintenance that runs after switching the active database. Posts work to [managerScope] on + * [dispatchers.io] so the switch path is not blocked by filesystem or search-index I/O. + * + * In production this schedules LRU cache-limit enforcement, legacy-DB cleanup, and FTS search-index backfill. + * In-memory test fixtures override it to no-op because they do not have a filesystem-backed database directory and + * must not access platform context singletons (e.g. `ContextServices.app`). + */ + protected open fun schedulePostSwitchMaintenance(dbName: String, db: MeshtasticDatabase) { // Defer LRU eviction so switch is not blocked by filesystem work - managerScope.launch(dispatchers.io) { enforceCacheLimit(activeDbName = dbName) } + launchManagerWork(dispatchers.io) { enforceCacheLimit() } // One-time cleanup: remove legacy DB if present and not active - managerScope.launch(dispatchers.io) { cleanupLegacyDbIfNeeded(activeDbName = dbName) } + launchManagerWork(dispatchers.io) { cleanupLegacyDbIfNeeded(activeDbName = dbName) } // Backfill FTS search index for any text messages missing messageText. // On the first real device DB, defer this so it does not starve the single DB connection while @@ -200,154 +466,390 @@ open class DatabaseManager( val shouldDelayBackfill = dbName != DatabaseConstants.DEFAULT_DB_NAME && !hasDelayedFirstDeviceBackfill if (shouldDelayBackfill) hasDelayedFirstDeviceBackfill = true scheduleSearchIndexBackfill(dbName = dbName, db = db, shouldDelayBackfill = shouldDelayBackfill) - - Logger.i { "Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}" } } - @Suppress("TooGenericExceptionCaught") - override suspend fun associateDevice(nodeNum: Int, deviceId: String?) { - mutex.withLock { - val sourceName = currentDbName - // Never claim or merge into the sentinel "no device" DB. - if (sourceName == DatabaseConstants.DEFAULT_DB_NAME) return@withLock - - // The device-id claim is the durable one (node numbers renumber under firmware 2.8); the - // node-num claim stays as the fallback for hardware without a device id, for lockdown - // sessions (device_id zeroed), and for claims written by older app versions. Writes always - // refresh both keys so either lookup path resolves on the next connection. - val deviceKey = validDeviceIdOrNull(deviceId)?.let(::deviceDbPrefKey) - val nodeKey = nodeDbPrefKey(nodeNum) - val prefs = datastore.data.first() - val claimed = resolveDbClaim(prefs, deviceKey, nodeKey) - suspend fun writeClaims(dbName: String) = datastore.edit { - deviceKey?.let { key -> it[key] = dbName } - it[nodeKey] = dbName - } - - when { - claimed == null -> { - // First transport to learn this device: its current DB becomes the device's canonical DB. - // No address alias is needed — a primary connection already resolves to this DB via buildDbName. - writeClaims(sourceName) - Logger.i { "Claimed ${anonymizeDbName(sourceName)} as canonical DB for node $nodeNum" } + @Suppress("TooGenericExceptionCaught", "CyclomaticComplexMethod", "LongMethod") + override suspend fun associateDevice( + address: String, + nodeNum: Int, + deviceId: String?, + isSessionActive: () -> Boolean, + ) = withManagerOperation { + try { + mutex.withLock { + fun ensureAssociationActive() { + if (!isSessionActive()) throw StaleAssociationException() } - claimed == sourceName -> { - // Already unified — just backfill/refresh any claim key that is missing or stale - // (e.g. the first connect after this device renumbered, or after an app update - // introduced device-id claims). - if ((deviceKey != null && prefs[deviceKey] != sourceName) || prefs[nodeKey] != sourceName) { - writeClaims(sourceName) + checkOpen() + ensureAssociationActive() + if (_currentAddress.value != address) { + Logger.i { + "Ignored stale database association for ${anonymizeAddress(address)}; active transport is " + + anonymizeAddress(_currentAddress.value) } + return@withLock + } + val sourceName = currentDbName + // Never claim or merge into the sentinel "no device" DB. + if (sourceName == DatabaseConstants.DEFAULT_DB_NAME) return@withLock + + // The device-id claim is the durable one (node numbers renumber under firmware 2.8); the + // node-num claim stays as the fallback for hardware without a device id, for lockdown + // sessions (device_id zeroed), and for claims written by older app versions. Writes always + // refresh both keys so either lookup path resolves on the next connection. + val deviceKey = validDeviceIdOrNull(deviceId)?.let(::deviceDbPrefKey) + val nodeKey = nodeDbPrefKey(nodeNum) + val prefs = datastore.data.first() + val claimed = resolveDbClaim(prefs, deviceKey, nodeKey) + suspend fun writeClaims(dbName: String) = datastore.edit { + ensureAssociationActive() + deviceKey?.let { key -> it[key] = dbName } + it[nodeKey] = dbName + ensureAssociationActive() } - else -> { - // Secondary transport reached an already-known node: fold this DB into the canonical one, - // switch the active DB to it, alias this address to it, and retire the now-merged source. - val source = _currentDb.value ?: return@withLock - val dest = withContext(dispatchers.io) { getOrOpenDatabase(claimed) } - - // Redirect live writers to the canonical DB BEFORE merging (a connect triggers a full NodeDB - // re-dump). The swap is published under the writer lock so a concurrent withDb write registers - // against `source` before it — and is drained below — or captures `dest` after it and is safe. - // New writers now capture `dest`; drainWriters then waits out any still writing to `source` so - // the merge can't snapshot `source` mid-write and lose it when `source` is later retired. - publishActiveDb(dest, claimed) - try { - withContext(dispatchers.io) { - drainWriters(source, sourceName) - DatabaseMerger.merge(source, dest, sourceName) - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - // merge() is atomic, so on failure `dest` is unchanged. Roll the active DB back to - // `source` so the address still resolves consistently and the merge retries next connect. - publishActiveDb(source, sourceName) - Logger.w(e) { - "Merge into ${anonymizeDbName(claimed)} failed; kept ${anonymizeDbName(sourceName)} active" - } - return@withLock + when { + claimed == null -> { + // First transport to learn this device: its current DB becomes the device's canonical DB. + // No address alias is needed — a primary connection already resolves to this DB via + // buildDbName. + writeClaims(sourceName) + Logger.i { "Claimed ${anonymizeDbName(sourceName)} as canonical DB for node $nodeNum" } } - markLastUsed(claimed) - // Refresh both claim keys (migrates a legacy nodeNum-only claim forward to the - // device-id key) and alias this transport's address to the canonical DB. - writeClaims(claimed) - datastore.edit { it[addrDbKey(_currentAddress.value)] = claimed } - Logger.i { - "Unified ${anonymizeDbName(sourceName)} into ${anonymizeDbName(claimed)} for node $nodeNum" + claimed == sourceName -> { + // Already unified — backfill or refresh any stale/missing routing metadata atomically. + // This also repairs a post-merge routing failure (merge committed but DataStore edit failed): + // the next connect reaches this branch and writes claims + alias in one edit without + // re-copying source (the merge marker prevents duplicate data). + val addressKey = addrDbKey(address) + val needsDeviceKey = deviceKey != null && prefs[deviceKey] != sourceName + val needsNodeKey = prefs[nodeKey] != sourceName + val needsAlias = prefs[addressKey] != sourceName + val pendingSourceKey = pendingSourceDbKey(address) + val pendingDestinationKey = pendingDestinationDbKey(address) + val needsPendingCleanup = + prefs[pendingSourceKey] != null || prefs[pendingDestinationKey] != null + if (needsDeviceKey || needsNodeKey || needsAlias || needsPendingCleanup) { + datastore.edit { + ensureAssociationActive() + deviceKey?.let { key -> if (needsDeviceKey) it[key] = sourceName } + if (needsNodeKey) it[nodeKey] = sourceName + if (needsAlias) it[addressKey] = sourceName + it.remove(pendingSourceKey) + it.remove(pendingDestinationKey) + it[lastUsedKey(sourceName)] = nowMillis + ensureAssociationActive() + } + Logger.i { "Refreshed routing metadata for ${anonymizeDbName(sourceName)}" } + } } - // Retire the merged source off the critical path; its data now lives in the canonical DB. - managerScope.launch(dispatchers.io) { retireDatabase(sourceName) } + else -> { + // Secondary transport reached an already-known node: fold this DB into the canonical one, + // switch the active DB to it, alias this address to it, and retire the now-merged source. + ensureAssociationActive() + val source = _currentDb.value + val dest = withContext(dispatchers.io) { getOrOpenDatabase(claimed) } + ensureAssociationActive() + + // Arm the writer-admission gate before any drain or merge work. The complete armed lifetime is + // enclosed by the try/finally below, so every CancellationException, Exception, and Error + // releases blocked writers onto source before commit or destination after commit. + val gate = CompletableDeferred() + val transportAddress = address + var mergeCommitted = false + var retirementPersisted = false + + // Publishes and completes this exact gate once. A repeated cleanup call is harmless and cannot + // clear a later association's gate. + suspend fun releaseWriterGate(canonicalDb: MeshtasticDatabase, canonicalName: String) { + withContext(NonCancellable) { + val released = + writerTrackerMutex.withLock { + if (writerGate !== gate) { + false + } else { + writerGate = null + _currentDb.value = canonicalDb + currentDbName = canonicalName + true + } + } + if (released) gate.complete(canonicalDb) + } + } + + suspend fun clearPendingRouteBestEffort() { + withContext(NonCancellable) { + try { + datastore.edit { + it.remove(pendingSourceDbKey(transportAddress)) + it.remove(pendingDestinationDbKey(transportAddress)) + } + } catch (cleanupFailure: Throwable) { + Logger.w(cleanupFailure) { "Failed to clear aborted pending database route" } + } + } + } + + try { + // Start the outer try before arming the gate. Once writerGate is assigned, no Throwable can + // escape without running the identity-checked release in finally. + writerTrackerMutex.withLock { + check(writerGate == null) { "Database writer gate already armed" } + writerGate = gate + } + try { + // Phase 1: drain every writer admitted against source before this gate was armed. + val drained = withContext(dispatchers.io) { drainWriters(source, sourceName) } + if (!drained) { + Logger.w { + "Aborted merge of ${anonymizeDbName(sourceName)} into " + + "${anonymizeDbName(claimed)}: writer drain timed out; kept " + + "${anonymizeDbName(sourceName)} active" + } + return@withLock + } + + // Phase 2: persist address-scoped intent, commit the database merge and marker, then + // finalize every route and remove intent in one DataStore transaction. If finalization + // fails after the merge commits, a later switch verifies the marker and repairs the + // alias before publishing. + withContext(NonCancellable + dispatchers.io) { + datastore.edit { + ensureAssociationActive() + it[pendingSourceDbKey(transportAddress)] = sourceName + it[pendingDestinationDbKey(transportAddress)] = claimed + ensureAssociationActive() + } + mergeDatabases(source, dest, sourceName, isSessionActive) + mergeCommitted = true + ensureAssociationActive() + datastore.edit { + ensureAssociationActive() + deviceKey?.let { key -> it[key] = claimed } + it[nodeKey] = claimed + it[addrDbKey(transportAddress)] = claimed + it.remove(pendingSourceDbKey(transportAddress)) + it.remove(pendingDestinationDbKey(transportAddress)) + it[lastUsedKey(claimed)] = nowMillis + it[retiredDbNamesKey] = it[retiredDbNamesKey].orEmpty() + sourceName + ensureAssociationActive() + } + retirementPersisted = true + Logger.i { + "Unified ${anonymizeDbName( + sourceName, + )} into ${anonymizeDbName(claimed)} for node $nodeNum" + } + } + currentCoroutineContext().ensureActive() + } catch (failure: Throwable) { + if (!mergeCommitted) { + clearPendingRouteBestEffort() + } + when (failure) { + is CancellationException, + is StaleAssociationException, + -> throw failure + + is Exception -> { + if (!mergeCommitted) { + Logger.w(failure) { + "Merge into ${anonymizeDbName(claimed)} failed; " + + "kept ${anonymizeDbName(sourceName)} active" + } + } else { + Logger.w(failure) { + "Routing metadata for ${anonymizeDbName(claimed)} failed after merge " + + "commit; destination remains active and the pending route will " + + "repair the address alias on a later switch" + } + } + return@withLock + } + + else -> throw failure + } + } + } finally { + withContext(NonCancellable) { + if (mergeCommitted) { + releaseWriterGate(dest, claimed) + recordLogicalRetirement(sourceName, persistIntent = !retirementPersisted) + } else { + releaseWriterGate(source, sourceName) + } + } + } + } } } + } catch (_: StaleAssociationException) { + Logger.i { + "Aborted stale database association for ${anonymizeAddress(address)} after transport-session rollover" + } + } + } + + /** + * Logically retires a database whose contents have been merged into another. + * + * Physical close/delete is deferred — the merged source was published through [currentDb]; app-wide Flow, Paging, + * UI, worker, and one-shot read consumers may still hold its Room instance. Physically closing it now can surface + * "Connection pool is closed" to those readers (see [switchActiveDatabase] and [reopenActiveDatabaseIfStillCurrent] + * no-sync-close discipline). Retirement intent is persisted so the next manager lifetime can safely remove the file + * before opening a device DB; [close] also performs orderly physical teardown when the platform invokes it. + */ + private suspend fun recordLogicalRetirement(dbName: String, persistIntent: Boolean) { + // associateDevice already owns the manager mutex. Reacquiring it here would deadlock finalization. + logicallyRetired.add(dbName) + if (persistIntent) persistRetirementIntent(dbName) + Logger.i { "Logically retired merged DB ${anonymizeDbName(dbName)}; physical cleanup deferred" } + } + + /** Persists merge retirement separately as a fallback when post-commit route finalization failed. */ + @Suppress("TooGenericExceptionCaught") + private suspend fun persistRetirementIntent(dbName: String) { + try { + datastore.edit { it[retiredDbNamesKey] = it[retiredDbNamesKey].orEmpty() + dbName } + } catch (failure: Throwable) { + Logger.w(failure) { "Failed to persist retirement for ${anonymizeDbName(dbName)}" } + } + } + + /** + * Reclaims retirement intents left by a previous application process. This runs at most once and before opening any + * device DB, which is the only point where no consumer from this process can hold a retired Room instance. + */ + @Suppress("TooGenericExceptionCaught") + private suspend fun cleanupPersistedRetirementsAtStartup() { + if (attemptedStartupRetirementCleanup) return + attemptedStartupRetirementCleanup = true + val retiredNames = + try { + datastore.data.first()[retiredDbNamesKey].orEmpty() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + Logger.w(failure) { "Failed to read persisted database retirements" } + return + } + retiredNames.forEach { + currentCoroutineContext().ensureActive() + physicallyRetireDatabase(it) + currentCoroutineContext().ensureActive() } } - /** Closes, deletes, and forgets a database whose contents have been merged into another. */ - private suspend fun retireDatabase(dbName: String) = mutex.withLock { - runCatching { - closeCachedDatabase(dbName) - deleteDatabase(dbName) - datastore.edit { it.remove(lastUsedKey(dbName)) } + /** Deletes one retired file, then atomically clears its retirement and last-used metadata. */ + @Suppress("TooGenericExceptionCaught") + private suspend fun physicallyRetireDatabase(dbName: String) { + try { + deleteDatabaseFiles(dbName) + } catch (failure: Throwable) { + Logger.w(failure) { "Failed to delete retired database ${anonymizeDbName(dbName)}" } + return + } + try { + datastore.edit { + it.remove(lastUsedKey(dbName)) + val remaining = it[retiredDbNamesKey].orEmpty() - dbName + if (remaining.isEmpty()) it.remove(retiredDbNamesKey) else it[retiredDbNamesKey] = remaining + } + Logger.i { "Physically retired merged DB ${anonymizeDbName(dbName)}" } + } catch (failure: Throwable) { + // The file deletion is idempotent. Retain the intent so a later process retries metadata cleanup. + Logger.w(failure) { "Failed to clear retirement metadata for ${anonymizeDbName(dbName)}" } } - .onSuccess { Logger.i { "Retired merged DB ${anonymizeDbName(dbName)}" } } - .onFailure { Logger.w(it) { "Failed to retire merged database ${anonymizeDbName(dbName)}" } } } /** * Closes and removes a cached database by name. Safe to call even if the database was already closed or not in the * cache. Does NOT delete the underlying file — the database can be re-opened on next access. * - * On JVM/Desktop, Room KMP has no auto-close timeout (Android-only API), so idle databases hold open SQLite - * connections (5 per WAL-mode DB) indefinitely until explicitly closed. This method is the primary mechanism for - * releasing those connections when a database is no longer the active target. + * Room KMP is configured with a single-connection pool on every platform and has no common auto-close timeout, so + * an idle cached database keeps that connection open until explicitly closed. This method is the primary mechanism + * for releasing it when a database is no longer the active target. The caller must hold [mutex]. */ - private fun closeCachedDatabase(dbName: String) { - val removed = dbCache.remove(dbName) ?: return - runCatching { removed.close() } - .onFailure { Logger.w(it) { "Failed to close cached database ${anonymizeDbName(dbName)}" } } + @Suppress("TooGenericExceptionCaught") + protected open suspend fun closeCachedDatabase(dbName: String) { + val database = dbCache[dbName] ?: return + try { + closeDatabase(database) + } catch (failure: Throwable) { + Logger.w(failure) { "Failed to close cached database ${anonymizeDbName(dbName)}" } + throw failure + } + dbCache.remove(dbName) Logger.d { "Closed inactive database ${anonymizeDbName(dbName)} to free connections" } } + /** Room-close seam used by deterministic shutdown tests. */ + protected open fun closeDatabase(database: MeshtasticDatabase) = database.close() + /** * Reopens the active database under [mutex], but only if it hasn't switched since the caller snapshotted it. * - * The replaced Room instance is intentionally left open for the rest of the process. [currentDb] is a derived - * StateFlow, so there is no deterministic handoff point where every app-wide collector has stopped using the old - * instance. + * The replaced Room instance is intentionally left open for the rest of the process. [currentDb] reads [_currentDb] + * directly, so every publication is visible to app-wide collectors on the same program step — but there is no + * deterministic handoff point where every collector has stopped using the previous instance. * - * Returns the reopened DB, or null if another coroutine already switched to a different device. + * Returns the reopened DB, or null if another coroutine switched databases or shutdown has started. */ + @Suppress("ReturnCount") private suspend fun reopenActiveDatabaseIfStillCurrent( expectedDb: MeshtasticDatabase, expectedDbName: String, - ): MeshtasticDatabase? = mutex.withLock { - if (_currentDb.value !== expectedDb || currentDbName != expectedDbName) return null + ): MeshtasticDatabase? = withManagerOperation { + mutex.withLock { + if (lifecycleState != LifecycleState.OPEN) return@withManagerOperation null + if (_currentDb.value !== expectedDb || currentDbName != expectedDbName) return@withManagerOperation null - val cached = dbCache[expectedDbName] - if (cached !== expectedDb) { - Logger.w { "withDb: active DB cache entry changed before reopen; skipping active DB reopen" } - return null - } + val registered = + dbCache[expectedDbName] + ?: if (expectedDbName == DatabaseConstants.DEFAULT_DB_NAME) { + synchronized(initializationLock) { initializedDefaultDb } + } else { + null + } + if (registered !== expectedDb) { + Logger.w { "withDb: active DB registration changed before reopen; skipping active DB reopen" } + return@withManagerOperation null + } - // Build a fresh instance directly (not through getOrPut) before touching the cache, - // so a failed or cancelled build leaves the existing cache entry and _currentDb consistent. - val reopened = withContext(dispatchers.io) { getDatabaseBuilder(expectedDbName).build() } - dbCache[expectedDbName] = reopened - _currentDb.value = reopened + // Build a fresh instance directly (not through getOrPut) before touching the cache, + // so a failed or cancelled build leaves the existing cache entry and _currentDb consistent. + val reopened = withContext(dispatchers.io) { buildDatabase(expectedDbName) } + if (lifecycleState != LifecycleState.OPEN) { + runCatching { closeDatabase(reopened) } + .onFailure { + detachedDatabases.add(NamedDatabase(expectedDbName, reopened)) + Logger.w(it) { "Failed to close database built during shutdown; retained for shutdown retry" } + } + return@withManagerOperation null + } + dbCache[expectedDbName] = reopened + if (expectedDbName == DatabaseConstants.DEFAULT_DB_NAME) { + synchronized(initializationLock) { initializedDefaultDb = reopened } + } + _currentDb.value = reopened + if (detachedDatabases.none { it.database === expectedDb }) { + detachedDatabases.add(NamedDatabase(expectedDbName, expectedDb)) + } - // Intentionally do not close expectedDb here. The public currentDb Flow is derived from - // _currentDb through stateIn, so downstream flatMapLatest collectors may still be using the - // replaced Room instance after this function emits the reopened DB. Closing the old pool here - // can surface "Connection pool is closed" to app-wide DB observers that do not have closed-pool - // recovery. This mirrors switchActiveDatabase's no-sync-close discipline; the leaked pool is - // bounded by rare active-DB reopen recovery events and is reclaimed on process death. Revisit - // once switching DB observers have explicit closed-pool resubscribe/retry handling. + // Intentionally do not close expectedDb here. The public currentDb Flow exposes _currentDb directly, + // so downstream flatMapLatest collectors may still be using the replaced Room instance after this + // function emits the reopened DB. Closing the old pool here can surface "Connection pool is closed" + // to app-wide DB observers that do not have closed-pool recovery. This mirrors switchActiveDatabase's + // no-sync-close discipline. [close] owns the detached-pool set and reclaims every replaced instance after + // all + // application consumers and admitted writers have stopped. - reopened + reopened + } } // Short-term runtime containment: route withDb entry through a single-lane dispatcher to narrow the Room/SQLite @@ -356,10 +858,16 @@ open class DatabaseManager( // one-shot DB-critical blocks through cancellation, then re-check cancellation so stale callers do not continue // after the DB releases. Long-lived Flow/Paging reads must stay out of withDb; revisit after direct currentDb.value // callers are audited and safe DB concurrency can be restored. - private val limitedIo = dispatchers.io.limitedParallelism(1) + protected open val limitedIo: CoroutineDispatcher by lazy { dispatchers.io.limitedParallelism(1) } - /** Execute [block] with the current DB instance. Retries once if the pool closes during a DB switch. */ - @Suppress("TooGenericExceptionCaught") + /** + * Executes [block] once against the admitted current DB instance. + * + * A callback is never replayed after it starts: an arbitrary block can perform one side effect and then fail, so + * transparently invoking it again against another pool could duplicate or split a logical write. Pool-timeout + * recovery may reopen the active database for future calls, but the original failure is still propagated. + */ + @Suppress("TooGenericExceptionCaught", "ReturnCount") override suspend fun withDb(block: suspend (MeshtasticDatabase) -> T): T? { val queuedAt = nowMillis return withContext(limitedIo) { @@ -384,89 +892,189 @@ open class DatabaseManager( } /** - * Atomically snapshots the active DB and registers a writer against it. Before the first [switchActiveDatabase] - * `_currentDb` is still null, so fall back to the public [currentDb] view — the eagerly-opened default DB — giving - * withDb the exact DB-resolution semantics of a direct `currentDb.value` caller. Desktop never calls [init] until a - * device is selected, so without the fallback pre-connection writes (quick-chat actions, firmware/hardware cache - * refreshes) would silently no-op there instead of landing in the default DB the pre-connection flows read from. + * Atomically snapshots the canonical active DB (held by [_currentDb], which initializes lazily to the default DB) + * and registers a writer against it. + * + * If an association attempt is in flight, the writer-admission gate is armed. The caller snapshots that gate under + * [writerTrackerMutex], awaits it outside the lock, then retries admission from the beginning. Selecting the active + * database and registering the writer happen in the same critical section, so an association cannot arm its gate + * between those operations. This guarantees a new `withDb` never writes to a DB that is being retired, nor lands on + * `dest` before its data exists. */ - private suspend fun beginWrite(): MeshtasticDatabase = writerTrackerMutex.withLock { - val db = _currentDb.value ?: currentDb.value - activeWriters[db] = (activeWriters[db] ?: 0) + 1 - db + private data class AdmittedDatabase(val database: MeshtasticDatabase, val name: String) + + private suspend fun beginWrite(): AdmittedDatabase { + while (true) { + var admitted: AdmittedDatabase? = null + val gate = + writerTrackerMutex.withLock { + checkOpen() + val pendingGate = writerGate + if (pendingGate == null) { + val db = _currentDb.value + activeWriters[db] = (activeWriters[db] ?: 0) + 1 + admitted = AdmittedDatabase(database = db, name = currentDbName) + } + pendingGate + } + admitted?.let { + return it + } + val pendingGate = checkNotNull(gate) { "Writer admission produced neither a database nor a gate" } + val released = + withTimeoutOrNull(WRITER_GATE_TIMEOUT_MS) { + pendingGate.await() + true + } + if (released == null) { + throw IllegalStateException( + "Timed out waiting ${WRITER_GATE_TIMEOUT_MS}ms for database writer admission gate", + ) + } + } } - /** - * Registers a writer against a specific [db] — used by the withDb retry paths, whose target is a recovered/new - * instance rather than the snapshotted active DB, so their writes stay visible to a concurrent drain too. - */ - private suspend fun registerWriter(db: MeshtasticDatabase) = - writerTrackerMutex.withLock { activeWriters[db] = (activeWriters[db] ?: 0) + 1 } - /** Deregisters a writer and releases any merge waiting for [db] to quiesce. Cancellation-safe (see call site). */ - private suspend fun endWrite(db: MeshtasticDatabase) = writerTrackerMutex.withLock { - val remaining = (activeWriters[db] ?: 1) - 1 - if (remaining <= 0) { - activeWriters.remove(db) - drainWaiters.remove(db)?.forEach { it.complete(Unit) } - } else { - activeWriters[db] = remaining + private suspend fun endWrite(db: MeshtasticDatabase) { + val retryEviction = + writerTrackerMutex.withLock { + val remaining = (activeWriters[db] ?: 1) - 1 + val drained = remaining <= 0 + if (drained) { + activeWriters.remove(db) + drainWaiters.remove(db)?.forEach { it.complete(Unit) } + } else { + activeWriters[db] = remaining + } + if (activeWriters.isEmpty()) shutdownWriterDrain?.complete(Unit) + drained && deferredEvictions.remove(db) + } + if (retryEviction && lifecycleState == LifecycleState.OPEN) { + launchManagerWork(dispatchers.io) { enforceCacheLimit() } } } - /** Publishes [db]/[name] as active under the writer lock so concurrent [beginWrite]s order against the swap. */ - private suspend fun publishActiveDb(db: MeshtasticDatabase, name: String) = writerTrackerMutex.withLock { - _currentDb.value = db - currentDbName = name + /** + * Folds [source] into [dest]. Override in tests to inject merge failures. Production delegates to + * [DatabaseMerger.merge]; the merge runs in a single transaction so a crash rolls back cleanly and the destination + * is never left half-merged. + */ + protected open suspend fun mergeDatabases( + source: MeshtasticDatabase, + dest: MeshtasticDatabase, + sourceName: String, + isAssociationActive: () -> Boolean, + ) { + DatabaseMerger.merge(source, dest, sourceName, isAssociationActive) } + /** + * Test-only snapshot of the writer tracker: total live writers and total pending drain waiters. Both are zero once + * every association attempt has released its gate and drained its source — a non-zero pair after a quiescent period + * indicates a leaked writer or waiter. + */ + internal suspend fun debugWriterCounts(): Pair = + writerTrackerMutex.withLock { activeWriters.values.sum() to drainWaiters.values.sumOf { it.size } } + + /** Test-only visibility for asserting an association did not leak writer admission. */ + internal suspend fun debugWriterGateArmed(): Boolean = writerTrackerMutex.withLock { writerGate != null } + + /** Test-only visibility for cancellation-atomic pending-route recovery assertions. */ + internal suspend fun debugIsLogicallyRetired(dbName: String): Boolean = + mutex.withLock { dbName in logicallyRetired } + + /** Test-only visibility for deterministic shutdown assertions. */ + internal fun debugAcceptingWrites(): Boolean = lifecycleState == LifecycleState.OPEN + /** * Suspends until every writer that captured [db] before this call has finished, so a merge never snapshots [db] * while a write is still in flight (and then loses it when [db] is retired). Bounded by [WRITER_DRAIN_TIMEOUT_MS] - * so a wedged writer can't pin the merge — and [mutex] — forever; falling through on timeout is no worse than the - * old barrier-less behavior for that rare case. + * so a wedged writer can't pin the merge — and [mutex] — forever. + * + * Returns `true` if all writers drained (or none were active), `false` on timeout. The caller must abort the merge + * and roll the active DB back to source on `false`. + * + * The waiter is removed in a [finally] block on every exit path — success, timeout, and external cancellation — so + * a stale [CompletableDeferred] never leaks into [drainWaiters]. The cleanup runs under [NonCancellable] so + * cancellation during cleanup doesn't skip the removal. */ - private suspend fun drainWriters(db: MeshtasticDatabase, dbName: String) { + @Suppress("ReturnCount") + private suspend fun drainWriters(db: MeshtasticDatabase, dbName: String): Boolean { val waiter = writerTrackerMutex.withLock { - if ((activeWriters[db] ?: 0) == 0) return + if ((activeWriters[db] ?: 0) == 0) return true CompletableDeferred().also { drainWaiters.getOrPut(db) { mutableListOf() }.add(it) } } - if (withTimeoutOrNull(WRITER_DRAIN_TIMEOUT_MS) { waiter.await() } == null) { - Logger.w { "Timed out draining writers on ${anonymizeDbName(dbName)} before merge" } + try { + val drained = withTimeoutOrNull(WRITER_DRAIN_TIMEOUT_MS) { waiter.await() } + if (drained == null) { + Logger.w { "Timed out draining writers on ${anonymizeDbName(dbName)} before merge" } + return false + } + return true + } finally { + // Remove our waiter on every exit path. On success, endWrite may have already removed the + // entire list — the removal is idempotent. On timeout or cancellation, the waiter is still + // registered and must be cleaned up so a late endWrite doesn't complete a dead deferred. + withContext(NonCancellable) { + writerTrackerMutex.withLock { + val list = drainWaiters[db] + if (list != null) { + list.remove(waiter) + if (list.isEmpty()) drainWaiters.remove(db) + } + } + } } } @Suppress("ReturnCount", "ThrowsCount", "TooGenericExceptionCaught", "CyclomaticComplexMethod") private suspend fun withCurrentDb(block: suspend (MeshtasticDatabase) -> T): T? { - val db = beginWrite() - val active = currentDbName + val admission = beginWrite() + val db = admission.database + val active = admission.name markLastUsed(active) try { return runCancellableDbBlock(db, block) } catch (e: CancellationException) { throw e // Preserve structured concurrency cancellation propagation. } catch (e: Exception) { - // If the active database switched while we held a reference to the old one, - // and the exception indicates a closed pool/connection, retry with the new DB. - val retryDb = _currentDb.value - if (retryDb != null && retryDb !== db && isDbClosedException(e)) { - Logger.w { "withDb: database closed during switch (${e.message}), retrying with current DB" } - return retryRegisteredDbBlock(retryDb, e, block) - } - - // Same active DB but Room's connection pool is wedged — reopen onto a fresh active instance once. - if (retryDb === db && isDbPoolAcquireTimeoutException(e)) { - val reopened = reopenActiveDatabaseIfStillCurrent(db, active) - val recoveredDb = reopened ?: _currentDb.value?.takeIf { it !== db } ?: throw e + // Shutdown in progress: do not touch _currentDb (its getter calls checkOpen()) nor attempt a reopen that + // would build a pool. Propagate the original failure with its message intact. + if (lifecycleState != LifecycleState.OPEN) throw e + val currentDb = _currentDb.value + if (currentDb !== db && isDbClosedException(e)) { + Logger.w { + "withDb: database closed during switch (${e.message}); callback will not be replayed automatically" + } + throw e + } + + // Same active DB but Room's connection pool is wedged. Reopen for future calls, but do not replay this + // callback: it may already have completed an earlier side effect before the timeout surfaced. + if (currentDb === db && isDbPoolAcquireTimeoutException(e)) { + val reopened = + try { + reopenActiveDatabaseIfStillCurrent(db, active) + } catch (recoveryCancel: CancellationException) { + throw recoveryCancel + } catch (recoveryFailure: Exception) { + e.addSuppressed(recoveryFailure) + Logger.w(recoveryFailure) { + "withDb: failed to reopen active DB after a connection-pool timeout" + } + null + } Logger.w { if (reopened != null) { - "withDb: reopened active DB after transient Room connection-pool timeout" + "withDb: reopened active DB after transient Room connection-pool timeout; " + + "failed callback was not replayed" } else { - "withDb: active DB switched during timeout recovery; retrying with current DB" + "withDb: active DB was not reopened during timeout recovery; failed callback was " + + "not replayed" } } - return retryRegisteredDbBlock(recoveredDb, e, block) + throw e } throw e @@ -477,32 +1085,6 @@ open class DatabaseManager( } } - /** - * Retries [block] against [db] — the recovered/new instance a withDb retry targets instead of the DB it originally - * registered against. Registers a writer on [db] for the duration so the retry write stays visible to a concurrent - * merge draining [db], with the same NonCancellable deregistration guarantee as [withCurrentDb]'s outer - * registration (which remains held on the original DB until that finally runs — the overlap is harmless, counts - * balance per instance). Any retry failure carries the original failure [cause] as a suppressed exception. - */ - @Suppress("TooGenericExceptionCaught") - private suspend fun retryRegisteredDbBlock( - db: MeshtasticDatabase, - cause: Exception, - block: suspend (MeshtasticDatabase) -> T, - ): T { - registerWriter(db) - try { - return runCancellableDbBlock(db, block) - } catch (retryCancel: CancellationException) { - throw retryCancel - } catch (retryEx: Exception) { - retryEx.addSuppressed(cause) - throw retryEx - } finally { - withContext(NonCancellable) { endWrite(db) } - } - } - private suspend fun runCancellableDbBlock(db: MeshtasticDatabase, block: suspend (MeshtasticDatabase) -> T): T { // Keep withDb callbacks bounded and one-shot: NonCancellable can hold the containment lane until this returns. currentCoroutineContext().ensureActive() @@ -527,6 +1109,7 @@ open class DatabaseManager( * Upper bound on how long a merge waits for in-flight writers on the source DB to drain (see [drainWriters]). */ private const val WRITER_DRAIN_TIMEOUT_MS = 5_000L + private const val WRITER_GATE_TIMEOUT_MS = 30_000L val DB_TERMS = listOf("pool", "database", "connection", "sqlite") private const val ROOM_POOL_ACQUIRE_TIMEOUT_PHRASE = "timed out attempting to acquire" @@ -572,7 +1155,7 @@ open class DatabaseManager( } private fun markLastUsed(dbName: String) { - managerScope.launch { datastore.edit { it[lastUsedKey(dbName)] = nowMillis } } + launchManagerWork { datastore.edit { it[lastUsedKey(dbName)] = nowMillis } } } private suspend fun lastUsed(dbName: String): Long { @@ -601,59 +1184,100 @@ open class DatabaseManager( .toList() } - private suspend fun enforceCacheLimit(activeDbName: String) = mutex.withLock { - val limit = getCurrentCacheLimit() - val all = listExistingDbNames() - // Only enforce the limit over device-specific DBs; exclude legacy and default DBs - val deviceDbs = - all.filterNot { it == DatabaseConstants.LEGACY_DB_NAME || it == DatabaseConstants.DEFAULT_DB_NAME } + private suspend fun enforceCacheLimit() = withManagerOperation { + mutex.withLock { + // Deferred enforcement can wait behind a later switch. Resolve the protected name under the same mutex + // that publishes currentDbName so the active database at execution time can never become an LRU victim. + val activeDbName = currentDbName + val limit = getCurrentCacheLimit() + val all = listExistingDbNames() + val pendingRouteNames = pendingRouteDbNames(datastore.data.first()) + val detachedDbNames = detachedDatabases.mapTo(mutableSetOf()) { it.dbName } + // Only enforce the limit over device-specific DBs. A detached pool is still live for a consumer of an + // earlier currentDb emission, so its files must remain protected until orderly shutdown. + val deviceDbs = + all.filterNot { + it in logicallyRetired || + it in detachedDbNames || + it == DatabaseConstants.LEGACY_DB_NAME || + it == DatabaseConstants.DEFAULT_DB_NAME + } - if (deviceDbs.size <= limit) return@withLock - val usageSnapshot = deviceDbs.associateWith { lastUsed(it) } - val victims = selectEvictionVictims(deviceDbs, activeDbName, limit, usageSnapshot) + if (deviceDbs.size <= limit) return@withLock + val usageSnapshot = deviceDbs.associateWith { lastUsed(it) } + // A pending destination can be the only merged copy and its merge marker is the proof needed to repair + // the route after a crash. Keep both route endpoints until address-scoped recovery finalizes or clears it. + val victims = + selectEvictionVictims( + dbNames = deviceDbs, + activeDbName = activeDbName, + limit = limit, + lastUsedMsByDb = usageSnapshot, + protectedDbNames = pendingRouteNames, + ) + val evictableVictims = + writerTrackerMutex.withLock { + victims.filter { name -> + val cached = dbCache[name] + if (cached != null && (activeWriters[cached] ?: 0) > 0) { + deferredEvictions.add(cached) + false + } else { + true + } + } + } - victims.forEach { name -> - runCatching { - // runCatching intentional: best-effort cleanup must not abort on cancellation - closeCachedDatabase(name) - deleteDatabase(name) - datastore.edit { it.remove(lastUsedKey(name)) } + evictableVictims.forEach { name -> + try { + closeCachedDatabase(name) + deleteDatabaseFiles(name) + datastore.edit { it.remove(lastUsedKey(name)) } + Logger.i { "Evicted cached DB ${anonymizeDbName(name)}" } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (@Suppress("TooGenericExceptionCaught") failure: Exception) { + Logger.w(failure) { "Failed to evict database ${anonymizeDbName(name)}" } + } } - .onSuccess { Logger.i { "Evicted cached DB ${anonymizeDbName(name)}" } } - .onFailure { Logger.w(it) { "Failed to evict database ${anonymizeDbName(name)}" } } } } - private suspend fun cleanupLegacyDbIfNeeded(activeDbName: String) = mutex.withLock { - val cleaned = datastore.data.first()[legacyCleanedKey] ?: false - if (cleaned) return@withLock + private suspend fun cleanupLegacyDbIfNeeded(activeDbName: String) = withManagerOperation { + mutex.withLock { + val cleaned = datastore.data.first()[legacyCleanedKey] ?: false + if (cleaned) return@withLock - val legacy = DatabaseConstants.LEGACY_DB_NAME - if (legacy == activeDbName) { - datastore.edit { it[legacyCleanedKey] = true } - return@withLock - } + val legacy = DatabaseConstants.LEGACY_DB_NAME + if (legacy == activeDbName) { + datastore.edit { it[legacyCleanedKey] = true } + return@withLock + } - if (dbFileExists(legacy)) { - runCatching { - // runCatching intentional: best-effort cleanup must not abort on cancellation - closeCachedDatabase(legacy) - deleteDatabase(legacy) + if (dbFileExists(legacy)) { + try { + closeCachedDatabase(legacy) + deleteDatabaseFiles(legacy) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (@Suppress("TooGenericExceptionCaught") failure: Exception) { + Logger.w(failure) { "Failed to delete legacy database ${anonymizeDbName(legacy)}" } + return@withLock + } + Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" } } - .onSuccess { Logger.i { "Deleted legacy DB ${anonymizeDbName(legacy)}" } } - .onFailure { Logger.w(it) { "Failed to delete legacy database ${anonymizeDbName(legacy)}" } } + datastore.edit { it[legacyCleanedKey] = true } } - datastore.edit { it[legacyCleanedKey] = true } } @Suppress("TooGenericExceptionCaught") private fun scheduleSearchIndexBackfill(dbName: String, db: MeshtasticDatabase, shouldDelayBackfill: Boolean) { backfillJob?.cancel() backfillJob = - managerScope.launch(dispatchers.io) { + launchManagerWork(dispatchers.io) { try { if (shouldDelayBackfill) delay(BACKFILL_COLD_START_DELAY_MS) - if (_currentDb.value !== db) return@launch + if (_currentDb.value !== db) return@launchManagerWork backfillSearchIndexIfNeeded(db) } catch (e: CancellationException) { throw e @@ -669,29 +1293,224 @@ open class DatabaseManager( * [PacketDao.backfillMessageTexts]); it cannot be read in SQL because the message body is stored as serialized * `bytes`, not a `text` JSON field. */ - private suspend fun backfillSearchIndexIfNeeded(db: MeshtasticDatabase) { - val needsBackfill = db.packetDao().countPacketsNeedingBackfill() > 0 - if (!needsBackfill) return + internal suspend fun backfillSearchIndexIfNeeded(scheduledDb: MeshtasticDatabase) { + val admission = beginWrite() + val admittedDb = admission.database + try { + if (admittedDb !== scheduledDb) return + runCancellableDbBlock(admittedDb) { performSearchIndexBackfill(it) } + } finally { + withContext(NonCancellable) { endWrite(admittedDb) } + } + } - // Perform the write operations inside NonCancellable to prevent - // connection pool leaks due to coroutine cancellation. - withContext(NonCancellable) { - val count = db.packetDao().backfillMessageTexts() - if (count > 0) { - Logger.i { "Backfilled $count messages for FTS search index" } - db.packetDao().rebuildFtsIndex() - Logger.i { "FTS search index rebuild complete" } - } + /** Performs count, message-text backfill, and FTS rebuild while caller holds one writer admission. */ + protected open suspend fun performSearchIndexBackfill(db: MeshtasticDatabase) { + if (db.packetDao().countPacketsNeedingBackfill() == 0) return + val count = db.packetDao().backfillMessageTexts() + if (count > 0) { + Logger.i { "Backfilled $count messages for FTS search index" } + db.packetDao().rebuildFtsIndex() + Logger.i { "FTS search index rebuild complete" } } } - /** Closes all open databases and cancels background work. */ - fun close() { - backfillJob?.cancel() - backfillJob = null - managerScope.cancel() - dbCache.values.forEach { it.close() } - dbCache.clear() - _currentDb.value = null + /** Platform file removal seam used by orderly retirement and test fixtures. */ + protected open fun deleteDatabaseFiles(dbName: String) = deleteDatabase(dbName) + + /** + * Establishes an orderly shutdown boundary: rejects new work, bounds manager-job cancellation, admitted + * serialized-operation draining, and admitted-writer draining, waits for the last serialized switch/association to + * finalize, then closes every manager-owned Room instance. If a cancelled child, admitted operation, writer, or + * pool close cannot finish successfully, ownership is retained and physical cleanup is skipped so a later [close] + * call can retry without losing track of live resources. Retried attempts may call Room's idempotent `close()` + * again for pools that completed during an earlier partial attempt. + */ + @Suppress("CyclomaticComplexMethod", "LongMethod", "TooGenericExceptionCaught") + suspend fun close() = withContext(NonCancellable) { + closeMutex.withLock closeAttempt@{ + var operationsDrain: CompletableDeferred? = null + var writerDrain: CompletableDeferred? = null + var jobsDrain: CompletableDeferred? = null + var closedSuccessfully = false + + val shouldClose = + writerTrackerMutex.withLock { + when (lifecycleState) { + LifecycleState.CLOSED -> false + + LifecycleState.OPEN, + LifecycleState.CLOSING, + -> { + lifecycleState = LifecycleState.CLOSING + operationsDrain = + if (activeManagerOperations.isNotEmpty()) { + CompletableDeferred().also { managerOperationDrain = it } + } else { + managerOperationDrain = null + null + } + writerDrain = + if (activeWriters.isNotEmpty()) { + CompletableDeferred().also { shutdownWriterDrain = it } + } else { + shutdownWriterDrain = null + null + } + true + } + } + } + if (!shouldClose) return@closeAttempt + + val managerJobs = + synchronized(managerJobLock) { + val jobs = activeManagerJobs.toList() + jobsDrain = + if (jobs.isNotEmpty()) { + CompletableDeferred().also { managerJobDrain = it } + } else { + managerJobDrain = null + null + } + jobs + } + + try { + // Manager-owned cleanup jobs hold manager-operation tokens. Publish cancellation before waiting for + // those operations so cancellable I/O can unwind instead of forcing every close attempt to time + // out. + managerJobs.forEach { it.cancel() } + + // Bound-wait for already-admitted serialized operations before acquiring [mutex]. New work is + // rejected + // while CLOSING, and a timed-out attempt leaves ownership intact so a later close() can retry. + val operationsDrained = + operationsDrain?.let { drain -> + val completedBeforeTimeout = + withContext(Dispatchers.Default) { + withTimeoutOrNull(WRITER_DRAIN_TIMEOUT_MS) { + drain.await() + true + } ?: false + } + completedBeforeTimeout || writerTrackerMutex.withLock { activeManagerOperations.isEmpty() } + } ?: true + if (!operationsDrained) { + Logger.w { + "Database shutdown timed out waiting for in-flight manager operations; retaining owned pools " + + "for a later close retry" + } + return@closeAttempt + } + + val managerJobsStopped = + jobsDrain?.let { withTimeoutOrNull(WRITER_DRAIN_TIMEOUT_MS) { it.await() } != null } ?: true + if (!managerJobsStopped) { + Logger.w { + "Timed out stopping database manager jobs; retaining owned pools for a later close retry" + } + return@closeAttempt + } + + val writersDrained = + writerDrain?.let { withTimeoutOrNull(WRITER_DRAIN_TIMEOUT_MS) { it.await() } != null } ?: true + if (!writersDrained) { + Logger.w { + "Database shutdown could not prove every writer stopped; retaining owned pools for a later " + + "close retry" + } + return@closeAttempt + } + + // Snapshot ownership without transferring it yet. A failed pool close must leave every instance and + // retirement intent reachable by a later close() attempt. + val snapshot = + mutex.withLock { + val databases = mutableListOf() + fun addDistinct(dbName: String, database: MeshtasticDatabase?) { + if (database == null) return + val existing = databases.firstOrNull { it.database === database } + if (existing == null) { + databases.add(ShutdownDatabase(database, mutableSetOf(dbName))) + } else { + existing.dbNames.add(dbName) + } + } + + dbCache.forEach { (dbName, database) -> addDistinct(dbName, database) } + detachedDatabases.forEach { addDistinct(it.dbName, it.database) } + synchronized(initializationLock) { + addDistinct(DatabaseConstants.DEFAULT_DB_NAME, initializedDefaultDb) + addDistinct(currentDbName, currentDbState?.value) + } + + val persistedRetiredNames = + try { + datastore.data.first()[retiredDbNamesKey].orEmpty() + } catch (failure: Throwable) { + Logger.w(failure) { + "Failed to read persisted database retirements during shutdown" + } + emptySet() + } + ShutdownSnapshot(databases, (logicallyRetired + persistedRetiredNames).toList()) + } + + // All tracked work has stopped. The remaining scope-owned collector does not touch Room; cancel it + // before closing pools, but keep ownership maps intact until every close succeeds. + managerScope.coroutineContext[Job]?.cancel() + backfillJob = null + + val failedCloseNames = mutableSetOf() + snapshot.databases.forEach { owned -> + runCatching { closeDatabase(owned.database) } + .onFailure { + failedCloseNames += owned.dbNames + Logger.w(it) { "Failed to close database during shutdown" } + } + } + if (failedCloseNames.isNotEmpty()) { + Logger.w { + "Database shutdown retained ${failedCloseNames.size} pool name(s) after close failure; " + + "a later close() will retry" + } + return@closeAttempt + } + + snapshot.retiredNames.forEach { physicallyRetireDatabase(it) } + + // Only now transfer ownership and publish the terminal state. No admitted work can add another pool + // because the manager has remained CLOSING throughout this attempt. + mutex.withLock { + dbCache.clear() + detachedDatabases.clear() + logicallyRetired.clear() + synchronized(initializationLock) { + initializedDefaultDb = null + currentDbState = null + } + } + writerTrackerMutex.withLock { + lifecycleState = LifecycleState.CLOSED + deferredEvictions.clear() + drainWaiters.clear() + activeWriters.clear() + activeManagerOperations.clear() + writerGate?.completeExceptionally(IllegalStateException("DatabaseManager is closing or closed")) + writerGate = null + } + closedSuccessfully = true + } finally { + synchronized(managerJobLock) { + if (managerJobDrain === jobsDrain) managerJobDrain = null + if (closedSuccessfully) activeManagerJobs.clear() + } + writerTrackerMutex.withLock { + if (managerOperationDrain === operationsDrain) managerOperationDrain = null + if (shutdownWriterDrain === writerDrain) shutdownWriterDrain = null + } + } + } } } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt index 313f9eaaf7..18919d4a2b 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt @@ -36,13 +36,24 @@ import org.meshtastic.core.database.entity.MergeMarkerEntity * remapping is needed there; autoincrement-keyed rows (packets, discovery) are re-inserted with fresh ids to avoid * collisions. */ +internal class StaleAssociationException : Exception("Transport session no longer authorizes database association") + object DatabaseMerger { /** * Folds [source] into [dest], skipping the work if [sourceName] was already merged into [dest]. [sourceName] is the * source database's file name — the stable key under which the merge is recorded (see [MergeMarkerEntity]). + * + * The caller owns the transport-session lifecycle lease through this method's return. That lease makes transaction + * commit and session rollover mutually exclusive; [isAssociationActive] remains a defensive check for authority + * lost before the merge entered its commit phase. */ - suspend fun merge(source: MeshtasticDatabase, dest: MeshtasticDatabase, sourceName: String) { + suspend fun merge( + source: MeshtasticDatabase, + dest: MeshtasticDatabase, + sourceName: String, + isAssociationActive: () -> Boolean = { true }, + ) { // All destination writes run in a single transaction so a crash or exception mid-merge rolls // back cleanly instead of leaving `dest` half-merged. The merge marker is written inside that // same transaction, so it commits atomically with the copied rows: on a retried merge (e.g. a @@ -54,7 +65,9 @@ object DatabaseMerger { var skipped = false dest.useWriterConnection { transactor -> transactor.immediateTransaction { + ensureAssociationActive(isAssociationActive) if (dest.mergeMarkerDao().isMerged(sourceName)) { + ensureAssociationActive(isAssociationActive) skipped = true return@immediateTransaction } @@ -69,6 +82,11 @@ object DatabaseMerger { mergeTraceroutePositions(source, dest) mergeDiscovery(source, dest) dest.mergeMarkerDao().insertMarker(MergeMarkerEntity(sourceDbName = sourceName, mergedAt = nowMillis)) + // Keep the defensive authority check as the final transaction statement. The caller-held lifecycle + // lease prevents rollover until commit returns; this check still rolls back if authority was lost + // before + // the transaction entered the lease-protected commit phase. + ensureAssociationActive(isAssociationActive) } } if (skipped) { @@ -78,6 +96,10 @@ object DatabaseMerger { } } + private fun ensureAssociationActive(isAssociationActive: () -> Boolean) { + if (!isAssociationActive()) throw StaleAssociationException() + } + /** * Message history is the primary payload. `uuid` is per-DB autoincrement, so re-insert with uuid = 0 to mint a * fresh id and avoid collisions; rebuild the FTS index once at the end so copied messages are searchable. diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseProvider.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseProvider.kt index fc9daabee0..ee4e29590a 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseProvider.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseProvider.kt @@ -25,10 +25,11 @@ import kotlinx.coroutines.flow.StateFlow * **Write policy:** every one-shot DAO *write* (insert/upsert/update/delete/clear) must go through [withDb], never * `currentDb.value` directly. [withDb] registers the write with the cross-transport merge drain barrier (see * [DatabaseManager.associateDevice]) so a merge can't snapshot a database while the write is still in flight and lose - * it when that database is retired — and it retries once if the pool closes under a concurrent DB switch. Direct - * `currentDb.value` is fine for one-shot *reads* (a torn read against a just-retired DB is recoverable and reads don't - * need drain visibility), and `currentDb` itself is the right latch for Flow/Paging factories, which must re-latch on - * DB switch and must stay out of [withDb]'s containment lane. + * it when that database is retired. The callback is never replayed automatically after it starts: callers that need + * retries must make that policy explicit at a higher layer where idempotency is known. Direct `currentDb.value` is fine + * for one-shot *reads* (a torn read against a just-retired DB is recoverable and reads don't need drain visibility), + * and `currentDb` itself is the right latch for Flow/Paging factories, which must re-latch on DB switch and must stay + * out of [withDb]'s containment lane. */ interface DatabaseProvider { /** Reactive stream of the currently active database instance. */ diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt index 066132219c..878bb25e61 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt @@ -88,6 +88,32 @@ interface MeshLogDao { @Query(LOGS_FROM_QUERY) suspend fun getLogsSnapshot(fromNum: Int, portNum: Int, maxItem: Int): List + /** + * Returns one deterministic keyset page for bounded snapshot processing. [beforeReceivedDate] and [beforeUuid] + * identify the last row of the previous page; null values start from the newest row. + */ + @Query( + """ + SELECT * FROM log + WHERE from_num = :fromNum + AND (:portNum = -1 OR port_num = :portNum) + AND ( + :beforeReceivedDate IS NULL + OR received_date < :beforeReceivedDate + OR (received_date = :beforeReceivedDate AND uuid < :beforeUuid) + ) + ORDER BY received_date DESC, uuid DESC + LIMIT :pageSize + """, + ) + suspend fun getLogsSnapshotPage( + fromNum: Int, + portNum: Int, + beforeReceivedDate: Long?, + beforeUuid: String?, + pageSize: Int, + ): List + /** * Atomically deletes all logs matching [uuids], chunking internally to stay under SQLite's bind-parameter limit. * The entire batch is all-or-nothing: a failure rolls back every chunk. diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index 3f5d86d3cc..b53c21a808 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt @@ -619,8 +619,8 @@ interface PacketDao { @Transaction suspend fun updatePacketByKey(data: DataPacket, routingError: Int) { findPacketsWithId(data.id) - .find { it.data.id == data.id && it.data.from == data.from && it.data.to == data.to } - ?.let { existing -> + .filter { it.data.id == data.id && it.data.from == data.from && it.data.to == data.to } + .forEach { existing -> val updated = if (routingError >= 0) { existing.copy(data = data, routingError = routingError) @@ -632,19 +632,26 @@ interface PacketDao { } /** - * Atomically finds a reaction by [replacement]'s packetId + userId + emoji and updates it, borrowing - * [myNodeNum][ReactionEntity.myNodeNum] from the existing row. No-op if no match is found. + * Atomically finds reactions by [replacement]'s packetId + userId + emoji and updates every ownership-scoped copy, + * borrowing [myNodeNum][ReactionEntity.myNodeNum] from each existing row. No-op if no match is found. */ @Transaction suspend fun updateReactionByKey(replacement: ReactionEntity) { - val existing = - findReactionsWithId(replacement.packetId).find { - it.userId == replacement.userId && it.emoji == replacement.emoji - } ?: return - // myNodeNum is part of the composite PK and must come from the existing row. - update(replacement.copy(myNodeNum = existing.myNodeNum)) + findReactionsWithId(replacement.packetId) + .filter { it.userId == replacement.userId && it.emoji == replacement.emoji } + .forEach { existing -> + // myNodeNum is part of the composite PK and must come from each existing row. + update(replacement.copy(myNodeNum = existing.myNodeNum)) + } } + // ── SFPP helpers: shared no-downgrade guard + timestamp resolution used by both applySFPPStatus and + // applySFPPStatusByHash. Extracted so a future status-guard change only touches one predicate. + private fun MessageStatus.isDowngradeFrom(current: MessageStatus?) = + current == MessageStatus.SFPP_CONFIRMED && this == MessageStatus.SFPP_ROUTING + + private fun resolveNewTime(rxTime: Long, fallback: Long) = if (rxTime > 0) rxTime * MILLIS_PER_SECOND else fallback + /** * Atomically applies an SFPP delivery-status transition to every packet and reaction matching [packetId] + address * ([from]/[to]). Preserves the no-downgrade invariant: an item already @@ -677,10 +684,8 @@ interface PacketDao { val fromMatches = packet.data.from == fromId || (isFromLocalNode && packet.data.from == NodeAddress.ID_LOCAL) if (fromMatches && packet.data.to == toId) { - if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { - return@forEach - } - val newTime = if (rxTime > 0) rxTime * MILLIS_PER_SECOND else packet.received_time + if (status.isDowngradeFrom(packet.data.status)) return@forEach + val newTime = resolveNewTime(rxTime, packet.received_time) val updatedData = packet.data.copy(status = status, sfppHash = hash, time = newTime) update(packet.copy(data = updatedData, sfpp_hash = hash, received_time = newTime)) } @@ -689,10 +694,8 @@ interface PacketDao { reactions.forEach { reaction -> val fromMatches = reaction.userId == fromId || (isFromLocalNode && reaction.userId == NodeAddress.ID_LOCAL) if (fromMatches && (reaction.to == null || reaction.to == toId)) { - if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { - return@forEach - } - val newTime = if (rxTime > 0) rxTime * MILLIS_PER_SECOND else reaction.timestamp + if (status.isDowngradeFrom(reaction.status)) return@forEach + val newTime = resolveNewTime(rxTime, reaction.timestamp) update(reaction.copy(status = status, sfpp_hash = hash, timestamp = newTime)) } } @@ -705,18 +708,14 @@ interface PacketDao { @Transaction suspend fun applySFPPStatusByHash(hash: ByteString, status: MessageStatus, rxTime: Long) { findPacketBySfppHash(hash)?.let { packet -> - if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { - return@let - } - val newTime = if (rxTime > 0) rxTime * MILLIS_PER_SECOND else packet.received_time + if (status.isDowngradeFrom(packet.data.status)) return@let + val newTime = resolveNewTime(rxTime, packet.received_time) val updatedData = packet.data.copy(status = status, sfppHash = hash, time = newTime) update(packet.copy(data = updatedData, sfpp_hash = hash, received_time = newTime)) } findReactionBySfppHash(hash)?.let { reaction -> - if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { - return@let - } - val newTime = if (rxTime > 0) rxTime * MILLIS_PER_SECOND else reaction.timestamp + if (status.isDowngradeFrom(reaction.status)) return@let + val newTime = resolveNewTime(rxTime, reaction.timestamp) update(reaction.copy(status = status, sfpp_hash = hash, timestamp = newTime)) } } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/SwitchingDiscoveryDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/SwitchingDiscoveryDao.kt index 5b3b758bfd..3dbf21c054 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/SwitchingDiscoveryDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/SwitchingDiscoveryDao.kt @@ -31,8 +31,8 @@ import org.meshtastic.core.database.entity.DiscoverySessionEntity * - Flow methods re-latch via `currentDb.flatMapLatest`, so an open discovery screen follows a device/DB switch instead * of watching the old database forever. * - Suspend methods go through [DatabaseProvider.withDb], so writes register with the cross-transport merge drain - * barrier (a mid-scan merge can't snapshot-then-retire the DB underneath an in-flight session write) and every call - * picks up withDb's closed-pool retry — a pinned DAO used to throw unrecoverably once its DB was retired. + * barrier (a mid-scan merge can't snapshot-then-retire the DB underneath an in-flight session write). A callback is + * never replayed after it starts, so higher layers must make any retry policy explicit where idempotency is known. * * `withDb` only returns null when no database is open, which [DatabaseProvider] guarantees can't happen (the default DB * is the floor), so the non-null coercions below are structural, not behavioral. diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerAssociationRecoveryTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerAssociationRecoveryTest.kt new file mode 100644 index 0000000000..7fec0c33b8 --- /dev/null +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerAssociationRecoveryTest.kt @@ -0,0 +1,515 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.database.entity.MyNodeEntity +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DatabaseManagerAssociationRecoveryTest : DatabaseManagerTestFixture() { + + @BeforeTest fun setUp() = setUpFixture() + + @AfterTest fun tearDown() = tearDownFixture() + + @Test + fun staleAddressIdentityCannotClaimTheActiveDatabase() = runTest(testDispatcher) { + manager.switchActiveDatabase("addrA") + val active = manager.currentDb.value + + // meshPrefs can publish addrB before switchActiveDatabase finishes while the previous node identity is + // still + // visible. The identity emission is bound to addrB and must not claim addrA's currently active database. + manager.associateDevice( + address = "addrB", + nodeNum = 123, + deviceId = "deadbeefdeadbeef", + isSessionActive = { true }, + ) + + assertTrue(manager.currentDb.value === active) + val prefs = armableDs.data.first() + assertNull(prefs[stringPreferencesKey("device_db_for:${deviceKeyHex("deadbeefdeadbeef")}")]) + assertNull(prefs[stringPreferencesKey("node_db_for:123")]) + assertNull(prefs[stringPreferencesKey("addr_db_for:ADDRB")]) + } + + // Association and recovery tests + + /** + * 1 + 2 combined: A writer holding the source DB's withDb lane causes the drain to time out, aborting the merge and + * restoring the source. After releasing the writer, a retry succeeds — the merge commits, dest becomes canonical, + * and the active DB switches. + */ + @Test + fun drainTimeoutRestoresSourceAndRetrySucceeds() = runTest(testDispatcher) { + val (dbA, dbB) = setupTwoDatabases() + assertEquals(dbB, manager.currentDb.value, "addrB's DB should be active before association") + + // Hold a withDb writer on addrB's DB so drainWriters can't complete. + val gate = CompletableDeferred() + val writerJob = launch { + manager.withDb { + gate.await() + null + } + } + + // associateDevice should reach the merge branch, publish dest, then time out on the drain. + val associateJob = launch { manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") } + + // Advance virtual time past WRITER_DRAIN_TIMEOUT_MS (5_000ms) to trigger the drain timeout. + advanceTimeBy(5_501) + assertTrue(associateJob.isCompleted, "associateDevice should complete after drain timeout") + assertEquals(dbB, manager.currentDb.value, "source (addrB) should be restored after drain timeout") + + // Release the writer gate so the source DB quiesces. + gate.complete(Unit) + writerJob.join() + + // Retry: with no writer in-flight, the drain succeeds and the merge commits. + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + + assertEquals( + dbA, + manager.currentDb.value, + "dest (addrA's canonical DB) should be active after successful merge", + ) + } + + /** + * 3: Cancelling [associateDevice] during the writer-drain wait propagates [CancellationException], restores the + * source DB, and does not leak the drain waiter — a subsequent retry succeeds. + */ + @Test + fun cancellationDuringDrainRestoresSource() = runTest(testDispatcher) { + val (dbA, dbB) = setupTwoDatabases() + + val gate = CompletableDeferred() + val writerJob = launch { + manager.withDb { + gate.await() + null + } + } + + val associateJob = launch { manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") } + + // Cancel while suspended in drainWriters (before the 5s timeout fires). + associateJob.cancelAndJoin() + + assertTrue(associateJob.isCancelled, "associateDevice coroutine should be cancelled") + assertEquals( + dbB, + manager.currentDb.value, + "source should be restored after cancellation (NonCancellable publishActiveDb)", + ) + + // Release the writer and retry — if the drain waiter leaked, the retry would also stall. + gate.complete(Unit) + writerJob.join() + + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + + assertEquals( + dbA, + manager.currentDb.value, + "retry should succeed after cancellation cleanup — dest is canonical", + ) + } + + /** + * 4: A successful association persists device, node, and address routing metadata in one atomic DataStore edit, all + * pointing to the same canonical DB. + */ + @Test + fun atomicRoutingMetadataAllPointToCanonical() = runTest(testDispatcher) { + manager.switchActiveDatabase("addrA") + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + manager.switchActiveDatabase("addrB") + + // No writer is held, so the drain succeeds immediately and the merge commits. + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + + val prefs = armableDs.data.first() + val deviceClaim = prefs[stringPreferencesKey("device_db_for:${deviceKeyHex("deadbeefdeadbeef")}")] + val nodeClaim = prefs[stringPreferencesKey("node_db_for:123")] + val addrClaim = prefs[stringPreferencesKey("addr_db_for:ADDRB")] + + assertNotNull(deviceClaim, "device-id claim should be persisted") + assertNotNull(nodeClaim, "node-num claim should be persisted") + assertNotNull(addrClaim, "address alias should be persisted") + assertEquals(deviceClaim, nodeClaim, "device and node claims point to the same DB") + assertEquals(deviceClaim, addrClaim, "address alias points to the same canonical DB") + assertNull(prefs[stringPreferencesKey("pending_source_db_for:ADDRB")]) + assertNull(prefs[stringPreferencesKey("pending_destination_db_for:ADDRB")]) + } + + @Test + fun pendingRouteIsPersistedBeforeMergeAndRemovedWithFinalRouting() = runTest(testDispatcher) { + setupTwoDatabases() + var pendingObserved = false + manager.beforeMerge = { _, _, sourceName -> + val prefs = armableDs.data.first() + assertEquals(sourceName, prefs[stringPreferencesKey("pending_source_db_for:ADDRB")]) + assertEquals(buildDbName("addrA"), prefs[stringPreferencesKey("pending_destination_db_for:ADDRB")]) + pendingObserved = true + } + + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + + assertTrue(pendingObserved, "pending route must be durable before merge starts") + val prefs = armableDs.data.first() + assertNull(prefs[stringPreferencesKey("pending_source_db_for:ADDRB")]) + assertNull(prefs[stringPreferencesKey("pending_destination_db_for:ADDRB")]) + } + + @Test + fun preCommitMergeFailureClearsPendingRouteAndKeepsSource() = runTest(testDispatcher) { + val (_, source) = setupTwoDatabases() + manager.failMergeWith = RuntimeException("pre-commit failure") + + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + + assertEquals(source, manager.currentDb.value) + val prefs = armableDs.data.first() + assertNull(prefs[stringPreferencesKey("pending_source_db_for:ADDRB")]) + assertNull(prefs[stringPreferencesKey("pending_destination_db_for:ADDRB")]) + assertFalse(manager.debugWriterGateArmed()) + } + + @Test + fun stalePendingRouteWithoutMarkerIsDiscarded() = runTest(testDispatcher) { + manager.switchActiveDatabase("addrB") + val fallback = manager.currentDb.value + manager.switchActiveDatabase(null) + armableDs.edit { + it[stringPreferencesKey("pending_source_db_for:ADDRB")] = buildDbName("addrB") + it[stringPreferencesKey("pending_destination_db_for:ADDRB")] = buildDbName("addrA") + } + + manager.switchActiveDatabase("addrB") + + assertEquals(fallback, manager.currentDb.value, "marker-free pending route must use the normal fallback") + val prefs = armableDs.data.first() + assertNull(prefs[stringPreferencesKey("addr_db_for:ADDRB")]) + assertNull(prefs[stringPreferencesKey("pending_source_db_for:ADDRB")]) + assertNull(prefs[stringPreferencesKey("pending_destination_db_for:ADDRB")]) + } + + @Test + fun committedPendingRouteForCurrentAddressSwitchesToRepairedDestination() = runTest(testDispatcher) { + val (destination, source) = setupTwoDatabases() + val sourceName = buildDbName("addrB") + val destinationName = buildDbName("addrA") + armableDs.edit { + it[stringPreferencesKey("pending_source_db_for:ADDRB")] = sourceName + it[stringPreferencesKey("pending_destination_db_for:ADDRB")] = destinationName + } + manager.markerVerification = { database, pendingSource -> + database === destination && pendingSource == sourceName + } + + manager.switchActiveDatabase("addrB") + + assertTrue(manager.currentDb.value === destination) + assertTrue(manager.currentDb.value !== source) + val prefs = armableDs.data.first() + assertEquals(destinationName, prefs[stringPreferencesKey("addr_db_for:ADDRB")]) + assertNull(prefs[stringPreferencesKey("pending_source_db_for:ADDRB")]) + assertNull(prefs[stringPreferencesKey("pending_destination_db_for:ADDRB")]) + } + + @Test + fun committedPendingRouteRepairsStaleExistingAliasBeforePublishing() = runTest(testDispatcher) { + val (destination, _) = setupTwoDatabases() + manager.afterMergeCommitted = { + armableDs.armFailBeforeCommit(RuntimeException("simulated final-routing failure")) + } + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + armableDs.edit { it[stringPreferencesKey("addr_db_for:ADDRB")] = buildDbName("addrB") } + + manager.switchActiveDatabase(null) + manager.switchActiveDatabase("addrB") + + assertEquals(destination, manager.currentDb.value, "merge marker must override the stale source alias") + val repaired = armableDs.data.first() + assertEquals(buildDbName("addrA"), repaired[stringPreferencesKey("addr_db_for:ADDRB")]) + assertNull(repaired[stringPreferencesKey("pending_source_db_for:ADDRB")]) + assertNull(repaired[stringPreferencesKey("pending_destination_db_for:ADDRB")]) + } + + @Test + fun recoveredSourceAfterDevicePublicationIsProtectedUntilShutdown() = runTest(testDispatcher) { + manager.switchActiveDatabase("already-published") + manager.switchActiveDatabase(null) + val sourceName = buildDbName("addrB") + val destinationName = buildDbName("addrA") + armableDs.edit { + it[stringPreferencesKey("pending_source_db_for:ADDRB")] = sourceName + it[stringPreferencesKey("pending_destination_db_for:ADDRB")] = destinationName + } + manager.markerVerification = { _, _ -> true } + + manager.switchActiveDatabase("addrB") + + assertTrue(manager.deletedDatabaseNames.isEmpty(), "same-process recovery must defer source deletion") + assertTrue(sourceName in armableDs.data.first()[retiredDbNamesKey].orEmpty()) + + manager.close() + assertEquals(listOf(sourceName), manager.deletedDatabaseNames) + } + + @Test + fun cancellationAfterRecoveredRouteCommitStillProtectsPublishedSource() = runTest(testDispatcher) { + manager.switchActiveDatabase("already-published") + manager.switchActiveDatabase(null) + val sourceName = buildDbName("addrB") + val destinationName = buildDbName("addrA") + armableDs.edit { + it[stringPreferencesKey("pending_source_db_for:ADDRB")] = sourceName + it[stringPreferencesKey("pending_destination_db_for:ADDRB")] = destinationName + } + manager.markerVerification = { _, _ -> true } + + val recovery = launch { + armableDs.armCancelAfterCommit(coroutineContext[Job]!!) + manager.switchActiveDatabase("addrB") + } + recovery.join() + + assertTrue(recovery.isCancelled, "recovery caller should observe cancellation after the durable commit") + assertTrue( + manager.debugIsLogicallyRetired(sourceName), + "the published source must remain eviction-protected", + ) + assertTrue( + manager.deletedDatabaseNames.isEmpty(), + "same-process recovery must not physically reclaim source", + ) + val prefs = armableDs.data.first() + assertEquals(destinationName, prefs[stringPreferencesKey("addr_db_for:ADDRB")]) + assertTrue(sourceName in prefs[retiredDbNamesKey].orEmpty()) + } + + @Test + fun markerVerificationFailureDoesNotPublishFallback() = runTest(testDispatcher) { + val original = manager.currentDb.value + armableDs.edit { + it[stringPreferencesKey("pending_source_db_for:ADDRB")] = buildDbName("addrB") + it[stringPreferencesKey("pending_destination_db_for:ADDRB")] = buildDbName("addrA") + } + manager.markerVerification = { _, _ -> throw TestError("marker read failed") } + + var caught: TestError? = null + try { + manager.switchActiveDatabase("addrB") + } catch (error: TestError) { + caught = error + } + + assertNotNull(caught) + assertEquals(original, manager.currentDb.value, "fallback source must never publish without marker proof") + assertNull(manager.currentAddress.value) + } + + /** + * Commit-then-cancel: the routing DataStore edit commits durably, then the wrapper throws CancellationException. + * Destination must remain active, all routing metadata must agree, and cancellation must propagate. + */ + @Test + fun cancellationAfterMergeCommitKeepsDestination() = runTest(testDispatcher) { + val (dbA, dbB) = setupTwoDatabases() + assertEquals(dbB, manager.currentDb.value, "addrB should be active before association") + + manager.afterMergeCommitted = { armableDs.armFailAfterCommit(CancellationException("post-merge cancel")) } + + var caught: CancellationException? = null + try { + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + } catch (e: CancellationException) { + caught = e + } + assertNotNull(caught, "CancellationException should propagate after merge commit") + assertEquals(dbA, manager.currentDb.value, "destination must remain active — source is NOT restored") + + // Routing metadata was durably committed before the wrapper threw. All claims must agree. + val prefs = armableDs.data.first() + val deviceClaim = prefs[stringPreferencesKey("device_db_for:${deviceKeyHex("deadbeefdeadbeef")}")] + val nodeClaim = prefs[stringPreferencesKey("node_db_for:123")] + val addrClaim = prefs[stringPreferencesKey("addr_db_for:ADDRB")] + assertNotNull(deviceClaim, "device claim committed") + assertNotNull(nodeClaim, "node claim committed") + assertNotNull(addrClaim, "address alias committed") + assertEquals(deviceClaim, nodeClaim, "device and node claims agree") + assertEquals(deviceClaim, addrClaim, "address alias agrees with claims") + } + + /** + * DataStore failure after merge: final routing does not commit. Destination stays active, the pending route remains + * as crash-recovery intent, and an ordinary in-process retry can also finalize all routing metadata. + */ + @Test + fun dataStoreFailureAfterMergeKeepsDestinationAndRepairsOnRetry() = runTest(testDispatcher) { + val (dbA, dbB) = setupTwoDatabases() + + manager.afterMergeCommitted = { + armableDs.armFailBeforeCommit(RuntimeException("simulated DataStore failure")) + } + + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + assertEquals(dbA, manager.currentDb.value, "dest must remain active — source is NOT restored") + + // Before retry: routing metadata was NOT persisted (FailBeforeCommit prevented the edit). + val prefsBefore = armableDs.data.first() + assertNull( + prefsBefore[stringPreferencesKey("addr_db_for:ADDRB")], + "address alias must be absent before repair — routing edit failed", + ) + assertNotNull( + prefsBefore[stringPreferencesKey("pending_source_db_for:ADDRB")], + "pending source must survive a post-commit routing failure", + ) + assertNotNull( + prefsBefore[stringPreferencesKey("pending_destination_db_for:ADDRB")], + "pending destination must survive a post-commit routing failure", + ) + + // Retry repairs routing metadata via the already-unified branch. + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + + val prefsAfter = armableDs.data.first() + val deviceClaim = prefsAfter[stringPreferencesKey("device_db_for:${deviceKeyHex("deadbeefdeadbeef")}")] + val nodeClaim = prefsAfter[stringPreferencesKey("node_db_for:123")] + val addrClaim = prefsAfter[stringPreferencesKey("addr_db_for:ADDRB")] + assertNotNull(deviceClaim, "device claim repaired") + assertNotNull(nodeClaim, "node claim repaired") + assertNotNull(addrClaim, "address alias repaired") + assertEquals(deviceClaim, nodeClaim, "device and node claims agree after repair") + assertEquals(deviceClaim, addrClaim, "address alias agrees after repair") + assertNull(prefsAfter[stringPreferencesKey("pending_source_db_for:ADDRB")]) + assertNull(prefsAfter[stringPreferencesKey("pending_destination_db_for:ADDRB")]) + } + + /** + * After a post-merge routing failure, new writes land in destination (not source), proving source is never + * reactivated past the merge-commit boundary. + */ + @Test + fun postMergeWriteLandsInDestination() = runTest(testDispatcher) { + val (dbA, dbB) = setupTwoDatabases() + + manager.afterMergeCommitted = { + armableDs.armFailBeforeCommit(RuntimeException("simulated DataStore failure")) + } + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + + val destDb = manager.currentDb.value + assertEquals(dbA, destDb, "destination is dbA (the canonical DB)") + + manager.withDb { + it.nodeInfoDao() + .setMyNodeInfo( + MyNodeEntity( + myNodeNum = 999, + model = null, + firmwareVersion = null, + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 0L, + messageTimeoutMsec = 0, + minAppVersion = 0, + maxChannels = 0, + hasWifi = false, + ), + ) + } + + // The write exists in destination. + val destMyNode = destDb.nodeInfoDao().getMyNodeInfo().first() + assertNotNull(destMyNode, "post-merge write landed in destination") + assertEquals(999, destMyNode.myNodeNum, "write is durable in destination") + + // The write does NOT exist in source. + val sourceMyNode = dbB.nodeInfoDao().getMyNodeInfo().first() + assertNull(sourceMyNode, "source must not have the post-merge write") + } + + /** A fatal failure before the merge commits still clears intent, releases the gate, and restores source. */ + @Test + fun errorBeforeMergeCommitReleasesGateAndRestoresSource() = runTest(testDispatcher) { + val (_, source) = setupTwoDatabases() + manager.failMergeWith = TestError("fatal pre-commit failure") + + var caught: TestError? = null + try { + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + } catch (error: TestError) { + caught = error + } + + assertNotNull(caught, "fatal failure must propagate") + assertEquals(source, manager.currentDb.value, "source remains canonical before the commit boundary") + assertFalse(manager.debugWriterGateArmed(), "fatal failure must release writer admission") + val (writers, waiters) = manager.debugWriterCounts() + assertEquals(0, writers) + assertEquals(0, waiters) + val prefs = armableDs.data.first() + assertNull(prefs[stringPreferencesKey("pending_source_db_for:ADDRB")]) + assertNull(prefs[stringPreferencesKey("pending_destination_db_for:ADDRB")]) + } + + /** A fatal final-routing failure after commit propagates but keeps destination and its repair intent canonical. */ + @Test + fun errorAfterMergeCommitReleasesGateAndKeepsDestination() = runTest(testDispatcher) { + val (destination, _) = setupTwoDatabases() + manager.afterMergeCommitted = { armableDs.armFailBeforeCommit(TestError("fatal routing failure")) } + + var caught: TestError? = null + try { + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + } catch (error: TestError) { + caught = error + } + + assertNotNull(caught, "fatal failure must propagate") + assertEquals(destination, manager.currentDb.value, "destination remains canonical after merge commit") + assertFalse(manager.debugWriterGateArmed(), "fatal failure must release writer admission") + val (writers, waiters) = manager.debugWriterCounts() + assertEquals(0, writers) + assertEquals(0, waiters) + val prefs = armableDs.data.first() + assertNotNull(prefs[stringPreferencesKey("pending_source_db_for:ADDRB")]) + assertNotNull(prefs[stringPreferencesKey("pending_destination_db_for:ADDRB")]) + } +} diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerBackfillTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerBackfillTest.kt new file mode 100644 index 0000000000..c20f063f5d --- /dev/null +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerBackfillTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DatabaseManagerBackfillTest : DatabaseManagerTestFixture() { + + @BeforeTest fun setUp() = setUpFixture() + + @AfterTest fun tearDown() = tearDownFixture() + + @Test + fun activeBackfillDelaysAssociationUntilItFinishes() = runTest(testDispatcher) { + val (destination, source) = setupTwoDatabases() + val backfillStarted = CompletableDeferred() + val releaseBackfill = CompletableDeferred() + manager.backfillStarted = backfillStarted + manager.backfillRelease = releaseBackfill + val mergeStarted = CompletableDeferred() + manager.beforeMerge = { _, _, _ -> mergeStarted.complete(Unit) } + + val backfillJob = launch { manager.backfillSearchIndexIfNeeded(source) } + assertEquals(source, backfillStarted.await()) + val associateJob = launch { manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") } + runCurrent() + + assertFalse(mergeStarted.isCompleted, "merge must wait for the admitted backfill writer") + assertTrue(manager.debugWriterGateArmed()) + releaseBackfill.complete(Unit) + backfillJob.join() + associateJob.join() + + assertTrue(mergeStarted.isCompleted) + assertEquals(destination, manager.currentDb.value) + assertFalse(manager.debugWriterGateArmed()) + val (writers, waiters) = manager.debugWriterCounts() + assertEquals(0, writers) + assertEquals(0, waiters) + } + + /** A delayed job scheduled for an old DB skips work after admission selects a different active DB. */ + @Test + fun staleScheduledBackfillSkipsWorkAndBalancesAdmission() = runTest(testDispatcher) { + manager.switchActiveDatabase("addrA") + val staleDb = manager.currentDb.value + manager.switchActiveDatabase("addrB") + + manager.backfillSearchIndexIfNeeded(staleDb) + + assertTrue(manager.backfillDatabases.isEmpty(), "stale scheduled DB must not be backfilled") + val (writers, waiters) = manager.debugWriterCounts() + assertEquals(0, writers) + assertEquals(0, waiters) + } + + /** Cancelling a backfill suspended at admission leaves no registration behind and does not disturb the merge. */ + @Test + fun cancellationWhileBackfillWaitsAtGateLeavesNoWriterLeak() = runTest(testDispatcher) { + val (destination, source) = setupTwoDatabases() + val mergeStarted = CompletableDeferred() + val releaseMerge = CompletableDeferred() + manager.beforeMerge = { _, _, _ -> + mergeStarted.complete(Unit) + releaseMerge.await() + } + val associateJob = launch { manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") } + mergeStarted.await() + + val backfillJob = launch { manager.backfillSearchIndexIfNeeded(source) } + runCurrent() + backfillJob.cancelAndJoin() + assertTrue(manager.backfillDatabases.isEmpty(), "cancelled gate waiter must not start backfill work") + val (writersDuringMerge, waitersDuringMerge) = manager.debugWriterCounts() + assertEquals(0, writersDuringMerge) + assertEquals(0, waitersDuringMerge) + + releaseMerge.complete(Unit) + associateJob.join() + assertEquals(destination, manager.currentDb.value) + assertFalse(manager.debugWriterGateArmed()) + val (writers, waiters) = manager.debugWriterCounts() + assertEquals(0, writers) + assertEquals(0, waiters) + } +} diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerEvictionTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerEvictionTest.kt index a881bad145..6f0a975ea8 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerEvictionTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerEvictionTest.kt @@ -16,6 +16,8 @@ */ package org.meshtastic.core.database +import androidx.datastore.preferences.core.preferencesOf +import androidx.datastore.preferences.core.stringPreferencesKey import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -54,6 +56,37 @@ class DatabaseManagerEvictionTest { assertEquals(listOf(a, b), victims) } + @Test + fun `pending route endpoints are excluded from eviction candidates`() { + val names = listOf(a, b, c, d) + val lastUsed = mapOf(a to 1L, b to 2L, c to 3L, d to 4L) + val victims = + selectEvictionVictims( + dbNames = names, + activeDbName = d, + limit = 2, + lastUsedMsByDb = lastUsed, + protectedDbNames = setOf(a, b), + ) + + // The cache remains one over its configured limit because recovery evidence outranks eviction. + assertEquals(listOf(c), victims) + } + + @Test + fun `extracts both sides of every persisted pending route`() { + val prefs = + preferencesOf( + stringPreferencesKey("${DatabaseConstants.PENDING_SOURCE_DB_FOR_PREFIX}ble:a") to a, + stringPreferencesKey("${DatabaseConstants.PENDING_DESTINATION_DB_FOR_PREFIX}ble:a") to b, + stringPreferencesKey("${DatabaseConstants.PENDING_SOURCE_DB_FOR_PREFIX}tcp:b") to c, + stringPreferencesKey("${DatabaseConstants.PENDING_DESTINATION_DB_FOR_PREFIX}tcp:b") to d, + stringPreferencesKey("unrelated") to "not-a-database", + ) + + assertEquals(setOf(a, b, c, d), pendingRouteDbNames(prefs)) + } + @Test fun `excludes legacy and default from accounting`() { val names = listOf(a, b, legacy, defaultDb) diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerRetirementTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerRetirementTest.kt new file mode 100644 index 0000000000..4c005a10d7 --- /dev/null +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerRetirementTest.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DatabaseManagerRetirementTest : DatabaseManagerTestFixture() { + + @BeforeTest fun setUp() = setUpFixture() + + @AfterTest fun tearDown() = tearDownFixture() + + @Test + fun sourcePoolStaysOpenAfterLogicalRetirement() = runTest(testDispatcher) { + val (dbA, dbB) = setupTwoDatabases() + assertEquals(dbB, manager.currentDb.value, "addrB's DB should be active before association") + val sourceDb = manager.currentDb.value + + // Successful merge logically retires the source but does not close its pool. + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + assertEquals(dbA, manager.currentDb.value, "destination must be canonical after successful merge") + + // Direct DAO read on the retained source reference — must succeed (pool still open). + val sourceMyNode = sourceDb.nodeInfoDao().getMyNodeInfo().first() + assertNull(sourceMyNode, "source read must succeed (pool open); source content is empty by default") + + val (writers, waiters) = manager.debugWriterCounts() + assertEquals(0, writers, "no leaked writers after association") + assertEquals(0, waiters, "no pending drain waiters after association") + assertTrue(buildDbName("addrB") in armableDs.data.first()[retiredDbNamesKey].orEmpty()) + assertTrue(manager.deletedDatabaseNames.isEmpty(), "same-process retirement must not delete the source") + } + + @Test + fun sessionRolloverDuringMergeRollsBackAssociationAndKeepsSourceActive() = runTest(testDispatcher) { + val (canonical, source) = setupTwoDatabases() + var sessionActive = true + manager.beforeMerge = { _, _, _ -> sessionActive = false } + + manager.associateCurrentDevice( + nodeNum = 123, + deviceId = "deadbeefdeadbeef", + isSessionActive = { sessionActive }, + ) + + val sourceName = buildDbName("addrB") + assertEquals(source, manager.currentDb.value, "stale association must leave the source active") + assertFalse(canonical.mergeMarkerDao().isMerged(sourceName), "stale merge must roll back its marker") + assertTrue(sourceName !in armableDs.data.first()[retiredDbNamesKey].orEmpty()) + assertTrue(manager.deletedDatabaseNames.isEmpty()) + } + + @Test + fun persistedRetirementIsReclaimedOnceBeforeFirstDeviceDatabaseOpen() = runTest(testDispatcher) { + val retiredName = buildDbName("retired-address") + val lastUsedKey = longPreferencesKey("db_last_used:$retiredName") + armableDs.edit { + it[retiredDbNamesKey] = setOf(retiredName) + it[lastUsedKey] = 123L + } + val restartedManager = createManager() + + restartedManager.switchActiveDatabase("addrA") + + assertEquals(listOf(retiredName), restartedManager.deletedDatabaseNames) + val cleanedPrefs = armableDs.data.first() + assertTrue(retiredName !in cleanedPrefs[retiredDbNamesKey].orEmpty()) + assertNull(cleanedPrefs[lastUsedKey]) + + restartedManager.switchActiveDatabase("addrB") + assertEquals( + listOf(retiredName), + restartedManager.deletedDatabaseNames, + "cleanup must run once per lifetime", + ) + } + + @Test + fun cachedPoolRemainsTrackedWhenCloseFails() = runTest(testDispatcher) { + manager.switchActiveDatabase("addrA") + val addrADatabase = manager.currentDb.value + manager.switchActiveDatabase("addrB") + val addrAName = buildDbName("addrA") + manager.failCloseFor = addrADatabase + + assertFailsWith { manager.closeCachedForTest(addrAName) } + + manager.failCloseFor = null + manager.closeCachedForTest(addrAName) + assertEquals(2, manager.closeAttempts.count { it === addrADatabase }) + assertEquals(1, manager.closedDatabases.count { it === addrADatabase }) + } + + @Test + fun shutdownDoesNotDeleteRetiredFileWhenItsPoolFailsToClose() = runTest(testDispatcher) { + val (_, retiredSource) = setupTwoDatabases() + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + val retiredName = buildDbName("addrB") + manager.failCloseFor = retiredSource + + manager.close() + + assertTrue(retiredName !in manager.deletedDatabaseNames) + assertTrue(retiredName in armableDs.data.first()[retiredDbNamesKey].orEmpty()) + assertFalse(manager.debugAcceptingWrites()) + + manager.failCloseFor = null + manager.close() + + assertEquals(2, manager.closeAttempts.count { it === retiredSource }) + assertEquals(1, manager.closedDatabases.count { it === retiredSource }) + assertEquals(1, manager.deletedDatabaseNames.count { it == retiredName }) + assertTrue(retiredName !in armableDs.data.first()[retiredDbNamesKey].orEmpty()) + } +} diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerShutdownTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerShutdownTest.kt new file mode 100644 index 0000000000..6f2379ce44 --- /dev/null +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerShutdownTest.kt @@ -0,0 +1,503 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DatabaseManagerShutdownTest : DatabaseManagerTestFixture() { + + @BeforeTest fun setUp() = setUpFixture() + + @AfterTest fun tearDown() = tearDownFixture() + + @Test + fun closeWaitsForAdmittedWriterBeforeClosingPools() = runTest(testDispatcher) { + manager.switchActiveDatabase("addrA") + val writerStarted = CompletableDeferred() + val releaseWriter = CompletableDeferred() + val writerJob = launch { + manager.withDb { + writerStarted.complete(Unit) + releaseWriter.await() + null + } + } + writerStarted.await() + + val closeJob = launch { manager.close() } + runCurrent() + + assertFalse(closeJob.isCompleted, "close must wait for the admitted writer") + assertTrue(manager.closedDatabases.isEmpty(), "no pool may close while an admitted writer is active") + releaseWriter.complete(Unit) + writerJob.join() + closeJob.join() + + manager.builtDatabases.forEach { database -> + assertEquals(1, manager.closedDatabases.count { it === database }) + } + } + + @Test + fun closeBoundsWedgedWriterDrainWithoutClosingReachablePools() = runTest(testDispatcher) { + manager.switchActiveDatabase("addrA") + val writerStarted = CompletableDeferred() + val releaseWriter = CompletableDeferred() + val writerJob = launch { + manager.withDb { + writerStarted.complete(Unit) + releaseWriter.await() + null + } + } + writerStarted.await() + val closeJob = launch { manager.close() } + runCurrent() + assertFalse(closeJob.isCompleted) + + advanceTimeBy(5_501) + runCurrent() + + assertTrue(closeJob.isCompleted, "shutdown must return after the bounded writer-drain timeout") + assertTrue(manager.closedDatabases.isEmpty(), "a writer may still resume, so no pool is safe to close") + assertTrue(manager.deletedDatabaseNames.isEmpty(), "no reachable database file may be retired") + assertFalse(manager.debugAcceptingWrites()) + + releaseWriter.complete(Unit) + writerJob.join() + manager.close() + manager.builtDatabases.forEach { database -> + assertEquals(1, manager.closedDatabases.count { it === database }) + } + } + + @Test + fun closeBoundsNoncancellableManagerJobWithoutClosingOwnedPools() = runTest(testDispatcher) { + manager.switchActiveDatabase("addrA") + val workStarted = CompletableDeferred() + val releaseWork = CompletableDeferred() + val blockedWork = + manager.launchManagerWorkForTest { + workStarted.complete(Unit) + withContext(NonCancellable) { releaseWork.await() } + } + workStarted.await() + + val closeJob = launch { manager.close() } + runCurrent() + assertFalse(closeJob.isCompleted) + advanceTimeBy(5_501) + runCurrent() + + assertTrue(closeJob.isCompleted, "shutdown must return after its manager-job cancellation bound") + assertFalse(manager.debugAcceptingWrites(), "shutdown must leave a terminal admission boundary") + assertTrue(manager.closedDatabases.isEmpty(), "the blocked manager job may still reach its database") + assertTrue(manager.deletedDatabaseNames.isEmpty(), "physical retirement must be skipped with live work") + manager.close() + assertTrue( + manager.closedDatabases.isEmpty(), + "retry must remain bounded while the manager job is still live", + ) + + releaseWork.complete(Unit) + blockedWork.join() + manager.close() + manager.builtDatabases.forEach { database -> + assertEquals(1, manager.closedDatabases.count { it === database }) + } + } + + @Test + fun closeUnregistersAcceptedLazyManagerJobCancelledBeforeBodyStarts() = runTest(testDispatcher) { + manager.switchActiveDatabase("addrA") + val queuedDispatcher = StandardTestDispatcher(testScheduler) + var bodyStarted = false + val queuedJob = manager.launchManagerWorkForTest(queuedDispatcher) { bodyStarted = true } + + assertFalse(bodyStarted, "the queued dispatcher must hold the LAZY job before its body starts") + + manager.close() + + assertTrue(queuedJob.isCancelled, "shutdown must cancel the accepted manager job") + assertFalse(bodyStarted, "cancelling before dispatch must not execute the job body") + assertTrue( + manager.closedDatabases.isNotEmpty(), + "completion-based unregistration must let shutdown reclaim its database pools without timing out", + ) + } + + @Test + fun closingRejectsNewWritersSwitchesAndAssociations() = runTest(testDispatcher) { + manager.switchActiveDatabase("addrA") + val writerStarted = CompletableDeferred() + val releaseWriter = CompletableDeferred() + val admittedWriter = launch { + manager.withDb { + writerStarted.complete(Unit) + releaseWriter.await() + null + } + } + writerStarted.await() + val closeJob = launch { manager.close() } + runCurrent() + assertFalse(manager.debugAcceptingWrites()) + + var rejectedWriterRan = false + assertFailsWith { + manager.withDb { + rejectedWriterRan = true + null + } + } + assertFalse(rejectedWriterRan) + assertFailsWith { manager.switchActiveDatabase("addrB") } + assertFailsWith { + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + } + + releaseWriter.complete(Unit) + admittedWriter.join() + closeJob.join() + } + + @Test + fun concurrentCloseCallsCleanEachPoolOnce() = runTest(testDispatcher) { + manager.switchActiveDatabase("addrA") + val writerStarted = CompletableDeferred() + val releaseWriter = CompletableDeferred() + val admittedWriter = launch { + manager.withDb { + writerStarted.complete(Unit) + releaseWriter.await() + null + } + } + writerStarted.await() + + val firstClose = launch { manager.close() } + val secondClose = launch { manager.close() } + runCurrent() + assertFalse(firstClose.isCompleted) + assertFalse(secondClose.isCompleted) + releaseWriter.complete(Unit) + admittedWriter.join() + firstClose.join() + secondClose.join() + + manager.builtDatabases.forEach { database -> + assertEquals(1, manager.closedDatabases.count { it === database }) + } + } + + @Test + fun timeoutRecoveryCannotReopenWhileClosing() = runTest(testDispatcher) { + manager.switchActiveDatabase("addrA") + val callbackStarted = CompletableDeferred() + val releaseFailure = CompletableDeferred() + val writerJob = async { + try { + manager.withDb { + callbackStarted.complete(Unit) + releaseFailure.await() + throw roomPoolTimeout() + } + null + } catch (failure: IllegalStateException) { + failure + } + } + callbackStarted.await() + val buildsBeforeClose = manager.builtDatabases.size + val closeJob = launch { manager.close() } + runCurrent() + assertFalse(manager.debugAcceptingWrites()) + + releaseFailure.complete(Unit) + val failure = writerJob.await() + closeJob.join() + + assertNotNull(failure) + assertTrue(failure.message.orEmpty().contains("Timed out attempting to acquire")) + assertEquals( + buildsBeforeClose, + manager.builtDatabases.size, + "closing must suppress timeout recovery reopen", + ) + } + + @Test + fun closeBeforeCurrentDbInitializationDoesNotBuildDefaultPool() = runTest(testDispatcher) { + assertTrue(manager.builtDatabases.isEmpty()) + + manager.close() + + assertTrue(manager.builtDatabases.isEmpty()) + assertFailsWith { manager.currentDb.value } + assertTrue(manager.builtDatabases.isEmpty(), "first access after close must not build the default pool") + } + + @Test + fun shutdownCannotMissDefaultPoolInitializationBeforePublication() = runTest(testDispatcher) { + val buildStarted = CompletableDeferred() + val releaseBuild = CompletableDeferred() + manager.defaultBuildStarted = buildStarted + manager.releaseDefaultBuild = releaseBuild + + val initialization = + async(Dispatchers.Default) { + try { + manager.currentDb.value + null + } catch (failure: IllegalStateException) { + failure + } + } + buildStarted.await() + val closeJob = async(Dispatchers.Default) { manager.close() } + while (manager.debugAcceptingWrites()) yield() + + releaseBuild.complete(Unit) + + assertNotNull(initialization.await(), "initialization racing shutdown must not publish the default pool") + closeJob.await() + val defaultDatabase = manager.builtDatabases.single() + assertEquals(1, manager.closedDatabases.count { it === defaultDatabase }) + assertFailsWith { manager.currentDb.value } + } + + @Test + fun timeoutRecoveryReopensTheLazyDefaultPool() = runTest(testDispatcher) { + val original = manager.currentDb.value + manager.switchActiveDatabase("addrA") + manager.switchActiveDatabase(null) + assertTrue(manager.currentDb.value === original, "the default pool should be cached before recovery") + var invocationCount = 0 + + val result = runCatching { + manager.withDb { + invocationCount += 1 + throw roomPoolTimeout() + } + } + + assertNotNull(result.exceptionOrNull()) + assertEquals(1, invocationCount, "default-pool recovery must not replay the callback") + val reopened = manager.currentDb.value + assertTrue(reopened !== original, "the lazily registered default pool should be replaceable") + + var laterCallDb: MeshtasticDatabase? = null + manager.withDb { database -> laterCallDb = database } + assertTrue(laterCallDb === reopened) + + manager.switchActiveDatabase("addrA") + manager.switchActiveDatabase(null) + assertTrue( + manager.currentDb.value === reopened, + "switching back to default must not republish the failed pool", + ) + + manager.close() + assertEquals(1, manager.closedDatabases.count { it === original }) + assertEquals(1, manager.closedDatabases.count { it === reopened }) + } + + @Test + fun timeoutRecoveryFailureDoesNotMaskOriginalCallbackFailure() = runTest(testDispatcher) { + manager.switchActiveDatabase("addrA") + manager.failNextBuildWith = IllegalStateException("forced reopen failure") + + val failure = assertFailsWith { manager.withDb { throw roomPoolTimeout() } } + + assertTrue(failure.message.orEmpty().contains("Timed out attempting to acquire")) + assertNull(manager.failNextBuildWith, "the reopen attempt should consume its injected failure") + } + + @Test + fun detachedReopenedPoolIsClosedDuringOrderlyShutdown() = runTest(testDispatcher) { + manager.switchActiveDatabase("addrA") + val original = manager.currentDb.value + var invocationCount = 0 + + val firstResult = runCatching { + manager.withDb { + invocationCount += 1 + throw roomPoolTimeout() + } + } + + assertNotNull(firstResult.exceptionOrNull()) + assertEquals(1, invocationCount, "pool recovery must not replay a callback that already started") + val reopened = manager.currentDb.value + assertTrue(reopened !== original) + assertEquals( + 0, + manager.closedDatabases.count { it === original }, + "reopen must not close the published pool", + ) + + var laterCallDb: MeshtasticDatabase? = null + manager.withDb { database -> laterCallDb = database } + assertTrue(laterCallDb === reopened, "a later explicit call should use the recovered pool") + + manager.close() + + assertEquals(1, manager.closedDatabases.count { it === original }) + assertEquals(1, manager.closedDatabases.count { it === reopened }) + } + + /** + * Shutdown racing final routing waits for association ownership, then observes and physically removes the newly + * retired source exactly once. This covers the process-lifetime cleanup gap left by asynchronous retirement jobs. + */ + @Test + fun shutdownRacingMergeFinalizationDeletesRetiredSourceExactlyOnce() = runTest(testDispatcher) { + setupTwoDatabases() + val mergeCommitted = CompletableDeferred() + val releaseFinalization = CompletableDeferred() + manager.afterMergeCommitted = { + mergeCommitted.complete(Unit) + releaseFinalization.await() + } + + val associateJob = launch { manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") } + mergeCommitted.await() + val closeJob = launch { manager.close() } + runCurrent() + assertFalse(closeJob.isCompleted, "shutdown must wait for association finalization") + + releaseFinalization.complete(Unit) + associateJob.join() + closeJob.join() + + val retiredSource = buildDbName("addrB") + assertEquals(1, manager.deletedDatabaseNames.count { it == retiredSource }) + manager.close() + assertEquals( + 1, + manager.deletedDatabaseNames.count { it == retiredSource }, + "second close must be idempotent", + ) + } + + /** + * [DatabaseManager.close] must be idempotent after logical retirement: calling close twice must not throw. The + * first close physically cleans the logically-retired source; the second close is a safe no-op because the cache + * and retirement set are already empty. + */ + @Test + fun closeIsIdempotentAndCleansRetiredSources() = runTest(testDispatcher) { + setupTwoDatabases() + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + + manager.close() + manager.close() + } + + /** + * When shutdown races an in-flight association that is paused before its merge commits, [close] must wait for the + * association to finalize (committing its retirement) before deciding ownership, then physically retire the merged + * source exactly once. This exercises the other side of + * [shutdownRacingMergeFinalizationDeletesRetiredSourceExactlyOnce], pausing at [TestDatabaseManager.beforeMerge] + * rather than after commit. + */ + @Test + fun shutdownWaitsForPausedAssociationToFinalizeAndRetiresOnce() = runTest(testDispatcher) { + setupTwoDatabases() + val beforeMergeReached = CompletableDeferred() + val releaseMerge = CompletableDeferred() + manager.beforeMerge = { _, _, _ -> + beforeMergeReached.complete(Unit) + releaseMerge.await() + } + + val associateJob = launch { manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") } + beforeMergeReached.await() + val closeJob = launch { manager.close() } + runCurrent() + assertFalse(closeJob.isCompleted, "shutdown must wait for association finalization") + + releaseMerge.complete(Unit) + associateJob.join() + closeJob.join() + + val retiredSource = buildDbName("addrB") + assertEquals(1, manager.deletedDatabaseNames.count { it == retiredSource }) + manager.close() + assertEquals( + 1, + manager.deletedDatabaseNames.count { it == retiredSource }, + "second close must be idempotent", + ) + } + + /** + * When shutdown begins before an association has been admitted, the CLOSING lifecycle state must exclude that + * association: it cannot start, so no merge/retirement occurs and the active source keeps its ownership (it is + * closed but never physically retired as a merged-away DB). + */ + @Test + fun shutdownRetainsOwnershipWhenAssociationIsBlockedFromStarting() = runTest(testDispatcher) { + setupTwoDatabases() + val releaseAssociate = CompletableDeferred() + val associationResult = CompletableDeferred>() + val associateJob = launch { + releaseAssociate.await() + associationResult.complete( + runCatching { manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") }, + ) + } + + val closeJob = launch { manager.close() } + closeJob.join() + + releaseAssociate.complete(Unit) + associateJob.join() + + val activeSource = buildDbName("addrB") + assertTrue( + associationResult.await().exceptionOrNull() is IllegalStateException, + "the association must be rejected after shutdown begins", + ) + assertTrue(activeSource !in armableDs.data.first()[retiredDbNamesKey].orEmpty()) + assertEquals( + 0, + manager.deletedDatabaseNames.count { it == activeSource }, + "a blocked (never-admitted) association must not retire the active source", + ) + } +} diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerTestFixture.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerTestFixture.kt new file mode 100644 index 0000000000..479cd57c8f --- /dev/null +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerTestFixture.kt @@ -0,0 +1,364 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringSetPreferencesKey +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.encodeUtf8 +import okio.FileSystem +import okio.Path +import org.meshtastic.core.database.entity.MyNodeEntity +import org.meshtastic.core.di.CoroutineDispatchers +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.uuid.Uuid + +/** + * Verifies the association and shutdown state machines in [DatabaseManager]: merge admission and recovery preserve the + * canonical database, routing metadata commits atomically, and orderly close rejects new work while draining admitted + * writers and reclaiming every owned Room pool. + * + * Uses [UnconfinedTestDispatcher] so background coroutines launched by [DatabaseManager] run eagerly, and virtual-time + * advancement to drive [DatabaseManager.WRITER_DRAIN_TIMEOUT_MS] deterministically. + */ +abstract class DatabaseManagerTestFixture { + + protected val testDispatcher = UnconfinedTestDispatcher() + protected lateinit var dataStoreScope: CoroutineScope + + protected lateinit var tmpDir: Path + protected lateinit var armableDs: ArmableDataStore + protected lateinit var dispatchers: CoroutineDispatchers + protected lateinit var manager: TestDatabaseManager + protected val managers = mutableListOf() + protected val retiredDbNamesKey = stringSetPreferencesKey(DatabaseConstants.RETIRED_DB_NAMES_KEY) + + protected fun setUpFixture() { + tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "dbManagerAssocTest-${Uuid.random()}" + FileSystem.SYSTEM.createDirectories(tmpDir) + dataStoreScope = CoroutineScope(SupervisorJob() + testDispatcher) + val realDatastore = + PreferenceDataStoreFactory.createWithPath( + scope = dataStoreScope, + produceFile = { tmpDir / "test.preferences_pb" }, + ) + armableDs = ArmableDataStore(realDatastore) + dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) + managers.clear() + manager = createManager() + } + + protected fun tearDownFixture() = runTest(testDispatcher) { + managers.asReversed().forEach { it.close() } + dataStoreScope.cancel() + runCurrent() + FileSystem.SYSTEM.deleteRecursively(tmpDir) + } + + protected fun createManager(): TestDatabaseManager = TestDatabaseManager(armableDs, dispatchers).also(managers::add) + + /** + * A callback that started on one pool must never be replayed after an active-database switch. The first invocation + * can already have performed a side effect before surfacing a closed-pool failure, so a transparent second + * invocation against the replacement pool would duplicate or split the logical operation. + */ + protected suspend fun TestScope.assertClosedPoolFailureIsNotReplayedAfterSwitch() { + manager.switchActiveDatabase("addrA") + val original = manager.currentDb.value + val firstBlockStarted = CompletableDeferred() + val releaseFirstBlock = CompletableDeferred() + var invocationCount = 0 + + val result = async { + runCatching { + manager.withDb { db -> + invocationCount += 1 + assertTrue(db === original) + firstBlockStarted.complete(Unit) + releaseFirstBlock.await() + throw RuntimeException("database connection closed") + } + } + } + + firstBlockStarted.await() + manager.switchActiveDatabase("addrB") + val replacement = manager.currentDb.value + assertTrue(replacement !== original) + + releaseFirstBlock.complete(Unit) + val failure = result.await().exceptionOrNull() + + assertNotNull(failure) + assertEquals("database connection closed", failure.message) + assertEquals(1, invocationCount, "a started callback must never be replayed automatically") + assertTrue(manager.currentDb.value === replacement) + assertFalse(manager.debugWriterGateArmed()) + val (writers, waiters) = manager.debugWriterCounts() + assertEquals(0, writers, "the original writer registration must balance") + assertEquals(0, waiters, "the failure path must not leak drain waiters") + } + + /** + * Overrides [buildDatabase] to create distinct in-memory DBs per [dbName]. Each call to + * [getInMemoryDatabaseBuilder] returns a new Room instance with its own connection, so + * [dbCache][DatabaseManager.dbCache] entries for different names are genuinely separate databases. + */ + protected class TestDatabaseManager( + datastore: DataStore, + private val testDispatchers: CoroutineDispatchers, + ) : DatabaseManager(datastore, testDispatchers) { + val builtDatabases = mutableListOf() + val closedDatabases = mutableListOf() + val closeAttempts = mutableListOf() + var failCloseFor: MeshtasticDatabase? = null + var failNextBuildWith: Throwable? = null + var defaultBuildStarted: CompletableDeferred? = null + var releaseDefaultBuild: CompletableDeferred? = null + + override fun buildDatabase(dbName: String): MeshtasticDatabase { + failNextBuildWith?.let { failure -> + failNextBuildWith = null + throw failure + } + val database = getInMemoryDatabaseBuilder().build().also(builtDatabases::add) + if (dbName == DatabaseConstants.DEFAULT_DB_NAME) { + defaultBuildStarted?.complete(Unit) + releaseDefaultBuild?.let { release -> runBlocking { release.await() } } + } + return database + } + + override fun closeDatabase(database: MeshtasticDatabase) { + closeAttempts += database + if (database === failCloseFor) throw IllegalStateException("forced database close failure") + closedDatabases += database + super.closeDatabase(database) + } + + suspend fun closeCachedForTest(dbName: String) = closeCachedDatabase(dbName) + + override val limitedIo: kotlinx.coroutines.CoroutineDispatcher + get() = testDispatchers.default + + /** + * No-op: in-memory test databases have no filesystem directory, so LRU eviction, legacy-DB cleanup, and FTS + * search-index backfill would crash on Android host tests trying to access platform singletons. + */ + override fun schedulePostSwitchMaintenance(dbName: String, db: MeshtasticDatabase) = Unit + + /** When non-null, [mergeDatabases] throws this before the merge commits, simulating a pre-commit failure. */ + var failMergeWith: Throwable? = null + var performRealMerge: Boolean = true + var beforeMerge: suspend (MeshtasticDatabase, MeshtasticDatabase, String) -> Unit = { _, _, _ -> } + var afterMergeCommitted: suspend () -> Unit = {} + var markerVerification: (suspend (MeshtasticDatabase, String) -> Boolean)? = null + var backfillStarted: CompletableDeferred? = null + var backfillRelease: CompletableDeferred? = null + val backfillDatabases = mutableListOf() + val deletedDatabaseNames = mutableListOf() + + override suspend fun mergeDatabases( + source: MeshtasticDatabase, + dest: MeshtasticDatabase, + sourceName: String, + isAssociationActive: () -> Boolean, + ) { + beforeMerge(source, dest, sourceName) + failMergeWith?.let { throw it } + if (performRealMerge) super.mergeDatabases(source, dest, sourceName, isAssociationActive) + afterMergeCommitted() + } + + override suspend fun verifyMergeMarker(destination: MeshtasticDatabase, sourceName: String): Boolean = + markerVerification?.invoke(destination, sourceName) ?: super.verifyMergeMarker(destination, sourceName) + + override suspend fun performSearchIndexBackfill(db: MeshtasticDatabase) { + backfillDatabases += db + backfillStarted?.complete(db) + backfillRelease?.await() + } + + override fun deleteDatabaseFiles(dbName: String) { + // In-memory test databases have no filesystem files; record the name so retirement assertions can verify + // which databases the manager logically retired without touching platform storage. + deletedDatabaseNames += dbName + } + + fun launchManagerWorkForTest( + dispatcher: kotlinx.coroutines.CoroutineDispatcher = testDispatchers.default, + block: suspend () -> Unit, + ): Job = launchManagerWork(dispatcher) { block() } + } + + protected class TestError(message: String) : Error(message) + + protected fun roomPoolTimeout() = + IllegalStateException("Timed out attempting to acquire a reader connection from the database pool") + + /** Builds a minimal [MyNodeEntity] carrying only [num] for association/merge write-leak checks. */ + protected fun myNode(num: Int) = MyNodeEntity( + myNodeNum = num, + model = null, + firmwareVersion = null, + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 0L, + messageTimeoutMsec = 0, + minAppVersion = 0, + maxChannels = 0, + hasWifi = false, + ) + + // Helpers + + /** Hex-encoded [deviceId] as [deviceDbPrefKey] would produce it. */ + protected fun deviceKeyHex(deviceId: String): String = deviceId.encodeUtf8().hex() + + /** + * Associates the fixture's currently active transport, matching the production address-bound handshake contract. + */ + protected suspend fun TestDatabaseManager.associateCurrentDevice( + nodeNum: Int, + deviceId: String?, + isSessionActive: () -> Boolean = { true }, + ) { + associateDevice( + address = assertNotNull(currentAddress.value), + nodeNum = nodeNum, + deviceId = deviceId, + isSessionActive = isSessionActive, + ) + } + + /** + * Sets up two switched databases (addrA claimed as canonical, addrB active) and returns the two DB instances plus + * the claimed canonical DB name. + */ + protected suspend fun setupTwoDatabases(): Pair { + manager.switchActiveDatabase("addrA") + manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") + val dbA = manager.currentDb.value + + manager.switchActiveDatabase("addrB") + val dbB = manager.currentDb.value + + return dbA to dbB + } + + // DataStore fault injection + + /** + * Armable DataStore wrapper that can inject faults deterministically. + * + * Modes: + * - [Mode.Normal]: delegates normally. + * - [Mode.FailBeforeCommit]: throws [exception] before delegating, then resets to Normal. + * - [Mode.FailAfterCommit]: delegates first (durably commits), then throws [exception], then resets to Normal. + * - [Mode.CancelAfterCommit]: delegates first, then cancels the supplied caller job and resets to Normal. + * + * Each fault fires once; subsequent edits succeed normally. This avoids fragile edit-count assumptions. + */ + protected class ArmableDataStore(private val delegate: DataStore) : DataStore { + override val data + get() = delegate.data + + sealed interface Mode { + data object Normal : Mode + + data class FailBeforeCommit(val exception: Throwable) : Mode + + data class FailAfterCommit(val exception: Throwable) : Mode + + data class CancelAfterCommit(val job: Job) : Mode + } + + var mode: Mode = Mode.Normal + private var faultFired = false + + @Suppress("TooGenericExceptionThrown") + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences = + when (val m = mode) { + is Mode.Normal -> delegate.updateData(transform) + + is Mode.FailBeforeCommit -> { + if (!faultFired) { + faultFired = true + mode = Mode.Normal + throw m.exception + } + delegate.updateData(transform) + } + + is Mode.FailAfterCommit -> { + val result = delegate.updateData(transform) + if (!faultFired) { + faultFired = true + mode = Mode.Normal + throw m.exception + } + result + } + + is Mode.CancelAfterCommit -> { + val result = delegate.updateData(transform) + if (!faultFired) { + faultFired = true + mode = Mode.Normal + m.job.cancel(CancellationException("cancelled after durable DataStore commit")) + } + result + } + } + + fun reset() { + mode = Mode.Normal + faultFired = false + } + + fun armFailBeforeCommit(exception: Throwable) { + mode = Mode.FailBeforeCommit(exception) + faultFired = false + } + + fun armFailAfterCommit(exception: Throwable) { + mode = Mode.FailAfterCommit(exception) + faultFired = false + } + + fun armCancelAfterCommit(job: Job) { + mode = Mode.CancelAfterCommit(job) + faultFired = false + } + } +} diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerWriterAdmissionTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerWriterAdmissionTest.kt new file mode 100644 index 0000000000..5b63aa5433 --- /dev/null +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseManagerWriterAdmissionTest.kt @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DatabaseManagerWriterAdmissionTest : DatabaseManagerTestFixture() { + + @BeforeTest fun setUp() = setUpFixture() + + @AfterTest fun tearDown() = tearDownFixture() + + @Test + fun writerArrivingDuringDrainIsBlockedAndReleasedToDestOnCommit() = runTest(testDispatcher) { + val (dbA, _) = setupTwoDatabases() + manager.performRealMerge = false + + // Hold a pre-existing source writer so the drain stays pending and a new writer can arrive into the gate. + val preGate = CompletableDeferred() + val preWriter = launch { + manager.withDb { + preGate.await() + null + } + } + val associateJob = launch { manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") } + + val newWriterStarted = CompletableDeferred() + val newWriterDb = CompletableDeferred() + var newWriterInvocationCount = 0 + val newWriterJob = launch { + manager.withDb { db -> + newWriterInvocationCount += 1 + newWriterStarted.complete(Unit) + newWriterDb.complete(db) + null + } + } + + // The new writer must be suspended at the admission gate, not running its block. + assertFalse(newWriterStarted.isCompleted, "new writer must block at the admission gate during the drain") + assertFalse(newWriterDb.isCompleted, "new writer must not have captured a DB yet") + + // Release the pre-existing writer: drain completes, merge commits, gate releases onto dest. + preGate.complete(Unit) + newWriterJob.join() + associateJob.join() + preWriter.join() + + assertTrue(newWriterStarted.isCompleted, "new writer must be released after the merge commits") + assertTrue(associateJob.isCompleted, "association must complete") + assertEquals(1, newWriterInvocationCount, "blocked writer must run exactly once") + assertEquals(dbA, newWriterDb.await(), "new writer released onto destination after successful merge") + assertEquals(dbA, manager.currentDb.value, "destination is canonical after commit") + assertFalse(manager.debugWriterGateArmed()) + val (writers, waiters) = manager.debugWriterCounts() + assertEquals(0, writers) + assertEquals(0, waiters) + } + + /** A successful merge releases the blocked writer onto destination (not source). */ + @Test + fun successfulMergeReleasesBlockedWriterOntoDestination() = runTest(testDispatcher) { + val (dbA, _) = setupTwoDatabases() + manager.performRealMerge = false + + val preGate = CompletableDeferred() + val preWriter = launch { + manager.withDb { + preGate.await() + null + } + } + val associateJob = launch { manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") } + + val newWriterDb = CompletableDeferred() + var newWriterInvocationCount = 0 + val newWriterJob = launch { + manager.withDb { db -> + newWriterInvocationCount += 1 + newWriterDb.complete(db) + null + } + } + + assertFalse(newWriterDb.isCompleted, "new writer must block during the drain") + preGate.complete(Unit) + val usedDb = newWriterDb.await() + assertEquals(dbA, usedDb, "blocked writer released onto destination after successful merge") + assertEquals(dbA, manager.currentDb.value, "destination is canonical after commit") + + associateJob.join() + preWriter.join() + newWriterJob.join() + assertTrue(associateJob.isCompleted, "association must complete") + assertEquals(1, newWriterInvocationCount, "blocked writer must run exactly once") + assertFalse(manager.debugWriterGateArmed()) + val (writers, waiters) = manager.debugWriterCounts() + assertEquals(0, writers) + assertEquals(0, waiters) + } + + /** A pre-commit merge failure releases the blocked writer onto source; the destination is never activated. */ + @Test + fun preCommitMergeFailureReleasesBlockedWriterOntoSource() = runTest(testDispatcher) { + val (dbA, dbB) = setupTwoDatabases() + + manager.failMergeWith = RuntimeException("simulated pre-commit merge failure") + + val preGate = CompletableDeferred() + val preWriter = launch { + manager.withDb { + preGate.await() + null + } + } + val associateJob = launch { manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") } + + val newWriterDb = CompletableDeferred() + val newWriterJob = launch { + manager.withDb { db -> + newWriterDb.complete(db) + null + } + } + + preGate.complete(Unit) + val usedDb = newWriterDb.await() + assertEquals(dbB, usedDb, "blocked writer released onto source after pre-commit merge failure") + associateJob.join() + assertEquals(dbB, manager.currentDb.value, "source remains active after merge failure") + + preWriter.join() + newWriterJob.join() + } + + /** + * Cancelling the attempt while a writer is blocked at the gate releases the gate onto source and leaves no waiter + * leaked. + */ + @Test + fun cancellationReleasesAdmissionGateOntoSource() = runTest(testDispatcher) { + val (dbA, dbB) = setupTwoDatabases() + + val preGate = CompletableDeferred() + val preWriter = launch { + manager.withDb { + preGate.await() + null + } + } + val associateJob = launch { manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") } + + val newWriterDb = CompletableDeferred() + val newWriterJob = launch { + manager.withDb { db -> + newWriterDb.complete(db) + null + } + } + + associateJob.cancelAndJoin() + val usedDb = newWriterDb.await() + assertEquals(dbB, usedDb, "gate released onto source when the attempt is cancelled") + assertEquals(dbB, manager.currentDb.value, "source active after cancellation") + + preGate.complete(Unit) + preWriter.join() + newWriterJob.join() + } + + /** + * After a full association cycle with an admission-gated writer, the writer tracker holds no live writers and no + * pending drain waiters — counts and waiters are balanced. + */ + @Test + fun writerCountsAndDrainWaitersRemainBalanced() = runTest(testDispatcher) { + val (destination, _) = setupTwoDatabases() + manager.performRealMerge = false + + val preGate = CompletableDeferred() + val preWriter = launch { + manager.withDb { + preGate.await() + null + } + } + val associateJob = launch { manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") } + var newWriterInvocationCount = 0 + val newWriterDb = CompletableDeferred() + val newWriterJob = launch { + manager.withDb { db -> + newWriterInvocationCount += 1 + newWriterDb.complete(db) + null + } + } + + preGate.complete(Unit) + associateJob.join() + preWriter.join() + newWriterJob.join() + + assertTrue(associateJob.isCompleted, "association must complete") + assertEquals(1, newWriterInvocationCount, "blocked writer must run exactly once") + assertEquals(destination, newWriterDb.await(), "blocked writer must use destination") + assertEquals(destination, manager.currentDb.value, "destination must be canonical") + assertFalse(manager.debugWriterGateArmed()) + val (writers, waiters) = manager.debugWriterCounts() + assertEquals(0, writers, "no leaked writers after association") + assertEquals(0, waiters, "no pending drain waiters after association") + } + + @Test + fun closedPoolFailureAfterSwitchDoesNotReplayCallback() = + runTest(testDispatcher) { assertClosedPoolFailureIsNotReplayedAfterSwitch() } + + /** A gate that never completes fails admission after a bounded wait without running or registering the writer. */ + @Test + fun writerAdmissionGateTimeoutIsBoundedAndBalanced() = runTest(testDispatcher) { + setupTwoDatabases() + val mergeStarted = CompletableDeferred() + val releaseMerge = CompletableDeferred() + manager.beforeMerge = { _, _, _ -> + mergeStarted.complete(Unit) + releaseMerge.await() + } + + val associateJob = launch { manager.associateCurrentDevice(nodeNum = 123, deviceId = "deadbeefdeadbeef") } + mergeStarted.await() + var writerBlockRan = false + val waitingWriter = async { + try { + manager.withDb { + writerBlockRan = true + null + } + null + } catch (failure: IllegalStateException) { + failure + } + } + + advanceTimeBy(30_001) + val failure = waitingWriter.await() + assertNotNull(failure) + assertTrue(failure.message.orEmpty().contains("Timed out waiting 30000ms")) + assertFalse(writerBlockRan, "a timed-out writer must never run its callback") + val (writersDuringMerge, waitersDuringMerge) = manager.debugWriterCounts() + assertEquals(0, writersDuringMerge, "gate wait must not register a writer") + assertEquals(0, waitersDuringMerge) + + releaseMerge.complete(Unit) + associateJob.join() + assertFalse(manager.debugWriterGateArmed()) + val (writers, waiters) = manager.debugWriterCounts() + assertEquals(0, writers) + assertEquals(0, waiters) + } +} diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt index a8fb3b4824..0785e68993 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt @@ -39,6 +39,7 @@ import org.meshtastic.proto.User import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -224,6 +225,43 @@ class DatabaseMergerTest { ) } + @Test + fun staleAssociationAtCommitRollsBackEntireMerge() = runTest { + source.nodeInfoDao().setMyNodeInfo(myNode()) + source.packetDao().insert(textPacket("0!broadcast", "must-not-commit", time = 200)) + + var authorityChecks = 0 + assertFailsWith { + DatabaseMerger.merge( + source = source, + dest = dest, + sourceName = sourceName, + isAssociationActive = { authorityChecks++ == 0 }, + ) + } + + assertEquals(2, authorityChecks, "authority is revalidated immediately before commit") + assertTrue(dest.packetDao().getAllPacketsSnapshot().isEmpty(), "stale merge writes must roll back") + assertTrue(!dest.mergeMarkerDao().isMerged(sourceName), "stale merge marker must roll back") + } + + @Test + fun alreadyMergedPathRechecksAssociationAuthority() = runTest { + DatabaseMerger.merge(source, dest, sourceName) + var authorityChecks = 0 + + assertFailsWith { + DatabaseMerger.merge( + source = source, + dest = dest, + sourceName = sourceName, + isAssociationActive = { authorityChecks++ == 0 }, + ) + } + + assertEquals(2, authorityChecks, "already-merged path must revalidate authority before returning") + } + /** * Regression for #6230: a crash after the merge transaction commits but before the address alias is persisted * re-runs [DatabaseMerger.merge] on the next connection. The non-idempotent tables (packets, discovery) must not be diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/MeshLogDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/MeshLogDaoTest.kt index 7cd6b7038d..d1434b0c86 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/MeshLogDaoTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/MeshLogDaoTest.kt @@ -147,6 +147,34 @@ class MeshLogDaoTest { assertEquals(setOf(firstUuid, secondUuid), remaining.map { it.uuid }.toSet()) } + @Test + fun testGetLogsSnapshotPageTraversesEqualTimestampsWithoutDuplicates() = runTest { + meshLogDao.insert(logEntry("log-a", time = 300)) + meshLogDao.insert(logEntry("log-c", time = 200)) + meshLogDao.insert(logEntry("log-b", time = 200)) + meshLogDao.insert(logEntry("log-a2", time = 100)) + + val first = + meshLogDao.getLogsSnapshotPage( + testFromNum, + PortNum.TELEMETRY_APP.value, + beforeReceivedDate = null, + beforeUuid = null, + pageSize = 2, + ) + val cursor = first.last() + val second = + meshLogDao.getLogsSnapshotPage( + testFromNum, + PortNum.TELEMETRY_APP.value, + beforeReceivedDate = cursor.received_date, + beforeUuid = cursor.uuid, + pageSize = 2, + ) + + assertEquals(listOf("log-a", "log-c", "log-b", "log-a2"), (first + second).map { it.uuid }) + } + @Test fun testGetLogsSnapshotReturnsMatchingLogs() = runTest { meshLogDao.insert(logEntry("log-1", portNum = PortNum.TELEMETRY_APP.value, time = 300)) diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketDaoAtomicTransactionTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketDaoAtomicTransactionTest.kt index fdce3d0f13..7180fe8100 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketDaoAtomicTransactionTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketDaoAtomicTransactionTest.kt @@ -103,9 +103,10 @@ class PacketDaoAtomicTransactionTest { status: MessageStatus = MessageStatus.UNKNOWN, routingError: Int = -1, sfppHash: ByteString? = null, + ownerNodeNum: Int = myNodeNum, ) = Packet( uuid = 0L, - myNodeNum = myNodeNum, + myNodeNum = ownerNodeNum, port_num = PortNum.TEXT_MESSAGE_APP.value, contact_key = contact, received_time = time, @@ -135,8 +136,9 @@ class PacketDaoAtomicTransactionTest { to: String? = null, status: MessageStatus = MessageStatus.UNKNOWN, sfppHash: ByteString? = null, + ownerNodeNum: Int = myNodeNum, ) = ReactionEntity( - myNodeNum = myNodeNum, + myNodeNum = ownerNodeNum, replyId = replyId, userId = userId, emoji = emoji, @@ -164,7 +166,7 @@ class PacketDaoAtomicTransactionTest { val settings = packetDao.getContactSettings(contact) assertNotNull(settings) - assertEquals(42L, settings!!.lastReadMessageUuid) + assertEquals(42L, settings.lastReadMessageUuid) assertEquals(1000L, settings.lastReadMessageTimestamp) } @@ -200,7 +202,7 @@ class PacketDaoAtomicTransactionTest { val settings = packetDao.getContactSettings(contact) assertNotNull(settings) - assertEquals(99L, settings!!.lastReadMessageUuid) + assertEquals(99L, settings.lastReadMessageUuid) assertEquals(3000L, settings.lastReadMessageTimestamp) } @@ -213,7 +215,7 @@ class PacketDaoAtomicTransactionTest { val settings = packetDao.getContactSettings(contact) assertNotNull(settings) - assertEquals(42L, settings!!.lastReadMessageUuid) + assertEquals(42L, settings.lastReadMessageUuid) assertEquals(2000L, settings.lastReadMessageTimestamp) } @@ -262,7 +264,7 @@ class PacketDaoAtomicTransactionTest { val settings = packetDao.getContactSettings(contact) assertNotNull(settings) - assertEquals(42L, settings!!.lastReadMessageUuid, "lastReadMessageUuid should be updated") + assertEquals(42L, settings.lastReadMessageUuid, "lastReadMessageUuid should be updated") assertEquals(3000L, settings.lastReadMessageTimestamp, "lastReadMessageTimestamp should be updated") assertEquals(999_999_999L, settings.muteUntil, "muteUntil must be preserved") assertTrue(settings.filteringDisabled, "filteringDisabled must be preserved") @@ -281,12 +283,12 @@ class PacketDaoAtomicTransactionTest { val settingsA = packetDao.getContactSettings(contactA) assertNotNull(settingsA) - assertEquals(30L, settingsA!!.lastReadMessageUuid, "contact A updated to 30") + assertEquals(30L, settingsA.lastReadMessageUuid, "contact A updated to 30") assertEquals(3000L, settingsA.lastReadMessageTimestamp) val settingsB = packetDao.getContactSettings(contactB) assertNotNull(settingsB) - assertEquals(20L, settingsB!!.lastReadMessageUuid, "contact B unchanged at 20") + assertEquals(20L, settingsB.lastReadMessageUuid, "contact B unchanged at 20") assertEquals(2000L, settingsB.lastReadMessageTimestamp) } @@ -299,7 +301,7 @@ class PacketDaoAtomicTransactionTest { val settings = packetDao.getContactSettings(contact) assertNotNull(settings) - assertEquals(42L, settings!!.lastReadMessageUuid) + assertEquals(42L, settings.lastReadMessageUuid) assertEquals(2000L, settings.lastReadMessageTimestamp) } @@ -327,6 +329,24 @@ class PacketDaoAtomicTransactionTest { assertEquals(MessageStatus.UNKNOWN, untouchedTo?.data?.status) } + @Test + fun updatePacketByKeyUpdatesEveryOwnershipScopedCopy() = runTest { + seedMyNodeInfo() + val canonical = textPacket("contact", "canonical", time = 100, packetId = 55, from = "!aa", to = "^all") + val legacy = + textPacket("contact", "legacy", time = 101, packetId = 55, from = "!aa", to = "^all", ownerNodeNum = 0) + packetDao.insert(canonical) + packetDao.insert(legacy) + + packetDao.updatePacketByKey(canonical.data.copy(status = MessageStatus.DELIVERED), routingError = 4) + + val matching = packetDao.findPacketsWithId(55).filter { it.data.from == "!aa" && it.data.to == "^all" } + assertEquals(2, matching.size) + assertTrue(matching.all { it.data.status == MessageStatus.DELIVERED }) + assertTrue(matching.all { it.routingError == 4 }) + assertEquals(setOf(0, myNodeNum), matching.map { it.myNodeNum }.toSet()) + } + @Test fun updatePacketByKeyAppliesRoutingErrorWhenNonNegative() = runTest { seedMyNodeInfo() @@ -377,11 +397,30 @@ class PacketDaoAtomicTransactionTest { val updated = packetDao.findReactionsWithId(80).find { it.userId == "!u" && it.emoji == "👍" } assertNotNull(updated) - assertEquals(999, updated!!.timestamp) + assertEquals(999, updated.timestamp) assertEquals(MessageStatus.SFPP_CONFIRMED, updated.status) assertEquals(sfppHash, updated.sfpp_hash) } + @Test + fun updateReactionByKeyUpdatesEveryOwnershipScopedCopy() = runTest { + seedMyNodeInfo() + packetDao.insert(textPacket("contact", "msg", time = 100, packetId = 84)) + packetDao.insert(reaction(replyId = 84, userId = "!u", emoji = "👍", timestamp = 1, packetId = 84)) + packetDao.insert( + reaction(replyId = 84, userId = "!u", emoji = "👍", timestamp = 2, packetId = 84, ownerNodeNum = 0), + ) + + packetDao.updateReactionByKey( + reaction(replyId = 84, userId = "!u", emoji = "👍", timestamp = 999, packetId = 84, ownerNodeNum = 0), + ) + + val matching = packetDao.findReactionsWithId(84).filter { it.userId == "!u" && it.emoji == "👍" } + assertEquals(2, matching.size) + assertTrue(matching.all { it.timestamp == 999L }) + assertEquals(setOf(0, myNodeNum), matching.map { it.myNodeNum }.toSet()) + } + @Test fun updateReactionByKeyPreservesExistingMyNodeNum() = runTest { seedMyNodeInfo() @@ -401,7 +440,7 @@ class PacketDaoAtomicTransactionTest { val updated = packetDao.findReactionsWithId(81).find { it.userId == "!u" && it.emoji == "❤️" } assertNotNull(updated) - assertEquals(myNodeNum, updated!!.myNodeNum, "myNodeNum must be borrowed from existing row, not 0") + assertEquals(myNodeNum, updated.myNodeNum, "myNodeNum must be borrowed from existing row, not 0") assertEquals(555, updated.timestamp) } @@ -743,11 +782,11 @@ class PacketDaoAtomicTransactionTest { val packet = packetDao.findPacketBySfppHash(fullHash) assertNotNull(packet) - assertEquals(MessageStatus.SFPP_ROUTING, packet!!.data.status) + assertEquals(MessageStatus.SFPP_ROUTING, packet.data.status) val reaction = packetDao.findReactionBySfppHash(fullHash) assertNotNull(reaction) - assertEquals(MessageStatus.SFPP_ROUTING, reaction!!.status) + assertEquals(MessageStatus.SFPP_ROUTING, reaction.status) } @Test @@ -810,7 +849,7 @@ class PacketDaoAtomicTransactionTest { val packet = packetDao.findPacketBySfppHash(sfppHash) assertNotNull(packet) - assertEquals(MessageStatus.SFPP_CONFIRMED, packet!!.data.status, "confirmed packet must not be downgraded") + assertEquals(MessageStatus.SFPP_CONFIRMED, packet.data.status, "confirmed packet must not be downgraded") } @Test @@ -824,7 +863,7 @@ class PacketDaoAtomicTransactionTest { val packet = packetDao.findPacketBySfppHash(sfppHash) assertNotNull(packet) - assertEquals(MessageStatus.UNKNOWN, packet!!.data.status, "unrelated hash should be untouched") + assertEquals(MessageStatus.UNKNOWN, packet.data.status, "unrelated hash should be untouched") } // ── deleteMessagesAtomic ───────────────────────────────────────────────────── diff --git a/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/DatabaseManagerPendingRouteRecoveryJvmTest.kt b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/DatabaseManagerPendingRouteRecoveryJvmTest.kt new file mode 100644 index 0000000000..b186e20e57 --- /dev/null +++ b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/DatabaseManagerPendingRouteRecoveryJvmTest.kt @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okio.FileSystem +import okio.Path +import org.meshtastic.core.database.MeshtasticDatabase.Companion.configureCommon +import org.meshtastic.core.database.entity.MyNodeEntity +import org.meshtastic.core.di.CoroutineDispatchers +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.uuid.Uuid + +/** + * JVM-only durability test for the database-association restart recovery. + * + * This exercises the real filesystem-backed restart path (close the manager, reopen the same persistent SQLite files + * with a fresh manager) which the common suite intentionally avoids — running real file create/close/reopen/delete + * twice (JVM + Android host) doubled the native SQLite worker churn that destabilized the `allTests` aggregate. Here it + * runs exactly once, on the JVM target. + */ +class DatabaseManagerPendingRouteRecoveryJvmTest { + + private lateinit var tmpDir: Path + private lateinit var persistentDir: Path + private lateinit var dataStoreScope: CoroutineScope + private lateinit var armableDs: ArmableDataStore + private lateinit var dispatchers: CoroutineDispatchers + private lateinit var testScheduler: TestCoroutineScheduler + private lateinit var testDispatcher: TestDispatcher + + @BeforeTest + fun setUp() { + tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "pendingRouteRecovery-${Uuid.random()}" + FileSystem.SYSTEM.createDirectories(tmpDir) + persistentDir = tmpDir / "persistent" + FileSystem.SYSTEM.createDirectories(persistentDir) + testScheduler = TestCoroutineScheduler() + testDispatcher = UnconfinedTestDispatcher(testScheduler) + dataStoreScope = CoroutineScope(SupervisorJob() + testDispatcher) + val realDatastore = + PreferenceDataStoreFactory.createWithPath( + scope = dataStoreScope, + produceFile = { tmpDir / "test.preferences_pb" }, + ) + armableDs = ArmableDataStore(realDatastore) + dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) + } + + @AfterTest + fun tearDown() = runTest(testDispatcher) { + dataStoreScope.coroutineContext[Job]?.cancelAndJoin() + FileSystem.SYSTEM.deleteRecursively(tmpDir) + } + + @Test + fun newManagerRepairsCommittedPendingRouteBeforePublishing() = runTest(testDispatcher) { + val firstManager = JvmPersistentManager(armableDs, dispatchers, persistentDir) + var firstClosed = false + try { + // First process: open persistent databases, seed durable data, and commit the merge whose final routing + // write fails. This simulates a crash AFTER the merge committed but BEFORE routing finalized, leaving a + // durable pending route for restart recovery. + firstManager.switchActiveDatabase("addrA") + firstManager.associateDevice( + address = "addrA", + nodeNum = 123, + deviceId = "deadbeefdeadbeef", + isSessionActive = { true }, + ) + val dbA = firstManager.currentDb.value + firstManager.switchActiveDatabase("addrB") + + dbA.nodeInfoDao().setMyNodeInfo(myNode(777)) + firstManager.afterMergeCommitted = { + armableDs.armFailBeforeCommit(RuntimeException("final routing failed")) + } + firstManager.associateDevice( + address = "addrB", + nodeNum = 123, + deviceId = "deadbeefdeadbeef", + isSessionActive = { true }, + ) + + val pendingPrefs = armableDs.data.first() + assertNull(pendingPrefs[stringPreferencesKey("addr_db_for:ADDRB")]) + assertNotNull(pendingPrefs[stringPreferencesKey("pending_source_db_for:ADDRB")]) + assertNotNull(pendingPrefs[stringPreferencesKey("pending_destination_db_for:ADDRB")]) + + // Real restart: fully close the first manager and its Room pools before constructing the replacement. + // The seeded destination data and the durable pending route live on disk; nothing else may hold the + // persistent SQLite files when the recreated manager reopens them. + firstManager.close() + runCurrent() + firstClosed = true + + val recreated = JvmPersistentManager(armableDs, dispatchers, persistentDir) + try { + val beforeSwitch = recreated.currentDb.value + val verificationStarted = CompletableDeferred() + val allowVerification = CompletableDeferred() + var verifiedSource: String? = null + recreated.markerVerification = { destinationDb, sourceName -> + verifiedSource = sourceName + verificationStarted.complete(Unit) + allowVerification.await() + destinationDb.mergeMarkerDao().isMerged(sourceName) + } + + val switchJob = launch { recreated.switchActiveDatabase("addrB") } + verificationStarted.await() + assertEquals( + beforeSwitch, + recreated.currentDb.value, + "source must not publish while marker is verified", + ) + assertNull(recreated.currentAddress.value) + allowVerification.complete(Unit) + switchJob.join() + + assertEquals(buildDbName("addrB"), verifiedSource) + assertEquals(777, recreated.currentDb.value.nodeInfoDao().getMyNodeInfo().first()?.myNodeNum) + val repairedPrefs = armableDs.data.first() + assertEquals(buildDbName("addrA"), repairedPrefs[stringPreferencesKey("addr_db_for:ADDRB")]) + assertNull(repairedPrefs[stringPreferencesKey("pending_source_db_for:ADDRB")]) + assertNull(repairedPrefs[stringPreferencesKey("pending_destination_db_for:ADDRB")]) + + recreated.markerVerification = null + recreated.switchActiveDatabase(null) + recreated.switchActiveDatabase("addrB") + assertEquals(777, recreated.currentDb.value.nodeInfoDao().getMyNodeInfo().first()?.myNodeNum) + } finally { + recreated.close() + } + } finally { + if (!firstClosed) runCatching { firstManager.close() } + } + } + + /** Minimal JVM [DatabaseManager] whose databases are always real persistent files under [persistentDir]. */ + private class JvmPersistentManager( + datastore: DataStore, + private val testDispatchers: CoroutineDispatchers, + private val persistentDir: Path, + ) : DatabaseManager(datastore, testDispatchers) { + override fun buildDatabase(dbName: String): MeshtasticDatabase = Room.databaseBuilder( + name = (persistentDir / "$dbName.db").toString(), + factory = { MeshtasticDatabaseConstructor.initialize() }, + ) + .configureCommon() + .setDriver(BundledSQLiteDriver()) + .build() + + override val limitedIo: kotlinx.coroutines.CoroutineDispatcher + get() = testDispatchers.default + + override fun deleteDatabaseFiles(dbName: String) { + listOf("$dbName.db", "$dbName.db-wal", "$dbName.db-shm", "$dbName.db-journal").forEach { fileName -> + FileSystem.SYSTEM.delete(persistentDir / fileName, mustExist = false) + } + } + + /** No-op: startup/retirement behavior is driven explicitly by this fixture. */ + override fun schedulePostSwitchMaintenance(dbName: String, db: MeshtasticDatabase) = Unit + + var afterMergeCommitted: suspend () -> Unit = {} + + var markerVerification: (suspend (MeshtasticDatabase, String) -> Boolean)? = null + + override suspend fun mergeDatabases( + source: MeshtasticDatabase, + dest: MeshtasticDatabase, + sourceName: String, + isAssociationActive: () -> Boolean, + ) { + super.mergeDatabases(source, dest, sourceName, isAssociationActive) + afterMergeCommitted() + } + + override suspend fun verifyMergeMarker(destination: MeshtasticDatabase, sourceName: String): Boolean = + markerVerification?.invoke(destination, sourceName) ?: super.verifyMergeMarker(destination, sourceName) + } + + private fun myNode(num: Int) = MyNodeEntity( + myNodeNum = num, + model = null, + firmwareVersion = null, + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 0L, + messageTimeoutMsec = 0, + minAppVersion = 0, + maxChannels = 0, + hasWifi = false, + ) + + /** + * Armable DataStore wrapper that can inject faults deterministically. + * + * Modes: + * - [Mode.Normal]: delegates normally. + * - [Mode.FailBeforeCommit]: throws [exception] before delegating, then resets to Normal. + * + * Each fault fires once; subsequent edits succeed normally. This avoids fragile edit-count assumptions. + */ + private class ArmableDataStore(private val delegate: DataStore) : DataStore { + override val data + get() = delegate.data + + sealed interface Mode { + data object Normal : Mode + + data class FailBeforeCommit(val exception: Throwable) : Mode + } + + var mode: Mode = Mode.Normal + private var faultFired = false + + @Suppress("TooGenericExceptionThrown") + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences = + when (val m = mode) { + is Mode.Normal -> delegate.updateData(transform) + + is Mode.FailBeforeCommit -> { + if (!faultFired) { + faultFired = true + mode = Mode.Normal + throw m.exception + } + delegate.updateData(transform) + } + } + + fun armFailBeforeCommit(exception: Throwable) { + mode = Mode.FailBeforeCommit(exception) + faultFired = false + } + } +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminPacketHandler.kt index 8b8249adf8..f2136f3c43 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminPacketHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminPacketHandler.kt @@ -26,5 +26,5 @@ interface AdminPacketHandler { * @param packet The received mesh packet. * @param myNodeNum The local node number. */ - fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) + fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int, session: RadioSessionContext) } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt index ab3d26abf8..d0ac970181 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt @@ -20,6 +20,6 @@ import org.meshtastic.proto.FromRadio /** Interface for dispatching non-packet [FromRadio] variants to their respective handlers. */ interface FromRadioPacketHandler { - /** Processes a [FromRadio] message. */ - fun handleFromRadio(proto: FromRadio) + /** Processes a [FromRadio] message admitted by [session]. */ + fun handleFromRadio(proto: FromRadio, session: RadioSessionContext) } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt index bd2f8c6121..b136c82973 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt @@ -23,14 +23,14 @@ import org.meshtastic.proto.NodeInfo /** Interface for managing the configuration flow, including local node info and metadata. */ interface MeshConfigFlowManager { - /** Handles received local node information. */ - fun handleMyInfo(myInfo: MyNodeInfo) + /** Handles local node information admitted by [session]. */ + fun handleMyInfo(myInfo: MyNodeInfo, session: RadioSessionContext) - /** Handles received local device metadata. */ - fun handleLocalMetadata(metadata: DeviceMetadata) + /** Handles received local device metadata admitted by [session]. */ + fun handleLocalMetadata(metadata: DeviceMetadata, session: RadioSessionContext): Boolean - /** Handles received node information. */ - fun handleNodeInfo(info: NodeInfo) + /** Handles received node information admitted by [session]. */ + fun handleNodeInfo(info: NodeInfo, session: RadioSessionContext): Boolean /** * Handles a [FileInfo] packet received during STATE_SEND_FILEMANIFEST. @@ -38,14 +38,14 @@ interface MeshConfigFlowManager { * Each packet describes one file available on the device. Accumulated into [RadioConfigRepository.fileManifestFlow] * and cleared at the start of each new handshake. */ - fun handleFileInfo(info: FileInfo) + fun handleFileInfo(info: FileInfo, session: RadioSessionContext): Boolean /** Returns the number of nodes received in the current stage. */ val newNodeCount: Int - /** Handles the completion of a configuration stage. */ - fun handleConfigComplete(configCompleteId: Int) + /** Handles the completion of a configuration stage admitted by [session]. */ + fun handleConfigComplete(configCompleteId: Int, session: RadioSessionContext): Boolean - /** Triggers a request for the full device configuration. */ - fun triggerWantConfig() + /** Triggers a request for the full device configuration when [session] still owns admission. */ + fun triggerWantConfig(session: RadioSessionContext): Boolean } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt index 56d9f38530..9bf19deb4c 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt @@ -33,24 +33,45 @@ interface MeshConfigHandler { /** Reactive local module configuration. */ val moduleConfig: StateFlow - /** Handles a received device configuration. */ - fun handleDeviceConfig(config: Config) + /** + * Handles a received device configuration. + * + * @param session The transport session that admitted [config]. + * @return `true` when the update is admitted for the current session; `false` when [session] is stale or revoked. + */ + fun handleDeviceConfig(config: Config, session: RadioSessionContext): Boolean - /** Handles a received module configuration. */ - fun handleModuleConfig(config: ModuleConfig) + /** + * Handles a received module configuration. + * + * @param session The transport session that admitted [config]. + * @return `true` when the update is admitted for the current session; `false` when [session] is stale or revoked. + */ + fun handleModuleConfig(config: ModuleConfig, session: RadioSessionContext): Boolean - /** Handles a received channel configuration. */ - fun handleChannel(channel: Channel) + /** + * Handles a received channel configuration. + * + * @param session The transport session that admitted [channel]. + * @return `true` when the update is admitted for the current session; `false` when [session] is stale or revoked. + */ + fun handleChannel(channel: Channel, session: RadioSessionContext): Boolean /** * Handles the [DeviceUIConfig] received during the config handshake (STATE_SEND_UIDATA). This arrives as the 2nd * packet in every handshake, immediately after my_info. + * + * @param session The transport session that admitted [config]. + * @return `true` when the update is admitted for the current session; `false` when [session] is stale or revoked. */ - fun handleDeviceUIConfig(config: DeviceUIConfig) + fun handleDeviceUIConfig(config: DeviceUIConfig, session: RadioSessionContext): Boolean /** * Handles the [LoRaRegionPresetMap] received during the config handshake (after metadata, before channels). It * describes which modem presets are legal in each LoRa region. Absent on firmware older than 2.8. + * + * @param session The transport session that admitted [map]. + * @return `true` when the update is admitted for the current session; `false` when [session] is stale or revoked. */ - fun handleRegionPresets(map: LoRaRegionPresetMap) + fun handleRegionPresets(map: LoRaRegionPresetMap, session: RadioSessionContext): Boolean } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt index 4879d757e4..231042948a 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt @@ -27,10 +27,17 @@ interface MeshDataHandler { * * @param packet The received mesh packet. * @param myNodeNum The local node number. + * @param session The transport session that admitted this packet. * @param logUuid Optional UUID for logging purposes. * @param logInsertJob Optional job that tracks the insertion of the packet into the log. */ - fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String? = null, logInsertJob: Job? = null) + fun handleReceivedData( + packet: MeshPacket, + myNodeNum: Int, + session: RadioSessionContext, + logUuid: String? = null, + logInsertJob: Job? = null, + ) /** * Persists a data packet in the history and triggers notifications if necessary. @@ -39,5 +46,10 @@ interface MeshDataHandler { * @param myNodeNum The local node number. * @param updateNotification Whether to trigger a notification for this packet. */ - fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean = true) + fun rememberDataPacket( + dataPacket: DataPacket, + myNodeNum: Int, + updateNotification: Boolean = true, + session: RadioSessionContext? = null, + ) } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt index 4b98e19ac0..ed2c026833 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt @@ -16,16 +16,15 @@ */ package org.meshtastic.core.repository -import org.meshtastic.proto.MeshPacket - /** Interface for processing incoming radio messages and mesh packets. */ interface MeshMessageProcessor { - /** Handles a raw message received from the radio. */ - fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) - - /** Handles a received mesh packet. */ - fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) + /** + * Handles [frame], a received radio message together with the immutable transport session that admitted it. + * + * @param myNodeNum local node number, or `null` while the current handshake has not resolved it + */ + suspend fun handleFromRadio(frame: ReceivedRadioFrame, myNodeNum: Int?) - /** Clears the buffer of early received packets. */ - fun clearEarlyPackets() + /** Clears buffered packets before a device boundary is published. */ + suspend fun clearEarlyPackets() } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt index 833d2a5ef5..58bd8cfa3c 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt @@ -29,6 +29,25 @@ import org.meshtastic.proto.User import org.meshtastic.proto.NodeInfo as ProtoNodeInfo import org.meshtastic.proto.Position as ProtoPosition +/** + * Fresh-handshake identity for one connection session. Separate from the general [NodeManager.myNodeNum]/ + * [NodeManager.myDeviceId] StateFlows, which can retain stale cached values across transport switches. [address], + * [nodeNum], and [deviceId] are captured from one selected transport and one MyNodeInfo handshake so consumers never + * have to combine independently delivered address and identity flows. [sessionGeneration] is the active transport + * session generation at handshake time, so the association collector can discard an identity retained from a previous + * transport instance even if every other field is equal. + */ +data class ConnectionIdentity( + val sessionGeneration: Long, + val address: String, + val nodeNum: Int, + val deviceId: String?, +) { + /** Privacy-safe diagnostic form: neither the transport address nor factory-burned device ID may enter logs. */ + override fun toString(): String = "ConnectionIdentity(sessionGeneration=$sessionGeneration, address=..., " + + "nodeNum=$nodeNum, deviceIdPresent=${deviceId != null})" +} + /** Interface for managing the in-memory node database and processing received node information. */ @Suppress("TooManyFunctions") interface NodeManager : NodeIdLookup { @@ -72,6 +91,31 @@ interface NodeManager : NodeIdLookup { /** Sets the firmware edition of the connected device. */ fun setFirmwareEdition(edition: FirmwareEdition?) + /** + * Fresh-handshake identity for the current connection session. Null when no handshake identity has been confirmed + * for the active transport. Cleared synchronously before a new address is published, and populated atomically when + * [handleMyInfo] captures the selected address and decodes nodeNum and deviceId from the same MyNodeInfo packet. + * Generation-boundary cleanup atomically preserves an identity already published for the new session. Never + * populated by [loadCachedNodeDB] or reactive DB cache emissions. + */ + val connectionIdentity: StateFlow + + /** Clears the connection-session identity source synchronously. */ + fun clearConnectionIdentity() + + /** + * Atomically clears an identity retained from a generation other than [activeSessionGeneration]. An identity that + * already belongs to the active generation is preserved, even when its publication races a delayed boundary + * collector from the same generation. + */ + fun clearStaleConnectionIdentity(activeSessionGeneration: Long) + + /** + * Publishes a fresh connection-session identity from a handshake MyNodeInfo. Invoked exactly once per handshake + * after [sessionGeneration], [address], [nodeNum], and [deviceId] have been captured for the same session. + */ + fun publishConnectionIdentity(sessionGeneration: Long, address: String, nodeNum: Int, deviceId: String?) + /** Loads the cached node database from the repository. */ fun loadCachedNodeDB() @@ -85,32 +129,69 @@ interface NodeManager : NodeIdLookup { fun getMyId(): String /** Processes a received user packet. */ - fun handleReceivedUser(fromNum: Int, p: User, channel: Int = 0, manuallyVerified: Boolean = false) + fun handleReceivedUser( + fromNum: Int, + p: User, + channel: Int = 0, + manuallyVerified: Boolean = false, + session: RadioSessionContext? = null, + ) /** Processes a received position packet. */ - fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) + fun handleReceivedPosition( + fromNum: Int, + myNodeNum: Int, + p: ProtoPosition, + defaultTime: Long, + session: RadioSessionContext? = null, + ) /** Processes a received telemetry packet. */ fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) /** Processes a received paxcounter packet. */ - fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount) + fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount, session: RadioSessionContext? = null) /** Processes a received node status message. */ - fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage) + fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage, session: RadioSessionContext? = null) /** Updates the status string for a node. */ fun updateNodeStatus(nodeNum: Int, status: String?) - /** Updates a node using a transformation function. */ + /** Updates node status and awaits any required persistence. */ + suspend fun updateNodeStatusAndPersist(nodeNum: Int, status: String?) + + /** + * Updates a node using a side-effect-free [transform] and schedules persistence. The transform may be evaluated + * more than once after compare-and-set contention; callers must perform notifications and other effects afterward. + */ fun updateNode(nodeNum: Int, channel: Int = 0, transform: (Node) -> Node) + /** Session-bound counterpart to [updateNode]; deferred persistence is admitted only for [session]. */ + fun updateNodeForSession(nodeNum: Int, session: RadioSessionContext, channel: Int = 0, transform: (Node) -> Node) + + /** + * Updates a node using a side-effect-free [transform] and awaits any required persistence before returning. The + * transform may be evaluated more than once after compare-and-set contention. + * + * Session-scoped packet processing uses this while holding transport authority so an old session cannot enqueue a + * database write that resumes after a device switch. Non-session UI and controller updates continue to use + * [updateNode]. + */ + suspend fun updateNodeAndPersist(nodeNum: Int, channel: Int = 0, transform: (Node) -> Node) + /** Removes a node from the in-memory database by its number. */ fun removeByNodenum(nodeNum: Int) - /** Installs node information from a ProtoNodeInfo object. */ + /** + * Installs node information in memory. Callers that require an immediate standalone write use + * [installNodeInfoAndPersist]; configuration handshakes batch the resulting snapshot through the repository. + */ fun installNodeInfo(info: ProtoNodeInfo) + /** Installs node information and awaits any required persistence. */ + suspend fun installNodeInfoAndPersist(info: ProtoNodeInfo) + /** Inserts hardware metadata for a node. */ - fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) + fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata, session: RadioSessionContext? = null) } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt index cbb6322f2c..28e94831ec 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt @@ -42,8 +42,8 @@ interface PacketHandler { /** Processes queue status updates from the radio. */ fun handleQueueStatus(queueStatus: QueueStatus) - /** Removes a pending response for a request. */ - fun removeResponse(dataRequestId: Int, complete: Boolean) + /** Removes and completes a pending response for a request before the caller's lifecycle lease is released. */ + suspend fun removeResponse(dataRequestId: Int, complete: Boolean) /** Stops the packet queue. */ fun stopPacketQueue() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt index 7e0ec10850..d4318512cd 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt @@ -24,6 +24,54 @@ import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.model.MeshActivity +/** A lifecycle lease held by one already-admitted operation for [session]. */ +interface RadioSessionLease { + val session: RadioSessionContext + + /** True until this admitted operation releases the session and rollover may complete. */ + fun isCurrent(): Boolean +} + +/** Owns admission and revocation for work bound to one transport session. */ +interface RadioSessionAuthority { + /** + * The transport session that still owns lifecycle completion, or null after teardown drains every admitted + * operation. Implementations close admission before teardown, so [isSessionActive] and the run helpers reject new + * work immediately even while this flow temporarily retains the draining session. + */ + val activeSession: StateFlow + + /** + * Returns whether [session] still owns transport admission. Implementations must make this reflect the admission + * gate, not only [activeSession], because the draining session may remain published after new work is rejected. + */ + fun isSessionActive(session: RadioSessionContext): Boolean + + /** + * Runs [block] only while [session] owns transport admission. Implementations must make the admission check and + * synchronous side effect atomic with teardown. + */ + fun runIfSessionActive(session: RadioSessionContext, block: () -> Unit): Boolean + + /** + * Acquires a lifecycle lease for suspend [block]. Once admitted, teardown closes admission to later work and waits + * for this block to finish before publishing session completion or starting a replacement transport. [block] may + * use [RadioSessionLease.isCurrent] for transaction-bound checks that must remain valid through commit even after + * teardown has closed new admission. Implementations must acquire and release the lease through the same admission + * state used by teardown; comparing only [activeSession] cannot satisfy the draining contract. Callers must keep + * the block bounded and must not invoke transport lifecycle methods from inside it. + */ + suspend fun runWithSessionLease(session: RadioSessionContext, block: suspend (RadioSessionLease) -> Unit): Boolean + + /** + * Runs [block] while holding the same lifecycle lease, without exposing the lease token. Implementations may + * serialize this convenience path to preserve handshake ordering; independently deferred work should use + * [runWithSessionLease] so it can acquire its own lease before its parent operation returns. + */ + suspend fun runWhileSessionActive(session: RadioSessionContext, block: suspend () -> Unit): Boolean = + runWithSessionLease(session) { block() } +} + /** * Interface for the low-level radio interface that handles raw byte communication. * @@ -39,7 +87,9 @@ import org.meshtastic.core.model.MeshActivity * * @see ServiceRepository.connectionState */ -interface RadioInterfaceService : RadioTransportCallback { +interface RadioInterfaceService : + RadioTransportCallback, + RadioSessionAuthority { /** The device types supported by this platform's radio interface. */ val supportedDeviceTypes: List @@ -65,17 +115,24 @@ interface RadioInterfaceService : RadioTransportCallback { /** Flow of the current device address. */ val currentDeviceAddressFlow: StateFlow + /** + * Monotonically increasing generation bumped on every transport start (including same-address reconnect). Consumers + * use this to discard state retained from a previous transport instance. Stub implementations that never start a + * real transport expose a constant zero flow. + */ + val sessionGeneration: StateFlow + /** Whether we are currently using a mock transport. */ fun isMockTransport(): Boolean /** - * Flow of raw data received from the radio. + * Flow of raw data received from the radio, bound to the transport session that admitted each frame. * * Emissions preserve the order in which bytes arrived from the hardware — this is required because the firmware * handshake (initial config packet ordering) depends on strict FIFO delivery. Implementations MUST guarantee * ordering; do not swap in a [SharedFlow] without preserving order. */ - val receivedData: Flow + val receivedData: Flow /** Flow of radio activity events. */ val meshActivity: Flow diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ReceivedRadioFrame.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ReceivedRadioFrame.kt new file mode 100644 index 0000000000..aa9d56700e --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ReceivedRadioFrame.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.repository + +import okio.ByteString + +/** Immutable identity of the transport session that admitted an inbound radio frame. */ +data class RadioSessionContext(val generation: Long, val address: String) { + /** Prevent accidental disclosure of the real transport address in diagnostic interpolation. */ + override fun toString(): String = "RadioSessionContext(generation=$generation, address=...)" +} + +/** + * An inbound radio payload bound to the transport session that admitted it. + * + * [payload] is an immutable [ByteString], so queued work cannot observe later mutation of a transport-owned byte array. + * [session] retains the real internal address required for database association; callers must anonymize that address + * before logging it. + */ +data class ReceivedRadioFrame(val payload: ByteString, val session: RadioSessionContext) { + /** Prevent accidental disclosure of raw radio payloads in diagnostic interpolation. */ + override fun toString(): String = "ReceivedRadioFrame(payloadSize=${payload.size}, session=$session)" +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt index e884a8d3c3..c39333755e 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt @@ -28,12 +28,17 @@ interface StoreForwardPacketHandler { * @param dataPacket The decoded data packet. * @param myNodeNum The local node number. */ - fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) + fun handleStoreAndForward( + packet: MeshPacket, + dataPacket: DataPacket, + myNodeNum: Int, + session: RadioSessionContext? = null, + ) /** * Handles a Store Forward++ packet. * * @param packet The received mesh packet. */ - fun handleStoreForwardPlusPlus(packet: MeshPacket) + fun handleStoreForwardPlusPlus(packet: MeshPacket, session: RadioSessionContext? = null) } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt index 31ec30db8d..a80d3eb9a7 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt @@ -27,6 +27,7 @@ interface TelemetryPacketHandler { * @param packet The received mesh packet. * @param dataPacket The decoded data packet. * @param myNodeNum The local node number. + * @param session The immutable transport session that admitted [packet]. */ - fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) + fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int, session: RadioSessionContext) } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt index c0cc42aa21..b698127873 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt @@ -30,6 +30,7 @@ interface TracerouteHandler { * @param packet The received mesh packet. * @param logUuid Optional UUID for the associated log entry. * @param logInsertJob Optional job for the log entry insertion, to ensure ordering. + * @param session The immutable transport session that admitted [packet]. */ - fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: Job?) + fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: Job?, session: RadioSessionContext) } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index 46655cc6a9..3afe5df6eb 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt @@ -166,9 +166,14 @@ class MeshServiceOrchestrator( radioInterfaceService.receivedData // This loop is the single lifeline for every inbound packet. handleFromRadio is already total, but guard // here too so nothing — now or later — can cancel the collection and leave the radio permanently deaf. - .onEach { bytes -> - safeCatching { messageProcessor.handleFromRadio(bytes, nodeManager.myNodeNum.value) } - .onFailure { Logger.e(it) { "Dropped inbound bytes after a receive-loop error" } } + .onEach { frame -> + val session = frame.session + if (!radioInterfaceService.isSessionActive(session)) { + Logger.d { "Dropping queued frame from stale transport session gen=${session.generation}" } + return@onEach + } + safeCatching { messageProcessor.handleFromRadio(frame, nodeManager.myNodeNum.value) } + .onFailure { Logger.e(it) { "Dropped inbound frame after a receive-loop error" } } } .launchIn(newScope) diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt index c8187214d1..8b353b666a 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt @@ -17,15 +17,24 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.repository.AdminController import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.ConnectionIdentity import org.meshtastic.core.repository.MeshDataHandler import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.MeshMessageProcessor @@ -41,10 +50,18 @@ import org.meshtastic.core.repository.QueryController import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.ClientNotification +private data class AssociationSnapshot( + val identity: ConnectionIdentity?, + val selectedAddress: String?, + val sessionGeneration: Long, + val activeSession: RadioSessionContext?, +) + /** * Platform-agnostic [RadioController] composition root for any target where the service runs in-process (Desktop, iOS, * or Android in single-process mode). @@ -90,19 +107,89 @@ class RadioControllerImpl( NodeController by NodeControllerImpl(commandSender, nodeManager, packetRepository, scope), QueryController by QueryControllerImpl(commandSender, nodeManager, uiPrefs) { + private val deviceSwitchMutex = Mutex() + init { - // Unify per-device databases across transports. When the handshake reports our node number, tell the - // DatabaseManager to claim (or merge into) that device's canonical DB, so the same device reached over BLE, - // TCP, or USB shares one stored history. The hardware device id (when reported) is the durable claim key — - // firmware 2.8 renumbers devices (num = crc32(public_key)) on upgrade/erase/re-key — with the node number as - // fallback. Keyed on (address, node, device) so a second transport for the same device re-fires even though - // the identity itself is unchanged. + // Reconcile the connection-session identity at every transport-session boundary (stop/start cycle), not only + // when the selected address changes. Without this, a same-address same-device reconnect can retain the old + // ConnectionIdentity. Capture the initial generation before subscribing instead of blindly dropping the first + // emission: if a transport starts between capture and collector subscription, the collector's first replay is + // the real boundary. The generation-aware atomic clear removes the old identity without erasing a fresh one + // that the new session may already have published while this collector was awaiting dispatch. + val initialSessionGeneration = radioInterfaceService.sessionGeneration.value + radioInterfaceService.sessionGeneration + .onEach { generation -> + if (generation == initialSessionGeneration) return@onEach + Logger.d { "[DeviceAssociation] reconciling identity at session-generation boundary gen=$generation" } + nodeManager.clearStaleConnectionIdentity(generation) + } + .launchIn(scope) + + // Unify per-device databases across transports. When a fresh connection-handshake identity + // arrives (nodeNum + deviceId from the same MyNodeInfo), tell the DatabaseManager to claim + // (or merge into) that device's canonical DB, so the same device reached over BLE, TCP, or + // USB shares one stored history. The hardware device id (when reported) is the durable claim + // key — firmware 2.8 renumbers devices (num = crc32(public_key)) on upgrade/erase/re-key — + // with the node number as fallback. Keyed on (address, identity) so a second transport for + // the same device re-fires even though the identity itself is unchanged. + // + // Uses the dedicated connectionIdentity source (not the general myNodeNum/myDeviceId flows) + // so that a stale cached identity from a previous transport cannot associate the new address + // with the old node number. Each identity carries the address selected for its handshake, so correctness does + // not depend on delivery order between independent StateFlows. The selected address is checked again at the + // point of association to discard a session that became stale while queued for collection. + // + // Collect the nullable identity together with both active-session coordinates. The admitted lifecycle lease + // closes new work on teardown but delays session rollover until association and any destination transaction + // commit return. collectLatest still cancels work whose identity or selected address changes independently. + // CancellationException preserves structured cancellation; recoverable Exceptions are logged, and fatal + // Errors propagate. + @Suppress("TooGenericExceptionCaught") scope.launch { - combine(meshPrefs.deviceAddress, nodeManager.myNodeNum, nodeManager.myDeviceId) { address, nodeNum, devId -> - Triple(address, nodeNum, devId) + combine( + nodeManager.connectionIdentity, + meshPrefs.deviceAddress, + radioInterfaceService.sessionGeneration, + radioInterfaceService.activeSession, + ) { identity, selectedAddress, sessionGeneration, activeSession -> + AssociationSnapshot(identity, selectedAddress, sessionGeneration, activeSession) } .distinctUntilChanged() - .collect { (_, nodeNum, deviceId) -> nodeNum?.let { databaseManager.associateDevice(it, deviceId) } } + .collectLatest { snapshot -> + val identity = snapshot.identity ?: return@collectLatest + val authorizingSession = + RadioSessionContext(generation = identity.sessionGeneration, address = identity.address) + if ( + snapshot.selectedAddress != identity.address || + snapshot.sessionGeneration != identity.sessionGeneration || + snapshot.activeSession != authorizingSession + ) { + Logger.d { "[DeviceAssociation] skip stale-session node=${identity.nodeNum}" } + return@collectLatest + } + Logger.d { + "[DeviceAssociation] associate address=... node=${identity.nodeNum}" + + " deviceIdPresent=${identity.deviceId != null}" + } + try { + val admitted = + radioInterfaceService.runWithSessionLease(authorizingSession) { lease -> + databaseManager.associateDevice( + identity.address, + identity.nodeNum, + identity.deviceId, + lease::isCurrent, + ) + } + if (!admitted) { + Logger.d { "[DeviceAssociation] skip revoked-session node=${identity.nodeNum}" } + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Exception) { + Logger.w(failure) { "Device association failed for current radio session" } + } + } } } @@ -132,9 +219,42 @@ class RadioControllerImpl( // ── Device Address ────────────────────────────────────────────────────── + @Suppress("TooGenericExceptionCaught") override suspend fun setDeviceAddress(address: String) { - switchDevice(address) - radioInterfaceService.setDeviceAddress(address) + deviceSwitchMutex.withLock { + // The transport service updates its selected-address flow synchronously, while MeshPrefs persists on an + // asynchronous DataStore scope. Prefer the transport snapshot so back-to-back selections cannot roll back + // to a stale preference value; fall back to MeshPrefs during startup before the transport has a selection. + val transportAddress = radioInterfaceService.getDeviceAddress() + val previousAddress = transportAddress ?: meshPrefs.deviceAddress.value + if (address == previousAddress) { + // This is intentionally more than a preference no-op: when the selected transport is disconnected, + // setDeviceAddress() cycles it as a repair attempt; when it is already connected, the transport + // reports a no-op and the postcondition below confirms that the requested address remains selected. + val accepted = radioInterfaceService.setDeviceAddress(address) + // SharedRadioInterfaceService reports an already-connected no-op as false; confirm that postcondition + // without allowing a genuine rejection to persist preferences or publish a success callback. + check(accepted || radioInterfaceService.getDeviceAddress() == address) { + "Transport rejected the existing device address" + } + meshPrefs.setDeviceAddress(address) + } else { + // Revoke the old transport session before publishing a different database. Otherwise the old transport + // can deliver one last frame after switchDevice() has moved currentDb, writing stale-session data into + // the new device's database. + radioInterfaceService.disconnect() + try { + switchDevice(address) + check(radioInterfaceService.setDeviceAddress(address)) { + "Transport rejected the new device address" + } + } catch (failure: Exception) { + withContext(NonCancellable) { rollbackDeviceSwitch(previousAddress, failure) } + throw failure + } + } + } + // Keep callbacks outside the transition mutex so observers cannot deadlock by scheduling another selection. onDeviceAddressChanged?.invoke() } @@ -143,15 +263,64 @@ class RadioControllerImpl( } private suspend fun switchDevice(deviceAddr: String) { - val currentAddr = meshPrefs.deviceAddress.value - if (deviceAddr != currentAddr) { - Logger.i { "Device address changed, switching database and clearing node DB" } - meshPrefs.setDeviceAddress(deviceAddr) + Logger.i { "Device address changed, switching database and clearing node DB" } + // Revoke identity and persistence before publishing the new database. A node write already admitted against + // the old pool drains through DatabaseManager; a queued write rechecks allowNodeDbWrites and cannot start on + // the new pool with the old node snapshot. + nodeManager.clearConnectionIdentity() + nodeManager.setAllowNodeDbWrites(false) + Logger.d { "[DeviceAssociation] session-cleared before address switch" } + messageProcessor.value.clearEarlyPackets() + databaseManager.switchActiveDatabase(deviceAddr) + nodeManager.clear() + notificationManager.cancelAll() + nodeManager.loadCachedNodeDB() + // Commit the persisted selection last. MeshPrefs writes asynchronously, so the transport's synchronous + // selected-address snapshot remains the rollback authority for a rapid subsequent selection. + meshPrefs.setDeviceAddress(deviceAddr) + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun rollbackDeviceSwitch(previousAddress: String?, originalFailure: Exception) { + suspend fun attemptRollback(description: String, block: suspend () -> Unit): Boolean = try { + block() + true + } catch (rollbackFailure: Exception) { + originalFailure.addSuppressed(rollbackFailure) + Logger.w(rollbackFailure) { "Failed to roll back $description after device-switch failure" } + false + } + + // Database ownership is the safety boundary. Never restore a transport until the database that belongs to it + // is active again; otherwise old-radio frames can resume against the new device's pool. + val databaseRestored = + attemptRollback("database selection") { databaseManager.switchActiveDatabase(previousAddress) } + if (!databaseRestored) { + // Keep each cleanup independent: failure in one best-effort reset must not prevent transport deselection. + attemptRollback("fail-closed node-write disable") { nodeManager.setAllowNodeDbWrites(false) } + attemptRollback("fail-closed connection-identity clear") { nodeManager.clearConnectionIdentity() } + attemptRollback("fail-closed node-state clear") { nodeManager.clear() } + attemptRollback("fail-closed early-packet clear") { messageProcessor.value.clearEarlyPackets() } + attemptRollback("fail-closed notification clear") { notificationManager.cancelAll() } + attemptRollback("fail-closed persisted selection") { meshPrefs.setDeviceAddress(null) } + attemptRollback("fail-closed transport selection") { + check(radioInterfaceService.setDeviceAddress(null)) { "Transport rejected fail-closed deselection" } + } + return + } + + attemptRollback("persisted device selection") { meshPrefs.setDeviceAddress(previousAddress) } + attemptRollback("node state") { + nodeManager.clearConnectionIdentity() nodeManager.clear() messageProcessor.value.clearEarlyPackets() - databaseManager.switchActiveDatabase(deviceAddr) notificationManager.cancelAll() nodeManager.loadCachedNodeDB() } + attemptRollback("transport selection") { + check(radioInterfaceService.setDeviceAddress(previousAddress)) { + "Transport rejected the previous device address" + } + } } } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt index 0062e51656..e2325334f0 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt @@ -20,8 +20,12 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope import co.touchlab.kermit.Logger import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow @@ -45,6 +49,8 @@ import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.ble.BluetoothRepository @@ -62,14 +68,30 @@ import org.meshtastic.core.network.repository.SerialDevicePresence import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs +import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.core.repository.RadioSessionLease import org.meshtastic.core.repository.RadioTransport import org.meshtastic.core.repository.RadioTransportFactory +import org.meshtastic.core.repository.ReceivedRadioFrame import org.meshtastic.core.repository.TransportDisconnectReason import org.meshtastic.proto.ToRadio import kotlin.concurrent.Volatile private const val USB_PERMISSION_DENIED_ERROR = "USB permission denied. Reconnect the device to try again." +/** + * Immutable per-start transport identity. [generation] is bumped on every transport start (including same-address + * reconnect) so the [SharedRadioInterfaceService] and its consumers can discard state retained from a previous + * transport instance even when the selected address is unchanged. + */ +data class RadioTransportSession(val generation: Long, val address: String) { + /** Immutable context carried with every frame admitted by this session. */ + val context = RadioSessionContext(generation = generation, address = address) + + /** Prevent accidental disclosure of the raw transport address in diagnostic interpolation. */ + override fun toString(): String = "RadioTransportSession(generation=$generation, address=...)" +} + private data class SelectedSerialPresence(val key: String?, val present: Boolean) private data class UsbRecoverySnapshot(val presence: SelectedSerialPresence, val state: ConnectionState) @@ -161,13 +183,140 @@ class SharedRadioInterfaceService( private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr.value) override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow() + // Monotonically increasing generation bumped on every transport start (including same-address reconnect). Exposed + // through [sessionGeneration] so the controller layer can clear connection-session identity at each session + // boundary instead of only when the selected address changes. + private val sessionGenerationCounter = atomic(0L) + private val _sessionGeneration = MutableStateFlow(0L) + override val sessionGeneration: StateFlow = _sessionGeneration.asStateFlow() + + private val _activeSession = MutableStateFlow(null) + override val activeSession: StateFlow = _activeSession.asStateFlow() + + /** + * The transport session that owns lifecycle completion. [stopTransportLocked] closes its admission first, waits for + * every already-admitted operation, then clears this token before closing the underlying transport. Close-time and + * post-close callbacks are rejected as soon as admission closes. + */ + @Volatile private var activeTransportSession: RadioTransportSession? = null + + /** Serializes session publication/revocation with callback and suspend-operation admission. */ + private val sessionCallbackLock = SynchronizedObject() + + /** Guarded by [sessionCallbackLock]. New work is rejected immediately when teardown closes this gate. */ + private var sessionAdmissionOpen = false + + /** Number of suspend operations admitted for [activeTransportSession], guarded by [sessionCallbackLock]. */ + private var admittedSessionOperations = 0 + + /** Completed by the last admitted operation after teardown closes admission. Guarded by [sessionCallbackLock]. */ + private var sessionDrainWaiter: CompletableDeferred? = null + + /** Preserves FIFO ordering for handshake work without blocking independently leased packet side effects. */ + private val sessionOperationMutex = Mutex() + + override fun isSessionActive(session: RadioSessionContext): Boolean = + synchronized(sessionCallbackLock) { sessionAdmissionOpen && activeTransportSession?.context == session } + + override fun runIfSessionActive(session: RadioSessionContext, block: () -> Unit): Boolean = + synchronized(sessionCallbackLock) { + if (!sessionAdmissionOpen || activeTransportSession?.context != session) return@synchronized false + block() + true + } + + override suspend fun runWithSessionLease( + session: RadioSessionContext, + block: suspend (RadioSessionLease) -> Unit, + ): Boolean { + val admittedSession = + synchronized(sessionCallbackLock) { + val active = activeTransportSession + if (!sessionAdmissionOpen || active?.context != session) { + null + } else { + admittedSessionOperations++ + active + } + } ?: return false + + val lease = + object : RadioSessionLease { + override val session: RadioSessionContext = session + + override fun isCurrent(): Boolean = + synchronized(sessionCallbackLock) { activeTransportSession === admittedSession } + } + + try { + block(lease) + return true + } finally { + val drainWaiter = + synchronized(sessionCallbackLock) { + check(activeTransportSession === admittedSession) { + "Session changed before an admitted operation released its lease" + } + check(admittedSessionOperations > 0) { "Session operation count underflow" } + admittedSessionOperations-- + if (admittedSessionOperations == 0) { + sessionDrainWaiter.also { sessionDrainWaiter = null } + } else { + null + } + } + drainWaiter?.complete(Unit) + } + } + + override suspend fun runWhileSessionActive(session: RadioSessionContext, block: suspend () -> Unit): Boolean = + sessionOperationMutex.withLock { runWithSessionLease(session) { block() } } + + /** Runs a callback only while [session] still owns admission, atomically with session teardown. */ + private inline fun runIfTransportSessionActive(session: RadioTransportSession, block: () -> Unit): Boolean = + synchronized(sessionCallbackLock) { + if (!sessionAdmissionOpen || activeTransportSession !== session) return@synchronized false + block() + true + } + + /** + * Closes admission for [session], waits for its existing suspend operations, then publishes lifecycle completion. + * The closed gate rejects queued callbacks and new operations immediately; the retained session token prevents a + * replacement generation from overlapping an admitted database commit. + */ + private suspend fun revokeTransportSession(session: RadioTransportSession?) { + if (session == null) return + withContext(NonCancellable) { + val drainWaiter = + synchronized(sessionCallbackLock) { + if (activeTransportSession !== session) return@synchronized null + sessionAdmissionOpen = false + if (admittedSessionOperations == 0) { + null + } else { + sessionDrainWaiter ?: CompletableDeferred().also { sessionDrainWaiter = it } + } + } + drainWaiter?.await() + synchronized(sessionCallbackLock) { + if (activeTransportSession === session) { + check(admittedSessionOperations == 0) { "Session revoked before admitted operations drained" } + activeTransportSession = null + _activeSession.value = null + sessionDrainWaiter = null + } + } + } + } + // Unbounded Channel preserves strict FIFO delivery of incoming radio bytes, which the // firmware handshake depends on (initial config packet ordering). A SharedFlow with // `launch { emit() }` per packet reorders under concurrent dispatch and breaks config load. // trySend on an UNLIMITED channel never suspends and never drops, so handleFromRadio can // remain a non-suspend synchronous callback. - private val _receivedData = Channel(Channel.UNLIMITED) - override val receivedData: Flow = _receivedData.receiveAsFlow() + private val _receivedData = Channel(Channel.UNLIMITED) + override val receivedData: Flow = _receivedData.receiveAsFlow() private val _meshActivity = MutableSharedFlow(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -380,7 +529,10 @@ class SharedRadioInterfaceService( // Mark the connection lifecycle as active BEFORE starting so concurrent // hardware/network listeners observe the gate as open. connectionRequested = true - startTransportLocked() + // connect() is fire-and-forget, so a recoverable factory failure has no caller to receive it. The + // start path already rolls back partial session state; contain the failure here so it does not become + // an uncaught lifecycle-scope exception, while CancellationException and fatal Errors still propagate. + ignoreExceptionSuspend { startTransportLocked() } } } initStateListeners() @@ -523,7 +675,10 @@ class SharedRadioInterfaceService( } ignoreExceptionSuspend { stopTransportLocked() } if (sanitized != null) { - startTransportLocked() + // setDeviceAddress() is fire-and-forget. startTransportLocked() has already rolled back any + // partially admitted session, so contain a recoverable factory failure instead of crashing the + // process-lifecycle scope. Explicit suspend restart callers still receive their failures. + ignoreExceptionSuspend { startTransportLocked() } } } } @@ -531,7 +686,8 @@ class SharedRadioInterfaceService( } /** Must be called under [transportMutex]. */ - private fun startTransportLocked() { + @Suppress("TooGenericExceptionCaught") + private suspend fun startTransportLocked() { if (radioTransport != null) return // Never autoconnect to the simulated node. The mock transport may be offered in the @@ -544,10 +700,42 @@ class SharedRadioInterfaceService( return } - Logger.i { "Starting radio transport for ${address.anonymize}" } - isStarted = true + // Build a fresh per-instance session and admit it BEFORE constructing the transport so the wrapper captures + // the new session. Bumping the public generation also signals downstream consumers (e.g. + // RadioControllerImpl) + // to invalidate session-scoped state from any previous transport instance. + val generation = sessionGenerationCounter.incrementAndGet() + val session = RadioTransportSession(generation = generation, address = address) + synchronized(sessionCallbackLock) { + check(activeTransportSession == null && admittedSessionOperations == 0) { + "Cannot admit a transport while the previous session is still draining" + } + activeTransportSession = session + sessionAdmissionOpen = true + _activeSession.value = session.context + _sessionGeneration.value = generation + } + val sessionBoundService = SessionBoundRadioInterfaceService(session) + val connectionStateBeforeStart = _connectionState.value + + Logger.i { "Starting radio transport for ${address.anonymize} (generation=$generation)" } + val newTransport = + try { + transportFactory.createTransport(address, sessionBoundService) + } catch (failure: Throwable) { + // A failed factory call consumed a generation that may already have reached observers. Keep the + // generation monotonic, but revoke the admitted session and every transport-lifecycle field before + // rethrowing the original failure. A later retry will receive a strictly newer generation. + revokeTransportSession(session) + radioTransport = null + runningTransportId = null + isStarted = false + _connectionState.value = connectionStateBeforeStart + throw failure + } + radioTransport = newTransport runningTransportId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } - radioTransport = transportFactory.createTransport(address, this) + isStarted = true startHeartbeat() } @@ -560,8 +748,16 @@ class SharedRadioInterfaceService( * tearing down. Set `false` when the transport is already dead (zombie session) to avoid writing into a broken * link. */ - private suspend fun stopTransportLocked(notifyPermanent: Boolean = true, sendPoliteDisconnect: Boolean = true) { + private suspend fun stopTransportLocked(notifyPermanent: Boolean = true, sendPoliteDisconnect: Boolean = true) = + withContext(NonCancellable) { finishTransportTeardown(notifyPermanent, sendPoliteDisconnect) } + + /** Completes teardown after admission closes, even if its requester is cancelled while leases drain. */ + private suspend fun finishTransportTeardown(notifyPermanent: Boolean, sendPoliteDisconnect: Boolean) { val currentTransport = radioTransport + val currentSession = synchronized(sessionCallbackLock) { activeTransportSession } + // Reject queued callbacks and new suspend work immediately, then drain existing leases before admitting a + // replacement generation or closing the old transport. + revokeTransportSession(currentSession) Logger.i { "Stopping transport $currentTransport" } // Best-effort polite goodbye: tell the firmware we're disconnecting on purpose so it can // tear down its side of the link cleanly instead of relying on timeouts / hardware events. @@ -572,26 +768,33 @@ class SharedRadioInterfaceService( // transport's own scope; the drain delay gives async transports a window to flush before // close() cancels their write scope. BLE's retry path backs off 500ms, so this window // also covers one retry on flaky GATT links. - if ( - sendPoliteDisconnect && currentTransport != null && _connectionState.value != ConnectionState.Disconnected - ) { - isStopping = true - ignoreExceptionSuspend { - currentTransport.handleSendToRadio(ToRadio(disconnect = true).encode()) - delay(POLITE_DISCONNECT_DRAIN_MS) + try { + if ( + sendPoliteDisconnect && + currentTransport != null && + _connectionState.value != ConnectionState.Disconnected + ) { + isStopping = true + ignoreExceptionSuspend { + currentTransport.handleSendToRadio(ToRadio(disconnect = true).encode()) + delay(POLITE_DISCONNECT_DRAIN_MS) + } + } + } finally { + isStarted = false + radioTransport = null + runningTransportId = null + isStopping = false + try { + currentTransport?.close() + } finally { + _serviceScope.cancel("stopping transport") + _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob()) + + if (notifyPermanent && currentTransport != null) { + onDisconnect(isPermanent = true) + } } - } - isStarted = false - radioTransport = null - runningTransportId = null - isStopping = false - currentTransport?.close() - - _serviceScope.cancel("stopping transport") - _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob()) - - if (notifyPermanent && currentTransport != null) { - onDisconnect(isPermanent = true) } } @@ -670,7 +873,10 @@ class SharedRadioInterfaceService( ignoreExceptionSuspend { stopTransportLocked(notifyPermanent = false, sendPoliteDisconnect = false) } - startTransportLocked() + // This restart is launched from a heartbeat callback and has no caller to receive a + // recoverable factory exception. The start path rolls back the failed session first; + // contain the rethrow here so a failed reconnect does not crash the app. + ignoreExceptionSuspend { startTransportLocked() } } } finally { isRestarting.value = false @@ -711,13 +917,27 @@ class SharedRadioInterfaceService( @Suppress("TooGenericExceptionCaught") override fun handleFromRadio(bytes: ByteArray) { + val admitted = + synchronized(sessionCallbackLock) { + val session = activeTransportSession ?: return@synchronized false + enqueueReceivedData(bytes, session) + true + } + if (!admitted) { + Logger.d { "Dropping ${bytes.size} received bytes without an active transport session" } + } + } + + @Suppress("TooGenericExceptionCaught") + private fun enqueueReceivedData(bytes: ByteArray, session: RadioTransportSession) { try { lastDataReceivedMillis = now() // trySend synchronously onto the unbounded Channel so packet order matches arrival // order. The previous `launch { emit() }` pattern dispatched each packet onto a // fresh coroutine, letting the scheduler reorder them — which broke the firmware // config handshake (see PhoneAPI.cpp initial-handshake sequence). - val result = _receivedData.trySend(bytes) + val frame = ReceivedRadioFrame(payload = bytes.toByteString(), session = session.context) + val result = _receivedData.trySend(frame) if (result.isFailure) { Logger.e(result.exceptionOrNull()) { "Failed to enqueue ${bytes.size} received bytes; dropping packet" } } @@ -736,6 +956,11 @@ class SharedRadioInterfaceService( } override fun onConnect() { + synchronized(sessionCallbackLock) { publishConnected() } + } + + /** Applies a callback already admitted under [sessionCallbackLock]. */ + private fun publishConnected() { // MutableStateFlow.value is thread-safe (backed by atomics) — assign directly rather than // launching a coroutine. The async launch pattern introduced a window where a concurrent // onDisconnect launch could execute AFTER an onConnect launch, leaving the service stuck @@ -748,6 +973,11 @@ class SharedRadioInterfaceService( } override fun onDisconnect(isPermanent: Boolean, errorMessage: String?, reason: TransportDisconnectReason?) { + synchronized(sessionCallbackLock) { publishDisconnected(isPermanent, errorMessage, reason) } + } + + /** Applies a callback already admitted under [sessionCallbackLock]. */ + private fun publishDisconnected(isPermanent: Boolean, errorMessage: String?, reason: TransportDisconnectReason?) { val resolvedErrorMessage = errorMessage ?: reason?.toConnectionErrorMessage() if (resolvedErrorMessage != null) { processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(resolvedErrorMessage) } @@ -758,4 +988,43 @@ class SharedRadioInterfaceService( _connectionState.value = newTargetState } } + + /** + * Per-transport-session wrapper around this service. Delegates every [RadioInterfaceService] surface (scope, + * address, sendToRadio, etc.) to the enclosing service; only the three [RadioTransportCallback] entry points are + * gated on the captured [session] still being the [activeTransportSession]. Admission and the synchronous callback + * side effect share [sessionCallbackLock] with teardown, eliminating a check-then-use window. Late callbacks from a + * torn-down transport are dropped BEFORE bytes enter the shared received-data channel or connection/config state + * mutates. Address is logged with [anonymize] and the session generation only — never the raw address bytes. + */ + private inner class SessionBoundRadioInterfaceService(val session: RadioTransportSession) : + RadioInterfaceService by this@SharedRadioInterfaceService { + override fun onConnect() { + val admitted = runIfTransportSessionActive(session, ::publishConnected) + if (!admitted) { + Logger.d { "Dropping stale onConnect gen=${session.generation} addr=${session.address.anonymize}" } + } + } + + override fun onDisconnect(isPermanent: Boolean, errorMessage: String?, reason: TransportDisconnectReason?) { + val admitted = + runIfTransportSessionActive(session) { publishDisconnected(isPermanent, errorMessage, reason) } + if (!admitted) { + Logger.d { "Dropping stale onDisconnect gen=${session.generation} addr=${session.address.anonymize}" } + } + } + + override fun handleFromRadio(bytes: ByteArray) { + val admitted = + runIfTransportSessionActive(session) { + this@SharedRadioInterfaceService.enqueueReceivedData(bytes, session) + } + if (!admitted) { + Logger.d { + "Dropping stale handleFromRadio (${bytes.size} bytes) gen=${session.generation} " + + "addr=${session.address.anonymize}" + } + } + } + } } diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt index bbc31356cb..a87b4e5aed 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher +import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState @@ -43,11 +44,15 @@ import org.meshtastic.core.repository.MeshNotificationManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.core.repository.ReceivedRadioFrame import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs import org.meshtastic.core.takserver.TAKMeshIntegration import org.meshtastic.core.takserver.TAKServerManager +import org.meshtastic.proto.FromRadio import org.meshtastic.proto.LocalModuleConfig +import org.meshtastic.proto.MyNodeInfo import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -55,6 +60,10 @@ import kotlin.test.assertTrue class MeshServiceOrchestratorTest { + private companion object { + const val DEFAULT_ADDRESS = "x:AA:BB:CC:DD:EE:FF" + } + private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill) private val serviceRepository: ServiceRepository = mock(MockMode.autofill) private val nodeManager: NodeManager = mock(MockMode.autofill) @@ -77,13 +86,17 @@ class MeshServiceOrchestratorTest { /** Stubs the shared flow dependencies used by every test and returns an orchestrator. */ private fun createOrchestrator( - receivedData: MutableSharedFlow = MutableSharedFlow(), + receivedData: MutableSharedFlow = MutableSharedFlow(), connectionError: MutableSharedFlow = MutableSharedFlow(), connectionState: MutableStateFlow = MutableStateFlow(ConnectionState.Disconnected), // A valid default address lets every start() proceed through the new // wait-for-valid-address -> DB switch -> connect ordering without per-test boilerplate. // Tests that need a different initial address (or no address) override it explicitly. - currentDeviceAddressFlow: MutableStateFlow = MutableStateFlow("x:AA:BB:CC:DD:EE:FF"), + currentDeviceAddressFlow: MutableStateFlow = MutableStateFlow(DEFAULT_ADDRESS), + sessionGeneration: MutableStateFlow = MutableStateFlow(0L), + activeSession: MutableStateFlow = + MutableStateFlow(currentDeviceAddressFlow.value?.let { RadioSessionContext(sessionGeneration.value, it) }), + isSessionActive: (RadioSessionContext) -> Boolean = { activeSession.value == it }, takEnabledFlow: MutableStateFlow = MutableStateFlow(false), takRunningFlow: MutableStateFlow = MutableStateFlow(false), ): MeshServiceOrchestrator { @@ -91,6 +104,12 @@ class MeshServiceOrchestratorTest { every { radioInterfaceService.connectionError } returns connectionError every { radioInterfaceService.connectionState } returns connectionState every { radioInterfaceService.currentDeviceAddressFlow } returns currentDeviceAddressFlow + every { radioInterfaceService.sessionGeneration } returns sessionGeneration + every { radioInterfaceService.activeSession } returns activeSession + every { radioInterfaceService.isSessionActive(any()) } calls + { + isSessionActive(it.args[0] as RadioSessionContext) + } every { serviceRepository.meshPacketFlow } returns MutableSharedFlow() every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig()) every { takPrefs.isTakServerEnabled } returns takEnabledFlow @@ -122,6 +141,11 @@ class MeshServiceOrchestratorTest { ) } + private fun frame( + bytes: ByteArray, + session: RadioSessionContext = RadioSessionContext(generation = 0L, address = DEFAULT_ADDRESS), + ) = ReceivedRadioFrame(bytes.toByteString(), session) + @Test fun testStartWiresComponents() { val orchestrator = createOrchestrator() @@ -240,23 +264,23 @@ class MeshServiceOrchestratorTest { */ @Test fun testFromRadioCollectorsTornDownOnStopAndRestartedCleanlyOnStart() { - val receivedData = MutableSharedFlow(extraBufferCapacity = 8) + val receivedData = MutableSharedFlow(extraBufferCapacity = 8) val orchestrator = createOrchestrator(receivedData = receivedData) every { nodeManager.myNodeNum } returns MutableStateFlow(null) orchestrator.start() - val packet1 = byteArrayOf(1, 2, 3) + val packet1 = frame(byteArrayOf(1, 2, 3)) receivedData.tryEmit(packet1) verifySuspend(exactly(1)) { messageProcessor.handleFromRadio(packet1, null) } orchestrator.stop() - val packet2 = byteArrayOf(4, 5, 6) + val packet2 = frame(byteArrayOf(4, 5, 6)) receivedData.tryEmit(packet2) // After stop(), the collector must be gone - the handler should not be invoked for packet2. verifySuspend(exactly(0)) { messageProcessor.handleFromRadio(packet2, null) } orchestrator.start() - val packet3 = byteArrayOf(7, 8, 9) + val packet3 = frame(byteArrayOf(7, 8, 9)) receivedData.tryEmit(packet3) // After restart, a single fresh collector must process packet3 exactly once (not twice). verifySuspend(exactly(1)) { messageProcessor.handleFromRadio(packet3, null) } @@ -264,6 +288,61 @@ class MeshServiceOrchestratorTest { orchestrator.stop() } + @Test + fun frameIsDiscardedAfterAdmissionClosesEvenWhileLifecycleTokenIsDraining() { + val receivedData = MutableSharedFlow(extraBufferCapacity = 1) + val session = RadioSessionContext(generation = 1L, address = DEFAULT_ADDRESS) + val activeSession = MutableStateFlow(session) + var admissionOpen = true + val orchestrator = + createOrchestrator( + receivedData = receivedData, + sessionGeneration = MutableStateFlow(1L), + activeSession = activeSession, + isSessionActive = { admissionOpen && activeSession.value == it }, + ) + every { nodeManager.myNodeNum } returns MutableStateFlow(null) + val frame = frame(byteArrayOf(1, 2, 3), session) + + orchestrator.start() + admissionOpen = false + receivedData.tryEmit(frame) + + verifySuspend(exactly(0)) { messageProcessor.handleFromRadio(frame, null) } + assertEquals(session, activeSession.value, "the lifecycle token may remain published while leases drain") + + orchestrator.stop() + } + + @Test + fun queuedMyNodeInfoFromOldSessionIsDiscardedAfterSameAddressReconnect() { + val receivedData = MutableSharedFlow(extraBufferCapacity = 2) + val activeGeneration = MutableStateFlow(1L) + val activeSession = + MutableStateFlow(RadioSessionContext(generation = 1L, address = DEFAULT_ADDRESS)) + val orchestrator = + createOrchestrator( + receivedData = receivedData, + sessionGeneration = activeGeneration, + activeSession = activeSession, + ) + every { nodeManager.myNodeNum } returns MutableStateFlow(null) + val payload = FromRadio(my_info = MyNodeInfo(my_node_num = 42)).encode() + val staleFrame = frame(payload, RadioSessionContext(generation = 1L, address = DEFAULT_ADDRESS)) + val freshFrame = frame(payload, RadioSessionContext(generation = 2L, address = DEFAULT_ADDRESS)) + + orchestrator.start() + activeGeneration.value = 2L + activeSession.value = RadioSessionContext(generation = 2L, address = DEFAULT_ADDRESS) + receivedData.tryEmit(staleFrame) + verifySuspend(exactly(0)) { messageProcessor.handleFromRadio(staleFrame, null) } + + receivedData.tryEmit(freshFrame) + verifySuspend(exactly(1)) { messageProcessor.handleFromRadio(freshFrame, null) } + + orchestrator.stop() + } + /** * Regression test for a channel-buffer-replay bug: the production [RadioInterfaceService] buffers inbound bytes in * a process-lifetime `Channel(UNLIMITED)`. Between `stop()` and the next `start()`, any bytes that arrive sit in @@ -289,7 +368,7 @@ class MeshServiceOrchestratorTest { /** Additional regression: after many start/stop cycles, collectors must not accumulate. */ @Test fun testRepeatedStartStopDoesNotAccumulateCollectors() { - val receivedData = MutableSharedFlow(extraBufferCapacity = 8) + val receivedData = MutableSharedFlow(extraBufferCapacity = 8) val orchestrator = createOrchestrator(receivedData = receivedData) every { nodeManager.myNodeNum } returns MutableStateFlow(null) @@ -299,7 +378,7 @@ class MeshServiceOrchestratorTest { } orchestrator.start() - val packet = byteArrayOf(42) + val packet = frame(byteArrayOf(42)) receivedData.tryEmit(packet) // Despite six total start() calls, only the most recent collector is live. @@ -350,7 +429,7 @@ class MeshServiceOrchestratorTest { */ @Test fun testConnectedWhileStoppedDoesNotRestartWithoutExplicitStart() { - val receivedData = MutableSharedFlow(extraBufferCapacity = 8) + val receivedData = MutableSharedFlow(extraBufferCapacity = 8) val connectionState = MutableStateFlow(ConnectionState.Disconnected) val orchestrator = createOrchestrator(receivedData = receivedData, connectionState = connectionState) every { nodeManager.myNodeNum } returns MutableStateFlow(null) @@ -363,7 +442,7 @@ class MeshServiceOrchestratorTest { assertFalse(orchestrator.isRunning) // No collector may have been attached: a packet emitted now is unhandled. - val packet = byteArrayOf(7, 7, 7) + val packet = frame(byteArrayOf(7, 7, 7)) receivedData.tryEmit(packet) verifySuspend(exactly(0)) { messageProcessor.handleFromRadio(packet, null) } diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt index babba159cf..e31c335e93 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt @@ -19,6 +19,7 @@ package org.meshtastic.core.service import dev.mokkery.MockMode import dev.mokkery.answering.calls import dev.mokkery.answering.returns +import dev.mokkery.answering.throws import dev.mokkery.every import dev.mokkery.everySuspend import dev.mokkery.matcher.any @@ -27,9 +28,13 @@ import dev.mokkery.verify import dev.mokkery.verify.VerifyMode.Companion.atLeast import dev.mokkery.verify.VerifyMode.Companion.exactly import dev.mokkery.verifySuspend +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.model.ConnectionState @@ -37,6 +42,7 @@ import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.ConnectionIdentity import org.meshtastic.core.repository.MeshDataHandler import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.MeshMessageProcessor @@ -48,6 +54,8 @@ import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.core.repository.RadioSessionLease import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.AdminMessage @@ -61,6 +69,7 @@ import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue @@ -82,15 +91,58 @@ class RadioControllerImplTest { private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill) private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) - private val testScope = TestScope() - private fun createController( serviceRepository: ServiceRepository = ServiceRepositoryImpl(), myNodeNum: Int? = 1234, + deviceAddress: MutableStateFlow = MutableStateFlow(null), + connectionIdentity: MutableStateFlow = MutableStateFlow(null), + sessionGeneration: MutableStateFlow = MutableStateFlow(0L), + activeSession: MutableStateFlow? = null, + onDeviceAddressChanged: (() -> Unit)? = null, + scope: CoroutineScope, ): RadioControllerImpl { every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) every { nodeManager.myDeviceId } returns MutableStateFlow(null) - every { meshPrefs.deviceAddress } returns MutableStateFlow(null) + every { nodeManager.connectionIdentity } returns connectionIdentity + every { radioInterfaceService.sessionGeneration } returns sessionGeneration + val resolvedActiveSession = + activeSession + ?: MutableStateFlow(deviceAddress.value?.let { RadioSessionContext(sessionGeneration.value, it) }) + every { radioInterfaceService.activeSession } returns resolvedActiveSession + everySuspend { radioInterfaceService.runWhileSessionActive(any(), any()) } calls + { + val session = it.args[0] as RadioSessionContext + + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as (suspend () -> Unit) + if (resolvedActiveSession.value == session) { + block() + true + } else { + false + } + } + everySuspend { radioInterfaceService.runWithSessionLease(any(), any()) } calls + { + val session = it.args[0] as RadioSessionContext + + @Suppress("UNCHECKED_CAST") + val block = it.args[1] as (suspend (RadioSessionLease) -> Unit) + if (resolvedActiveSession.value == session) { + block( + object : RadioSessionLease { + override val session: RadioSessionContext = session + + override fun isCurrent(): Boolean = resolvedActiveSession.value == session + }, + ) + true + } else { + false + } + } + every { meshPrefs.deviceAddress } returns deviceAddress + every { radioInterfaceService.getDeviceAddress() } returns deviceAddress.value return RadioControllerImpl( serviceRepository = serviceRepository, nodeRepository = nodeRepository, @@ -107,14 +159,173 @@ class RadioControllerImplTest { notificationManager = notificationManager, messageProcessor = lazy { messageProcessor }, radioConfigRepository = radioConfigRepository, - scope = testScope, + scope = scope, + onDeviceAddressChanged = onDeviceAddressChanged, + ) + } + + @Test + fun staleAddressIdentityCannotAssociateSelectedTransport() = runTest { + val selectedAddress = MutableStateFlow("tcp:new") + val identity = MutableStateFlow(ConnectionIdentity(0L, "ble:old", 42, "device")) + + createController(scope = backgroundScope, deviceAddress = selectedAddress, connectionIdentity = identity) + runCurrent() + + verifySuspend(exactly(0)) { databaseManager.associateDevice(any(), any(), any(), any()) } + } + + @Test + fun delayedOldIdentityCannotPairWithNewAddress() = runTest { + val selectedAddress = MutableStateFlow("ble:old") + val activeSession = MutableStateFlow(RadioSessionContext(0L, "ble:old")) + val identity = MutableStateFlow(ConnectionIdentity(0L, "ble:old", 42, "device")) + + createController( + scope = backgroundScope, + deviceAddress = selectedAddress, + connectionIdentity = identity, + activeSession = activeSession, + ) + selectedAddress.value = "tcp:new" + activeSession.value = RadioSessionContext(1L, "tcp:new") + runCurrent() + + verifySuspend(exactly(0)) { databaseManager.associateDevice(any(), any(), any(), any()) } + } + + @Test + fun freshAddressBoundIdentityAssociatesExactlyOnceWithNodeFallback() = runTest { + val selectedAddress = MutableStateFlow("tcp:selected") + val identity = MutableStateFlow(null) + createController(scope = backgroundScope, deviceAddress = selectedAddress, connectionIdentity = identity) + runCurrent() + + identity.value = ConnectionIdentity(0L, "tcp:selected", 99, null) + runCurrent() + + verifySuspend(exactly(1)) { databaseManager.associateDevice("tcp:selected", 99, null, any()) } + } + + @Test + fun samePhysicalIdentityAssociatesEachAddressBoundSession() = runTest { + val selectedAddress = MutableStateFlow("ble:one") + val activeSession = MutableStateFlow(RadioSessionContext(0L, "ble:one")) + val identity = MutableStateFlow(null) + createController( + scope = backgroundScope, + deviceAddress = selectedAddress, + connectionIdentity = identity, + activeSession = activeSession, + ) + runCurrent() + + identity.value = ConnectionIdentity(0L, "ble:one", 123, "device") + runCurrent() + identity.value = null + selectedAddress.value = "tcp:two" + activeSession.value = RadioSessionContext(0L, "tcp:two") + identity.value = ConnectionIdentity(0L, "tcp:two", 123, "device") + runCurrent() + + verifySuspend(exactly(1)) { databaseManager.associateDevice("ble:one", 123, "device", any()) } + verifySuspend(exactly(1)) { databaseManager.associateDevice("tcp:two", 123, "device", any()) } + } + + @Test + fun collectorStartupDoesNotMissFirstSessionGenerationBoundary() = runTest { + val sessionGeneration = MutableStateFlow(0L) + + createController(scope = backgroundScope, sessionGeneration = sessionGeneration) + sessionGeneration.value = 1L + runCurrent() + + verify(exactly(1)) { nodeManager.clearStaleConnectionIdentity(1L) } + } + + @Test + fun freshIdentityPublishedBeforeBoundaryCollectorRunsIsPreserved() = runTest { + val sessionGeneration = MutableStateFlow(0L) + val oldIdentity = ConnectionIdentity(0L, "ble:same", 42, "device") + val identity = MutableStateFlow(oldIdentity) + every { nodeManager.clearStaleConnectionIdentity(1L) } calls + { + identity.value = identity.value?.takeIf { it.sessionGeneration == 1L } + } + + createController(scope = backgroundScope, connectionIdentity = identity, sessionGeneration = sessionGeneration) + sessionGeneration.value = 1L + val freshIdentity = oldIdentity.copy(sessionGeneration = 1L) + identity.value = freshIdentity + runCurrent() + + assertEquals(freshIdentity, identity.value) + verify(exactly(1)) { nodeManager.clearStaleConnectionIdentity(1L) } + } + + @Test + fun sameAddressReconnectRejectsOldGenerationUntilFreshIdentityArrives() = runTest { + val selectedAddress = MutableStateFlow("ble:same") + val sessionGeneration = MutableStateFlow(2L) + val identity = MutableStateFlow(ConnectionIdentity(1L, "ble:same", 42, "device")) + + createController( + scope = backgroundScope, + deviceAddress = selectedAddress, + connectionIdentity = identity, + sessionGeneration = sessionGeneration, ) + runCurrent() + verifySuspend(exactly(0)) { databaseManager.associateDevice(any(), any(), any(), any()) } + + identity.value = ConnectionIdentity(2L, "ble:same", 42, "device") + runCurrent() + + verifySuspend(exactly(1)) { databaseManager.associateDevice("ble:same", 42, "device", any()) } } @Test - fun connectionStateAndClientNotificationDelegateToServiceRepository() { + fun sessionChangeCancelsInFlightAssociation() = runTest { + val selectedAddress = MutableStateFlow("tcp:selected") + val sessionGeneration = MutableStateFlow(1L) + val activeSession = MutableStateFlow(RadioSessionContext(1L, "tcp:selected")) + val identity = MutableStateFlow(ConnectionIdentity(1L, "tcp:selected", 99, "device")) + val associationStarted = CompletableDeferred() + val associationCancelled = CompletableDeferred() + val releaseAssociation = CompletableDeferred() + everySuspend { databaseManager.associateDevice("tcp:selected", 99, "device", any()) } calls + { + associationStarted.complete(Unit) + try { + releaseAssociation.await() + } catch (cancellation: CancellationException) { + associationCancelled.complete(Unit) + throw cancellation + } + } + + createController( + scope = backgroundScope, + deviceAddress = selectedAddress, + connectionIdentity = identity, + sessionGeneration = sessionGeneration, + activeSession = activeSession, + ) + runCurrent() + assertTrue(associationStarted.isCompleted) + + activeSession.value = null + runCurrent() + + assertTrue(associationCancelled.isCompleted) + releaseAssociation.complete(Unit) + verifySuspend(exactly(1)) { databaseManager.associateDevice("tcp:selected", 99, "device", any()) } + } + + @Test + fun connectionStateAndClientNotificationDelegateToServiceRepository() = runTest { val serviceRepository = ServiceRepositoryImpl() - val controller = createController(serviceRepository = serviceRepository) + val controller = createController(scope = backgroundScope, serviceRepository = serviceRepository) val notification = ClientNotification() assertSame(serviceRepository.connectionState, controller.connectionState) @@ -133,7 +344,7 @@ class RadioControllerImplTest { @Test fun sendMessageDelegatesToCommandSender() = runTest { - val controller = createController(myNodeNum = 456) + val controller = createController(scope = backgroundScope, myNodeNum = 456) val packet = DataPacket(to = NodeAddress.ID_BROADCAST, channel = 1, text = "ping") controller.sendMessage(packet) @@ -144,7 +355,7 @@ class RadioControllerImplTest { @Test fun sendSharedContactCallsCommandSenderAdminAwait() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) val nodeNum = 321 val user = User(id = NodeAddress.numToDefaultId(nodeNum), long_name = "Remote Node", short_name = "RN") val node = Node(num = nodeNum, user = user, manuallyVerified = true) @@ -159,7 +370,7 @@ class RadioControllerImplTest { @Test fun requestConfigOperationsDelegateToCommandSender() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) controller.getOwner(destNum = 101, packetId = 1) controller.getConfig(destNum = 102, configType = 2, packetId = 3) @@ -174,8 +385,8 @@ class RadioControllerImplTest { } @Test - fun stopProvideLocationDelegatesToLocationManager() { - val controller = createController() + fun stopProvideLocationDelegatesToLocationManager() = runTest { + val controller = createController(scope = backgroundScope) controller.stopProvideLocation() @@ -184,34 +395,192 @@ class RadioControllerImplTest { @Test fun setDeviceAddressSwitchesDatabaseAndTransport() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow("old:addr") + val callOrder = mutableListOf() + everySuspend { radioInterfaceService.disconnect() } calls { callOrder += "disconnect" } + every { nodeManager.setAllowNodeDbWrites(false) } calls { callOrder += "writes-off" } + every { meshPrefs.setDeviceAddress("tcp:192.168.1.1") } calls { callOrder += "prefs" } + everySuspend { messageProcessor.clearEarlyPackets() } calls { callOrder += "buffer" } + everySuspend { databaseManager.switchActiveDatabase("tcp:192.168.1.1") } calls { callOrder += "database" } + every { radioInterfaceService.setDeviceAddress("tcp:192.168.1.1") } calls + { + callOrder += "transport" + true + } controller.setDeviceAddress("tcp:192.168.1.1") - testScope.advanceUntilIdle() + advanceUntilIdle() + + assertEquals(listOf("disconnect", "writes-off", "buffer", "database", "prefs", "transport"), callOrder) + } - // Verify ordering: switchDevice completes before transport reconfiguration - verifySuspend { meshPrefs.setDeviceAddress("tcp:192.168.1.1") } + @Test + fun setDeviceAddressRestoresPreviousSelectionWhenTransportRejectsNewAddress() = runTest { + val selectedAddress = MutableStateFlow("old:addr") + val controller = createController(scope = backgroundScope, deviceAddress = selectedAddress) + everySuspend { databaseManager.switchActiveDatabase("tcp:192.168.1.1") } returns Unit + everySuspend { databaseManager.switchActiveDatabase("old:addr") } returns Unit + every { meshPrefs.setDeviceAddress("tcp:192.168.1.1") } calls { selectedAddress.value = "tcp:192.168.1.1" } + every { meshPrefs.setDeviceAddress("old:addr") } calls { selectedAddress.value = "old:addr" } + every { radioInterfaceService.setDeviceAddress("tcp:192.168.1.1") } returns false + every { radioInterfaceService.setDeviceAddress("old:addr") } returns true + + assertFailsWith { controller.setDeviceAddress("tcp:192.168.1.1") } + + verifySuspend { radioInterfaceService.disconnect() } verifySuspend { databaseManager.switchActiveDatabase("tcp:192.168.1.1") } - verify { radioInterfaceService.setDeviceAddress("tcp:192.168.1.1") } + verifySuspend { databaseManager.switchActiveDatabase("old:addr") } + verify { meshPrefs.setDeviceAddress("tcp:192.168.1.1") } + verify { meshPrefs.setDeviceAddress("old:addr") } + verify { radioInterfaceService.setDeviceAddress("old:addr") } + assertEquals("old:addr", selectedAddress.value) + } + + @Test + fun setDeviceAddressFailsClosedWhenDatabaseRollbackFails() = runTest { + val selectedAddress = MutableStateFlow("old:addr") + val controller = createController(scope = backgroundScope, deviceAddress = selectedAddress) + everySuspend { databaseManager.switchActiveDatabase("tcp:new") } returns Unit + everySuspend { databaseManager.switchActiveDatabase("old:addr") } throws + IllegalStateException("database rollback failed") + every { meshPrefs.setDeviceAddress("tcp:new") } calls { selectedAddress.value = "tcp:new" } + every { meshPrefs.setDeviceAddress(null) } calls { selectedAddress.value = null } + every { radioInterfaceService.setDeviceAddress("tcp:new") } returns false + every { radioInterfaceService.setDeviceAddress(null) } returns true + + val failure = assertFailsWith { controller.setDeviceAddress("tcp:new") } + + assertTrue( + failure.suppressedExceptions.any { it.message == "database rollback failed" }, + "the database rollback failure must be surfaced on the original switch failure", + ) + assertNull(selectedAddress.value, "fail-closed rollback must clear the persisted selection") + verify(atLeast(2)) { nodeManager.setAllowNodeDbWrites(false) } + verify { radioInterfaceService.setDeviceAddress(null) } + verify(exactly(0)) { radioInterfaceService.setDeviceAddress("old:addr") } + verify(exactly(0)) { meshPrefs.setDeviceAddress("old:addr") } + } + + @Test + fun setDeviceAddressUsesTransportSnapshotWhenPreferenceFlowLags() = runTest { + val selectedAddress = MutableStateFlow("old:preference") + val controller = createController(scope = backgroundScope, deviceAddress = selectedAddress) + every { radioInterfaceService.getDeviceAddress() } returns "tcp:first" + everySuspend { databaseManager.switchActiveDatabase("tcp:second") } returns Unit + everySuspend { databaseManager.switchActiveDatabase("tcp:first") } returns Unit + every { meshPrefs.setDeviceAddress("tcp:second") } calls { selectedAddress.value = "tcp:second" } + every { meshPrefs.setDeviceAddress("tcp:first") } calls { selectedAddress.value = "tcp:first" } + every { radioInterfaceService.setDeviceAddress("tcp:second") } returns false + every { radioInterfaceService.setDeviceAddress("tcp:first") } returns true + + assertFailsWith { controller.setDeviceAddress("tcp:second") } + + verifySuspend { databaseManager.switchActiveDatabase("tcp:first") } + verify { meshPrefs.setDeviceAddress("tcp:first") } + verify { radioInterfaceService.setDeviceAddress("tcp:first") } + verifySuspend(exactly(0)) { databaseManager.switchActiveDatabase("old:preference") } + assertEquals("tcp:first", selectedAddress.value) + } + + @Test + fun setDeviceAddressRestoresPreviousSelectionWhenInitializationFails() = runTest { + val selectedAddress = MutableStateFlow("old:addr") + val controller = createController(scope = backgroundScope, deviceAddress = selectedAddress) + everySuspend { databaseManager.switchActiveDatabase("tcp:192.168.1.1") } returns Unit + everySuspend { databaseManager.switchActiveDatabase("old:addr") } returns Unit + everySuspend { messageProcessor.clearEarlyPackets() } calls + { + throw IllegalStateException("forced initialization failure") + } + every { meshPrefs.setDeviceAddress("old:addr") } calls { selectedAddress.value = "old:addr" } + every { radioInterfaceService.setDeviceAddress("old:addr") } returns true + + assertFailsWith { controller.setDeviceAddress("tcp:192.168.1.1") } + + verifySuspend { radioInterfaceService.disconnect() } + verifySuspend(exactly(0)) { databaseManager.switchActiveDatabase("tcp:192.168.1.1") } + verifySuspend { databaseManager.switchActiveDatabase("old:addr") } + verify(exactly(0)) { meshPrefs.setDeviceAddress("tcp:192.168.1.1") } + verify { meshPrefs.setDeviceAddress("old:addr") } + verify { radioInterfaceService.setDeviceAddress("old:addr") } + assertEquals("old:addr", selectedAddress.value) + } + + @Test + fun concurrentDeviceSelectionsAreSerialized() = runTest { + val selectedAddress = MutableStateFlow("old:addr") + val controller = createController(scope = backgroundScope, deviceAddress = selectedAddress) + val firstSwitchStarted = CompletableDeferred() + val releaseFirstSwitch = CompletableDeferred() + everySuspend { databaseManager.switchActiveDatabase("tcp:first") } calls + { + firstSwitchStarted.complete(Unit) + releaseFirstSwitch.await() + } + everySuspend { databaseManager.switchActiveDatabase("tcp:second") } returns Unit + every { meshPrefs.setDeviceAddress("tcp:first") } calls { selectedAddress.value = "tcp:first" } + every { meshPrefs.setDeviceAddress("tcp:second") } calls { selectedAddress.value = "tcp:second" } + every { radioInterfaceService.setDeviceAddress("tcp:first") } returns true + every { radioInterfaceService.setDeviceAddress("tcp:second") } returns true + + val first = launch { controller.setDeviceAddress("tcp:first") } + firstSwitchStarted.await() + val second = launch { controller.setDeviceAddress("tcp:second") } + runCurrent() + + verifySuspend(exactly(0)) { databaseManager.switchActiveDatabase("tcp:second") } + + releaseFirstSwitch.complete(Unit) + first.join() + second.join() + + verifySuspend(exactly(1)) { databaseManager.switchActiveDatabase("tcp:first") } + verifySuspend(exactly(1)) { databaseManager.switchActiveDatabase("tcp:second") } + assertEquals("tcp:second", selectedAddress.value) } @Test fun setDeviceAddressSkipsSwitchWhenAddressUnchanged() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) every { meshPrefs.deviceAddress } returns MutableStateFlow("tcp:192.168.1.1") + every { radioInterfaceService.getDeviceAddress() } returns "tcp:192.168.1.1" + every { radioInterfaceService.setDeviceAddress("tcp:192.168.1.1") } returns false controller.setDeviceAddress("tcp:192.168.1.1") - testScope.advanceUntilIdle() + advanceUntilIdle() - // switchDevice should skip when addresses match, but transport still reconfigures + // Database work is skipped, while both selectors are converged on the requested address. verify { radioInterfaceService.setDeviceAddress("tcp:192.168.1.1") } - verifySuspend(exactly(0)) { meshPrefs.setDeviceAddress("tcp:192.168.1.1") } + verifySuspend(exactly(0)) { radioInterfaceService.disconnect() } + verify(exactly(1)) { meshPrefs.setDeviceAddress("tcp:192.168.1.1") } + } + + @Test + fun setDeviceAddressDoesNotPersistOrReportSuccessWhenPreferenceMatchesButTransportRejects() = runTest { + val selectedAddress = MutableStateFlow("tcp:192.168.1.1") + var successCallbacks = 0 + val controller = + createController( + scope = backgroundScope, + deviceAddress = selectedAddress, + onDeviceAddressChanged = { successCallbacks += 1 }, + ) + // The persisted selection matches, but the transport has no matching active selection and rejects the request. + every { radioInterfaceService.getDeviceAddress() } returns null + every { radioInterfaceService.setDeviceAddress("tcp:192.168.1.1") } returns false + + assertFailsWith { controller.setDeviceAddress("tcp:192.168.1.1") } + + assertEquals("tcp:192.168.1.1", selectedAddress.value) + assertEquals(0, successCallbacks) + verify(exactly(0)) { meshPrefs.setDeviceAddress("tcp:192.168.1.1") } + verifySuspend(exactly(0)) { databaseManager.switchActiveDatabase(any()) } } @Test fun sendReactionPersistsToDatabase() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) val user = User(id = "!abcd1234", long_name = "Test", short_name = "T") val node = Node(num = 1234, user = user) every { nodeManager.nodeDBbyNodeNum } returns mapOf(1234 to node) @@ -226,7 +595,7 @@ class RadioControllerImplTest { @Test fun setFavoriteSendsAdminAndUpdatesState() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) val node = Node(num = 99, user = User(id = "!node99"), isFavorite = false) every { nodeManager.nodeDBbyNodeNum } returns mapOf(99 to node) @@ -238,7 +607,7 @@ class RadioControllerImplTest { @Test fun setFavoriteIsNoOpWhenAlreadyInRequestedState() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) val node = Node(num = 99, user = User(id = "!node99"), isFavorite = true) every { nodeManager.nodeDBbyNodeNum } returns mapOf(99 to node) @@ -250,12 +619,12 @@ class RadioControllerImplTest { @Test fun setIgnoredSendsAdminUpdatesStateAndFiltersPackets() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) val node = Node(num = 99, user = User(id = "!node99"), isIgnored = false) every { nodeManager.nodeDBbyNodeNum } returns mapOf(99 to node) controller.setIgnored(99, ignored = true) - testScope.advanceUntilIdle() + runCurrent() verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } verify { nodeManager.updateNode(any(), any(), any()) } @@ -264,7 +633,7 @@ class RadioControllerImplTest { @Test fun toggleMutedSendsAdminAndUpdatesState() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) val node = Node(num = 99, user = User(id = "!node99"), isMuted = false) every { nodeManager.nodeDBbyNodeNum } returns mapOf(99 to node) @@ -276,7 +645,7 @@ class RadioControllerImplTest { @Test fun nodeManagementReturnsEarlyWhenMyNodeNumIsNull() = runTest { - val controller = createController(myNodeNum = null) + val controller = createController(scope = backgroundScope, myNodeNum = null) controller.setFavorite(99, favorite = true) controller.setIgnored(99, ignored = true) @@ -287,7 +656,7 @@ class RadioControllerImplTest { @Test fun removeByNodenumAlwaysRemovesLocallyAndSendsAdminWhenConnected() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) controller.removeByNodenum(packetId = 1, nodeNum = 55) @@ -297,7 +666,7 @@ class RadioControllerImplTest { @Test fun removeByNodenumRemovesLocallyEvenWhenDisconnected() = runTest { - val controller = createController(myNodeNum = null) + val controller = createController(scope = backgroundScope, myNodeNum = null) controller.removeByNodenum(packetId = 1, nodeNum = 55) @@ -308,7 +677,7 @@ class RadioControllerImplTest { @Test fun rebootSendsAdminMessageWithDelay() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) controller.reboot(destNum = 101, packetId = 7) @@ -317,7 +686,7 @@ class RadioControllerImplTest { @Test fun shutdownSendsAdminMessage() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) controller.shutdown(destNum = 101, packetId = 8) @@ -326,7 +695,7 @@ class RadioControllerImplTest { @Test fun factoryResetSendsAdminMessage() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) controller.factoryReset(destNum = 101, packetId = 9) @@ -335,7 +704,7 @@ class RadioControllerImplTest { @Test fun nodedbResetSendsAdminMessage() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) controller.nodedbReset(destNum = 101, packetId = 10, preserveFavorites = true) @@ -344,7 +713,7 @@ class RadioControllerImplTest { @Test fun setTimeSendsAdminMessageWithCurrentEpochSeconds() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) var sentMessage: AdminMessage? = null everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } calls @@ -363,7 +732,7 @@ class RadioControllerImplTest { @Test fun refreshMetadataSendsAdminWithWantResponse() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) controller.refreshMetadata(destNum = 101) @@ -372,13 +741,13 @@ class RadioControllerImplTest { @Test fun editLocalSettingsChannelWritesDoNotMirrorToLocalCache() = runTest { - val controller = createController(myNodeNum = 1234) + val controller = createController(scope = backgroundScope, myNodeNum = 1234) controller.editLocalSettings { setChannel(Channel(index = 0, role = Channel.Role.PRIMARY, settings = ChannelSettings(name = "A"))) setChannel(Channel(index = 1, role = Channel.Role.SECONDARY, settings = ChannelSettings(name = "B"))) } - testScope.advanceUntilIdle() + advanceUntilIdle() // Exactly 4 admin packets: begin + 2 channel writes + commit. The tight count also catches a duplicated // begin/commit or an accidental double-write per channel. @@ -392,8 +761,8 @@ class RadioControllerImplTest { @Test fun importContactSendsAdminAndUpdatesNodeManager() = runTest { - val controller = createController() - // A scanned contact arrives with manually_verified = false (proto default). + val controller = createController(scope = backgroundScope) + // A QR-scanned contact arrives with manually_verified = false (proto default). val contact = SharedContact(node_num = 42, user = User(id = "!0000002a", long_name = "Test")) var sentMessage: AdminMessage? = null @@ -413,7 +782,7 @@ class RadioControllerImplTest { @Test fun importContactPreservesEncodedVerificationState() = runTest { - val controller = createController() + val controller = createController(scope = backgroundScope) // A contact shared as already verified stays verified on import. val contact = SharedContact(node_num = 42, user = User(id = "!0000002a", long_name = "Test"), manually_verified = true) @@ -434,7 +803,7 @@ class RadioControllerImplTest { @Test fun setHamModeSendsAdminWithEchoedLoraValuesAndUpdatesUser() = runTest { - val controller = createController(myNodeNum = 123) + val controller = createController(scope = backgroundScope, myNodeNum = 123) val existingUser = User(id = "!0000007b", long_name = "Old Name", short_name = "OLD") every { nodeManager.nodeDBbyNodeNum } returns mapOf(123 to Node(num = 123, user = existingUser)) every { radioConfigRepository.localConfigFlow } returns @@ -467,7 +836,7 @@ class RadioControllerImplTest { @Test fun setHamModeWithNoCachedLoraConfigSendsProtoDefaults() = runTest { - val controller = createController(myNodeNum = 123) + val controller = createController(scope = backgroundScope, myNodeNum = 123) every { nodeManager.nodeDBbyNodeNum } returns emptyMap() every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig()) @@ -496,7 +865,7 @@ class RadioControllerImplTest { @Test fun setHamModeIgnoresRemoteDestinations() = runTest { - val controller = createController(myNodeNum = 123) + val controller = createController(scope = backgroundScope, myNodeNum = 123) controller.setHamMode(456, HamParameters(call_sign = "KK7ABC", short_name = "KK7A"), 42) @@ -506,7 +875,7 @@ class RadioControllerImplTest { @Test fun importContactReturnsEarlyWhenDisconnected() = runTest { - val controller = createController(myNodeNum = null) + val controller = createController(scope = backgroundScope, myNodeNum = null) val contact = SharedContact(node_num = 42, user = User(id = "!0000002a")) controller.importContact(contact) diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt index 08cc353c09..ea7a762857 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt @@ -27,12 +27,15 @@ import dev.mokkery.every import dev.mokkery.matcher.any import dev.mokkery.mock import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -45,6 +48,7 @@ import org.meshtastic.core.model.DeviceType import org.meshtastic.core.network.repository.NetworkRepository import org.meshtastic.core.network.repository.SerialDevicePresence import org.meshtastic.core.repository.PlatformAnalytics +import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioTransport import org.meshtastic.core.repository.RadioTransportFactory import org.meshtastic.core.repository.TransportDisconnectReason @@ -54,8 +58,10 @@ import org.meshtastic.core.testing.FakeRadioTransport import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -75,6 +81,17 @@ class SharedRadioInterfaceServiceLivenessTest { private lateinit var processLifecycleOwner: TestLifecycleOwner + @Test + fun `transport session diagnostic redacts its address`() { + val rawAddress = "xAA:BB:CC:DD:EE:FF" + + val diagnostic = RadioTransportSession(generation = 7L, address = rawAddress).toString() + + assertFalse(rawAddress in diagnostic) + assertTrue("generation=7" in diagnostic) + assertTrue("address=..." in diagnostic) + } + @BeforeTest fun setUp() { // processLifecycle.coroutineScope uses Dispatchers.Main.immediate internally; @@ -86,6 +103,7 @@ class SharedRadioInterfaceServiceLivenessTest { // USB tests leave serialDeviceKeys non-empty; reset before each test so non-USB // tests start from the documented empty default. serialDeviceKeys.value = emptySet() + bluetoothRepository.setBluetoothEnabled(true) } @AfterTest @@ -229,12 +247,14 @@ class SharedRadioInterfaceServiceLivenessTest { * to bring the service to Connected state (FakeRadioTransport does not call onConnect itself). * * Pass [transportProvider] to swap in a custom test double (e.g. a suspending-close fake) instead of the default - * [FakeRadioTransport]; the default records each created transport in [createdTransports]. + * [FakeRadioTransport]; the default records each created transport in [createdTransports]. Set [startConnected] to + * false when a test needs to inspect construction or an initial transport-start failure before connection. */ private fun createConnectedService( address: String, transportProvider: () -> RadioTransport = { FakeRadioTransport().also { createdTransports.add(it) } }, networkAvailability: MutableStateFlow = MutableStateFlow(true), + startConnected: Boolean = true, ): SharedRadioInterfaceService { every { networkRepository.networkAvailable } returns networkAvailability every { networkRepository.resolvedList } returns MutableSharedFlow() @@ -262,11 +282,213 @@ class SharedRadioInterfaceServiceLivenessTest { // Register the service so tearDown can disconnect it deterministically (the heartbeat loop // launched in _serviceScope would otherwise outlive the test). services.add(service) - service.connect() - service.onConnect() + if (startConnected) { + service.connect() + service.onConnect() + } return service } + @Test + fun `transport factory failure revokes admitted session and retry uses a fresh generation`() = + runTest(testDispatcher) { + bluetoothRepository.setBluetoothEnabled(false) + val networkAvailability = MutableStateFlow(false) + var failCreation = true + var failedSessionCallback: RadioInterfaceService? = null + val service = + createConnectedService( + address = "t192.0.2.1", + networkAvailability = networkAvailability, + startConnected = false, + ) + every { transportFactory.createTransport(any(), any()) } calls + { + if (failCreation) { + val callback = it.args[1] as RadioInterfaceService + failedSessionCallback = callback + callback.onConnect() + throw IllegalStateException("transport factory failed") + } + FakeRadioTransport().also { createdTransports.add(it) } + } + try { + service.connect() + testDispatcher.scheduler.runCurrent() + + assertNull(service.activeSession.value, "a failed factory call must revoke the admitted session") + assertEquals(1L, service.sessionGeneration.value, "the observed failed generation stays consumed") + assertEquals( + ConnectionState.Disconnected, + service.connectionState.value, + "a synchronous partial callback must not leave the failed transport connected", + ) + assertTrue(createdTransports.isEmpty(), "a failed factory call must not publish a transport") + + val queuedFrame = async(start = CoroutineStart.UNDISPATCHED) { service.receivedData.first() } + try { + val revokedCallback = requireNotNull(failedSessionCallback) + revokedCallback.onConnect() + revokedCallback.handleFromRadio(byteArrayOf(1, 2, 3)) + testDispatcher.scheduler.runCurrent() + assertEquals( + ConnectionState.Disconnected, + service.connectionState.value, + "callbacks from a revoked factory session must not restore connection state", + ) + assertFalse( + queuedFrame.isCompleted, + "callbacks from a revoked factory session must not enqueue data", + ) + } finally { + queuedFrame.cancel() + } + + failCreation = false + service.connect() + testDispatcher.scheduler.runCurrent() + + assertEquals(2L, service.sessionGeneration.value, "a retry must receive a strictly newer generation") + assertEquals(2L, service.activeSession.value?.generation) + assertEquals("t192.0.2.1", service.activeSession.value?.address) + assertEquals(1, createdTransports.size, "the successful retry must publish exactly one transport") + } finally { + service.disconnect() + } + } + + @Test + fun `setDeviceAddress contains factory failure and same-address repair can retry`() = runTest(testDispatcher) { + bluetoothRepository.setBluetoothEnabled(false) + val networkAvailability = MutableStateFlow(false) + var failCreation = true + val service = + createConnectedService( + address = "t192.0.2.1", + networkAvailability = networkAvailability, + startConnected = false, + ) + every { transportFactory.createTransport(any(), any()) } calls + { + if (failCreation) throw IllegalStateException("transport factory failed") + FakeRadioTransport().also { createdTransports.add(it) } + } + try { + assertTrue(service.setDeviceAddress("t192.0.2.2")) + testDispatcher.scheduler.runCurrent() + + assertNull(service.activeSession.value, "the failed setDeviceAddress start must revoke its session") + assertEquals(1L, service.sessionGeneration.value, "the failed generation remains consumed") + assertTrue(createdTransports.isEmpty(), "a failed factory call must not publish a transport") + + failCreation = false + assertTrue(service.setDeviceAddress("t192.0.2.2"), "a disconnected same-address selection is a repair") + testDispatcher.scheduler.runCurrent() + + assertEquals(2L, service.sessionGeneration.value, "the repair retry must use a fresh generation") + assertEquals(2L, service.activeSession.value?.generation) + assertEquals("t192.0.2.2", service.activeSession.value?.address) + assertEquals(1, createdTransports.size) + } finally { + service.disconnect() + } + } + + @Test + fun `ordered session lane serializes handshake work while independent leases remain concurrent`() = + runTest(testDispatcher) { + val service = createConnectedService("xAA:BB:CC:DD:EE:FF") + val session = requireNotNull(service.activeSession.value) + val firstStarted = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val secondStarted = CompletableDeferred() + val independentStarted = CompletableDeferred() + + val first = launch { + service.runWhileSessionActive(session) { + firstStarted.complete(Unit) + releaseFirst.await() + } + } + firstStarted.await() + val second = launch { service.runWhileSessionActive(session) { secondStarted.complete(Unit) } } + val independent = launch { service.runWithSessionLease(session) { independentStarted.complete(Unit) } } + try { + testDispatcher.scheduler.runCurrent() + + assertFalse(secondStarted.isCompleted, "ordered work must wait for the current lane owner") + assertTrue(independentStarted.isCompleted, "independent deferred work must acquire its own lease") + + releaseFirst.complete(Unit) + first.join() + second.join() + independent.join() + assertTrue(secondStarted.isCompleted) + } finally { + releaseFirst.complete(Unit) + first.cancel() + second.cancel() + independent.cancel() + first.join() + second.join() + independent.join() + service.disconnect() + advanceTimeBy(1_000L) + } + } + + @Test + fun `transport teardown remains cancellation safe while draining existing session leases`() = + runTest(testDispatcher) { + val service = createConnectedService("xAA:BB:CC:DD:EE:FF") + val session = requireNotNull(service.activeSession.value) + val workStarted = CompletableDeferred() + val releaseWork = CompletableDeferred() + val leaseStillCurrent = CompletableDeferred() + + val workJob = launch { + assertTrue( + service.runWithSessionLease(session) { lease -> + workStarted.complete(Unit) + releaseWork.await() + leaseStillCurrent.complete(lease.isCurrent()) + }, + ) + } + workStarted.await() + + val disconnectJob = launch { service.disconnect() } + try { + testDispatcher.scheduler.runCurrent() + + assertFalse(disconnectJob.isCompleted, "disconnect must wait for the admitted lease") + assertEquals( + session, + service.activeSession.value, + "the session token remains published until its admitted operation finishes", + ) + assertFalse(service.isSessionActive(session), "teardown must reject new work immediately") + assertFalse( + service.runWhileSessionActive(session) { error("late operation must not run") }, + "work queued after admission closes must be rejected", + ) + disconnectJob.cancel() + } finally { + releaseWork.complete(Unit) + workJob.join() + assertTrue( + leaseStillCurrent.await(), + "an admitted lease must remain authoritative until its transaction-bound work returns", + ) + testDispatcher.scheduler.runCurrent() + advanceTimeBy(1_000L) + disconnectJob.join() + } + + assertNull(service.activeSession.value, "revocation publishes only after admitted work drains") + assertTrue(createdTransports.single().closeCalled, "cancellation must not strand the revoked transport") + } + // ─── BLE: Liveness timeout triggers recovery ─────────────────────────────────────────────── @Test @@ -293,6 +515,43 @@ class SharedRadioInterfaceServiceLivenessTest { } } + @Test + fun `BLE liveness restart contains factory failure and a later connect can retry`() = runTest(testDispatcher) { + clock = 0L + var failRestart = false + val service = createConnectedService("xAA:BB:CC:DD:EE:FF") + every { transportFactory.createTransport(any(), any()) } calls + { + if (failRestart) throw IllegalStateException("restart factory failed") + FakeRadioTransport().also { createdTransports.add(it) } + } + try { + failRestart = true + clock = 65_000L + service.checkLiveness() + testDispatcher.scheduler.runCurrent() + + assertTrue( + createdTransports.single().closeCalled, + "the failed restart must still close the old transport", + ) + assertNull(service.activeSession.value, "the failed replacement session must be revoked") + assertEquals(2L, service.sessionGeneration.value, "the failed replacement generation remains consumed") + assertEquals(ConnectionState.DeviceSleep, service.connectionState.value) + + failRestart = false + service.connect() + testDispatcher.scheduler.runCurrent() + + assertEquals(3L, service.sessionGeneration.value, "the later retry must receive a fresh generation") + assertEquals(3L, service.activeSession.value?.generation) + assertEquals(2, createdTransports.size, "the later retry must publish one replacement transport") + } finally { + service.disconnect() + advanceTimeBy(1_000L) + } + } + @Test fun `BLE liveness restart does not emit permanent Disconnected`() = runTest(testDispatcher) { clock = 0L @@ -523,12 +782,20 @@ class SharedRadioInterfaceServiceLivenessTest { @Test fun `inbound data resets liveness timer so timeout does not fire`() = runTest(testDispatcher) { clock = 0L - val service = createConnectedService("xAA:BB:CC:DD:EE:FF") + val address = "xAA:BB:CC:DD:EE:FF" + val service = createConnectedService(address) try { // Advance 30s, then receive data (resets lastDataReceivedMillis to clock=30s) clock = 30_000L - service.handleFromRadio(byteArrayOf(1, 2, 3)) + val payload = byteArrayOf(1, 2, 3) + val received = async(start = CoroutineStart.UNDISPATCHED) { service.receivedData.first() } + service.handleFromRadio(payload) + val frame = received.await() + + assertEquals(address, frame.session.address) + assertEquals(service.sessionGeneration.value, frame.session.generation) + assertContentEquals(payload, frame.payload.toByteArray()) // 30s since last data → within 60s threshold → should NOT fire clock = 60_000L diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt index 0cbdda725a..f5d79f40ec 100644 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt @@ -40,6 +40,7 @@ import org.meshtastic.core.model.service.TracerouteResponse import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioSessionContext import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel @@ -224,15 +225,15 @@ class TAKMeshIntegrationTest { override val localConfig: StateFlow = MutableStateFlow(LocalConfig()) override val moduleConfig: StateFlow = MutableStateFlow(LocalModuleConfig()) - override fun handleDeviceConfig(config: Config) {} + override fun handleDeviceConfig(config: Config, session: RadioSessionContext): Boolean = true - override fun handleModuleConfig(config: ModuleConfig) {} + override fun handleModuleConfig(config: ModuleConfig, session: RadioSessionContext): Boolean = true - override fun handleChannel(channel: Channel) {} + override fun handleChannel(channel: Channel, session: RadioSessionContext): Boolean = true - override fun handleDeviceUIConfig(config: DeviceUIConfig) {} + override fun handleDeviceUIConfig(config: DeviceUIConfig, session: RadioSessionContext): Boolean = true - override fun handleRegionPresets(map: LoRaRegionPresetMap) {} + override fun handleRegionPresets(map: LoRaRegionPresetMap, session: RadioSessionContext): Boolean = true } private class FakeNodeRepository(firmwareVersion: String? = "2.8.0.0") : NodeRepository { diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt index 190efb97be..74b825cdaf 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt @@ -29,6 +29,7 @@ class FakeDatabaseManager : override val cacheLimit: StateFlow = _cacheLimit var lastSwitchedAddress: String? = null + var lastAssociatedAddress: String? = null var lastAssociatedNode: Int? = null var lastAssociatedDeviceId: String? = null val existingDatabases = mutableSetOf() @@ -37,6 +38,7 @@ class FakeDatabaseManager : registerResetAction { _cacheLimit.value = DEFAULT_CACHE_LIMIT lastSwitchedAddress = null + lastAssociatedAddress = null lastAssociatedNode = null lastAssociatedDeviceId = null existingDatabases.clear() @@ -53,7 +55,14 @@ class FakeDatabaseManager : lastSwitchedAddress = address } - override suspend fun associateDevice(nodeNum: Int, deviceId: String?) { + override suspend fun associateDevice( + address: String, + nodeNum: Int, + deviceId: String?, + isSessionActive: () -> Boolean, + ) { + if (lastSwitchedAddress != address || !isSessionActive()) return + lastAssociatedAddress = address lastAssociatedNode = nodeNum lastAssociatedDeviceId = deviceId } diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt index da85ca08db..207effa79e 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt @@ -17,8 +17,12 @@ package org.meshtastic.core.testing import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -26,11 +30,18 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.model.MeshActivity import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.core.repository.RadioSessionLease +import org.meshtastic.core.repository.ReceivedRadioFrame import org.meshtastic.core.repository.TransportDisconnectReason /** @@ -39,7 +50,8 @@ import org.meshtastic.core.repository.TransportDisconnectReason * The [connectionState] here mirrors the transport-level semantics of the real implementation. In production, only * [MeshConnectionManager][org.meshtastic.core.repository.MeshConnectionManager] observes this flow; tests should verify * that bridging behavior rather than consuming it directly from UI/feature test code (use - * [FakeServiceRepository.connectionState] instead). + * [FakeServiceRepository.connectionState] instead). Address selection remains separate from transport admission: + * [connect] and an in-flight [restartTransport] publish a fresh monotonically increasing session generation. */ @Suppress("TooManyFunctions") class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = MainScope()) : RadioInterfaceService { @@ -53,10 +65,79 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main private val _currentDeviceAddressFlow = MutableStateFlow(null) override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow + private val _sessionGeneration = MutableStateFlow(0L) + override val sessionGeneration: StateFlow = _sessionGeneration + + private val _activeSession = MutableStateFlow(null) + override val activeSession: StateFlow = _activeSession + + private val sessionAdmissionLock = SynchronizedObject() + private var sessionAdmissionOpen = false + private var admittedSessionOperations = 0 + private var sessionDrainWaiter: CompletableDeferred? = null + private val sessionOperationMutex = Mutex() + + override fun isSessionActive(session: RadioSessionContext): Boolean = + synchronized(sessionAdmissionLock) { sessionAdmissionOpen && _activeSession.value == session } + + override fun runIfSessionActive(session: RadioSessionContext, block: () -> Unit): Boolean = + synchronized(sessionAdmissionLock) { + if (!sessionAdmissionOpen || _activeSession.value != session) return@synchronized false + block() + true + } + + override suspend fun runWithSessionLease( + session: RadioSessionContext, + block: suspend (RadioSessionLease) -> Unit, + ): Boolean { + val admittedSession = + synchronized(sessionAdmissionLock) { + val active = _activeSession.value + if (!sessionAdmissionOpen || active != session) { + null + } else { + admittedSessionOperations++ + active + } + } ?: return false + + val lease = + object : RadioSessionLease { + override val session: RadioSessionContext = session + + override fun isCurrent(): Boolean = + synchronized(sessionAdmissionLock) { _activeSession.value == admittedSession } + } + + try { + block(lease) + return true + } finally { + val waiter = + synchronized(sessionAdmissionLock) { + check(_activeSession.value == admittedSession) { + "Fake session changed before an admitted operation released its lease" + } + check(admittedSessionOperations > 0) { "Session operation count underflow" } + admittedSessionOperations-- + if (admittedSessionOperations == 0) { + sessionDrainWaiter.also { sessionDrainWaiter = null } + } else { + null + } + } + waiter?.complete(Unit) + } + } + + override suspend fun runWhileSessionActive(session: RadioSessionContext, block: suspend () -> Unit): Boolean = + sessionOperationMutex.withLock { runWithSessionLease(session) { block() } } + // Use an unbounded Channel to mirror SharedRadioInterfaceService semantics. A MutableSharedFlow would // hide the stop/start backlog bug that motivated the resetReceivedBuffer() API. - private val _receivedData = Channel(Channel.UNLIMITED) - override val receivedData: Flow = _receivedData.receiveAsFlow() + private val _receivedData = Channel(Channel.UNLIMITED) + override val receivedData: Flow = _receivedData.receiveAsFlow() private val _meshActivity = MutableSharedFlow() override val meshActivity: Flow = _meshActivity.asFlow() @@ -77,14 +158,20 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main override fun connect() { connectCalled = true + if (_activeSession.value == null) admitSelectedSession() } override suspend fun disconnect() { connectCalled = false + revokeActiveSession() } override suspend fun restartTransport() { restartTransportCalled = true + if (connectCalled && _activeSession.value != null) { + revokeActiveSession() + admitSelectedSession() + } } override fun getDeviceAddress(): String? = _currentDeviceAddressFlow.value @@ -94,6 +181,47 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main return true } + private fun admitSelectedSession() { + val address = _currentDeviceAddressFlow.value + synchronized(sessionAdmissionLock) { + check(_activeSession.value == null && admittedSessionOperations == 0) { + "Cannot admit a fake transport while the previous session is still draining" + } + if (address == null) { + sessionAdmissionOpen = false + return + } + val generation = _sessionGeneration.value + 1 + _sessionGeneration.value = generation + _activeSession.value = RadioSessionContext(generation, address) + sessionAdmissionOpen = true + } + } + + private suspend fun revokeActiveSession() { + val session = synchronized(sessionAdmissionLock) { _activeSession.value } ?: return + withContext(NonCancellable) { + val waiter = + synchronized(sessionAdmissionLock) { + if (_activeSession.value != session) return@synchronized null + sessionAdmissionOpen = false + if (admittedSessionOperations == 0) { + null + } else { + sessionDrainWaiter ?: CompletableDeferred().also { sessionDrainWaiter = it } + } + } + waiter?.await() + synchronized(sessionAdmissionLock) { + if (_activeSession.value == session) { + check(admittedSessionOperations == 0) { "Fake session revoked before admitted operations drained" } + _activeSession.value = null + sessionDrainWaiter = null + } + } + } + } + override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "$interfaceId:$rest" override fun onConnect() { @@ -105,7 +233,8 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main } override fun handleFromRadio(bytes: ByteArray) { - _receivedData.trySend(bytes) + val session = _activeSession.value ?: return + runIfSessionActive(session) { emitFromRadio(bytes, session) } } override fun resetReceivedBuffer() { @@ -115,8 +244,8 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main // --- Helper methods for testing --- - fun emitFromRadio(bytes: ByteArray) { - _receivedData.trySend(bytes) + fun emitFromRadio(bytes: ByteArray, session: RadioSessionContext) { + _receivedData.trySend(ReceivedRadioFrame(bytes.toByteString(), session)) } fun setConnectionState(state: ConnectionState) { diff --git a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceServiceSessionTest.kt b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceServiceSessionTest.kt new file mode 100644 index 0000000000..4179b5fe3b --- /dev/null +++ b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceServiceSessionTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.testing + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FakeRadioInterfaceServiceSessionTest { + + @Test + fun `selection does not admit a session and every connected start advances generation`() = runTest { + val service = FakeRadioInterfaceService(serviceScope = backgroundScope) + + service.setDeviceAddress("ble:same") + assertNull(service.activeSession.value, "address selection alone must not admit transport callbacks") + assertEquals(0L, service.sessionGeneration.value) + + service.connect() + assertEquals(1L, service.sessionGeneration.value) + assertEquals(1L, service.activeSession.value?.generation) + assertEquals("ble:same", service.activeSession.value?.address) + + service.connect() + assertEquals(1L, service.sessionGeneration.value, "repeated connect must preserve the active session") + + service.restartTransport() + assertEquals(2L, service.sessionGeneration.value) + assertEquals(2L, service.activeSession.value?.generation) + assertEquals("ble:same", service.activeSession.value?.address) + + service.disconnect() + assertNull(service.activeSession.value) + + service.restartTransport() + assertEquals(2L, service.sessionGeneration.value, "inactive restart must not admit a transport session") + assertNull(service.activeSession.value) + + service.connect() + assertEquals(3L, service.sessionGeneration.value) + assertEquals(3L, service.activeSession.value?.generation) + assertEquals("ble:same", service.activeSession.value?.address) + } + + @Test + fun `disconnect closes admission and waits for admitted fake session work`() = runTest { + val service = FakeRadioInterfaceService(serviceScope = backgroundScope) + service.setDeviceAddress("ble:test") + service.connect() + val session = requireNotNull(service.activeSession.value) + val workStarted = CompletableDeferred() + val releaseWork = CompletableDeferred() + val leaseStillCurrent = CompletableDeferred() + + val work = launch { + assertTrue( + service.runWithSessionLease(session) { lease -> + workStarted.complete(Unit) + releaseWork.await() + leaseStillCurrent.complete(lease.isCurrent()) + }, + ) + } + workStarted.await() + + val disconnect = launch { service.disconnect() } + runCurrent() + + assertFalse(disconnect.isCompleted, "disconnect must drain the admitted fake session lease") + assertFalse(service.isSessionActive(session), "disconnect must reject late fake-session work immediately") + assertEquals(session, service.activeSession.value, "lifecycle completion remains published while work drains") + + releaseWork.complete(Unit) + work.join() + assertTrue(leaseStillCurrent.await(), "admitted fake work retains its lifecycle lease until return") + disconnect.join() + assertNull(service.activeSession.value) + } +} diff --git a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt index f9a63c712e..e9c5f8d4e5 100644 --- a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt +++ b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt @@ -111,6 +111,27 @@ class RepositoryFakesTest { assertNull(repo.lastRequestId("other")) } + @Test + fun `FakeDatabaseManager associates only the active address`() = runTest { + val manager = FakeDatabaseManager() + manager.switchActiveDatabase("active") + + manager.associateDevice(address = "stale", nodeNum = 1, deviceId = "old", isSessionActive = { true }) + assertNull(manager.lastAssociatedAddress) + assertNull(manager.lastAssociatedNode) + assertNull(manager.lastAssociatedDeviceId) + + manager.associateDevice(address = "active", nodeNum = 2, deviceId = "inactive", isSessionActive = { false }) + assertNull(manager.lastAssociatedAddress) + assertNull(manager.lastAssociatedNode) + assertNull(manager.lastAssociatedDeviceId) + + manager.associateDevice(address = "active", nodeNum = 3, deviceId = "fresh", isSessionActive = { true }) + assertEquals("active", manager.lastAssociatedAddress) + assertEquals(3, manager.lastAssociatedNode) + assertEquals("fresh", manager.lastAssociatedDeviceId) + } + @Test fun `FakeRadioConfigRepository tracks channel set and module config`() = runTest { val repo = FakeRadioConfigRepository() diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt index d03c47b224..3e2cebe9a9 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.emptyFlow import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType @@ -39,6 +40,9 @@ import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.MeshWorkerManager import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.core.repository.RadioSessionLease +import org.meshtastic.core.repository.ReceivedRadioFrame import org.meshtastic.core.repository.TransportDisconnectReason import org.meshtastic.proto.MqttClientProxyMessage import org.meshtastic.mqtt.ConnectionState as MqttConnectionState @@ -67,10 +71,21 @@ class NoopRadioInterfaceService : RadioInterfaceService { override val connectionState = MutableStateFlow(ConnectionState.Disconnected) override val currentDeviceAddressFlow = MutableStateFlow(null) + override val sessionGeneration: StateFlow = MutableStateFlow(0L) + override val activeSession: StateFlow = MutableStateFlow(null) + + override fun isSessionActive(session: RadioSessionContext): Boolean = false + + override fun runIfSessionActive(session: RadioSessionContext, block: () -> Unit): Boolean = false + + override suspend fun runWithSessionLease( + session: RadioSessionContext, + block: suspend (RadioSessionLease) -> Unit, + ): Boolean = false override fun isMockTransport(): Boolean = false - override val receivedData = MutableSharedFlow() + override val receivedData = MutableSharedFlow() override val meshActivity: Flow = MutableSharedFlow() override val connectionError: Flow = MutableSharedFlow()