Skip to content

Commit 9b5e3bb

Browse files
authored
fix(settings): Apply manual channel writes in order (#6077)
1 parent 6b58a97 commit 9b5e3bb

2 files changed

Lines changed: 546 additions & 22 deletions

File tree

feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt

Lines changed: 225 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ package org.meshtastic.feature.settings.radio
1919
import androidx.lifecycle.ViewModel
2020
import androidx.lifecycle.viewModelScope
2121
import co.touchlab.kermit.Logger
22+
import kotlinx.coroutines.CancellationException
2223
import kotlinx.coroutines.Job
24+
import kotlinx.coroutines.NonCancellable
2325
import kotlinx.coroutines.delay
2426
import kotlinx.coroutines.flow.MutableStateFlow
2527
import kotlinx.coroutines.flow.StateFlow
@@ -33,6 +35,9 @@ import kotlinx.coroutines.flow.map
3335
import kotlinx.coroutines.flow.onEach
3436
import kotlinx.coroutines.flow.update
3537
import kotlinx.coroutines.launch
38+
import kotlinx.coroutines.sync.Mutex
39+
import kotlinx.coroutines.sync.withLock
40+
import kotlinx.coroutines.withContext
3641
import org.jetbrains.compose.resources.StringResource
3742
import org.koin.core.annotation.InjectedParam
3843
import org.koin.core.annotation.KoinViewModel
@@ -76,6 +81,7 @@ import org.meshtastic.core.resources.key_backup_restore_failed
7681
import org.meshtastic.core.resources.key_backup_restored
7782
import org.meshtastic.core.resources.key_backup_saved
7883
import org.meshtastic.core.resources.timeout
84+
import org.meshtastic.core.resources.unknown_error
7985
import org.meshtastic.core.ui.util.SnackbarManager
8086
import org.meshtastic.core.ui.util.getChannelList
8187
import org.meshtastic.core.ui.viewmodel.safeLaunch
@@ -98,8 +104,11 @@ import org.meshtastic.proto.LocalModuleConfig
98104
import org.meshtastic.proto.MeshPacket
99105
import org.meshtastic.proto.ModuleConfig
100106
import org.meshtastic.proto.User
107+
import kotlin.time.Duration
101108
import kotlin.time.Duration.Companion.seconds
102109

110+
internal val MANUAL_CHANNEL_WRITE_DELAY: Duration = 1.seconds
111+
103112
/** Data class that represents the current RadioConfig state. */
104113
data class RadioConfigState(
105114
val isLocal: Boolean = false,
@@ -218,6 +227,9 @@ open class RadioConfigViewModel(
218227
val mqttProbeStatus: StateFlow<MqttProbeStatus?> = _mqttProbeStatus.asStateFlow()
219228

220229
private var probeJob: Job? = null
230+
private val channelUpdateMutex = Mutex()
231+
private var manualChannelBatchEnqueueing = false
232+
private val manualChannelBatchRequestIds = mutableSetOf<Int>()
221233

222234
/**
223235
* Run a one-shot reachability/credentials probe against an MQTT broker. Cancels any in-flight probe before starting
@@ -249,6 +261,7 @@ open class RadioConfigViewModel(
249261
get() = _destNode
250262

251263
private val requestIds = MutableStateFlow(hashSetOf<Int>())
264+
private val requestTimeoutJobs = mutableMapOf<Int, Job>()
252265
private val _radioConfigState = MutableStateFlow(RadioConfigState())
253266
val radioConfigState: StateFlow<RadioConfigState> = _radioConfigState
254267

@@ -401,22 +414,83 @@ open class RadioConfigViewModel(
401414
}
402415
}
403416

417+
@Suppress("TooGenericExceptionCaught")
404418
fun updateChannels(new: List<ChannelSettings>, old: List<ChannelSettings>) {
405419
val destNum = destNum ?: destNode.value?.num ?: return
406-
getChannelList(new, old).forEach { channel ->
407-
safeLaunch(tag = "setRemoteChannel") {
408-
val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel)
409-
registerRequestId(packetId)
420+
421+
safeLaunch(tag = "setRemoteChannels") {
422+
// Manual channel saves are an ordered batch: only update canonical local state after every write request is
423+
// enqueued. Serialize batches so two ordered write plans cannot interleave on the radio link, and diff
424+
// each queued save against the canonical list at the moment it starts.
425+
channelUpdateMutex.withLock {
426+
val current = radioConfigState.value.channelList.ifEmpty { old }
427+
val updatePlan = getManualChannelUpdatePlan(new, current)
428+
if (updatePlan.isEmpty()) return@withLock
429+
if (!beginManualChannelBatch(updatePlan.size)) return@withLock
430+
val batchRequestIds = mutableSetOf<Int>()
431+
432+
try {
433+
applyManualChannelUpdatePlan(
434+
updatePlan = updatePlan,
435+
currentSettings = current,
436+
finalSettings = new,
437+
writeChannel = { channel -> radioConfigUseCase.setRemoteChannel(destNum, channel) },
438+
registerRequestId = { packetId ->
439+
batchRequestIds.add(packetId)
440+
registerManualChannelBatchRequestId(packetId)
441+
},
442+
onInterrupted = { result ->
443+
reconcileInterruptedManualChannelUpdate(
444+
destNum = destNum,
445+
oldSettings = current,
446+
appliedSettings = result.appliedSettings,
447+
)
448+
},
449+
)
450+
commitManualChannelSettings(destNum = destNum, oldSettings = current, newSettings = new)
451+
finishManualChannelBatch()
452+
} catch (e: CancellationException) {
453+
abortManualChannelBatch(batchRequestIds)
454+
throw e
455+
} catch (e: Throwable) {
456+
abortManualChannelBatch(batchRequestIds)
457+
Logger.w(e) { "Manual channel update failed after enqueue" }
458+
e.message?.let(::sendError) ?: sendError(Res.string.unknown_error)
459+
}
460+
}
461+
}
462+
}
463+
464+
private fun getManualChannelUpdatePlan(new: List<ChannelSettings>, old: List<ChannelSettings>): List<Channel> =
465+
getChannelList(new, old).sortedBy { it.index }
466+
467+
private suspend fun commitManualChannelSettings(
468+
destNum: Int,
469+
oldSettings: List<ChannelSettings>,
470+
newSettings: List<ChannelSettings>,
471+
) {
472+
withContext(NonCancellable) {
473+
if (destNum == myNodeNum) {
474+
packetRepository.migrateChannelsByPSK(oldSettings, newSettings)
475+
radioConfigRepository.replaceAllSettings(newSettings)
410476
}
477+
_radioConfigState.update { it.copy(channelList = newSettings) }
411478
}
479+
}
412480

413-
if (destNum == myNodeNum) {
414-
safeLaunch(tag = "migrateChannels") {
415-
packetRepository.migrateChannelsByPSK(old, new)
416-
radioConfigRepository.replaceAllSettings(new)
481+
private suspend fun reconcileInterruptedManualChannelUpdate(
482+
destNum: Int,
483+
oldSettings: List<ChannelSettings>,
484+
appliedSettings: List<ChannelSettings>,
485+
) {
486+
withContext(NonCancellable) {
487+
Logger.w { "Reconciling interrupted manual channel update appliedSettings=${appliedSettings.size}" }
488+
if (destNum == myNodeNum) {
489+
packetRepository.migrateChannelsByPSK(oldSettings, appliedSettings)
490+
radioConfigRepository.replaceAllSettings(appliedSettings)
417491
}
492+
_radioConfigState.update { it.copy(channelList = appliedSettings) }
418493
}
419-
_radioConfigState.update { it.copy(channelList = new) }
420494
}
421495

422496
fun setConfig(config: Config) {
@@ -628,7 +702,7 @@ open class RadioConfigViewModel(
628702
}
629703

630704
fun clearPacketResponse() {
631-
requestIds.value = hashSetOf()
705+
clearRequestIds()
632706
_radioConfigState.update { it.copy(responseState = ResponseState.Empty) }
633707
}
634708

@@ -740,6 +814,38 @@ open class RadioConfigViewModel(
740814
}
741815
}
742816

817+
private fun beginManualChannelBatch(total: Int): Boolean {
818+
if (hasUnrelatedPendingRequest() || hasPendingRequestRegistration()) {
819+
Logger.w { "Manual channel update skipped while another radio request is pending" }
820+
return false
821+
}
822+
manualChannelBatchEnqueueing = true
823+
_radioConfigState.update { state ->
824+
state.copy(route = "", responseState = ResponseState.Loading(total = total))
825+
}
826+
return true
827+
}
828+
829+
private fun finishManualChannelBatch() {
830+
manualChannelBatchEnqueueing = false
831+
if (requestIds.value.isEmpty()) {
832+
setResponseStateSuccess()
833+
}
834+
}
835+
836+
private fun abortManualChannelBatch(batchRequestIds: Set<Int>) {
837+
manualChannelBatchEnqueueing = false
838+
removeRequestIds(batchRequestIds)
839+
}
840+
841+
private fun completeSetRequestOrProgressBatch() {
842+
if (manualChannelBatchEnqueueing) {
843+
incrementCompleted()
844+
} else {
845+
setResponseStateSuccess()
846+
}
847+
}
848+
743849
protected fun setResponseStateSuccess() {
744850
_radioConfigState.update { state ->
745851
if (state.responseState is ResponseState.Loading) {
@@ -772,7 +878,8 @@ open class RadioConfigViewModel(
772878
}
773879

774880
private fun registerRequestId(packetId: Int) {
775-
requestIds.update { it.apply { add(packetId) } }
881+
requestTimeoutJobs.remove(packetId)?.cancel()
882+
requestIds.update { it.withPacketId(packetId) }
776883
_radioConfigState.update { state ->
777884
if (state.responseState is ResponseState.Loading) {
778885
val total = maxOf(requestIds.value.size, state.responseState.total)
@@ -786,15 +893,46 @@ open class RadioConfigViewModel(
786893
}
787894

788895
val requestTimeout = 30.seconds
789-
safeLaunch(tag = "requestTimeout") {
790-
delay(requestTimeout)
791-
if (requestIds.value.contains(packetId)) {
792-
requestIds.update { it.apply { remove(packetId) } }
793-
if (requestIds.value.isEmpty()) {
794-
sendError(Res.string.timeout)
896+
requestTimeoutJobs[packetId] =
897+
safeLaunch(tag = "requestTimeout") {
898+
delay(requestTimeout)
899+
if (requestIds.value.contains(packetId)) {
900+
removeRequestId(packetId)
901+
if (requestIds.value.isEmpty()) {
902+
sendError(Res.string.timeout)
903+
}
795904
}
796905
}
797-
}
906+
}
907+
908+
private fun registerManualChannelBatchRequestId(packetId: Int) {
909+
manualChannelBatchRequestIds.add(packetId)
910+
registerRequestId(packetId)
911+
}
912+
913+
private fun hasUnrelatedPendingRequest(): Boolean = requestIds.value.any { it !in manualChannelBatchRequestIds }
914+
915+
private fun hasPendingRequestRegistration(): Boolean = requestIds.value.isEmpty() &&
916+
manualChannelBatchRequestIds.isEmpty() &&
917+
radioConfigState.value.responseState is ResponseState.Loading
918+
919+
private fun clearRequestIds() {
920+
requestTimeoutJobs.values.forEach { it.cancel() }
921+
requestTimeoutJobs.clear()
922+
manualChannelBatchRequestIds.clear()
923+
requestIds.value = hashSetOf()
924+
}
925+
926+
private fun removeRequestId(packetId: Int) {
927+
requestTimeoutJobs.remove(packetId)?.cancel()
928+
manualChannelBatchRequestIds.remove(packetId)
929+
requestIds.update { it.withoutPacketId(packetId) }
930+
}
931+
932+
private fun removeRequestIds(packetIds: Set<Int>) {
933+
packetIds.forEach { requestTimeoutJobs.remove(it)?.cancel() }
934+
manualChannelBatchRequestIds.removeAll(packetIds)
935+
requestIds.update { ids -> ids.withoutPacketIds(packetIds) }
798936
}
799937

800938
private fun processPacketResponse(packet: MeshPacket) {
@@ -813,9 +951,9 @@ open class RadioConfigViewModel(
813951
is RadioResponseResult.Success -> {
814952
if (route.isEmpty()) {
815953
val data = packet.decoded!!
816-
requestIds.update { it.apply { remove(data.request_id) } }
954+
removeRequestId(data.request_id)
817955
if (requestIds.value.isEmpty()) {
818-
setResponseStateSuccess()
956+
completeSetRequestOrProgressBatch()
819957
} else {
820958
incrementCompleted()
821959
}
@@ -938,14 +1076,79 @@ open class RadioConfigViewModel(
9381076
}
9391077

9401078
val requestId = packet.decoded?.request_id ?: return
941-
requestIds.update { it.apply { remove(requestId) } }
1079+
removeRequestId(requestId)
9421080

9431081
if (requestIds.value.isEmpty()) {
9441082
if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
9451083
clearPacketResponse()
9461084
} else if (route.isEmpty()) {
947-
setResponseStateSuccess()
1085+
completeSetRequestOrProgressBatch()
9481086
}
9491087
}
9501088
}
9511089
}
1090+
1091+
internal data class ManualChannelUpdateResult(val packetIds: List<Int>, val finalSettings: List<ChannelSettings>)
1092+
1093+
internal data class InterruptedManualChannelUpdate(
1094+
val appliedSettings: List<ChannelSettings>,
1095+
val appliedWriteCount: Int,
1096+
)
1097+
1098+
internal suspend fun applyManualChannelUpdatePlan(
1099+
updatePlan: List<Channel>,
1100+
currentSettings: List<ChannelSettings>,
1101+
finalSettings: List<ChannelSettings>,
1102+
writeChannel: suspend (Channel) -> Int,
1103+
registerRequestId: (Int) -> Unit,
1104+
onInterrupted: suspend (InterruptedManualChannelUpdate) -> Unit = {},
1105+
writeDelay: Duration = MANUAL_CHANNEL_WRITE_DELAY,
1106+
delayFn: suspend (Duration) -> Unit = { delay(it) },
1107+
): ManualChannelUpdateResult {
1108+
val packetIds = mutableListOf<Int>()
1109+
val appliedSettings = currentSettings.toMutableList()
1110+
var appliedWriteCount = 0
1111+
var updateComplete = false
1112+
try {
1113+
for ((index, channel) in updatePlan.withIndex()) {
1114+
val packetId = writeChannel(channel)
1115+
packetIds.add(packetId)
1116+
registerRequestId(packetId)
1117+
appliedSettings.applyManualChannelWrite(channel)
1118+
appliedWriteCount++
1119+
if (index < updatePlan.lastIndex) {
1120+
delayFn(writeDelay)
1121+
}
1122+
}
1123+
updateComplete = true
1124+
} finally {
1125+
if (!updateComplete && appliedWriteCount > 0) {
1126+
onInterrupted(
1127+
InterruptedManualChannelUpdate(
1128+
appliedSettings = appliedSettings.toList(),
1129+
appliedWriteCount = appliedWriteCount,
1130+
),
1131+
)
1132+
}
1133+
}
1134+
return ManualChannelUpdateResult(packetIds = packetIds, finalSettings = finalSettings)
1135+
}
1136+
1137+
private fun MutableList<ChannelSettings>.applyManualChannelWrite(channel: Channel) {
1138+
while (size <= channel.index) {
1139+
add(ChannelSettings())
1140+
}
1141+
this[channel.index] =
1142+
if (channel.role == Channel.Role.DISABLED) {
1143+
ChannelSettings()
1144+
} else {
1145+
channel.settings ?: ChannelSettings()
1146+
}
1147+
}
1148+
1149+
private fun HashSet<Int>.withPacketId(packetId: Int): HashSet<Int> = HashSet(this).apply { add(packetId) }
1150+
1151+
private fun HashSet<Int>.withoutPacketId(packetId: Int): HashSet<Int> = HashSet(this).apply { remove(packetId) }
1152+
1153+
private fun HashSet<Int>.withoutPacketIds(packetIds: Set<Int>): HashSet<Int> =
1154+
HashSet(this).apply { removeAll(packetIds) }

0 commit comments

Comments
 (0)