Skip to content

fix(dfu): Detect legacy bootloaders before secure fallback#6079

Merged
jamesarich merged 4 commits into
meshtastic:mainfrom
jeremiah-k:bugfix/dfu-protocol-fallback
Jul 9, 2026
Merged

fix(dfu): Detect legacy bootloaders before secure fallback#6079
jamesarich merged 4 commits into
meshtastic:mainfrom
jeremiah-k:bugfix/dfu-protocol-fallback

Conversation

@jeremiah-k

@jeremiah-k jeremiah-k commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Overview

This PR makes BLE DFU protocol detection more reliable when a device reboots into a Legacy DFU bootloader.

Previously, if the app missed the Legacy DFU service during the initial scan, the update flow could continue down the Secure DFU path and keep retrying the wrong service. That made OTA updates fail even though the device was advertising a valid bootloader protocol.

This change keeps bootloader detection explicit, preserves inconclusive scan results, and only falls back to the alternate protocol before a DFU session has actually connected.

Key Changes

  • Added explicit bootloader detection results for Legacy, Secure, and Unknown.
  • Kept inconclusive detection from being treated as Secure-only.
  • Moved protocol ordering and fallback decisions into a dedicated coordinator.
  • Tried Legacy first when Legacy is observed.
  • Tried Secure first when Secure is observed.
  • Tried Legacy first when detection is inconclusive, since Unknown can mean the Legacy advertisement was missed.
  • Allowed alternate-protocol fallback only before protocol connection succeeds.
  • Preserved the full Legacy session retry budget for observed Legacy and inconclusive Legacy-first attempts.
  • Preserved Legacy stale-session reset behavior.
  • Preserved existing bond handling.
  • Kept failed transport cleanup non-cancellable.
  • Added focused coverage for protocol ordering, fallback, retry limits, and post-connect fallback blocking.

Testing

  • Added unit coverage for Legacy, Secure, and Unknown ordering.
  • Added coverage for fallback before connection.
  • Added coverage proving fallback is blocked after protocol connection.
  • Added coverage for retry-budget behavior.
  • Added coverage proving inconclusive detection gives the Legacy-first attempt the Legacy recovery retry budget.
  • Verified git diff --check passes.

Migration Notes

  • No user data migration is required.
  • Existing BLE bonds are unchanged.
  • This only changes DFU bootloader protocol detection and fallback behavior.

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 577e26fa-5c4d-4f7c-847a-a8a053dcef84

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

SecureDfuHandler now detects bootloader protocol with an explicit Unknown outcome, routes uploads through DfuFallbackCoordinator, and returns DFU result objects from upload and connection paths. Added tests cover protocol ordering, fallback gating, and session-attempt behavior.

Changes

DFU Fallback and Detection

Layer / File(s) Summary
New protocol and fallback coordinator
feature/firmware/.../SecureDfuHandler.kt
Adds DfuProtocolKind, BootloaderDetection, DfuUploadResult, and DfuFallbackCoordinator with ordered fallback and protocol-to-UUID mapping.
startUpdate wiring to detection and coordinator
feature/firmware/.../SecureDfuHandler.kt
startUpdate computes BootloaderDetection, logs it, and delegates protocol execution to DfuFallbackCoordinator.execute.
Detection scan and upload retry rework
feature/firmware/.../SecureDfuHandler.kt
detectBootloaderProtocol scans Legacy then Secure and returns Unknown; runDfuUploadWithRetry and runUploadSession return DfuUploadResult and track protocolEngaged.
Connection retry protocol-aware logging
feature/firmware/.../SecureDfuHandler.kt
connectWithRetry accepts the selected protocol, logs protocol and service UUID context, and throws DfuException.ConnectionFailed with protocol-specific messaging.
DfuFallbackCoordinator unit tests
feature/firmware/.../DfuFallbackCoordinatorTest.kt
New tests verify protocol selection order, fallback behavior, protocolEngaged gating, and per-protocol session attempt counts.

Estimated code review effort: 4 (Complex) | ~60 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: detecting legacy bootloaders before falling back to Secure DFU.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the bugfix PR tag label Jul 5, 2026
@jeremiah-k
jeremiah-k marked this pull request as ready for review July 5, 2026 14:52

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt (2)

51-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Misleading test name for the assertion made.

Test name says "Unknown tries Legacy then Secure" but the body only verifies Legacy is tried first (success on first call short-circuits fallback), never exercising the Secure path. Consider renaming to something like Unknown tries Legacy first.

🤖 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
`@feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt`
around lines 51 - 60, The test name in DfuFallbackCoordinatorTest does not match
what the assertion actually verifies. Update the test case around
DfuFallbackCoordinator.execute so the name reflects the behavior being checked,
since it only confirms that BootloaderDetection.Unknown starts with
DfuProtocolKind.LEGACY and does not reach Secure on success. Rename the test to
something like “Unknown tries Legacy first” to align with the single-path
assertion.

63-77: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Missing coverage for suppressed prior-error chaining.

Per the coordinator's implementation (priorError?.let { result.error.addSuppressed(it) }), when both protocols fail before engagement, the first error should be attached as a suppressed exception on the final thrown error. None of these fallback tests assert on suppressedExceptions, so a regression in that chaining wouldn't be caught.

✅ Example additional assertion
         assertFailsWith<RuntimeException> {
             coordinator.execute { protocol, _ ->
                 protocols.add(protocol)
                 if (protocol == DfuProtocolKind.LEGACY) {
                     DfuUploadResult.Failure(RuntimeException("connect failed"), protocolEngaged = false)
                 } else {
                     DfuUploadResult.Failure(RuntimeException("also failed"), protocolEngaged = false)
                 }
             }
-        }
+        }.also { thrown ->
+            assertEquals(1, thrown.suppressedExceptions.size)
+            assertEquals("connect failed", thrown.suppressedExceptions.first().message)
+        }

Also applies to: 115-129

🤖 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
`@feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt`
around lines 63 - 77, The fallback tests for DfuFallbackCoordinator do not
verify suppressed-error chaining, so add assertions that the final thrown error
from execute includes the first failure as a suppressed exception when both
protocol attempts fail before engagement. Update the affected test cases in
DfuFallbackCoordinatorTest to inspect the thrown RuntimeException’s
suppressedExceptions and confirm it contains the earlier error, matching the
coordinator’s priorError?.let { result.error.addSuppressed(it) } behavior.
🤖 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.

Inline comments:
In
`@feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt`:
- Around line 167-172: sessionAttemptsFor() is not giving the Legacy primary
flow the larger retry budget when detection is Unknown, so the reset-prime
recovery path in runDfuUploadWithRetry() never gets a chance to run. Update the
retry selection in SecureDfuHandler so the Legacy primary case uses
LEGACY_SESSION_ATTEMPTS not only for BootloaderDetection.LegacyObserved but also
for the Unknown Legacy path, while keeping non-primary and non-Legacy cases on
LIMITED_SESSION_ATTEMPTS.

---

Nitpick comments:
In
`@feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt`:
- Around line 51-60: The test name in DfuFallbackCoordinatorTest does not match
what the assertion actually verifies. Update the test case around
DfuFallbackCoordinator.execute so the name reflects the behavior being checked,
since it only confirms that BootloaderDetection.Unknown starts with
DfuProtocolKind.LEGACY and does not reach Secure on success. Rename the test to
something like “Unknown tries Legacy first” to align with the single-path
assertion.
- Around line 63-77: The fallback tests for DfuFallbackCoordinator do not verify
suppressed-error chaining, so add assertions that the final thrown error from
execute includes the first failure as a suppressed exception when both protocol
attempts fail before engagement. Update the affected test cases in
DfuFallbackCoordinatorTest to inspect the thrown RuntimeException’s
suppressedExceptions and confirm it contains the earlier error, matching the
coordinator’s priorError?.let { result.error.addSuppressed(it) } behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b5aa7426-d425-4564-9ab8-8df489847ffa

📥 Commits

Reviewing files that changed from the base of the PR and between d8afe84 and f528444.

📒 Files selected for processing (2)
  • feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt
  • feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt

@jeremiah-k
jeremiah-k marked this pull request as draft July 5, 2026 14:58
@jeremiah-k
jeremiah-k force-pushed the bugfix/dfu-protocol-fallback branch from f528444 to 3336489 Compare July 5, 2026 17:43
@jeremiah-k
jeremiah-k marked this pull request as ready for review July 5, 2026 18:36

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt (1)

96-116: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: add coverage for the fallback/alternate protocol's session-attempt budget.

Existing tests confirm LEGACY_SESSION_ATTEMPTS (3) for the Legacy primary case, but no test asserts LIMITED_SESSION_ATTEMPTS (1) for an alternate/fallback protocol attempt (e.g., Secure as fallback under LegacyObserved, or Legacy as fallback under SecureObserved). Not required, but would close a small coverage gap in the retry-budget contract.

🤖 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
`@feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt`
around lines 96 - 116, Add a test that verifies the alternate/fallback protocol
uses the limited session-attempt budget in DfuFallbackCoordinator.execute. The
current DfuFallbackCoordinatorTest only covers LEGACY_SESSION_ATTEMPTS for
LegacyObserved and Unknown, so extend it with a case for a fallback path such as
Secure under LegacyObserved or Legacy under SecureObserved, and assert the
attempts value is LIMITED_SESSION_ATTEMPTS (1). Use the existing
DfuFallbackCoordinator and BootloaderDetection setup to locate the retry-budget
behavior.
🤖 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
`@feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt`:
- Around line 96-116: Add a test that verifies the alternate/fallback protocol
uses the limited session-attempt budget in DfuFallbackCoordinator.execute. The
current DfuFallbackCoordinatorTest only covers LEGACY_SESSION_ATTEMPTS for
LegacyObserved and Unknown, so extend it with a case for a fallback path such as
Secure under LegacyObserved or Legacy under SecureObserved, and assert the
attempts value is LIMITED_SESSION_ATTEMPTS (1). Use the existing
DfuFallbackCoordinator and BootloaderDetection setup to locate the retry-budget
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c044a72f-7c7a-46ae-844b-e1beebc417d4

📥 Commits

Reviewing files that changed from the base of the PR and between f528444 and 3336489.

📒 Files selected for processing (2)
  • feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt
  • feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuFallbackCoordinatorTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt

@jeremiah-k
jeremiah-k force-pushed the bugfix/dfu-protocol-fallback branch from 3336489 to 83a2d17 Compare July 5, 2026 18:45

@jamesarich jamesarich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the force-push. Two of the bigger items are addressed — nice:

  • ✅ Unknown detection now grants the Legacy primary the full 3-attempt budget, so the stale-session reset-prime path can actually run and a wedged legacy bootloader with a missed 1530 ad recovers instead of hard-failing.
  • DfuFallbackCoordinatorTest now covers the LegacyObserved→Secure ordering, per-leg budgets (listOf(3, 1)), and the suppressed-error shape.

A couple of things still stand (inline), plus a latency note: the 3-attempt fix means an Unknown-detected Secure device (FE59 ad missed) now burns 3× the doomed Legacy connect cycle before the Secure leg is tried — acceptable, but worth a comment so it's a deliberate choice. Not blocking.

is DfuUploadResult.Failure -> {
if (result.protocolEngaged || !hasAlternateProtocol) {
priorError?.let { result.error.addSuppressed(it) }
throw result.error

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On exhaustion the thrown error is the alternate leg's, with the primary only addSuppressed — and the UI renders e.message. So a legacy device that fails both legs shows "Failed to connect… via SECURE after 4 attempts", naming the protocol it doesn't speak. Consider throwing the primary (detection-matched) leg's error and suppressing the alternate, or composing a message that names both.

* is the alternate, attempted only if the primary fails before protocol engagement.
*/
private fun BootloaderDetection.orderedProtocols(): List<DfuProtocolKind> = when (this) {
BootloaderDetection.LegacyObserved -> listOf(DfuProtocolKind.LEGACY, DfuProtocolKind.SECURE)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conclusive detections still carry the opposite protocol as a fallback leg, so a pre-engagement connect flake on the primary triggers a full doomed alternate pass (up to ~140s of scans+retries against a service the device never advertises). The insurance is reasonable for a wrong "conclusive" call, but gating the alternate leg on "primary failed after engaging its DFU service" (vs. never connected) would avoid the worst-case stall on a simple flake.

@jeremiah-k
jeremiah-k marked this pull request as draft July 5, 2026 20:33
@jeremiah-k
jeremiah-k force-pushed the bugfix/dfu-protocol-fallback branch 5 times, most recently from 8c70ff8 to 0ef3798 Compare July 8, 2026 19:37
@jeremiah-k
jeremiah-k marked this pull request as ready for review July 8, 2026 20:26
@jeremiah-k

Copy link
Copy Markdown
Contributor Author

@jamesarich I pushed a refreshed branch with the fallback behavior tightened.

Conclusive Legacy/Secure detections now only try the alternate protocol after the primary protocol has connected, and the surfaced error stays tied to the primary/detection-matched failure. The Unknown path still goes Legacy-first with the full Legacy retry budget so the stale-session reset case remains covered.

@jamesarich jamesarich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the fallback state machine closely — the core safety is solid: wrong-protocol attempts fail cleanly at the scan step (each transport scan-filters on its own service UUID, so a Legacy attempt never writes opcodes to a Secure bootloader), the protocolEngaged gate has no suspension-point window where a live session could be torn down, and the NonCancellable cleanup can't leak a half-open transport. Nice.

One reliability concern before this goes in — the Unknown retry budget:

sessionAttemptsFor gives Unknown → Legacy the full LEGACY_SESSION_ATTEMPTS (3) budget, and orderedProtocols puts Legacy first for Unknown. So a genuinely Secure-only device that momentarily reads Unknown (exactly the transient causes your own KDoc lists — scan duty-cycling, adv cache, bootloader not yet re-advertising after the reboot wait) burns 3 doomed Legacy sessions before it ever tries Secure. Each is a connectWithRetry (4 attempts) scanning ~10s for the 1530 service that isn't there — order of minutes of "connecting" with the phone never touching the device. By the time Secure is tried once, the Secure bootloader may well have hit its DFU-inactivity timeout and rebooted back to app, so the flash fails — where the old Unknown→Secure path completed it immediately.

Suggest dropping Unknown→Legacy to LIMITED_SESSION_ATTEMPTS (1) so fallback to Secure happens in tens of seconds instead of minutes. Legacy-first ordering for Unknown is reasonable; it's just the 3× budget on the speculative primary that's expensive on the ambiguous case this PR is meant to help.

Minor: the per-attempt Logger.w { ... } dropped the throwable arg, so retry-loop failures now log only the exception class name, not the stack — a bit harder to field-diagnose. Terminal error is still preserved in the final Failure, so not a blocker.

@jeremiah-k
jeremiah-k marked this pull request as draft July 8, 2026 22:38
Replace the brittle single-scan Legacy DFU detection (which treated
a missed scan as proof of Secure DFU) with a resilient detection and
fallback architecture:

- Add BootloaderDetection sealed result type (LegacyObserved,
  SecureObserved, Unknown) so the inconclusive case stays
  representable instead of being silently coerced to Secure.
- Scan for Legacy DFU service first; if observed, return immediately
  without scanning for Secure DFU (Legacy wins per orderedProtocols).
- Add runDfuUploadWithFallback wrapper that resolves a detection
  into an ordered protocol list (Legacy first for Unknown) and tries
  each in turn, falling back to the alternate only on pre-transfer
  failures.
- Guard fallback with TransferProgression flag set immediately before
  firmware transfer; once streaming begins, failures remain
  protocol-specific and throw immediately with no fallback.
- Reworked fallback loop so the final throw is reachable for
  last-protocol pre-engagement failures.
- Thread protocol kind into connectWithRetry and log the scanned
  service UUID per connect attempt.
- Log detection outcome, fallback decisions, and address-set type
  using privacy-safe redacted forms (no raw BLE MAC addresses).

Preserves: bond-preservation contract, Legacy stale-session
(INVALID_STATE) reset handling, per-session NonCancellable GATT
cleanup, and Legacy retry semantics (fresh transport + reconnect
per retry).
Move DFU protocol ordering and session retry budget selection into a fallback coordinator so SecureDfuHandler delegates protocol fallback decisions instead of owning them directly.

Replace the mutable transfer progression flag with an explicit upload result that reports whether a protocol session was engaged before a failure. This keeps alternate-protocol fallback limited to pre-engagement failures while preserving Legacy retry handling, stale-session reset priming, and NonCancellable transport cleanup.
Keep Unknown detection fallback behavior, but stop conclusive Legacy/Secure detections from spending a full alternate-protocol pass when the observed primary service never engages.

When both protocol legs fail, throw the primary detection-matched error and attach the alternate failure as suppressed so the UI reports the relevant protocol.
Unknown detection still tries Legacy first, but using the full Legacy retry budget can burn minutes on a Secure-only bootloader before Secure gets a chance to connect.

Keep confirmed Legacy on the reset-prime session budget, limit speculative Unknown Legacy probes to one session, and restore throwable logging in retry warnings for field diagnostics.
@jeremiah-k
jeremiah-k force-pushed the bugfix/dfu-protocol-fallback branch from 0ef3798 to 7e7448a Compare July 8, 2026 23:33
@jeremiah-k
jeremiah-k marked this pull request as ready for review July 9, 2026 00:09

@jamesarich jamesarich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the fallback-budget commits. Both points addressed: Unknown→Legacy now bounded to a single speculative probe (1 attempt) before Secure fallback — new Unknown gives Legacy primary 1 session attempt test pins it — so a mis-detected Secure device no longer burns minutes of doomed Legacy scans. Per-attempt session log restored to Logger.w(result.error). Wrong-protocol scan-filter safety, the protocolEngaged gate, and NonCancellable cleanup were already sound. LGTM.

@jamesarich
jamesarich added this pull request to the merge queue Jul 9, 2026
Merged via the queue into meshtastic:main with commit 3e86c37 Jul 9, 2026
17 checks passed
@jeremiah-k
jeremiah-k deleted the bugfix/dfu-protocol-fallback branch July 9, 2026 01:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix PR tag

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants