diff --git a/library/include/updater.h b/library/include/updater.h
index 857567c..9860712 100644
--- a/library/include/updater.h
+++ b/library/include/updater.h
@@ -130,7 +130,9 @@ SHOREBIRD_EXPORT bool shorebird_should_auto_update(void);
/**
* The currently running patch number, or 0 if the release has not been
- * patched.
+ * patched. The internal name for this concept is `running_patch`; the
+ * FFI symbol keeps the historical `current_boot_patch_number` spelling
+ * because Flutter Engine links against it.
*/
SHOREBIRD_EXPORT uintptr_t shorebird_current_boot_patch_number(void);
diff --git a/library/src/c_api/mod.rs b/library/src/c_api/mod.rs
index 11b4971..e256923 100644
--- a/library/src/c_api/mod.rs
+++ b/library/src/c_api/mod.rs
@@ -190,12 +190,14 @@ pub extern "C" fn shorebird_should_auto_update() -> bool {
}
/// The currently running patch number, or 0 if the release has not been
-/// patched.
+/// patched. The internal name for this concept is `running_patch`; the
+/// FFI symbol keeps the historical `current_boot_patch_number` spelling
+/// because Flutter Engine links against it.
#[no_mangle]
pub extern "C" fn shorebird_current_boot_patch_number() -> usize {
log_on_error(
- || Ok(updater::current_boot_patch()?.map_or(0, |p| p.number)),
- "fetching next_boot_patch_number",
+ || Ok(updater::running_patch()?.map_or(0, |p| p.number)),
+ "fetching running_patch_number",
0,
)
}
@@ -402,6 +404,7 @@ mod test {
use crate::{
network::{
testing_set_network_hooks, DownloadResult, PatchCheckResponse, UNEXPECTED_DOWNLOAD,
+ UNEXPECTED_REPORT,
},
test_utils::write_fake_apk,
};
@@ -473,6 +476,66 @@ mod test {
)
}
+ /// A precomputed bidiff patch artifact along with the inputs that
+ /// produced it. Generate one with:
+ /// cargo run --bin string_patch -- "" ""
+ /// Then paste the four pieces into a `PatchFixture` constant:
+ /// - `base`: the `` argument (must match the bytes
+ /// `write_fake_apk` writes for the test's fake APK)
+ /// - `new`: the `` argument (the inflated content after
+ /// applying the patch — what tests assert against)
+ /// - `bytes`: the "Patch:" byte array
+ /// - `hash`: the "Hash (new):" sha256 hex of `new`
+ ///
+ /// All fixtures used together in a single test must share the same
+ /// `base`, since `write_fake_apk` only writes one set of bytes.
+ struct PatchFixture {
+ base: &'static str,
+ new: &'static str,
+ hash: &'static str,
+ bytes: &'static [u8],
+ }
+
+ impl PatchFixture {
+ /// Helper for `testing_set_network_hooks` download callbacks: writes
+ /// the fixture's patch bytes to `dest` and returns the matching
+ /// `DownloadResult`.
+ fn write_to(&self, dest: &Path) -> anyhow::Result {
+ let total_bytes = self.bytes.len() as u64;
+ std::fs::write(dest, self.bytes)?;
+ Ok(DownloadResult {
+ total_bytes,
+ content_length: Some(total_bytes),
+ })
+ }
+ }
+
+ /// `string_patch "hello world" "hello tests"` — the default fixture
+ /// used by tests that don't care which patch is which.
+ const HELLO_TESTS_PATCH: PatchFixture = PatchFixture {
+ base: "hello world",
+ new: "hello tests",
+ hash: "bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45",
+ bytes: &[
+ 40, 181, 47, 253, 0, 128, 177, 0, 0, 223, 177, 0, 0, 0, 16, 0, 0, 6, 0, 0, 0, 0, 0, 0,
+ 5, 116, 101, 115, 116, 115, 0,
+ ],
+ };
+
+ /// `string_patch "hello world" "hello patch 2"` — distinct from
+ /// `HELLO_TESTS_PATCH` for tests that need two non-equal artifacts
+ /// (e.g. patch-to-patch rollback). Shares the same `base` so both
+ /// fixtures can be used in the same test.
+ const HELLO_PATCH_2_PATCH: PatchFixture = PatchFixture {
+ base: "hello world",
+ new: "hello patch 2",
+ hash: "2bc806572d14496a1ddfbddf7ed7380fca87a80b739b2881544aba397d267c68",
+ bytes: &[
+ 40, 181, 47, 253, 0, 128, 193, 0, 0, 223, 177, 0, 0, 0, 16, 0, 0, 6, 0, 0, 0, 0, 0, 0,
+ 7, 112, 97, 116, 99, 104, 32, 50, 0,
+ ],
+ };
+
#[serial]
#[test]
fn init_with_nulls() {
@@ -583,11 +646,11 @@ mod test {
testing_reset_config();
let tmp_dir = TempDir::new().unwrap();
- // Generated by `string_patch "hello world" "hello tests"`
- let base = "hello world";
- let expected_new: &str = "hello tests";
let apk_path = tmp_dir.path().join("base.apk");
- write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes());
+ write_fake_apk(
+ apk_path.to_str().unwrap(),
+ HELLO_TESTS_PATCH.base.as_bytes(),
+ );
let fake_libapp_path = tmp_dir.path().join("lib/arch/ignored.so");
let c_params = parameters(&tmp_dir, fake_libapp_path.to_str().unwrap());
// app_id is required or shorebird_init will fail.
@@ -602,32 +665,18 @@ mod test {
// We didn't specify a channel in either the shorebird_check_for_downloadable_update
// call or the shorebird.yaml, so we should default to "stable".
assert_eq!(request.channel, "stable");
- // Generated by `string_patch "hello world" "hello tests"`
- let hash = "bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45";
Ok(PatchCheckResponse {
patch_available: true,
patch: Some(crate::Patch {
number: 1,
- hash: hash.to_owned(),
+ hash: HELLO_TESTS_PATCH.hash.to_owned(),
download_url: "ignored".to_owned(),
hash_signature: None,
}),
rolled_back_patch_numbers: None,
})
},
- |_url, dest: &Path, _resume_from: u64| {
- // Generated by `string_patch "hello world" "hello tests"`
- let patch_bytes: Vec = vec![
- 40, 181, 47, 253, 0, 128, 177, 0, 0, 223, 177, 0, 0, 0, 16, 0, 0, 6, 0, 0, 0,
- 0, 0, 0, 5, 116, 101, 115, 116, 115, 0,
- ];
- let total_bytes = patch_bytes.len() as u64;
- std::fs::write(dest, &patch_bytes)?;
- Ok(DownloadResult {
- total_bytes,
- content_length: Some(total_bytes),
- })
- },
+ |_url, dest: &Path, _resume_from: u64| HELLO_TESTS_PATCH.write_to(dest),
|_url, _event| Ok(()),
);
// There is an update available.
@@ -644,7 +693,7 @@ mod test {
let path = to_rust(c_path).unwrap();
unsafe { shorebird_free_string(c_path) };
let new = std::fs::read_to_string(path).unwrap();
- assert_eq!(new, expected_new);
+ assert_eq!(new, HELLO_TESTS_PATCH.new);
}
#[serial]
@@ -653,11 +702,11 @@ mod test {
testing_reset_config();
let tmp_dir = TempDir::new().unwrap();
- // Generated by `string_patch "hello world" "hello tests"`
- let base = "hello world";
- let expected_new: &str = "hello tests";
let apk_path = tmp_dir.path().join("base.apk");
- write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes());
+ write_fake_apk(
+ apk_path.to_str().unwrap(),
+ HELLO_TESTS_PATCH.base.as_bytes(),
+ );
let fake_libapp_path = tmp_dir.path().join("lib/arch/ignored.so");
let c_params = parameters(&tmp_dir, fake_libapp_path.to_str().unwrap());
// app_id is required or shorebird_init will fail.
@@ -670,32 +719,18 @@ mod test {
testing_set_network_hooks(
|_url, request| {
assert_eq!(request.channel, "beta");
- // Generated by `string_patch "hello world" "hello tests"`
- let hash = "bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45";
Ok(PatchCheckResponse {
patch_available: true,
patch: Some(crate::Patch {
number: 1,
- hash: hash.to_owned(),
+ hash: HELLO_TESTS_PATCH.hash.to_owned(),
download_url: "ignored".to_owned(),
hash_signature: None,
}),
rolled_back_patch_numbers: None,
})
},
- |_url, dest: &Path, _resume_from: u64| {
- // Generated by `string_patch "hello world" "hello tests"`
- let patch_bytes: Vec = vec![
- 40, 181, 47, 253, 0, 128, 177, 0, 0, 223, 177, 0, 0, 0, 16, 0, 0, 6, 0, 0, 0,
- 0, 0, 0, 5, 116, 101, 115, 116, 115, 0,
- ];
- let total_bytes = patch_bytes.len() as u64;
- std::fs::write(dest, &patch_bytes)?;
- Ok(DownloadResult {
- total_bytes,
- content_length: Some(total_bytes),
- })
- },
+ |_url, dest: &Path, _resume_from: u64| HELLO_TESTS_PATCH.write_to(dest),
|_url, _event| Ok(()),
);
// There is an update available.
@@ -719,7 +754,7 @@ mod test {
let path = to_rust(c_path).unwrap();
unsafe { shorebird_free_string(c_path) };
let new = std::fs::read_to_string(path).unwrap();
- assert_eq!(new, expected_new);
+ assert_eq!(new, HELLO_TESTS_PATCH.new);
Ok(())
}
@@ -730,10 +765,11 @@ mod test {
testing_reset_config();
let tmp_dir = TempDir::new().unwrap();
- // Generated by `string_patch "hello world" "hello tests"`
- let base = "hello world";
let apk_path = tmp_dir.path().join("base.apk");
- write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes());
+ write_fake_apk(
+ apk_path.to_str().unwrap(),
+ HELLO_TESTS_PATCH.base.as_bytes(),
+ );
let fake_libapp_path = tmp_dir.path().join("lib/arch/ignored.so");
let c_params = parameters(&tmp_dir, fake_libapp_path.to_str().unwrap());
// app_id is required or shorebird_init will fail.
@@ -770,10 +806,11 @@ mod test {
testing_reset_config();
let tmp_dir = TempDir::new().unwrap();
- // Generated by `string_patch "hello world" "hello tests"`
- let base = "hello world";
let apk_path = tmp_dir.path().join("base.apk");
- write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes());
+ write_fake_apk(
+ apk_path.to_str().unwrap(),
+ HELLO_TESTS_PATCH.base.as_bytes(),
+ );
let fake_libapp_path = tmp_dir.path().join("lib/arch/ignored.so");
let c_params = parameters(&tmp_dir, fake_libapp_path.to_str().unwrap());
// app_id is required or shorebird_init will fail.
@@ -804,10 +841,11 @@ mod test {
testing_reset_config();
let tmp_dir = TempDir::new().unwrap();
- // Generated by `string_patch "hello world" "hello tests"`
- let base = "hello world";
let apk_path = tmp_dir.path().join("base.apk");
- write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes());
+ write_fake_apk(
+ apk_path.to_str().unwrap(),
+ HELLO_TESTS_PATCH.base.as_bytes(),
+ );
let fake_libapp_path = tmp_dir.path().join("lib/arch/ignored.so");
let c_params = parameters(&tmp_dir, fake_libapp_path.to_str().unwrap());
// app_id is required or shorebird_init will fail.
@@ -822,14 +860,11 @@ mod test {
// shorebird_update_with_result was called with the beta channel, ensure that is
// piped through to the network request.
assert_eq!(request.channel, "beta");
-
- // Generated by `string_patch "hello world" "hello tests"`
- let hash = "bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45";
Ok(PatchCheckResponse {
patch_available: true,
patch: Some(crate::Patch {
number: 1,
- hash: hash.to_owned(),
+ hash: HELLO_TESTS_PATCH.hash.to_owned(),
download_url: "ignored".to_owned(),
hash_signature: None,
}),
@@ -855,14 +890,15 @@ mod test {
#[serial]
#[test]
- fn current_boot_patch_set_after_reporting_launch_start() {
+ fn running_patch_set_after_reporting_launch_start() {
testing_reset_config();
let tmp_dir = TempDir::new().unwrap();
- // Generated by `string_patch "hello world" "hello tests"`
- let base = "hello world";
let apk_path = tmp_dir.path().join("base.apk");
- write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes());
+ write_fake_apk(
+ apk_path.to_str().unwrap(),
+ HELLO_TESTS_PATCH.base.as_bytes(),
+ );
let fake_libapp_path = tmp_dir.path().join("lib/arch/ignored.so");
let c_params = parameters(&tmp_dir, fake_libapp_path.to_str().unwrap());
// app_id is required or shorebird_init will fail.
@@ -874,32 +910,18 @@ mod test {
// set up the network hooks to return a patch.
testing_set_network_hooks(
|_url, _request| {
- // Generated by `string_patch "hello world" "hello tests"`
- let hash = "bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45";
Ok(PatchCheckResponse {
patch_available: true,
patch: Some(crate::Patch {
number: 1,
- hash: hash.to_owned(),
+ hash: HELLO_TESTS_PATCH.hash.to_owned(),
download_url: "ignored".to_owned(),
hash_signature: None,
}),
rolled_back_patch_numbers: None,
})
},
- |_url, dest: &Path, _resume_from: u64| {
- // Generated by `string_patch "hello world" "hello tests"`
- let patch_bytes: Vec = vec![
- 40, 181, 47, 253, 0, 128, 177, 0, 0, 223, 177, 0, 0, 0, 16, 0, 0, 6, 0, 0, 0,
- 0, 0, 0, 5, 116, 101, 115, 116, 115, 0,
- ];
- let total_bytes = patch_bytes.len() as u64;
- std::fs::write(dest, &patch_bytes)?;
- Ok(DownloadResult {
- total_bytes,
- content_length: Some(total_bytes),
- })
- },
+ |_url, dest: &Path, _resume_from: u64| HELLO_TESTS_PATCH.write_to(dest),
|_url, _event| Ok(()),
);
@@ -925,6 +947,253 @@ mod test {
assert_eq!(shorebird_current_boot_patch_number(), 1);
}
+ /// Regression test for the patch-to-release rollback bug.
+ /// Customer scenario: device is running patch 1, server rolls patch 1
+ /// back to the base release (no replacement patch). After
+ /// shorebird_check_for_downloadable_update processes the rollback,
+ /// shorebird_current_boot_patch_number must still report 1 — the running
+ /// process is still using patch 1 and needs to restart. Pair that with
+ /// shorebird_next_boot_patch_number == 0 so callers can detect the
+ /// "current != next" condition that signals restart_required.
+ #[serial]
+ #[test]
+ fn rollback_to_release_keeps_running_patch() {
+ testing_reset_config();
+ let tmp_dir = TempDir::new().unwrap();
+
+ let apk_path = tmp_dir.path().join("base.apk");
+ write_fake_apk(
+ apk_path.to_str().unwrap(),
+ HELLO_TESTS_PATCH.base.as_bytes(),
+ );
+ let fake_libapp_path = tmp_dir.path().join("lib/arch/ignored.so");
+ let c_params = parameters(&tmp_dir, fake_libapp_path.to_str().unwrap());
+ let c_yaml = c_string("app_id: foo");
+ assert!(shorebird_init(&c_params, FileCallbacks::new(), c_yaml));
+ free_c_string(c_yaml);
+ free_parameters(c_params);
+
+ // First, install patch 1 and report a successful launch.
+ testing_set_network_hooks(
+ |_url, _request| {
+ Ok(PatchCheckResponse {
+ patch_available: true,
+ patch: Some(crate::Patch {
+ number: 1,
+ hash: HELLO_TESTS_PATCH.hash.to_owned(),
+ download_url: "ignored".to_owned(),
+ hash_signature: None,
+ }),
+ rolled_back_patch_numbers: None,
+ })
+ },
+ |_url, dest: &Path, _resume_from: u64| HELLO_TESTS_PATCH.write_to(dest),
+ |_url, _event| Ok(()),
+ );
+
+ assert!(shorebird_check_for_downloadable_update(std::ptr::null()));
+ shorebird_update();
+ shorebird_report_launch_start();
+ shorebird_report_launch_success();
+
+ // Sanity: we are now running patch 1.
+ assert_eq!(shorebird_current_boot_patch_number(), 1);
+ assert_eq!(shorebird_next_boot_patch_number(), 1);
+
+ // Now the server rolls back patch 1 with no replacement. The device
+ // should fall back to the base release on the *next* boot, and the
+ // running session should be told a restart is required.
+ // Phase-1 spawned threads (PatchDownload, PatchInstallSuccess) hold a
+ // clone of the config from when they were spawned, so they hit the old
+ // report hook above. Nothing in phase 2 should report — only a
+ // patch-check request happens — so use UNEXPECTED_REPORT to assert
+ // that.
+ testing_set_network_hooks(
+ |_url, _request| {
+ Ok(PatchCheckResponse {
+ patch_available: false,
+ patch: None,
+ rolled_back_patch_numbers: Some(vec![1]),
+ })
+ },
+ UNEXPECTED_DOWNLOAD,
+ UNEXPECTED_REPORT,
+ );
+
+ // Server has no downloadable update — just the rollback signal.
+ assert!(!shorebird_check_for_downloadable_update(std::ptr::null()));
+
+ // The bug: pre-fix this returns 0. Post-fix it must return 1, because
+ // the running process is still on patch 1.
+ assert_eq!(shorebird_current_boot_patch_number(), 1);
+ // Next boot has been cleared — the device will boot the release.
+ assert_eq!(shorebird_next_boot_patch_number(), 0);
+ }
+
+ /// After a patch-to-release rollback, the next launch boots the base
+ /// release. `running_patch` must reflect that — it cannot keep
+ /// reporting the rolled-back patch from the previous run, or callers
+ /// would see a perpetual `restartRequired`. The contract: a fresh
+ /// process starts with `running_patch == None` (it's a session-scoped
+ /// global, not persisted) and `report_launch_start` keeps it `None`
+ /// because `next_boot_patch` is also `None`.
+ #[serial]
+ #[test]
+ fn rollback_to_release_then_restart_clears_running_patch() {
+ testing_reset_config();
+ let tmp_dir = TempDir::new().unwrap();
+
+ let apk_path = tmp_dir.path().join("base.apk");
+ write_fake_apk(
+ apk_path.to_str().unwrap(),
+ HELLO_TESTS_PATCH.base.as_bytes(),
+ );
+ let fake_libapp_path = tmp_dir.path().join("lib/arch/ignored.so");
+
+ // Phase 1: install patch 1 and report a successful launch.
+ {
+ let c_params = parameters(&tmp_dir, fake_libapp_path.to_str().unwrap());
+ let c_yaml = c_string("app_id: foo");
+ assert!(shorebird_init(&c_params, FileCallbacks::new(), c_yaml));
+ free_c_string(c_yaml);
+ free_parameters(c_params);
+ }
+
+ testing_set_network_hooks(
+ |_url, _request| {
+ Ok(PatchCheckResponse {
+ patch_available: true,
+ patch: Some(crate::Patch {
+ number: 1,
+ hash: HELLO_TESTS_PATCH.hash.to_owned(),
+ download_url: "ignored".to_owned(),
+ hash_signature: None,
+ }),
+ rolled_back_patch_numbers: None,
+ })
+ },
+ |_url, dest: &Path, _resume_from: u64| HELLO_TESTS_PATCH.write_to(dest),
+ |_url, _event| Ok(()),
+ );
+ assert!(shorebird_check_for_downloadable_update(std::ptr::null()));
+ shorebird_update();
+ shorebird_report_launch_start();
+ shorebird_report_launch_success();
+ assert_eq!(shorebird_current_boot_patch_number(), 1);
+
+ // Phase 2: server rolls back patch 1 with no replacement.
+ // Phase-1 spawned threads (PatchDownload, PatchInstallSuccess) hold a
+ // clone of the config from when they were spawned, so they hit the
+ // phase-1 hooks above. Phase 2 only does a patch check, so no report
+ // is expected here.
+ testing_set_network_hooks(
+ |_url, _request| {
+ Ok(PatchCheckResponse {
+ patch_available: false,
+ patch: None,
+ rolled_back_patch_numbers: Some(vec![1]),
+ })
+ },
+ UNEXPECTED_DOWNLOAD,
+ UNEXPECTED_REPORT,
+ );
+ assert!(!shorebird_check_for_downloadable_update(std::ptr::null()));
+ assert_eq!(shorebird_current_boot_patch_number(), 1);
+ assert_eq!(shorebird_next_boot_patch_number(), 0);
+
+ // Phase 3: simulate app restart by resetting config and re-initializing
+ // against the same on-disk state (same tmp_dir).
+ testing_reset_config();
+ {
+ let c_params = parameters(&tmp_dir, fake_libapp_path.to_str().unwrap());
+ let c_yaml = c_string("app_id: foo");
+ assert!(shorebird_init(&c_params, FileCallbacks::new(), c_yaml));
+ free_c_string(c_yaml);
+ free_parameters(c_params);
+ }
+
+ // The release boot has no next patch. running_patch is a
+ // session-scoped global, so a fresh process starts with it None.
+ // report_launch_start keeps it None because next_boot_patch is None.
+ assert_eq!(shorebird_next_boot_patch_number(), 0);
+ shorebird_report_launch_start();
+ assert_eq!(shorebird_current_boot_patch_number(), 0);
+ shorebird_report_launch_success();
+ assert_eq!(shorebird_current_boot_patch_number(), 0);
+ }
+
+ /// Patch-to-patch rollback: device on patch 2, server rolls back to
+ /// patch 1 (sends rollback signal AND a downloadable replacement).
+ /// `check_for_downloadable_update` returns true (replacement available),
+ /// and after `update()` installs patch 1, the running session sees
+ /// `current=2, next=1` — the signal Dart needs for `restartRequired`.
+ #[serial]
+ #[test]
+ fn rollback_patch_to_patch_reports_current_and_next_distinctly() {
+ testing_reset_config();
+ let tmp_dir = TempDir::new().unwrap();
+
+ let apk_path = tmp_dir.path().join("base.apk");
+ write_fake_apk(
+ apk_path.to_str().unwrap(),
+ HELLO_TESTS_PATCH.base.as_bytes(),
+ );
+ let fake_libapp_path = tmp_dir.path().join("lib/arch/ignored.so");
+ let c_params = parameters(&tmp_dir, fake_libapp_path.to_str().unwrap());
+ let c_yaml = c_string("app_id: foo");
+ assert!(shorebird_init(&c_params, FileCallbacks::new(), c_yaml));
+ free_c_string(c_yaml);
+ free_parameters(c_params);
+
+ // Set up patch 2 (HELLO_PATCH_2_PATCH) as the running patch.
+ testing_set_network_hooks(
+ |_url, _request| {
+ Ok(PatchCheckResponse {
+ patch_available: true,
+ patch: Some(crate::Patch {
+ number: 2,
+ hash: HELLO_PATCH_2_PATCH.hash.to_owned(),
+ download_url: "ignored".to_owned(),
+ hash_signature: None,
+ }),
+ rolled_back_patch_numbers: None,
+ })
+ },
+ |_url, dest: &Path, _resume_from: u64| HELLO_PATCH_2_PATCH.write_to(dest),
+ |_url, _event| Ok(()),
+ );
+ assert!(shorebird_check_for_downloadable_update(std::ptr::null()));
+ shorebird_update();
+ shorebird_report_launch_start();
+ shorebird_report_launch_success();
+ assert_eq!(shorebird_current_boot_patch_number(), 2);
+
+ // Server rolls back patch 2 with patch 1 (HELLO_TESTS_PATCH) as
+ // replacement. The replacement is a distinct artifact from patch 2.
+ testing_set_network_hooks(
+ |_url, _request| {
+ Ok(PatchCheckResponse {
+ patch_available: true,
+ patch: Some(crate::Patch {
+ number: 1,
+ hash: HELLO_TESTS_PATCH.hash.to_owned(),
+ download_url: "ignored".to_owned(),
+ hash_signature: None,
+ }),
+ rolled_back_patch_numbers: Some(vec![2]),
+ })
+ },
+ |_url, dest: &Path, _resume_from: u64| HELLO_TESTS_PATCH.write_to(dest),
+ |_url, _event| Ok(()),
+ );
+ assert!(shorebird_check_for_downloadable_update(std::ptr::null()));
+ shorebird_update();
+
+ // Running process is still on patch 2; next boot will be patch 1.
+ assert_eq!(shorebird_current_boot_patch_number(), 2);
+ assert_eq!(shorebird_next_boot_patch_number(), 1);
+ }
+
#[serial]
#[test]
fn forgot_init() {
diff --git a/library/src/cache/patch_manager.rs b/library/src/cache/patch_manager.rs
index 4c6ca7a..6fc8b2e 100644
--- a/library/src/cache/patch_manager.rs
+++ b/library/src/cache/patch_manager.rs
@@ -37,7 +37,13 @@ struct PatchMetadata {
/// What gets serialized to disk
#[derive(Debug, Default, Deserialize, Serialize)]
struct PatchesState {
- /// The patch we are currently running, if any.
+ /// Historical record of the patch that most recently completed
+ /// `record_boot_success` on any run. Used as a fallback target by
+ /// `try_fall_back_from_patch` when the next-boot patch is invalid.
+ /// Updated only by `record_boot_success` — boot failures and
+ /// server-driven rollbacks of the running patch don't erase the
+ /// historical fact that the patch booted. Not the same thing as the
+ /// patch this process is running; see `running_patch` for that.
last_booted_patch: Option,
/// The patch that will be run on the next app boot, if any. This may be the same
@@ -84,10 +90,30 @@ pub trait ManagePatches {
signature: Option<&'a str>,
) -> Result<()>;
- /// Returns the patch we most recently successfully booted from (usually the currently running patch),
- /// or None if no patch is installed.
+ /// The patch most recently known to have successfully booted on a prior
+ /// run, or None if no patch is installed. Used as a fallback target by
+ /// `try_fall_back_from_patch` when the next-boot patch becomes invalid.
+ /// Not the same thing as the patch this process is running; for that, see
+ /// [`running_patch`].
fn last_successfully_booted_patch(&self) -> Option;
+ /// The patch this process is using, set at `report_launch_start` from
+ /// whatever `next_boot_patch` was at that moment. `None` means the
+ /// process is running the base release. Survives server-driven
+ /// rollbacks of that patch (the process is still using it). Backed by
+ /// a session-scoped global, not by `PatchesState` on disk: a fresh
+ /// process starts with `None` until the next `report_launch_start`,
+ /// which flutter_engine calls before `dart:ffi` is available, so the
+ /// `None` window is not observable from Dart. Distinct from
+ /// `currently_booting_patch`, which is the persisted "boot in progress"
+ /// breadcrumb used for cross-restart crash detection.
+ fn running_patch(&self) -> Option;
+
+ /// Sets the patch this process is using. Called from
+ /// `report_launch_start` with `Some(n)` when launching a patch, or
+ /// `None` when launching the base release.
+ fn set_running_patch(&mut self, patch_number: Option);
+
/// The patch we are currently booting, if any. This will only have a value:
/// 1. Between record_boot_start_for_patch and record_boot_success or record_boot_failure_for_patch
/// 2. On init if we attempted to boot a patch but never recorded a successful boot (e.g., because
@@ -312,16 +338,35 @@ impl PatchManager {
.unwrap_or(false);
if is_bad_patch_last_booted_patch && is_bad_patch_next_boot_patch {
- // If both patches are bad, delete them both and boot from the base release.
- shorebird_info!("Clearing last booted patch and next boot patch");
- self.patches_state.last_booted_patch = None;
+ // The bad patch is both the last successfully-booted patch and
+ // the queued next-boot patch. Clear `next_boot_patch` so we boot
+ // the base release on next launch, but leave `last_booted_patch`
+ // alone — the patch *did* successfully boot, and the running
+ // process is still using it. Erasing that historical record
+ // while the field was being read by FFI as "what's running"
+ // caused the patch-to-release rollback bug in
+ // shorebirdtech/shorebird#3728.
+ //
+ // The else-if fallback path consults `is_known_bad_patch`
+ // before promoting `last_booted_patch` back to
+ // `next_boot_patch`. For server-driven rollbacks, `remove_patch`
+ // adds the patch to `known_bad_patches` first, so a later
+ // fallback can't accidentally re-promote a rolled-back patch
+ // even if its artifact deletion above silently failed.
+ shorebird_info!(
+ "Clearing next boot patch (rollback target was both last booted and next boot)"
+ );
self.patches_state.next_boot_patch = None;
} else if is_bad_patch_next_boot_patch {
shorebird_info!("Clearing next boot patch");
self.patches_state.next_boot_patch = None;
if let Some(last_boot_patch) = self.patches_state.last_booted_patch.clone() {
- if self.validate_patch_is_bootable(&last_boot_patch).is_ok() {
+ let is_known_bad = self
+ .patches_state
+ .known_bad_patches
+ .contains(&last_boot_patch.number);
+ if !is_known_bad && self.validate_patch_is_bootable(&last_boot_patch).is_ok() {
shorebird_info!(
"Setting last booted patch {} as next boot patch",
last_boot_patch.number
@@ -329,7 +374,7 @@ impl PatchManager {
self.patches_state.next_boot_patch = Some(last_boot_patch);
} else {
shorebird_info!(
- "Last booted patch {} is not bootable, deleting artifacts",
+ "Last booted patch {} is not a valid fallback, deleting artifacts",
last_boot_patch.number
);
self.patches_state.last_booted_patch = None;
@@ -445,6 +490,14 @@ impl ManagePatches for PatchManager {
.map(|patch| self.patch_info_for_number(patch.number))
}
+ fn running_patch(&self) -> Option {
+ crate::config::running_patch_number().map(|number| self.patch_info_for_number(number))
+ }
+
+ fn set_running_patch(&mut self, patch_number: Option) {
+ crate::config::set_running_patch_number(patch_number);
+ }
+
fn currently_booting_patch(&self) -> Option {
self.patches_state
.currently_booting_patch
@@ -540,6 +593,11 @@ impl ManagePatches for PatchManager {
}
fn remove_patch(&mut self, patch_number: usize) -> Result<()> {
+ // Server-driven rollback: mark known-bad so a later fallback path
+ // (e.g. record_boot_failure_for_patch on a *different* patch) can't
+ // promote `last_booted_patch` back to `next_boot_patch` if its
+ // artifact deletion in try_fall_back_from_patch silently fails.
+ self.patches_state.known_bad_patches.insert(patch_number);
self.try_fall_back_from_patch(patch_number)
}
@@ -1282,6 +1340,56 @@ mod fall_back_tests {
Ok(())
}
+ /// Server rolls back patch 1, then patch 2 arrives and fails to boot.
+ /// The else-if fallback path must not promote patch 1 back into
+ /// `next_boot_patch` even though `last_booted_patch` still points at
+ /// patch 1 — the server told us not to use it. `remove_patch` records
+ /// patch 1 as known-bad so this can't happen.
+ #[test]
+ fn rollback_then_failed_replacement_does_not_resurrect_rolled_back_patch() -> Result<()> {
+ let temp_dir = TempDir::new()?;
+ let mut manager = PatchManager::manager_for_test(&temp_dir);
+
+ // Patch 1: install + successful boot.
+ manager.add_patch_for_test(&temp_dir, 1)?;
+ manager.record_boot_start_for_patch(1)?;
+ manager.record_boot_success()?;
+
+ // Server rolls back patch 1.
+ manager.remove_patch(1)?;
+ assert!(manager.is_known_bad_patch(1));
+ assert!(manager.patches_state.next_boot_patch.is_none());
+ // last_booted_patch is intentionally preserved; the running
+ // process is still using patch 1 until it restarts.
+ assert_eq!(
+ manager
+ .patches_state
+ .last_booted_patch
+ .as_ref()
+ .unwrap()
+ .number,
+ 1
+ );
+
+ // Re-create patch 1's artifact to simulate a silent
+ // delete_patch_artifacts failure (e.g. transient FS issue).
+ let patch_1_path = manager.patch_artifact_path(1);
+ std::fs::create_dir_all(patch_1_path.parent().unwrap())?;
+ std::fs::write(&patch_1_path, "patch contents")?;
+
+ // Patch 2 arrives and then fails to boot.
+ manager.add_patch_for_test(&temp_dir, 2)?;
+ manager.record_boot_start_for_patch(2)?;
+ manager.record_boot_failure_for_patch(2)?;
+
+ // Patch 1 must NOT be promoted back to next_boot_patch.
+ assert!(manager.patches_state.next_boot_patch.is_none());
+ assert!(manager.is_known_bad_patch(1));
+ assert!(manager.is_known_bad_patch(2));
+
+ Ok(())
+ }
+
#[test]
fn succeeds_if_deleting_artifacts_fails() -> Result<()> {
let temp_dir = TempDir::new()?;
@@ -1445,8 +1553,14 @@ mod record_boot_failure_for_patch_tests {
Ok(())
}
+ /// Patch 1 successfully booted on a prior run; on this run boot fails.
+ /// `last_successfully_booted_patch` keeps reporting patch 1 — it *did*
+ /// successfully boot once, that's a historical fact. The operational
+ /// "don't try this patch again" intent is captured by
+ /// `is_known_bad_patch` and by deleting the artifacts; nobody falls
+ /// back to a patch with missing artifacts.
#[test]
- fn clears_last_booted_patch_if_it_is_the_failed_patch() -> Result<()> {
+ fn preserves_last_booted_patch_on_failure_but_marks_bad() -> Result<()> {
let temp_dir = TempDir::new()?;
let mut manager = PatchManager::manager_for_test(&temp_dir);
manager.add_patch_for_test(&temp_dir, 1)?;
@@ -1463,7 +1577,9 @@ mod record_boot_failure_for_patch_tests {
// Now pretend it failed to boot
assert!(manager.record_boot_start_for_patch(1).is_ok());
assert!(manager.record_boot_failure_for_patch(1).is_ok());
- assert!(manager.last_successfully_booted_patch().is_none());
+ // Historical fact preserved.
+ assert_eq!(manager.last_successfully_booted_patch().unwrap().number, 1);
+ // Operational state: don't try this patch again.
assert!(manager.next_boot_patch().is_none());
assert!(manager.is_known_bad_patch(1));
assert!(!patch_artifact_path.exists());
diff --git a/library/src/cache/updater_state.rs b/library/src/cache/updater_state.rs
index f5d0d47..e8ca3b0 100644
--- a/library/src/cache/updater_state.rs
+++ b/library/src/cache/updater_state.rs
@@ -233,11 +233,19 @@ impl UpdaterState {
self.patch_manager.last_successfully_booted_patch()
}
- /// This is the current patch that is running.
- pub fn current_boot_patch(&self) -> Option {
- self.patch_manager
- .currently_booting_patch()
- .or(self.patch_manager.last_successfully_booted_patch())
+ /// The patch this process is using, set at `report_launch_start` and
+ /// kept until the next launch. `None` means the process is running the
+ /// base release. Survives server-driven rollbacks of that patch — the
+ /// running process is still using it.
+ pub fn running_patch(&self) -> Option {
+ self.patch_manager.running_patch()
+ }
+
+ /// Records which patch this process is using. Called from
+ /// `report_launch_start` with `Some(n)` when launching a patch, or
+ /// `None` when launching the base release.
+ pub fn set_running_patch(&mut self, patch_number: Option) {
+ self.patch_manager.set_running_patch(patch_number);
}
/// This is the patch that will be used for the next boot.
@@ -486,37 +494,6 @@ mod tests {
assert_eq!(state.last_successfully_booted_patch(), Some(patch));
}
- #[test]
- fn current_boot_patch_returns_currently_booting_patch_if_present() {
- let tmp_dir = TempDir::new().unwrap();
- let patch1 = fake_patch(&tmp_dir, 1);
- let patch2 = fake_patch(&tmp_dir, 2);
- let mut mock_manage_patches = MockManagePatches::new();
- mock_manage_patches
- .expect_last_successfully_booted_patch()
- .return_const(Some(patch1.clone()));
- mock_manage_patches
- .expect_currently_booting_patch()
- .return_const(Some(patch2.clone()));
- let state = test_state(&tmp_dir, mock_manage_patches);
- assert_eq!(state.current_boot_patch(), Some(patch2));
- }
-
- #[test]
- fn current_boot_patch_returns_last_successfully_booted_patch_if_no_patch_is_booting() {
- let tmp_dir = TempDir::new().unwrap();
- let patch = fake_patch(&tmp_dir, 1);
- let mut mock_manage_patches = MockManagePatches::new();
- mock_manage_patches
- .expect_last_successfully_booted_patch()
- .return_const(Some(patch.clone()));
- mock_manage_patches
- .expect_currently_booting_patch()
- .return_const(None);
- let state = test_state(&tmp_dir, mock_manage_patches);
- assert_eq!(state.current_boot_patch(), Some(patch));
- }
-
#[test]
fn next_boot_patch_forwards_from_patch_manager() {
let patch_number = 1;
diff --git a/library/src/config.rs b/library/src/config.rs
index 3a1b143..ac5fd9a 100644
--- a/library/src/config.rs
+++ b/library/src/config.rs
@@ -28,12 +28,40 @@ fn global_config() -> &'static Mutex