Skip to main content

tauri_plugin_hot_update/
machine.rs

1//! The rollback state machine, as pure Rust.
2//!
3//! Every transition takes the current [`State`] plus an event and returns the
4//! new state together with [`Effect`]s for the I/O shell ([`crate::store`],
5//! [`crate::runtime`]) to execute. **No I/O happens here** — that is what
6//! makes every anti-brick scenario unit-testable.
7//!
8//! The three-state pointer (design doc, "State machine"):
9//!
10//! ```text
11//! download ──▶ staged ──(next cold boot, persisted BEFORE serving)──▶ booting
12//!                            booting ──(notifyAppReady ack)──────▶ committed
13//!                            booting ──(1st boot, no ack)──▶ booting (re-armed)
14//!                            booting ──(2nd boot, no ack)──▶ failed (by archive sha256)
15//! ```
16//!
17//! Invariants enforced here:
18//! - Absence of the ack **is** the failure signal (no boot timer): a `booting`
19//!   pointer found at boot means the previous launch never acked. Desktop
20//!   launches are rare and sessions long, so a single miss only *re-arms* the
21//!   same bundle for one more trial; a *second* consecutive unacked launch
22//!   blacklists its archive sha256 and serving falls back to
23//!   committed/embedded. A bundle a newer embedded frontend has caught up to
24//!   is discarded outright (not blacklisted) — the embedded frontend wins.
25//! - `max_version_seen` is a monotonic watermark (downgrade-replay and
26//!   embedded-newer-than-OTA protection). It only ever rises.
27//! - Failed archive hashes are never staged or armed again.
28//! - The ack commits the seq the process actually booted (passed in by the
29//!   caller from memory), never a value re-read from disk — a download that
30//!   finishes mid-session must not be committed untried.
31
32use std::collections::{BTreeMap, BTreeSet};
33
34use semver::Version;
35use serde::{Deserialize, Serialize};
36
37/// Per-bundle metadata recorded at staging time.
38///
39/// `archive_sha256` is the identity used for the failure blacklist: a fixed
40/// deploy ships under a new hash, so blacklisting by hash never blocks a fix.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct BundleMeta {
44    pub version: Version,
45    pub archive_sha256: String,
46}
47
48/// The persisted contents of `state.json`.
49///
50/// Serialized as camelCase per the design doc. Unknown fields from newer
51/// plugin versions are ignored on read (forward compatibility); a file that
52/// fails to parse at all is treated as fresh by the store (never a panic).
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase", default)]
55pub struct State {
56    /// The last bundle that completed a trial boot and was acked.
57    pub committed: Option<u64>,
58    /// Last-known-good bundle. Moves together with `committed` in v1; kept
59    /// as a separate pointer per the design for future recovery flows.
60    pub last_good: Option<u64>,
61    /// Downloaded and extracted, waiting for the next cold boot.
62    pub staged: Option<u64>,
63    /// Armed for a trial boot. Present at boot time ⇒ the previous trial
64    /// never acked.
65    pub booting: Option<u64>,
66    /// Consecutive unacked launches of the current `booting` bundle. Desktop
67    /// launches are rare and sessions long, so a *good* bundle whose process
68    /// quit before `notifyAppReady()` must not be blacklisted on a single
69    /// miss: the bundle is re-armed once (strike 1) and only blacklisted after
70    /// a *second* consecutive unacked launch (strike 2). Reset to 0 on commit
71    /// and whenever a fresh bundle is promoted from `staged`. `#[serde(default)]`
72    /// so a pre-2-strike `state.json` (which lacks the field) loads as 0.
73    #[serde(default)]
74    pub booting_strikes: u32,
75    /// Blacklist of archive sha256 hashes that failed a trial boot.
76    pub failed: BTreeSet<String>,
77    /// Monotonic watermark: max(embedded version, highest committed OTA
78    /// version). Manifests/bundles at or below it are refused.
79    pub max_version_seen: Option<Version>,
80    /// seq → metadata for every bundle the state still references.
81    pub versions: BTreeMap<u64, BundleMeta>,
82}
83
84impl State {
85    /// Seqs the state still references after a transition — everything else
86    /// on disk is garbage.
87    fn referenced(&self) -> BTreeSet<u64> {
88        [self.committed, self.last_good, self.staged, self.booting]
89            .into_iter()
90            .flatten()
91            .collect()
92    }
93}
94
95/// Which source the provider serves this process lifetime.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum Active {
98    Embedded,
99    Ota(u64),
100}
101
102/// Side effects for the shell to execute after persisting the new state.
103/// Deletions are best-effort: a failure leaves an orphan dir that the next
104/// boot's GC removes.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum Effect {
107    DeleteBundle(u64),
108}
109
110/// Result of boot-time resolution.
111#[derive(Debug)]
112pub struct BootOutcome {
113    pub state: State,
114    /// What this process must serve. The shell must persist `state` BEFORE
115    /// serving a single byte when this is a newly armed bundle
116    /// (`state.booting`), otherwise a crash would evade rollback detection.
117    pub active: Active,
118    /// Seq blamed for the previous unacked trial boot, if any.
119    pub rolled_back: Option<u64>,
120    pub effects: Vec<Effect>,
121}
122
123/// Boot-time resolution: rollback detection, watermark fold-in,
124/// embedded-newer-than-OTA discard, staged→booting promotion, GC.
125///
126/// `present` is the set of bundle dirs that actually exist on disk; pointers
127/// to absent dirs are dropped rather than armed/served.
128pub fn resolve_boot(
129    mut state: State,
130    embedded_version: &Version,
131    present: &BTreeSet<u64>,
132) -> BootOutcome {
133    // 1. Unacked trial boot from a previous launch. The bundle pointed at by
134    //    `booting` was armed last launch and never acked. Two-strike softening
135    //    (design §4, "desktop-hardened rollback"): desktop launches are rare
136    //    and a good bundle is often quit before `notifyAppReady()`, so a
137    //    single miss must not condemn it.
138    let mut rolled_back = None;
139    if let Some(seq) = state.booting.take() {
140        let meta = state.versions.get(&seq).cloned();
141        let superseded = meta
142            .as_ref()
143            .is_some_and(|m| m.version <= *embedded_version);
144
145        if superseded {
146            // A newer embedded frontend (e.g. a Sparkle/native update that
147            // landed while this trial was mid-flight) has caught up to the
148            // trial bundle. It is stale, not broken: discard it (GC sweeps the
149            // dir) and let embedded win — no blacklist, not a failure rollback.
150            state.booting_strikes = 0;
151        } else if state.booting_strikes == 0 && present.contains(&seq) && meta.is_some() {
152            // Strike 1: still applicable and on disk. Give it exactly one more
153            // launch to ack before condemning it. `booting` stays occupied, so
154            // step 4 will not promote a waiting staged bundle over it.
155            state.booting = Some(seq);
156            state.booting_strikes = 1;
157        } else {
158            // Strike 2 (a genuine second unacked launch), a bundle whose dir
159            // vanished, or a corrupt pointer with no metadata: this bundle can
160            // never be trusted again. Blacklist its archive sha256 when known
161            // (a fixed redeploy ships under a new hash and is unaffected) and
162            // roll back to committed/embedded.
163            if let Some(m) = &meta {
164                state.failed.insert(m.archive_sha256.clone());
165            }
166            state.booting_strikes = 0;
167            rolled_back = Some(seq);
168        }
169    }
170
171    // 2. Fold the embedded version into the monotonic watermark.
172    state.max_version_seen = Some(match state.max_version_seen.take() {
173        Some(seen) => seen.max(embedded_version.clone()),
174        None => embedded_version.clone(),
175    });
176
177    // 3. Embedded-newer-than-OTA discard: a store update whose embedded
178    //    frontend is at least as new as the committed OTA bundle wins, and a
179    //    committed pointer whose dir vanished cannot be served.
180    let ota_beats_embedded = |seq: u64, state: &State| -> bool {
181        present.contains(&seq)
182            && state
183                .versions
184                .get(&seq)
185                .is_some_and(|meta| meta.version > *embedded_version)
186    };
187    if let Some(seq) = state.committed {
188        if !ota_beats_embedded(seq, &state) {
189            state.committed = None;
190        }
191    }
192    if let Some(seq) = state.last_good {
193        if !ota_beats_embedded(seq, &state) {
194            state.last_good = None;
195        }
196    }
197
198    // 4. Staged promotion, re-gated against the *updated* watermark and the
199    //    blacklist. The download gates already checked these, but arming is
200    //    the last line of defense and the situation can legitimately change
201    //    between staging and boot (e.g. a store update raised the watermark).
202    //    Skipped while `booting` is still occupied by a strike-1 re-arm: the
203    //    queued bundle stays staged and waits for the next boot rather than
204    //    jumping ahead of the bundle currently on its second trial.
205    let promote = if state.booting.is_some() {
206        None
207    } else {
208        state.staged.take()
209    };
210    if let Some(seq) = promote {
211        let arm = present.contains(&seq)
212            && state.versions.get(&seq).is_some_and(|meta| {
213                !state.failed.contains(&meta.archive_sha256)
214                    && state
215                        .max_version_seen
216                        .as_ref()
217                        .map_or(true, |seen| meta.version > *seen)
218            });
219        if arm {
220            state.booting = Some(seq);
221            // A freshly promoted bundle starts its trial with a clean strike
222            // count, even if a leftover count somehow survived a prior commit.
223            state.booting_strikes = 0;
224        }
225        // Not armable ⇒ silently discarded; GC removes the dir.
226    }
227
228    // 5. Active source for this process lifetime.
229    let active = match state.booting.or(state.committed) {
230        Some(seq) => Active::Ota(seq),
231        None => Active::Embedded,
232    };
233
234    // 6. GC: every seq on disk or in the versions map that the final state
235    //    no longer references is garbage (orphans from a kill between
236    //    extraction and state write, rolled-back bundles, discarded staged).
237    let referenced = state.referenced();
238    let version_seqs: BTreeSet<u64> = state.versions.keys().copied().collect();
239    let mut effects = Vec::new();
240    for seq in present.union(&version_seqs).copied() {
241        if !referenced.contains(&seq) {
242            if present.contains(&seq) {
243                effects.push(Effect::DeleteBundle(seq));
244            }
245            state.versions.remove(&seq);
246        }
247    }
248
249    BootOutcome {
250        state,
251        active,
252        rolled_back,
253        effects,
254    }
255}
256
257/// Result of an ack ([`ack`]).
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub enum AckOutcome {
260    /// The trial bundle is now committed and last-good.
261    Committed(u64),
262    /// Steady state: the booted bundle was already committed (every launch
263    /// calls `notifyAppReady`, and the call is idempotent).
264    AlreadyCommitted(u64),
265    /// Serving embedded — nothing to commit.
266    EmbeddedNoop,
267    /// The booted seq no longer matches the on-disk `booting` pointer (e.g.
268    /// a `reset()` ran mid-session). Refusing to commit is the safe answer.
269    Stale(u64),
270}
271
272/// `notifyAppReady`: commit the bundle this process booted.
273///
274/// `booted` MUST be the in-memory value captured at boot resolution — never
275/// re-read from disk, because a download finishing mid-session may have
276/// re-written `staged` (and `staged` must stay untouched for the next boot).
277pub fn ack(mut state: State, booted: Active) -> (State, AckOutcome, Vec<Effect>) {
278    let seq = match booted {
279        Active::Embedded => return (state, AckOutcome::EmbeddedNoop, Vec::new()),
280        Active::Ota(seq) => seq,
281    };
282    if state.booting != Some(seq) {
283        let outcome = if state.committed == Some(seq) {
284            AckOutcome::AlreadyCommitted(seq)
285        } else {
286            AckOutcome::Stale(seq)
287        };
288        return (state, outcome, Vec::new());
289    }
290
291    state.booting = None;
292    // A bundle that acks once is good: clear any strike accumulated during a
293    // re-armed trial so it can never contaminate the next bundle's trial.
294    state.booting_strikes = 0;
295    let previous = state.committed.replace(seq);
296    state.last_good = Some(seq);
297    if let Some(meta) = state.versions.get(&seq) {
298        state.max_version_seen = Some(match state.max_version_seen.take() {
299            Some(seen) => seen.max(meta.version.clone()),
300            None => meta.version.clone(),
301        });
302    }
303
304    // The bundle this one replaces is no longer referenced (unless a pointer
305    // still names it, e.g. it is also the staged seq in a corrupt file).
306    let mut effects = Vec::new();
307    if let Some(prev) = previous {
308        if !state.referenced().contains(&prev) {
309            state.versions.remove(&prev);
310            effects.push(Effect::DeleteBundle(prev));
311        }
312    }
313
314    (state, AckOutcome::Committed(seq), effects)
315}
316
317/// Why a staging request was refused.
318#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
319pub enum StageError {
320    #[error("bundle version {version} is not newer than the watermark {watermark}")]
321    VersionNotNewer {
322        version: Version,
323        watermark: Version,
324    },
325    #[error("archive sha256 {0} previously failed a trial boot and is blacklisted")]
326    HashBlacklisted(String),
327    #[error("seq {0} is already referenced by the state")]
328    SeqInUse(u64),
329}
330
331/// Stage a freshly extracted bundle (the WP3 downloader's final step).
332///
333/// Gates: the version must be strictly above the watermark (downgrade-replay
334/// protection) and the archive hash must not be blacklisted. Replacing an
335/// earlier staged-but-never-armed bundle orphans it (delete effect).
336pub fn stage(
337    mut state: State,
338    seq: u64,
339    meta: BundleMeta,
340) -> (State, Result<Vec<Effect>, StageError>) {
341    if let Some(watermark) = &state.max_version_seen {
342        if meta.version <= *watermark {
343            let err = StageError::VersionNotNewer {
344                version: meta.version,
345                watermark: watermark.clone(),
346            };
347            return (state, Err(err));
348        }
349    }
350    if state.failed.contains(&meta.archive_sha256) {
351        let err = StageError::HashBlacklisted(meta.archive_sha256);
352        return (state, Err(err));
353    }
354    if state.referenced().contains(&seq) || state.versions.contains_key(&seq) {
355        return (state, Err(StageError::SeqInUse(seq)));
356    }
357
358    let mut effects = Vec::new();
359    if let Some(prev) = state.staged.replace(seq) {
360        state.versions.remove(&prev);
361        effects.push(Effect::DeleteBundle(prev));
362    }
363    state.versions.insert(seq, meta);
364    (state, Ok(effects))
365}
366
367/// `reset()`: the debug/support escape hatch — factory state, embedded
368/// serving, watermark restarted from the embedded version.
369///
370/// The bundle the process is *currently serving* (if any) is deliberately not
371/// deleted — yanking files out from under the running webview could tear the
372/// UI. It is unreferenced by the fresh state, so the next boot's GC sweeps it.
373pub fn reset(state: State, embedded_version: &Version, active: Active) -> (State, Vec<Effect>) {
374    let keep = match active {
375        Active::Embedded => None,
376        Active::Ota(seq) => Some(seq),
377    };
378    let effects = state
379        .versions
380        .keys()
381        .copied()
382        .filter(|seq| Some(*seq) != keep)
383        .map(Effect::DeleteBundle)
384        .collect();
385    let fresh = State {
386        max_version_seen: Some(embedded_version.clone()),
387        ..State::default()
388    };
389    (fresh, effects)
390}
391
392#[cfg(test)]
393mod tests;