Eric Seidel 1f2abac401 fix: current_boot_patch survives server-driven rollback (#348)
* fix: current_boot_patch survives server-driven rollback

Customer report (shorebirdtech/shorebird#3728): when the device's running
patch is rolled back to the base release (no replacement patch), the
running session sees `checkForUpdate` return `upToDate` even though a
restart is needed. Patch-to-patch rollback works because the server's
replacement patch makes `check_for_downloadable_update` return true, so
Dart short-circuits to `outdated` before the comparison runs.

Root cause: `UpdaterState::current_boot_patch()` derived its return value
from `currently_booting_patch.or(last_successfully_booted_patch)`. After
boot success, only `last_booted_patch` reflected the running patch. When
the server rolled back that patch, `try_fall_back_from_patch` cleared
`last_booted_patch` (correctly — it's no longer a valid fallback), and
the FFI `shorebird_current_boot_patch_number` then reported 0 even
though the process was still running the rolled-back patch.

The conflation: `last_booted_patch` was doing two unrelated jobs —
"fallback target" (its real role) and "what's running" (the proxy via
`.or()` that broke under rollback). The earlier Dart-only fix in
shorebirdtech/updater#312 assumed the FFI would still report the
running patch number; that assumption only held in the mock.

Fix: introduce a dedicated `current_boot_patch: Option<usize>` field
on `PatchesState`. Set by `report_launch_start` from `next_boot_patch`
(or `None` for a release boot). Read directly by
`UpdaterState::current_boot_patch()` — no derivation, no fallback.
Each field now has exactly one job:

- `last_booted_patch`: fallback target for `try_fall_back_from_patch`.
  Doc updated to remove the "(usually the currently running patch)"
  parenthetical that perpetuated the conflation.
- `current_boot_patch` (new): what this process is using. Survives
  rollbacks of that patch (the process is still using it). Reset on
  the next `report_launch_start` — including `None` on a release
  boot, so it doesn't go stale.
- `currently_booting_patch`: unchanged. Still the boot-in-progress
  flag for crash detection on the next init.

C API surface unchanged. `shorebird_current_boot_patch_number` still
returns the same `usize` it always has — it just gets the right answer
under rollback now.

Verification (testing at the C API level, since that's the contract):

- New regression test `rollback_to_release_keeps_current_boot_patch`
  reproduces the customer's bug. Fails on the parent commit
  (`current_boot_patch_number` returns 0); passes after this fix
  (returns 1).
- New `rollback_to_release_then_restart_clears_current_boot_patch`
  proves the post-restart cleanup: the on-disk `current_boot_patch`
  is `Some(1)` from the previous run, but the next launch's
  `report_launch_start` resets it to `None` since `next_boot_patch`
  is `None`. No false-positive `restartRequired` on the release boot.
- New `rollback_patch_to_patch_reports_current_and_next_distinctly`
  proves we didn't break the patch-to-patch case. Running on patch 2,
  server rolls back to patch 1: after `update()`, `current=2, next=1`.
- All 225 existing tests pass without modification, including every
  C API test.

Refs: shorebirdtech/shorebird#3728, shorebirdtech/updater#312, #270

* docs: TODOs for follow-up cleanup of patch state model

Two cleanups deferred from #348 to keep the rollback fix focused:

1. Rename `last_booted_patch` → `fallback_patch`. Single mechanical
   rename, but touches ~30 test names that read in terms of the
   current field name.
2. Remove `currently_booting_patch` entirely. With `current_boot_patch`
   now tracking what's running, the boot-in-progress signal collapses
   to `boot_started_at.is_some()`, and the crashed-patch-on-init
   identification falls out of the previous run's `current_boot_patch`.
   This is the larger of the two — touches crash-detection logic and
   the boot-record helpers.

Both should land as their own commits so the diff for each is easy to
read and the rollback fix stays minimal.

* test: assert rollback-only phases never report events

In `rollback_to_release_keeps_current_boot_patch` and
`rollback_to_release_then_restart_clears_current_boot_patch`, the
phase that performs only the server-driven rollback never calls
`shorebird_update` or `shorebird_report_launch_*`, so no event
should ever be reported during it. Replace the no-op report hook
with `UNEXPECTED_REPORT` to make that an asserted property of the
test rather than a silent assumption — if a future change starts
queueing or sending events from `check_for_downloadable_update`,
these tests will surface it immediately.

Phase-1 spawned threads (PatchDownload, PatchInstallSuccess) are
unaffected: they hold a clone of the config from when they were
spawned, so they hit the phase-1 hooks and never reach phase-2's
panicking handler.

The patch-to-patch test keeps the no-op hook because phase 2 there
calls `shorebird_update`, which legitimately spawns a PatchDownload
event using the new hooks.

* docs: flag the last_booted_patch conflation as the underlying bug

Replace the rename TODO with one that names the actual unfixed bug:
`last_booted_patch` gets cleared in `try_fall_back_from_patch` while
the running process is still using the patch. That's the deeper
incoherence — the field's name and `record_boot_success` say it's a
historical record, but the rollback path treats it as an operational
fallback target. Those two roles only diverge under server rollback,
which is the customer's case.

This PR sidesteps the conflation by adding `current_boot_patch` for
the "what's running" semantic. The TODOs now flag both:

- The conflation itself, on the field declaration.
- The specific line in try_fall_back_from_patch that does the
  historically-incorrect clearing.

A sibling PR will prototype the alternative — keep last_booted_patch
historical, express "don't fall back to this patch" via a separate
signal — so we can compare the two approaches.

* fix: stop clearing last_booted_patch when its patch is rolled back

Roll #349 into this PR. Both fixes together — they address different
real bugs and combining eliminates each PR's loose ends.

Underlying data-model bug: `last_booted_patch` was conflated. Its
field name and `record_boot_success` say it's a *historical* record
(\"the patch that last successfully booted, ever\"). But
`try_fall_back_from_patch`'s \"both bad\" branch clears it whenever
the patch becomes invalid as a fallback — including when the server
rolls it back, while the running process is still using it.

Fix: in the \"both bad\" branch, only clear `next_boot_patch`. Leave
`last_booted_patch` alone — that history shouldn't change because
the server told us not to use the patch next time. The \"don't fall
back to this patch\" intent is already covered by:

- `delete_patch_artifacts(bad_patch_number)` at the top of the
  function, which removes the on-disk artifacts.
- `validate_patch_is_bootable` in the else-if branch, which refuses
  to fall back to a patch with missing artifacts.
- `is_known_bad_patch`, which records boot failures explicitly.

`record_boot_failure_for_patch` flows through the same branch and
benefits from the same correction — boot history is preserved across
boot failures. Updated the corresponding test
(`clears_last_booted_patch_if_it_is_the_failed_patch` →
`preserves_last_booted_patch_on_failure_but_marks_bad`) to assert
the new behavior: history preserved, known-bad recorded, artifacts
deleted.

Removes the two TODOs added in the previous commit:
- The conflation TODO on `last_booted_patch` (now fixed).
- The TODO on the offending line (line is being changed).

This pairs with the `current_boot_patch` field added earlier in the
same PR. The two fixes are orthogonal:

- `current_boot_patch` gives us a session-scoped \"what's running\"
  signal, reset on `report_launch_start`. It's what the FFI reads.
- The data-model fix here keeps `last_booted_patch` historically
  accurate, so the field's name finally matches what it stores.

With both, `current_boot_patch()` no longer needs the `.or()`
fallback that was the original source of the customer's bug.
2026-05-01 16:38:47 -07:00
2024-04-19 13:51:22 -07:00

Updater library

codecov License: MIT License: Apache

This is the Rust side of the Shorebird code push system. This is built in Rust with a C API for easy calling from other languages, most notably for linking into libflutter.so.

The primary modification Shorebird makes to the stock Flutter engine is adding support for the updater library (this repo). The updater library is written in Rust and is used to update the code running in the Flutter app. The updater library is built as a static library and is linked into the Flutter engine during build time.

Parts

  • library: Runtime library linked into Flutter Engine to unpack and apply updates. Build into libupdater.a and linked into libflutter.so.
  • patch: Developer tooling to package Shorebird updates ("patches"). Built into patch.exe and downloaded and run by shorebird command line tool: https://github.com/shorebirdtech/shorebird/tree/main/packages/shorebird_cli.
  • shorebird_code_push: The Dart bindings for communicating with the updater library from within a Flutter app. Published to pub.dev, usage is optional by developers.

Most interesting code is in the library directory. There is also a README.md in that directory explaining the design.

Developing

It's best to edit this repository from within an engine checkout. See BUILDING_ENGINE.md for instructions on how to set up an engine checkout.

The workflow I use involves 2 to 3 VSC windows:

  1. Opening the engine src.

In that terminal I:

cd third_party/updater
  1. To build the updater as part of the engine:
cargo ndk --target aarch64-linux-android build --release && \
    ninja -C ../../out/android_release_arm64 && say "done"

The cargo part should not be needed, but I haven't yet done the work to integrate the Rust code into the gn files for the Flutter engine yet.

I add say "done" to the end as linking can take several minutes for release Android builds.

  1. In a second window, I open code third_party/updater. I do this because otherwise the rust_analyzer can't seem to find the rust code. We could fix this by adding the directory to the VSC workspace, but I'm not sure where we would put the workspace file in the first place. src is actually shorebirdtech/buildroot and is controlled via gclient by shorebirdtech/engine/DEPS.

  2. In a third window I open my test app. e.g.:

flutter create test_app
cd test_app
shorebird init
shorebird release
code .
  1. To run the test app with my local engine I use:
shorebird run --local-engine-src-path $HOME/Documents/GitHub/engine/src \
    --local-engine android_release_arm64

You may also need to build out/host_release once as flutter build looks for some Dart .dill files in host_release.

Coverage

We'd like to get to 100% coverage but aren't there yet.

https://github.com/taiki-e/cargo-llvm-cov is the best tool I've found for generating coverage reports.

Install: https://github.com/taiki-e/cargo-llvm-cov#installation

cargo llvm-cov will then generate the report.

S
Description
No description provided
Readme 1.6 MiB
Languages
Rust 46.3%
Dart 45.3%
C++ 3.2%
CMake 2.7%
C 1.6%
Other 0.9%