OTA: fix the flash-area ID that bricks the board, add an integration test and a CI job - #492
Draft
nateinaction wants to merge 14 commits into
Draft
OTA: fix the flash-area ID that bricks the board, add an integration test and a CI job#492nateinaction wants to merge 14 commits into
nateinaction wants to merge 14 commits into
Conversation
… literal PREPARE_UPDATE erases FlashWorker::REGION_NUMBER, which was hardcoded to 2 with a comment claiming that meant slot1. Zephyr assigns flash-area IDs by devicetree dependency ordinal, so adding any partition anywhere in the devicetree renumbers every area. On the current main layout 2 is slot1 and OTA works; on a tree that adds one more partition (a keystore partition, for instance) 2 becomes slot0_partition, and PREPARE_UPDATE erases the firmware that is executing -- bricking the board until it is reflashed over UF2 or SWD. Resolve the region from the slot1_partition label instead, and static_assert that it never coincides with the running code partition. Adds an OTA integration test covering the full cycle -- prepare, uplink, UPDATE_IMAGE_FROM, CONFIGURE_NEXT_BOOT TEST, swap reboot, CONFIRM_UPDATE -- with a first case that asserts the board still answers commands after PREPARE_UPDATE, which is the direct regression guard for the brick. The suite is marked `ota` and excluded from the default integration run since it erases a flash slot, uplinks ~1.4 MB and reboots the board. Rewrites the README OTA section, which was working notes ending in the advice to try flipping the region number by hand.
Runs the full over-the-air cycle on the integration cube: erase the staging slot, uplink a signed image, write it to slot1, arm a TEST boot, reboot through the MCUboot swap, and confirm. To prove a swap actually happened rather than that the commands merely returned OK, the build job produces a second image under a throwaway `ota-ci-<run_id>` git tag. Project version comes from `git describe`, so that image reports a distinct version and nothing else about it changes; the test asserts the board reports that version after the reboot. The version files have to be deleted before the second build -- fprime generates them from a cmake custom command with no declared inputs, so ninja otherwise keeps the stale string. The job is opt-in (workflow_dispatch input or the `test-ota` PR label): it holds the single integration cube for most of an hour, since a 1.4 MB uplink at the default file-uplink cooldown takes ~48 minutes on its own. `GDS_EXTRA_ARGS` on `make gds-integration` is the lever to speed that up, exposed as a workflow input. Both slots are erased/reflashed on the way in and on the way out, so a leftover staged image from a previous run cannot swap itself in mid-job and the next job on the runner starts from the artifact build.
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The OTA job is gated on the `test-ota` label, but `on: pull_request` without an explicit `types:` list does not include `labeled`, so adding the label to an open PR triggered nothing. Spell out the default types plus `labeled`.
Every integration job ends with "Power Off Satellite", and the OTA job's first hardware action is an OpenOCD erase rather than the flash-firmware composite that powers the board up elsewhere. The erase therefore ran against a dark board.
There is one integration cube. While iterating on the OTA job, leaving these enabled means every attempt queues behind a full uart+radio suite. Restore before merging.
The CI run showed PREPARE_UPDATE dropped by the TC replay window (SequenceNumberInvalid: Received=37, LastAccepted=37) because the GDS emitted the same sequence number for the session's first two commands. prepare_update used a raw send_command with no retry, so the drop burned the full 120 s event timeout and failed test_01 outright. Retry on the component's "started" event rather than its completion, so a retry cannot re-issue work already underway, and match the outcome against both the success and failure events so a failed write reports immediately instead of waiting out the 15-minute ceiling. Also drops the claim that the erase outruns the ack window: it measured 2.8 s on the CI cube.
This was referenced Aug 4, 2026
The GDS runs the file-uplink encoding chain in a separate process from the one framing commands, and each process builds its own AuthenticateFramer. The instances have no way to learn what sequence number the other has used beyond the shared file, and the old read-modify-write was neither atomic nor consulted per frame: the constructor cached the file's value and each frame emitted the cached number before incrementing. Two frames built at nearly the same time in different processes could therefore emit the same number, which the flight side correctly rejects as a replay (SequenceNumberInvalid). Derive every frame's number from the file under an flock, guarded within the process by a mutex. The file is opened "a+" so the create-if-missing case falls out of the same path instead of needing its own FileNotFoundError arm.
writeSequenceNumber persists on every accepted TC frame, and file-uplink Data packets are TC frames too, so a 726 KB image means ~3600 of these writes interleaved with FileUplink's own writes to a different file on the same disk. Opening and closing the file each time forces the flash-disk driver's single-page write-back cache to commit and reload per frame, thrashing against those uplink writes. Hold the handle for the component's lifetime and seek to 0 before each write, turning each persist into a seek plus write on an already-open file. The seek is required because writeToFile writes at the current position, which for a handle that stays open is just past the previous write.
fprime_gds's FileUplinker has no retry of its own. A Start packet is a bare FW_PACKET_FILE frame with no opcode, so it can never match PacketBypasser's allowlist; if it lands on the TC replay window it comes back unauthenticated and ProvesRouter drops it silently, after which the uplinker sits until its 20 s handshake timeout expires and gives up. Poll the active TransmitFile's seek for progress and re-enqueue when it stalls, cancelling both ends first so the flight-side receiver abandons its partial file. The uplinker's own timeout may already have called finish(), so retry only when it is not IDLE -- a second finish() raises FileNotFoundError on the unlinked up_store copy. Also reapply the file-uplink chunk size and cooldown here. IntegrationTestAPI builds its own StandardPipeline and never reads fprime-gds.yml, so it defaults to a 256 B chunk that overflows the 248 B frame, and to a cooldown fast enough to overrun the flight side. There is no back-pressure on file uplink: packets that arrive with the ProvesRouter buffer pool empty are dropped while the ground keeps streaming, and the transfer still reports FileReceived at full length -- only the CRC reveals the loss. 0.400 matches fprime-gds.yml and is the lowest value measured clean on the bench. Ignore ota-image/, where the locally built staging image is kept.
The OTA job ties up the single integration cube for the better part of an hour, so it was opt-in via a workflow_dispatch input or the `test-ota` PR label. Neither gets it run regularly, and the label trigger forced `ci` to carry OTA-specific inputs and a non-default `labeled` PR trigger. Give it its own `ota` workflow on a 10:00 UTC daily schedule plus manual dispatch, and extract the build job into a reusable `build` workflow so the two callers share it rather than duplicating the Zephyr setup. The OTA staging image is now gated on a `build_ota_image` input.
…-core-reference into ota-integration-test
…arget The test proved a confirmed update sticks, but not the property that makes a bad update survivable without a ground pass: an image that never sends CONFIRM_UPDATE must be rolled back by MCUboot on its own. Both endings are flight-critical, so cover both. test_03 now leaves the trial image unconfirmed, test_04 reboots out of it and asserts the board is back on the pre-update version, and test_05 re-stages, confirms, and reboots again. The trial image is staged twice from a single uplink because re-flashing the staging slot costs seconds while re-uplinking costs the better part of an hour. The image build moves out of build.yaml into an `ota-test-image` target so the bench and CI produce it the same way. Doing it by hand fails silently two ways: fprime only writes versions/version.json when it is missing, so a plain rebuild after a new tag re-links the old version string, and `git describe --tags` does not prefer the newest tag when several point at HEAD. The target also deletes its throwaway tag on the way out, including on failure, and leaves bootable.* pointing at the build you flash. Bench: 5 passed in 27:04 on a V5e flight controller board plus face, no battery board. The swap is proven rather than vacuous -- the board went from ota-0804181900-dirty to the freshly built ota-0804201828-dirty and back again on the revert. Raise the ota job timeout to 180 minutes: the suite now writes the staging slot twice and reboots four times, and uplink runtime is not stable -- one bench attempt spent ~70 minutes in uplink against ~25 in the green run.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Reported from the bench:
PREPARE_UPDATEbricks the cube onhmac-to-storage/feature/expose-active-key-spis.Reproduced by reading the generated devicetree headers.
FlashWorker::REGION_NUMBERwas hardcoded to
2, with a comment asserting0: bootloader, 1: slot0, 2: slot1.That mapping is not a Zephyr guarantee — flash-area IDs are assigned by devicetree
dependency ordinal (a global counter in
gen_defines.pyover nodes sorted bydep_ordinal), so adding a partition anywhere in the DT renumbers every area:main(5 partitions)keystore_partitionOn those branches
boot_erase_img_bank(2)erases the running firmware. That isthe brick, and it happens the instant the command lands — before any image is
uplinked. Note this means OTA on
mainis not broken by this; ifmainalsobricked on that board, the board was already dead from the branch run.
What
Fix. Resolve the region from the devicetree label and make the failure mode
impossible to reintroduce silently:
Test.
PROVESFlightControllerReference/test/int/ota_test.py, markedotaandexcluded from the default run:
PREPARE_UPDATE, then assert the board still answers commands — the directregression guard for this brick.
UPDATE_IMAGE_FROMwithout a preceding prepare is rejected.CONFIGURE_NEXT_BOOT TEST→ reboot → assert thereported
ProjectVersionmatches the version it verified is present in theuplinked image bytes. The trial image is deliberately left unconfirmed.
that makes a bad update survivable without a ground pass: an image that comes
up too broken to send
CONFIRM_UPDATEundoes itself on the next reset.CONFIRM_UPDATE, reboot again, and assert it stuck.Both endings of step 6 of the update cycle are flight-critical — a good image
must stick and a bad one must roll itself back — so both are covered. Steps 4
and 5 stage the trial image twice from a single uplink, because re-flashing
the staging slot costs seconds while re-uplinking costs the better part of an
hour.
Build.
make ota-test-imagebuilds the distinctly-versioned staging image,so the bench and CI produce it the same way. The OTA test only proves a swap
happened if the uplinked image reports a different
project_versionthan therunning one — uplinking the running build makes the assertion vacuously true.
Project version comes from
git describe, so a throwaway tag renames the buildand changes nothing else: same sources, same config, same signing key. The tag
is deleted again on the way out, including on failure, and
bootable.uf2/bootable.signed.hexkeep pointing at the build you flash.The target exists because doing this by hand fails silently two ways, and both
cost a bench session to find:
versions/version.{hpp,cpp,json}from a cmake custom commandwith no declared inputs, so ninja considers them up to date as soon as they
exist — a rebuild after a new tag re-links a binary carrying the old version
string. They have to be deleted first.
git describe --tagsdoes not prefer the newest tag when several point atHEAD. It picked an alphabetically-lower stale tag over a just-created one.
CI. The OTA test lives in its own
otaworkflow, on a 10:00 UTC dailyschedule plus manual dispatch — it ties up the single integration cube for the
better part of an hour, so it does not belong in the PR run, and the previous
opt-in triggers (a
workflow_dispatchinput and atest-otaPR label) meant itnever ran on its own. The build job is now a reusable
buildworkflow shared byciandota, and it gets the staging image from the samemake ota-test-imagetarget the bench uses. Both flash slots are erased/reflashed on the way in and
out so a leftover staged image cannot swap itself in mid-job or leak into the
next job on the runner.
The job timeout is 180 minutes. The suite now writes the staging slot twice and
reboots the board four times, and uplink runtime is not stable — see below.
Docs. The README OTA section was working notes ending in the advice to try
flipping the region number by hand; replaced with the procedure and a
failure-mode guide.
Bench verification
5 passed in 27:04 on hardware. Bench is a V5e flight controller board plus
face, no battery board, on
/dev/cu.usbmodem1101with the debug probe on/dev/cu.usbmodem102. The run mirrored the CI job step for step:make ota-test-image-> GDS -> sync sequence number -> format filesystem -> reset ->sync ->
make test-integration TEST=ota_test.py FILTER=ota PYTEST_ARGS="--ota-image=ota-image/zephyr.signed.bin".The swap is proven, not vacuous: the board was flashed with one build and
uplinked a differently tagged one. It reported the uplinked
project_versionafter the trial boot, went back to the pre-update version on the
unconfirmed reboot, and held the uplinked version after the confirmation
reboot. Flight events:
PrepareUpdateSucceeded->FileReceived /update/zephyr.signed.bin->UpdateSucceeded(the CRC is checked flight-sidebefore a byte is written, so the uplink was lossless) ->
SetNextBoot TEST->ConfirmBoot.make ota-test-imagewas exercised twice and cleaned up its throwaway tag bothtimes.
Answers to the three open questions above:
fprime-gds.ymldefaults is~3560 chunks and ~25 minutes on the paper rate, which is what the green run
did. An earlier attempt on the same bench and image spent ~70 minutes in
uplink alone, apparently in the stall-and-restart path. The 90 minute per-file
ceiling absorbed it, but the 150 minute job timeout would not have, hence 180.
PREPARE_UPDATEfits easily. The 1 MB erase completes in a few seconds,well inside the 120 s allowed.
flash erase_address 0x10200000 0x100000erases exactly slot1 and leaves the running image alive.Three flight/GDS fixes were needed to get there, one per commit:
--file-uplink-cooldownbelow 0.400 is not safe on this link, and failssilently. There is no flight-side back-pressure:
ProvesRoutertakes eachfile packet from a pool of
commsFileBuffCount(5) buffers and drops thepacket when the pool is empty, while the ground keeps streaming. The transfer
still emits
FileReceivedat full length and only the CRC reveals thateverything past the first few packets was discarded (
PacketOutOfOrderreporting "packet N after packet 5" is the tell). Measured against a 32 KB
file with
fileManager.CalculateCrc: 0.100 corrupts, 0.400 and 1.000 areclean. The
ota_uplink_cooldownworkflow input should not be lowered untilthat pool grows. The test now pins the chunk size and cooldown itself,
because
IntegrationTestAPIbuilds its ownStandardPipelineand never readsfprime-gds.yml-- its 256 B chunk default also overflows the 248 B frame.correctly rejected as a replay. The file-uplink encoding chain runs in a
different process from the one framing commands, and each
AuthenticateFramercached the number rather than reading it per frame under a lock.
carries no opcode so it can never match
PacketBypasser's allowlist; if it isdropped the uplinker sits until its 20 s timeout and gives up. The test now
watches the uplinker's seek for progress and restarts a stalled transfer.
Bench hazard worth knowing before you run this
Two host-side failures during bring-up looked exactly like OTA product defects
and neither is one:
fprime_gds.executables.commfrom an earlier session fighting forthe serial port. It presents as
Checksum validation failedwith emptyevent.log/channel.logand every test erroring in setup, while a rawpyserial read of the same port shows the board streaming happily. Check
pgrep -f fprime_gds.executables.commreturns exactly one PID.enumerates (
ioregshowsPROVES Flight Control Board v5e, so the app bootedpast USB init) but a fresh
open()on the port returns zero bytes, andOpenOCD simultaneously fails to read USB string descriptors for every device.
It surfaces as
board did not come back after the swap reboot. Only aphysical replug clears it; the identical swap then succeeded in 46 s and four
more times in the green run. Note the replug power-cycles the board, so an
unconfirmed trial image reverts — leaving the board on the pre-update version,
which reads as "the swap never happened".
To iterate on the back half without re-paying the uplink: the image stays at
/update/zephyr.signed.binacross reboots, sostage_image+arm_trial_bootrebootre-runs the swap in ~46 s.Also verified locally:
make buildis clean with the static_assert passing;slot1_partitionresolves topartition@200000; the test's CRC isbyte-identical to
tools/bin/calculate-crc.py.Still unexercised by this run: the
otaworkflow itself, which has not yetexecuted end to end on the runner.
Related
Worth landing #495 alongside this. That fixes a latent ComQueue arming race that
makes a build boot permanently silent, and the outcome is deterministic per
binary -- this test is unusually exposed to it because it runs two different
binaries (the flashed one and the uplinked one), so both have to win the race
independently. A build that lost would surface here as "board did not come back
after the swap reboot", i.e. as an OTA failure rather than as the comms bug it
is. The bench run above did not hit it.
Out of scope, noted
Updater.cpp(in thefprime-extrassubmodule) guards its busy flag withcompare_exchange_weak, which is allowed to fail spuriously. Today that onlymeans an occasional bogus
BUSYrejection, but it should be_strong.