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 52698b5264..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 @@ -180,28 +180,45 @@ 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. 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 - // 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) + 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 + } + } while (page.size == TELEMETRY_SNAPSHOT_PAGE_SIZE) + + if (uuidsToDelete.isNotEmpty()) { + dao.deleteLogsByUuidAtomic(uuidsToDelete) } } Unit @@ -217,8 +234,6 @@ 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 + 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 0f42fef0aa..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 @@ -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 @@ -92,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) { @@ -107,21 +107,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 +296,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) { @@ -365,122 +327,22 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val status: MessageStatus, 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) - } - 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) + withContext(dispatchers.io) { + dbManager.withDb { + it.packetDao().applySFPPStatus(packetId, from, to, hash.toByteString(), status, rxTime, myNodeNum) } } } - override suspend fun updateSFPPStatusByHash(hash: ByteArray, status: MessageStatus, rxTime: Long): Unit = + override suspend fun updateSFPPStatusByHash(hash: ByteArray, status: MessageStatus, rxTime: Long) { 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) - } - } - Unit + dbManager.withDb { it.packetDao().applySFPPStatusByHash(hash.toByteString(), status, rxTime) } } + } - 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) - } - } - Unit + override suspend fun deleteMessages(uuidList: List) { + withContext(dispatchers.io) { dbManager.withDb { it.packetDao().deleteMessagesAtomic(uuidList) } } } override suspend fun deleteContacts(contactList: List) { @@ -628,7 +490,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/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/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 9369bbf0e7..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 @@ -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,50 @@ 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 + + /** + * 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. + */ + @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/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index 801f20b86f..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 @@ -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,167 @@ 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) + .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) + } else { + existing.copy(data = data) + } + update(updated) + } + } + + /** + * 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) { + 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 + * [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 (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)) + } + } + + reactions.forEach { reaction -> + val fromMatches = reaction.userId == fromId || (isFromLocalNode && reaction.userId == NodeAddress.ID_LOCAL) + if (fromMatches && (reaction.to == null || reaction.to == toId)) { + if (status.isDowngradeFrom(reaction.status)) return@forEach + val newTime = resolveNewTime(rxTime, 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 (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 (status.isDowngradeFrom(reaction.status)) return@let + val newTime = resolveNewTime(rxTime, 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/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/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/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 new file mode 100644 index 0000000000..d1434b0c86 --- /dev/null +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/MeshLogDaoTest.kt @@ -0,0 +1,188 @@ +/* + * 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 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)) + 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/PacketDaoAtomicTransactionTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketDaoAtomicTransactionTest.kt new file mode 100644 index 0000000000..7180fe8100 --- /dev/null +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketDaoAtomicTransactionTest.kt @@ -0,0 +1,967 @@ +/* + * 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, + ownerNodeNum: Int = myNodeNum, + ) = Packet( + uuid = 0L, + myNodeNum = ownerNodeNum, + 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, + ownerNodeNum: Int = myNodeNum, + ) = ReactionEntity( + myNodeNum = ownerNodeNum, + 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 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() + 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 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() + 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") + } +} 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()) + } +} 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()