refactor(qr): apply channel imports atomically via edit-settings transaction#6170
Conversation
…saction Route QR/URL channel import through the firmware begin/commit edit-settings transaction instead of a paced sequence of fire-and-forget channel writes. Firmware defers persist/reconfigure to the commit, so the whole import — channels plus LoRa — applies atomically in a single reboot. This retires the transport scaffolding from #5999 and #6072 (per-write pacing, LoRa settle delays, interrupted-replacement reconciliation): the transaction makes the reliability problem those addressed structurally impossible rather than patched. The normalization / dedup / slot-role logic from that work is unchanged and still drives the new path. - Merge applyReplacementChannelSet + applyImportedLoraConfigAfterChannelReplacement into one importChannelSet, called once at each import site (QR + channel URL). - Add AdminController.editLocalSettings { } (local-node editSettings), mirroring the pattern InstallProfileUseCase already uses for profile install. - Remove ~12s of artificial import delay; net -151 lines. The manual channel editor is intentionally left as-is — it is ACK-gated on per-write firmware routing responses (progress bar), so it cannot move to the fire-and-forget transaction without losing that UX. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds local-node transactional editing, rewrites channel import into a single edit transaction, updates consumers to use it, and expands tests to cover failure, normalization, and LoRa-config behavior. ChangesLocal settings transaction and channel import rework
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant ChannelViewModel
participant importChannelSet
participant AdminController
participant RadioConfigRepository
ChannelViewModel->>importChannelSet: importChannelSet(channelSet, radioController, radioConfigRepository)
importChannelSet->>AdminController: editLocalSettings { ... }
loop for each replacement channel
importChannelSet->>AdminController: setChannel(channel)
end
alt lora_config differs
importChannelSet->>AdminController: setConfig(Config(lora))
end
AdminController-->>importChannelSet: transaction committed
importChannelSet->>RadioConfigRepository: replaceAllSettings(normalizedSettings)
RadioConfigRepository-->>ChannelViewModel: local cache updated once
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
@jeremiah-k — GitHub won't let me add you to the formal Reviewers list (that needs collaborator access; you're read-only on the repo), so flagging here instead: I'd really value your review on this one, since it supersedes the transport layer from #5999 and #6072. The single thing I most want your take on: is firmware safe receiving un-paced |
|
@jamesarich Thanks for tagging me. I pulled the Android PR head and traced the firmware path from the bundle I had. I think the direction is good, but I’d narrow a couple of claims before un-drafting. Short version:
Firmware sideThe set_channel
→ AdminModule::handleSetChannel(...)
→ channels.setChannel(cc)
→ channels.onConfigChanged()
→ saveChanges(SEGMENT_CHANNELS, false)
That said, I do not think this means the old pacing is still required.
The heavier path is behind void AdminModule::saveChanges(int saveWhat, bool shouldReboot)
{
if (!hasOpenEditTransaction) {
service->reloadConfig(saveWhat);
} else {
LOG_INFO("Delay save of changes to disk until the open transaction is committed");
}
if (shouldReboot && !hasOpenEditTransaction) {
reboot(DEFAULT_REBOOT_SECONDS);
}
}Then So my firmware-side read is: The PR can say edit-settings defers disk persistence, radio reload/reconfiguration, and reboot until commit. I would avoid saying or implying that For the specific un-paced-write question, I do not see a firmware reason that an 8-channel burst should be unsafe from a hardware/reconfigure standpoint, because the expensive work appears deferred. The per-packet work is in-memory channel replacement, channel fixup, primary-index recalculation, possible MQTT start, warning/clamp handling, and delayed-save logging. So I think the Android sideThis is the part I’d want fixed. The new import shape is good in principle: radioController.editLocalSettings {
for (channel in replacements) {
setChannel(channel)
}
importedLoraConfig?.let { setConfig(Config(lora = it)) }
}
withContext(NonCancellable) {
radioConfigRepository.replaceAllSettings(normalizedSettings)
}That should mean: send all writes inside the edit session, then update the local cache once after the session succeeds. But the real controller still has an eager local cache update inside the transaction.
override suspend fun setChannel(channel: Channel) =
setRemoteChannel(destNum, channel, commandSender.generatePacketId())And override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {
commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_channel = channel) }
if (destNum == nodeManager.myNodeNum.value) {
scope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) }
}
}Since So if the edit session fails or the connection drops after one or two channel writes, the final The current fake test does not really prove the interrupted-import claim, because Suggested fixI would not ask for a firmware change here. Based on the firmware path I checked, I do not think deferring I would fix the Android cache behavior instead:
In other words: firmware’s expensive work is commit-shaped, but Android’s local cache is still slot-shaped. I think the cache should be commit-shaped for this import path too. I’d also soften the PR wording/KDoc to something like:
Recommended stanceI’m not opposed to the direction. I think the transaction likely addresses the hardware-timing concern that the old pacing was trying to avoid. I just would not call it ready exactly as written yet. Before un-drafting, I’d ask for:
My direct answer: I think firmware is safe receiving un-paced But it is not fully atomic for live in-memory channel state, and the current Android implementation is not atomic for local cache state because of the eager I’d be comfortable with the transport direction once the Android cache side is fixed and the PR wording is narrowed. |
Address @jeremiah-k's review. EditSettingsSession.setChannel delegated to setRemoteChannel, which for the local node mirrors each write to the local channel cache (updateChannelSettings). So an import interrupted before commit could leave the cache partially mutated, and the fake-based test passed without modeling that side effect — the "interrupted import leaves cache untouched" claim wasn't actually true. - setChannel inside the edit session now sends the set_channel admin packet without the per-slot local-cache mirror. importChannelSet owns the cache and writes it once after commit (replaceAllSettings), so the cache is commit-shaped and an interrupted import leaves it untouched. Only the import path uses transactional setChannel (InstallProfileUseCase installs no channels), so profile install is unaffected. - Add RadioControllerImplTest.editLocalSettingsChannelWritesDoNotMirrorToLocalCache, asserting against the real controller + mock repo that a transactional setChannel does NOT call updateChannelSettings — the coverage the extension fake couldn't provide. - Narrow the KDoc per Jeremiah's firmware trace: the transaction defers disk persistence, radio reload/reconfiguration, and reboot to commit; firmware still writes each set_channel into its in-memory channel table on arrival. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@jeremiah-k This is a fantastic review — thank you for pulling the head and actually tracing the firmware path. You caught a real bug, and you're right on the wording. Pushed 79fdc89 addressing it. The Android cache leak — fixed. You nailed it: // transactional channel write — no per-slot local-cache mirror
override suspend fun setChannel(channel: Channel) =
commandSender.sendAdmin(destNum) { AdminMessage(set_channel = channel) }
Test — moved to where it can actually catch this. You were right that the extension-level fake test couldn't prove the claim. Added Wording — narrowed. KDoc now says the transaction defers disk persistence, radio reload/reconfiguration, and reboot to commit, and explicitly notes firmware still writes each On your four asks: (1) ✅ non-mirroring transactional Still open — the one thing I can't do from here: hardware validation (full 8-channel replacement + a LoRa change on a real device). Your firmware trace + the Thanks again — this is exactly the firmware-side context I was hoping for. |
Bench test: real hardware, both revisionsTested against a physical Heltec V3 DUT (BLE) with an independent MCP serial oracle on the same radio, driving the app via Rev 1 (
|
|
Hardware validation passed on a physical Heltec V3 (full writeup above) — marking ready for review. Both claims held up on metal:
@jeremiah-k — the second result is your review point, confirmed end-to-end: on the pre-fix commit that same interrupted sequence would have left the app's channel list partially mutated (radio untouched, but UI wrong), because One unrelated finding to flag (not this PR): a crafted LoRaConfig with |
|
@jamesarich Thanks, this is exactly the hardware validation I was hoping to see. The Rev 1 results answer my firmware concern. As you noted:
That lines up with what I saw in the firmware path: per-channel
is a strong confirmation that the final stored state landed correctly. The Rev 2 interrupted-import test also covers the Android cache issue I called out. This part is especially important:
and then on the app side:
That’s the end-to-end behavior we want. The unit test guards the call-level behavior, and this bench test confirms the real app/radio path behaves the same way under interruption. Your summary matches that nicely:
The heap corruption note also sounds correctly scoped:
Agreed that this looks unrelated to the transaction logic, but definitely worth a separate firmware hardening issue. From my side, with the wording narrowed, the channel-cache mirroring fixed, and this real-hardware success/interruption coverage, I’m comfortable approving this PR. Only small caveat I’d keep in mind for future work: transactional |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt (1)
372-390: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winTighten the
sendAdmincall-count assertion.
atLeast(2)is trivially satisfied and won't catch a regression where extra/duplicate packets are sent (e.g., a duplicatebegin/commit, or an accidental double-write per channel). The transaction here sends exactly 4 packets (begin + 2 writes + commit).♻️ Proposed tightening
- verifySuspend(atLeast(2)) { commandSender.sendAdmin(any(), any(), any(), any()) } + verifySuspend(exactly(4)) { commandSender.sendAdmin(any(), any(), any(), any()) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt` around lines 372 - 390, The test in RadioControllerImplTest.editLocalSettingsChannelWritesDoNotMirrorToLocalCache uses a too-weak sendAdmin assertion; tighten it to verify the exact transaction packet count instead of atLeast(2). Keep the existing radioConfigRepository.updateChannelSettings(exactly(0)) check, and update the sendAdmin verification around editLocalSettings/importChannelSet behavior so it fails if begin/commit or either channel write is duplicated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In
`@core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt`:
- Around line 372-390: The test in
RadioControllerImplTest.editLocalSettingsChannelWritesDoNotMirrorToLocalCache
uses a too-weak sendAdmin assertion; tighten it to verify the exact transaction
packet count instead of atLeast(2). Keep the existing
radioConfigRepository.updateChannelSettings(exactly(0)) check, and update the
sendAdmin verification around editLocalSettings/importChannelSet behavior so it
fails if begin/commit or either channel write is duplicated.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 73a6d9eb-f024-450a-a3cf-5b73bb511ed3
📒 Files selected for processing (8)
core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.ktcore/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.ktcore/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.ktcore/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.ktcore/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.ktcore/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.ktcore/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.ktfeature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt
…ding (review) Address CodeRabbit: assert the edit-settings transaction sends exactly 4 admin packets (begin + 2 channel writes + commit) instead of atLeast(2), so a duplicated begin/commit or double-write per channel fails the test. Confirmed passing on JVM + androidHostTest. Address @jeremiah-k's approval caveat: transactional setConfig still mirrors to the local cache, so narrow importChannelSet's KDoc (and PR body) to scope the commit-shaped-cache claim to the channel cache, and document that the imported LoRa config still writes through the cache-mirroring setConfig — a single trailing write. Making setConfig transaction-aware is noted as future work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@coderabbitai Good catch — tightened 🤖 Addressed by Claude Code |
|
@jeremiah-k Thank you — appreciate the firmware trace and the approval. Acted on your |
|
Tip For best results, initiate chat on the files or code changes.
|
Why
QR/URL channel import currently replays each channel as an individual fire-and-forget
set_channelwrite, then writes LoRa config separately. Because those writes aren't atomic, the path has accreted defensive scaffolding across several recent PRs (#5999, #6072): 1s per-channel pacing, 2s + 2s LoRa settle delays, and interrupted-replacement cache reconciliation. A mid-import disconnect can still leave the node half-configured, and a full import costs ~12s of artificial delay.The firmware already exposes a begin/commit edit-settings transaction (
begin_edit_settings/commit_edit_settings), andInstallProfileUseCasealready uses it to install owner / config / module-config. Inside a transaction firmware defers disk persistence, radio reload/reconfiguration, and reboot until commit — which is exactly the expensive, race-prone work the old pacing/settle delays were guarding against. (Per @jeremiah-k's firmware trace:set_channelstill writes each channel into firmware's in-memory table on arrival, so it isn't a full staging of channel state — but the costly persist/reload path runs once at commit.)🛠️ Changes
AdminController.editLocalSettings { }— local-nodeeditSettings, mirroring the existingsetLocalConfig/setLocalChannelnaming.applyReplacementChannelSet+applyImportedLoraConfigAfterChannelReplacementinto oneimportChannelSet, called once at each import site (ChannelViewModel,ScannedQrCodeViewModel). Channels and LoRa are written inside one transaction and committed atomically — hardware-verified as a single save-to-disk pass + single reboot (see Testing).reconcileInterruptedReplacement.setChannelno longer mirrors to the local cache per slot.importChannelSetowns the cache and writes it once after commit (replaceAllSettings), so the local channel cache is commit-shaped and an interrupted import leaves it untouched. Profile install is unaffected — it installs no channels.🧹 Result
setConfig— a single trailing write, called out in the KDoc; makingsetConfigtransaction-aware is future work, per @jeremiah-k.)Out of scope (intentional)
The manual channel editor (
applyManualChannelUpdatePlan,MANUAL_CHANNEL_WRITE_DELAY) is left as-is. It's ACK-gated on per-write firmware routing responses that drive its progress bar and 30s per-write timeout, so it can't move to the fire-and-forget transaction without losing that UX.Testing Performed
:core:service:allTests— green, incl. newRadioControllerImplTest.editLocalSettingsChannelWritesDoNotMirrorToLocalCache, which asserts against the real controller + mock repo that a transactionalsetChanneldoes not callupdateChannelSettings(regression guard for the cache leak Jeremiah caught).:core:ui:allTests,:feature:settings:allTests— green. Import + call-site tests rewritten for the transactional semantics.spotlessCheck,detekt— clean.assembleDebug— app-wide compile clean (verifies theAdminControllerinterface addition across all modules).meshtastic.org/e/#…import URLs — no mocks on either side of the app boundary (full writeup):3dd05768a): all 8set_channelwrites + the LoRa write loggedDelay save … until the open transaction is committed; commit → a single save-to-disk (config + channels + device + module together) → one reboot; post-rebootget_channel_url/get_config loraround-tripped byte-for-byte (NVS-persisted, not just RAM). Stimulus→reboot was BLE-pacing-bound, notdelay()-bound — consistent with ~12s → ~instant.79fdc896f): force-stopping the app ~3s in showedBegin transaction→ 1 of 8 writes →BLE disconnected, with no commit / save / reboot; radio untouched, and the app cache showed no partial mutation on relaunch — the exact leak the pre-fix commit would have produced.🤖 Generated with Claude Code
Summary by CodeRabbit