Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .skills/compose-ui/strings-index.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ class CommandSenderImpl(
}
}

override suspend fun requestPosition(destNum: Int, currentPosition: Position) {
override suspend fun requestPosition(destNum: Int, currentPosition: Position, requestId: Int) {
// Firmware coarsens a phone-originated position to the channel precision, so an explicit 0,0 goes out as a
// real-looking coordinate. Leave the fields absent when we have none.
val meshPosition =
Expand All @@ -288,6 +288,7 @@ class CommandSenderImpl(
enqueueOrThrow(
buildMeshPacket(
to = destNum,
id = requestId.nonZeroRequestId(),
channel = getChannelIndex(destNum),
priority = MeshPacket.Priority.BACKGROUND,
decoded =
Expand Down Expand Up @@ -317,12 +318,13 @@ class CommandSenderImpl(
}
}

override suspend fun requestUserInfo(destNum: Int) {
override suspend fun requestUserInfo(destNum: Int, requestId: Int) {
val myNum = nodeManager.myNodeNum.value ?: throw LocalNodeUnavailableException("User-info request")
val myNode = nodeManager.nodeDBbyNodeNum[myNum] ?: throw LocalNodeUnavailableException("User-info request")
enqueueOrThrow(
buildMeshPacket(
to = destNum,
id = requestId.nonZeroRequestId(),
channel = getChannelIndex(destNum),
decoded =
Data.Builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ class MeshDataHandlerImpl(
}
handleAckNak(
packet.decoded?.request_id ?: 0,
nodeManager.toNodeID(packet.from),
packet.from,
r.error_reason?.value ?: 0,
dataPacket.relayNode,
session,
Expand All @@ -400,12 +400,13 @@ class MeshDataHandlerImpl(
@Suppress("CyclomaticComplexMethod", "LongMethod")
private fun handleAckNak(
requestId: Int,
fromId: String,
fromNum: Int,
routingError: Int,
relayNode: Int?,
session: RadioSessionContext,
ackProofStatus: Int = MeshPacket.AckProofStatus.ACK_PROOF_ABSENT.value,
) {
val fromId = nodeManager.toNodeID(fromNum)
radioInterfaceService.launchSessionWork(scope, session) {
val isAck = routingError == Routing.Error.NONE.value
val absentAckProof = MeshPacket.AckProofStatus.ACK_PROOF_ABSENT.value
Expand Down Expand Up @@ -471,7 +472,7 @@ class MeshDataHandlerImpl(
packetRepository.value.updateReaction(settled.copy(ackProofStatus = provenAckProof))
}
}
packetHandler.completeDispatchedResponse(requestId, complete = isAck)
packetHandler.completeDispatchedResponse(requestId, complete = isAck, from = fromNum)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import org.meshtastic.core.database.dao.shouldApplyOutgoingQueueStatus
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
Expand Down Expand Up @@ -425,9 +426,11 @@ class PacketHandlerImpl(
}
}

override suspend fun completeDispatchedResponse(dataRequestId: Int, complete: Boolean) {
override suspend fun completeDispatchedResponse(dataRequestId: Int, complete: Boolean, from: Int?) {
responseMutex.withLock {
val pending = queueResponse[dataRequestId]?.takeIf { it.wasDispatched } ?: return@withLock
val to = pending.packet.to
if (complete && from != null && from != to && to != NodeAddress.NODENUM_BROADCAST) return@withLock
pending.completeRouting(complete.toAwaitedSendStatus())
removePendingIfTerminalLocked(dataRequestId, pending)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -773,7 +773,7 @@ class MeshDataHandlerTest {
handler.handleReceivedData(routingPacket(Routing.Error.NONE), 123)
advanceUntilIdle()

verifySuspend { packetHandler.completeDispatchedResponse(99, complete = true) }
verifySuspend { packetHandler.completeDispatchedResponse(99, complete = true, from = 456) }
}

private fun sentPacket(ackProofStatus: Int = 0) = DataPacket(
Expand Down Expand Up @@ -915,7 +915,7 @@ class MeshDataHandlerTest {
handler.handleReceivedData(routingPacket(Routing.Error.NO_ROUTE), 123)
advanceUntilIdle()

verifySuspend { packetHandler.completeDispatchedResponse(99, complete = false) }
verifySuspend { packetHandler.completeDispatchedResponse(99, complete = false, from = 456) }
}

@Test
Expand Down Expand Up @@ -952,7 +952,7 @@ class MeshDataHandlerTest {
verifySuspend(exactly(0)) { packetRepository.findPacketsWithId(any()) }
verifySuspend(exactly(0)) { packetRepository.findReactionsWithId(any()) }
verifySuspend(exactly(0)) { packetRepository.update(any(), any()) }
verifySuspend(exactly(0)) { packetHandler.completeDispatchedResponse(any(), any()) }
verifySuspend(exactly(0)) { packetHandler.completeDispatchedResponse(any(), any(), any()) }
}

// --- Telemetry handling ---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,38 @@ class PacketHandlerImplTest {
assertTrue(rejected.dispatched)
}

@Test
fun `a relay ack leaves the wait open for the destination nak`() = runTest(testDispatcher) {
connectionStateFlow.value = ConnectionState.Connected
val packet =
MeshPacket.Builder()
.also { wb ->
wb.id = 830
wb.to = 500
}
.build()
val result = async { handler.sendToRadioAndAwaitResult(packet) }
testScheduler.runCurrent()
handler.handleQueueStatus(
QueueStatus.Builder()
.also { wb ->
wb.mesh_packet_id = 830
wb.res = 0
wb.free = 16
}
.build(),
)
testScheduler.runCurrent()

handler.completeDispatchedResponse(dataRequestId = 830, complete = true, from = 123)
testScheduler.runCurrent()
assertFalse(result.isCompleted)

handler.completeDispatchedResponse(dataRequestId = 830, complete = false, from = 500)
testScheduler.runCurrent()
assertEquals(AwaitedSendStatus.RADIO_REJECTED, result.await().status)
}

@Test
fun `late queue status cannot replace an already completed response`() = runTest(testDispatcher) {
connectionStateFlow.value = ConnectionState.Connected
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
Expand All @@ -33,6 +34,7 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.di.ServiceScope
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.model.routingNakOrNull
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.SessionManager
Expand Down Expand Up @@ -75,43 +77,42 @@ open class EnsureRemoteAdminSessionUseCase(
return EnsureSessionResult.AlreadyActive
}

val deferred =
inFlightMutex.withLock {
inFlight[destNum]?.takeIf { !it.isCompleted }
?: run {
lateinit var newDeferred: Deferred<EnsureSessionResult>
newDeferred =
serviceScope.async(start = CoroutineStart.LAZY) {
try {
runEnsure(destNum)
} finally {
// Cleanup belongs to the shared Deferred itself. NonCancellable guarantees that a
// service-scope cancellation cannot strand a completed entry in the dedupe map.
withContext(NonCancellable) {
inFlightMutex.withLock {
if (inFlight[destNum] === newDeferred) inFlight.remove(destNum)
}
val deferred = inFlightMutex.withLock {
inFlight[destNum]?.takeIf { !it.isCompleted }
?: run {
lateinit var newDeferred: Deferred<EnsureSessionResult>
newDeferred =
serviceScope.async(start = CoroutineStart.LAZY) {
try {
runEnsure(destNum)
} finally {
// Cleanup belongs to the shared Deferred itself. NonCancellable guarantees that a
// service-scope cancellation cannot strand a completed entry in the dedupe map.
withContext(NonCancellable) {
inFlightMutex.withLock {
if (inFlight[destNum] === newDeferred) inFlight.remove(destNum)
}
}
}
// Register before the lazy deferred starts. The identity check above prevents an old completion
// from removing a newer ensure for the same node.
inFlight[destNum] = newDeferred
// The service scope, not the first awaiting caller, owns dispatch once the entry is visible.
// A lazy child can already be cancelled when its parent scope is shutting down; in that case
// its body never runs, so remove the entry here instead of relying on the body-level finally.
if (!newDeferred.start() && inFlight[destNum] === newDeferred) inFlight.remove(destNum)
newDeferred
}
}
}
// Register before the lazy deferred starts. The identity check above prevents an old completion
// from removing a newer ensure for the same node.
inFlight[destNum] = newDeferred
// The service scope, not the first awaiting caller, owns dispatch once the entry is visible.
// A lazy child can already be cancelled when its parent scope is shutting down; in that case
// its body never runs, so remove the entry here instead of relying on the body-level finally.
if (!newDeferred.start() && inFlight[destNum] === newDeferred) inFlight.remove(destNum)
newDeferred
}
}
return deferred.await()
}

private suspend fun runEnsure(destNum: Int): EnsureSessionResult {
Logger.d { "EnsureRemoteAdminSession dispatching metadata request to $destNum" }
return withTimeoutOrNull(UX_TIMEOUT) {
coroutineScope {
// Subscribe to both terminal events before dispatch so neither a synchronous response nor a
// Subscribe to every terminal event before dispatch so neither a synchronous response nor a
// concurrent connection departure can be missed. UNDISPATCHED is deliberate: these children do no
// work before suspending in first(), so starting inline only establishes the subscriptions.
val refreshed =
Expand All @@ -122,16 +123,25 @@ open class EnsureRemoteAdminSessionUseCase(
async(start = CoroutineStart.UNDISPATCHED) {
serviceRepository.connectionState.filter { it != ConnectionState.Connected }.first()
}
val requestId = radioController.generatePacketId()
val rejected =
async(start = CoroutineStart.UNDISPATCHED) {
serviceRepository.meshPacketFlow
.mapNotNull { it.routingNakOrNull() }
.first { it.requestId == requestId }
}
try {
if (disconnected.isCompleted) return@coroutineScope EnsureSessionResult.Disconnected
radioController.refreshMetadata(destNum)
radioController.refreshMetadata(destNum, requestId)
select<EnsureSessionResult> {
disconnected.onAwait { EnsureSessionResult.Disconnected }
refreshed.onAwait { EnsureSessionResult.Refreshed }
rejected.onAwait { EnsureSessionResult.Rejected(it) }
}
} finally {
disconnected.cancel()
refreshed.cancel()
rejected.cancel()
}
}
} ?: EnsureSessionResult.Timeout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
*/
package org.meshtastic.core.domain.usecase.session

import org.meshtastic.core.model.RoutingNak

/**
* Transient outcome of a single call to [EnsureRemoteAdminSessionUseCase]. This is the *event* the UI reacts to
* (snackbar / navigate / disable button) — distinct from the durable `SessionStatus` flow used by chips and gates.
Expand All @@ -33,6 +35,9 @@ sealed interface EnsureSessionResult {
*/
data object Timeout : EnsureSessionResult

/** A routing NAK answered the metadata request, such as the target refusing our admin key. */
data class Rejected(val nak: RoutingNak) : EnsureSessionResult

/** The radio is not in [org.meshtastic.core.model.ConnectionState.Connected]; no packet was sent. */
data object Disconnected : EnsureSessionResult
}
Loading