* test: test api calls * chore: add docs * chore: fix cspell * fix: use no-op network hooks in multi_engine tests Set no-op network hooks after init_for_testing so the fire-and-forget thread spawned by report_launch_success completes instantly without network I/O, preventing leaked threads from interfering with subsequent serial tests that use mock servers.
6.9 KiB
Boot State Machine
Overview
The Shorebird updater manages over-the-air code updates for Flutter applications. A critical part of this is the boot state machine, which tracks:
- Which patch should be loaded on app start
- Whether a patch booted successfully
- Automatic rollback if a patch crashes during boot
State Variables
Persisted to Disk (patches_state.json)
| Variable | Type | Description |
|---|---|---|
next_boot_patch |
Option<PatchMetadata> |
The patch to boot on next app start. Set when a patch is downloaded/installed. |
last_booted_patch |
Option<PatchMetadata> |
The patch that last completed a successful boot cycle. Our "known good" state. |
currently_booting_patch |
Option<PatchMetadata> |
Transient flag: set when boot starts, cleared on success/failure. If set on init, indicates crash. |
known_bad_patches |
HashSet<usize> |
Patches that have failed to boot. Never attempt these again for this release. |
In-Memory (Config)
| Variable | Description |
|---|---|
UpdateConfig |
Global config set once via init(). Contains app ID, paths, network hooks, etc. |
Boot Lifecycle
Happy Path
[Process Start]
│
▼
init()
│
├─► Check currently_booting_patch
│ └─► If set: previous boot crashed → mark patch as bad, fall back
│
▼
Engine gets next_boot_patch path
│
▼
TryLoadFromPatch() loads patch snapshot
│
└─► report_launch_start() [called once via std::once_flag]
│ └─► currently_booting_patch = next_boot_patch
│
▼
Shell::Shell() constructor completes
│
└─► report_launch_success()
│ ├─► last_booted_patch = currently_booting_patch
│ └─► currently_booting_patch = None
│
▼
[App Running - Dart code executing]
Crash Recovery
If the app crashes between report_launch_start() and
report_launch_success():
- Process dies with
currently_booting_patchstill set on disk - New process starts, calls
init() handle_prior_boot_failure_if_necessary()seescurrently_booting_patchis set- Marks that patch as failed (adds to
known_bad_patches) - Falls back to
last_booted_patchor base release
Implementation Details
Where Boot Lifecycle Calls Are Made
report_launch_start() is called from TryLoadFromPatch() in
runtime/shorebird/patch_cache.cc:
std::shared_ptr<const fml::Mapping> TryLoadFromPatch(...) {
// ... validation and patch loading ...
// Only report launch_start when we're actually about to use a patch,
// and only once per process (for the first symbol, isolate data)
static std::once_flag launch_start_flag;
if (symbol == kIsolateDataSymbol) {
std::call_once(launch_start_flag, []() {
shorebird_report_launch_start();
});
}
// Return the mapping
// ...
}
report_launch_success() is called from Shell::Shell() constructor in
shell/common/shorebird/shorebird.cc, after the Dart VM is created
successfully.
Why This Placement Matters
The boot lifecycle calls are placed at specific points for good reason:
-
report_launch_start()inTryLoadFromPatch(): Called right before the patched Dart snapshot is actually loaded. This ensures:- FlutterEngineGroup warmup (which doesn't load patches) doesn't trigger false crash detection
- The call only happens when we're actually about to use a patch
std::once_flagensures exactly one boot cycle per process
-
report_launch_success()in Shell constructor: Called after the Dart VM is created, indicating the patch loaded successfully. -
Crash on patch load failure: If
TryLoadFromPatch()fails to load the patch, it callsFML_LOG(FATAL)which crashes the process. On the next launch, crash recovery naturally handles it.
Invariants
- Config is set once per process:
set_config()returns error if already set - Crash recovery runs once per process: Only on first successful
init() - One boot cycle per process:
std::once_flagensuresreport_launch_start()is called at most once - State is persisted atomically: Each state change writes to disk immediately
- Bad patches are permanent: Once in
known_bad_patches, never tried again for this release
API Behavior
init()
- Sets global config (once per process, subsequent calls return
AlreadyInitialized) - Calls
handle_prior_boot_failure_if_necessary()only on first init - Does NOT run crash recovery if config already initialized
report_launch_start()
- If
next_boot_patchexists, setscurrently_booting_patch - In production, called only once per process due to
std::once_flagin C++
report_launch_success()
- Clears
currently_booting_patch - Sets
last_booted_patch - Subsequent calls are no-ops if
currently_booting_patchis None
report_launch_failure()
- Marks
currently_booting_patchas bad - Falls back to previous good state
- Queues failure event for server
Historical Context: FlutterEngineGroup Bug
Prior to the current implementation, report_launch_start() was called from
ConfigureShorebird() during FlutterMain::Init(). This caused a bug:
Problem: FlutterEngineGroup's constructor calls
ensureInitializationComplete() which triggered report_launch_start(), but
does NOT create a Shell (no report_launch_success()). If the app was killed
before createAndRunEngine() was called, crash recovery incorrectly marked the
patch as bad.
Solution: Move report_launch_start() to TryLoadFromPatch(), which is
only called when actually loading a patch. FlutterEngineGroup never calls
TryLoadFromPatch() (no Shell created), so no false positives occur.
Multiple Processes (Android)
If multiple processes access the same state file:
- Each process has its own Java
initCalledflag - Each process has its own Rust global config
- BUT they share the same on-disk state files
- No cross-process locking exists
This could cause issues if:
- Process A sets
currently_booting_patch - Process B also sets
currently_booting_patch(it's the first engine in THAT process) - Process A completes, clears flag
- Process B is killed before completing
- On restart: crash recovery sees flag set (by Process B) and marks patch as bad
Testing
Unit tests in library/src/updater.rs (multi_engine_tests module) verify the
boot state machine behavior:
multi_engine_false_positive_rollback: Demonstrates the historical bug where multiplereport_launch_start()calls caused false positive rollbacksinterleaved_boot_calls_success_clears_flag: Verifies thatreport_launch_success()properly clears the booting flag
Note: In production, the C++ std::once_flag prevents multiple
report_launch_start() calls, so the Rust-level tests demonstrate behavior that
can only occur if the C++ guard is bypassed.