Skip to content

Commit 07ea84a

Browse files
committed
refactor: address self-review feedback on lifecycle PR
Nine fixes from the self-review pass on #352: - Drop `partial_size` from PatchState::Downloading. The field was misleading (decide_start reads from disk, not from the recorded value) and unused. record_download_started loses its 5th arg. - Gate `UpdaterState::install_patch` to `#[cfg(test)]`. Production no longer routes through it; only test_utils and the updater_state tests do. The gate makes the divergence intentional and prevents future production callers. - install_patch defensively removes any prior `dlc.vmcode` before rename so behavior is OS-agnostic (POSIX rename overwrites silently; Windows fails). Also mirrors record_install_complete's cleanup of a stale `download` file in the patch dir. - Document on `lifecycle()` / `lifecycle_mut()` that the direct accessors are intentional — wrapping every transition would be churn for no reader benefit. - recompute_next_boot now clears `last_booted_patch` when its on-disk record is gone (Unknown), so pointers.json doesn't accumulate references to nothing. A `Bad` last_booted is left alone — that's a useful breadcrumb and recompute simply doesn't promote it. - Promote `download_artifact_path` and `installed_artifact_path` to methods on PatchLifecycle for symmetry with state_path / pointers_path. Updates all call sites. - Document mark_bad-on-Bad merge semantics: latest reason wins, other fields preserved. Hypothetical in practice but no longer silent. - Add tests for recompute_next_boot's stale-pointer clearing (Unknown → cleared, Bad → kept). Brings the suite to 214 tests. The mark_bad-cleanup-stale-bytes recovery path I flagged was already covered by `cleanup_on_bad_patch_keeps_tombstone` — no new test needed.
1 parent 1b8cef2 commit 07ea84a

3 files changed

Lines changed: 163 additions & 70 deletions

File tree

library/src/cache/lifecycle.rs

Lines changed: 114 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,13 @@ const POINTERS_FILE: &str = "pointers.json";
4444
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
4545
#[serde(tag = "kind")]
4646
pub enum PatchState {
47-
/// Compressed bytes are partially on disk. Resume sends
48-
/// `Range: bytes={partial_size}-`.
47+
/// Compressed bytes are partially on disk. The current bytes-on-disk
48+
/// count is read from the `download` file at resume time — the state
49+
/// itself just records "we're mid-download for this url+hash."
4950
Downloading {
5051
url: String,
5152
hash: String,
5253
signature: Option<String>,
53-
partial_size: u64,
5454
},
5555
/// Compressed bytes are fully on disk and the size matches what we
5656
/// recorded after the download completed. Bytes are untrusted until
@@ -183,14 +183,23 @@ impl PatchLifecycle {
183183
/// Write-then-cleanup ordering means a crash between the two leaves a
184184
/// tombstone with stale-but-unused artifact bytes — sweeping picks
185185
/// them up on the next `cleanup` call.
186+
///
187+
/// Marking an already-Bad patch overwrites the `reason` field with
188+
/// the new one (the old hash/signature/size are preserved). In
189+
/// practice we don't double-fail patches — this just makes the
190+
/// behavior obvious if it ever happens.
186191
pub fn mark_bad(&self, n: usize, reason: BadReason) -> Result<()> {
187192
let (hash, signature, size) = match self.read_state(n) {
188193
Some(PatchState::Downloading {
189-
hash,
190-
signature,
191-
partial_size,
192-
..
193-
}) => (Some(hash), signature, Some(partial_size)),
194+
hash, signature, ..
195+
}) => {
196+
let size = self
197+
.download_artifact_path(n)
198+
.metadata()
199+
.ok()
200+
.map(|m| m.len());
201+
(Some(hash), signature, size)
202+
}
194203
Some(PatchState::Downloaded {
195204
hash,
196205
signature,
@@ -265,6 +274,18 @@ impl PatchLifecycle {
265274
Ok(())
266275
}
267276

277+
/// Path the caller streams compressed download bytes to. Lives at
278+
/// `{root}/patches/{N}/download`.
279+
pub fn download_artifact_path(&self, n: usize) -> PathBuf {
280+
self.patch_dir(n).join("download")
281+
}
282+
283+
/// Path of the installed (inflated) artifact. Lives at
284+
/// `{root}/patches/{N}/dlc.vmcode`.
285+
pub fn installed_artifact_path(&self, n: usize) -> PathBuf {
286+
self.patch_dir(n).join("dlc.vmcode")
287+
}
288+
268289
fn patches_root(&self) -> PathBuf {
269290
self.root.join(PATCHES_DIR)
270291
}
@@ -282,20 +303,6 @@ impl PatchLifecycle {
282303
}
283304
}
284305

285-
/// Convenience accessor; returns the path a caller would write the
286-
/// compressed download bytes to. Public so the network layer can stream
287-
/// directly into it without knowing the on-disk layout details.
288-
pub fn download_artifact_path(root: &Path, n: usize) -> PathBuf {
289-
root.join(PATCHES_DIR).join(n.to_string()).join("download")
290-
}
291-
292-
/// Path to the installed (inflated) artifact for patch `n`.
293-
pub fn installed_artifact_path(root: &Path, n: usize) -> PathBuf {
294-
root.join(PATCHES_DIR)
295-
.join(n.to_string())
296-
.join("dlc.vmcode")
297-
}
298-
299306
/// What `update_internal` should do when starting work on a patch.
300307
///
301308
/// Returned by [`PatchLifecycle::decide_start`] after inspecting the
@@ -304,8 +311,8 @@ pub fn installed_artifact_path(root: &Path, n: usize) -> PathBuf {
304311
#[derive(Debug, Clone, PartialEq)]
305312
pub enum DownloadAction {
306313
/// No usable prior bytes — start a fresh download. The caller should
307-
/// `record_download_started(... partial_size: 0)` and issue a GET
308-
/// without a Range header.
314+
/// `record_download_started(...)` and issue a GET without a Range
315+
/// header.
309316
Fresh,
310317
/// Partial bytes from a matching prior attempt are on disk. The
311318
/// caller resumes from `offset` (the existing partial file size)
@@ -336,11 +343,10 @@ impl PatchLifecycle {
336343
/// trusted.
337344
pub fn decide_start(&self, n: usize, url: &str, hash: &str) -> DownloadAction {
338345
// For Downloading/Downloaded, the on-disk file is the source
339-
// of truth for "how many bytes do we have." `partial_size` in
340-
// the state is denormalized info from when the state was last
341-
// written and may lag a partially-completed download. Reading
342-
// from disk avoids needing to update the sidecar mid-stream.
343-
let download_path = download_artifact_path(&self.root, n);
346+
// of truth for "how many bytes do we have." The state itself
347+
// just records the url/hash/signature so we can detect a
348+
// server change since the prior attempt.
349+
let download_path = self.download_artifact_path(n);
344350
match self.read_state(n) {
345351
None => DownloadAction::Fresh,
346352
Some(PatchState::Downloading {
@@ -373,24 +379,23 @@ impl PatchLifecycle {
373379
}
374380
}
375381

376-
/// Records that a download is starting (or restarting). `partial_size`
377-
/// is what the caller will tell the server via Range — typically `0`
378-
/// for a fresh download, or the existing file size for a resume.
382+
/// Records that a download is starting (or restarting). The actual
383+
/// bytes-on-disk count comes from the `download` file at resume
384+
/// time; this just persists the url/hash/signature so a subsequent
385+
/// `decide_start` can match against the server's current offer.
379386
pub fn record_download_started(
380387
&self,
381388
n: usize,
382389
url: &str,
383390
hash: &str,
384391
signature: Option<&str>,
385-
partial_size: u64,
386392
) -> Result<()> {
387393
self.write_state(
388394
n,
389395
&PatchState::Downloading {
390396
url: url.to_string(),
391397
hash: hash.to_string(),
392398
signature: signature.map(String::from),
393-
partial_size,
394399
},
395400
)
396401
}
@@ -523,22 +528,39 @@ impl PatchLifecycle {
523528
/// a freshly installed newer patch by promoting the older
524529
/// `last_booted_patch` back into `next_boot_patch`.
525530
///
531+
/// Also clears `last_booted_patch` if its on-disk record is gone
532+
/// (Unknown), so `pointers.json` doesn't accumulate references to
533+
/// nothing. A `last_booted_patch` whose state is `Bad` is left
534+
/// alone — that's a useful historical breadcrumb and recompute
535+
/// will simply not promote it.
536+
///
526537
/// We deliberately don't scan `patches/` for arbitrary Installed
527538
/// patches — within a release there are at most a couple of patches
528539
/// active at once, and the last successfully booted patch is the
529540
/// only one we have evidence works on this device.
530541
pub fn recompute_next_boot(&mut self) -> Result<()> {
531-
if let Some(n) = self.pointers.next_boot_patch {
532-
if matches!(self.read_state(n), Some(PatchState::Installed { .. })) {
533-
return Ok(());
542+
let mut dirty = false;
543+
if let Some(lb) = self.pointers.last_booted_patch {
544+
if self.read_state(lb).is_none() {
545+
self.pointers.last_booted_patch = None;
546+
dirty = true;
534547
}
535548
}
536-
let new_target = self
549+
let already_valid = self
537550
.pointers
538-
.last_booted_patch
539-
.filter(|&lb| matches!(self.read_state(lb), Some(PatchState::Installed { .. })));
540-
if self.pointers.next_boot_patch != new_target {
541-
self.pointers.next_boot_patch = new_target;
551+
.next_boot_patch
552+
.is_some_and(|n| matches!(self.read_state(n), Some(PatchState::Installed { .. })));
553+
if !already_valid {
554+
let new_target = self
555+
.pointers
556+
.last_booted_patch
557+
.filter(|&lb| matches!(self.read_state(lb), Some(PatchState::Installed { .. })));
558+
if self.pointers.next_boot_patch != new_target {
559+
self.pointers.next_boot_patch = new_target;
560+
dirty = true;
561+
}
562+
}
563+
if dirty {
542564
self.save_pointers()?;
543565
}
544566
Ok(())
@@ -577,7 +599,7 @@ impl PatchLifecycle {
577599
}) => (size, signature),
578600
other => bail!("Patch {n} is not Installed: {other:?}"),
579601
};
580-
let path = installed_artifact_path(&self.root, n);
602+
let path = self.installed_artifact_path(n);
581603
if !path.exists() {
582604
bail!("Patch {n} artifact missing at {}", path.display());
583605
}
@@ -879,9 +901,9 @@ mod tests {
879901

880902
#[test]
881903
fn artifact_path_helpers_match_state_directory() {
882-
let (tmp, lifecycle) = fixture();
883-
let download = download_artifact_path(tmp.path(), 7);
884-
let installed = installed_artifact_path(tmp.path(), 7);
904+
let (_tmp, lifecycle) = fixture();
905+
let download = lifecycle.download_artifact_path(7);
906+
let installed = lifecycle.installed_artifact_path(7);
885907
assert_eq!(download.parent().unwrap(), lifecycle.patch_dir(7));
886908
assert_eq!(installed.parent().unwrap(), lifecycle.patch_dir(7));
887909
}
@@ -905,11 +927,10 @@ mod tests {
905927
url: "https://example/p".into(),
906928
hash: "h".into(),
907929
signature: None,
908-
partial_size: 250,
909930
},
910931
)
911932
.unwrap();
912-
std::fs::write(download_artifact_path(&lifecycle.root, 1), vec![0u8; 250]).unwrap();
933+
std::fs::write(lifecycle.download_artifact_path(1), vec![0u8; 250]).unwrap();
913934
assert_eq!(
914935
lifecycle.decide_start(1, "https://example/p", "h"),
915936
DownloadAction::Resume { offset: 250 }
@@ -928,7 +949,6 @@ mod tests {
928949
url: "https://example/p".into(),
929950
hash: "h".into(),
930951
signature: None,
931-
partial_size: 250,
932952
},
933953
)
934954
.unwrap();
@@ -948,7 +968,6 @@ mod tests {
948968
url: "https://old.example/p".into(),
949969
hash: "h".into(),
950970
signature: None,
951-
partial_size: 100,
952971
},
953972
)
954973
.unwrap();
@@ -989,7 +1008,7 @@ mod tests {
9891008
},
9901009
)
9911010
.unwrap();
992-
std::fs::write(download_artifact_path(&lifecycle.root, 1), vec![0u8; 1000]).unwrap();
1011+
std::fs::write(lifecycle.download_artifact_path(1), vec![0u8; 1000]).unwrap();
9931012
assert_eq!(
9941013
lifecycle.decide_start(1, "u", "h"),
9951014
DownloadAction::Complete
@@ -1056,15 +1075,14 @@ mod tests {
10561075
fn record_download_started_writes_downloading_state() {
10571076
let (_tmp, lifecycle) = fixture();
10581077
lifecycle
1059-
.record_download_started(1, "u", "h", Some("s"), 0)
1078+
.record_download_started(1, "u", "h", Some("s"))
10601079
.unwrap();
10611080
assert_eq!(
10621081
lifecycle.read_state(1).unwrap(),
10631082
PatchState::Downloading {
10641083
url: "u".into(),
10651084
hash: "h".into(),
10661085
signature: Some("s".into()),
1067-
partial_size: 0,
10681086
}
10691087
);
10701088
}
@@ -1073,7 +1091,7 @@ mod tests {
10731091
fn record_download_complete_transitions_downloading_to_downloaded() {
10741092
let (_tmp, lifecycle) = fixture();
10751093
lifecycle
1076-
.record_download_started(1, "u", "h", None, 0)
1094+
.record_download_started(1, "u", "h", None)
10771095
.unwrap();
10781096
lifecycle.record_download_complete(1, 1234).unwrap();
10791097
assert_eq!(
@@ -1167,7 +1185,7 @@ mod tests {
11671185
}
11681186

11691187
fn install_patch(lifecycle: &PatchLifecycle, n: usize, size: u64) {
1170-
let path = installed_artifact_path(&lifecycle.root, n);
1188+
let path = lifecycle.installed_artifact_path(n);
11711189
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
11721190
std::fs::write(&path, vec![0u8; size as usize]).unwrap();
11731191
lifecycle
@@ -1272,6 +1290,46 @@ mod tests {
12721290
assert_eq!(lifecycle.pointers().next_boot_patch, None);
12731291
}
12741292

1293+
#[test]
1294+
fn recompute_next_boot_clears_stale_last_booted() {
1295+
let (_tmp, mut lifecycle) = fixture();
1296+
// last_booted points at a patch we've forgotten — e.g. an older
1297+
// release version was wiped and we're carrying a stale pointer.
1298+
lifecycle.pointers.last_booted_patch = Some(7);
1299+
lifecycle.save_pointers().unwrap();
1300+
1301+
lifecycle.recompute_next_boot().unwrap();
1302+
1303+
assert_eq!(lifecycle.pointers().last_booted_patch, None);
1304+
assert_eq!(lifecycle.pointers().next_boot_patch, None);
1305+
}
1306+
1307+
#[test]
1308+
fn recompute_next_boot_keeps_bad_last_booted_pointer() {
1309+
// A `Bad` patch in last_booted is a useful breadcrumb — recompute
1310+
// shouldn't promote it (next_boot stays None) but shouldn't clear
1311+
// the historical pointer either.
1312+
let (_tmp, mut lifecycle) = fixture();
1313+
lifecycle
1314+
.write_state(
1315+
3,
1316+
&PatchState::Bad {
1317+
reason: BadReason::BootCrash,
1318+
hash: None,
1319+
signature: None,
1320+
size: None,
1321+
},
1322+
)
1323+
.unwrap();
1324+
lifecycle.pointers.last_booted_patch = Some(3);
1325+
lifecycle.save_pointers().unwrap();
1326+
1327+
lifecycle.recompute_next_boot().unwrap();
1328+
1329+
assert_eq!(lifecycle.pointers().last_booted_patch, Some(3));
1330+
assert_eq!(lifecycle.pointers().next_boot_patch, None);
1331+
}
1332+
12751333
#[test]
12761334
fn detect_boot_crash_on_init_recovers_when_breadcrumb_set() {
12771335
let tmp = TempDir::new().unwrap();
@@ -1308,7 +1366,7 @@ mod tests {
13081366
lifecycle.pointers.next_boot_patch = Some(1);
13091367
lifecycle.save_pointers().unwrap();
13101368
// Truncate the artifact so it no longer matches.
1311-
std::fs::write(installed_artifact_path(&lifecycle.root, 1), b"short").unwrap();
1369+
std::fs::write(lifecycle.installed_artifact_path(1), b"short").unwrap();
13121370

13131371
let result = lifecycle.validate_next_boot_patch(None, PatchVerificationMode::default());
13141372
assert!(result.is_err());

0 commit comments

Comments
 (0)