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;