fix(updater): keep the backup when the app cannot be put back

`replace_and_relaunch` moves the current bundle into the stage, then
renames the replacement into its place. Between those two lines there is
no app at all, and the backup inside the stage is the only copy.

If the second rename failed, the restore was attempted with `let _ =` and
the stage was deleted regardless. A restore that failed therefore took
the backup with it: nothing at `current`, no copy left, and an error that
named only the swap. That is a reinstall.

The launch-failure arm ten lines below already does this correctly — it
matches on the restore, reports it in the message, and leaves the stage
alone so the backup survives. This one now does the same. Two arms of the
same function disagreeing about how much care a missing app deserves is
the whole of the bug.

The new test covers the swap-failure path, which had none: a replacement
that was never extracted fails the second rename, and the previous app
has to be back at `current` with its own marker. Removing the restore
fails it.

What it does not cover is the restore *itself* failing — the seams here
are `launch` and `report`, and neither runs between the two renames, so
forcing that would mean injecting the rename. The correctness of that arm
rests on mirroring the tested one below it.
This commit is contained in:
l0ng-ai
2026-08-16 04:03:41 +08:00
parent 07790f589d
commit 8959421e2d
+47 -3
View File
@@ -327,9 +327,21 @@ mod macos {
}
if let Err(error) = fs::rename(replacement, current) {
let _ = fs::rename(&backup, current);
let _ = fs::remove_dir_all(stage);
let result = Err(format!("putting the staged app in place: {error}"));
// The same care the launch-failure arm below takes, and for a
// sharper reason: `current` has already been moved into the stage,
// so the backup is the only app there is. Dropping the stage
// without checking the restore first deleted it — leaving nothing
// at `current`, and an error that mentioned only the swap.
let result = match fs::rename(&backup, current) {
Ok(()) => {
let _ = fs::remove_dir_all(stage);
Err(format!("putting the staged app in place: {error}"))
}
Err(restore) => Err(format!(
"putting the staged app in place: {error}; \
restoring the previous app: {restore}"
)),
};
report(&result);
return result;
}
@@ -475,6 +487,38 @@ mod macos {
assert!(!root.path().join(".tty7.app.tty7-update-backup").exists());
}
#[test]
fn a_failed_swap_puts_the_previous_app_back() {
// The window this covers: `current` has already been renamed into
// the stage, so between the two renames there is no app at all.
// A replacement that is not there fails the second one.
let root = tempfile::tempdir().unwrap();
let current = root.path().join("tty7.app");
let stage = root.path().join("stage");
bundle(&current, "old");
fs::create_dir_all(&stage).unwrap();
let replacement = stage.join("never-extracted.app");
let error = replace_and_relaunch(
&current,
&replacement,
&stage,
|_| panic!("nothing should be launched when the swap failed"),
|_| (),
)
.unwrap_err();
assert!(
error.starts_with("putting the staged app in place:"),
"{error}"
);
assert!(
current.exists(),
"the previous app must be back where it was"
);
assert_eq!(fs::read_to_string(current.join("marker")).unwrap(), "old");
}
#[test]
fn failed_launch_restores_and_relaunches_the_previous_app() {
let root = tempfile::tempdir().unwrap();