@@ -44,13 +44,13 @@ const POINTERS_FILE: &str = "pointers.json";
4444#[ derive( Clone , Debug , Serialize , Deserialize , PartialEq ) ]
4545#[ serde( tag = "kind" ) ]
4646pub 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 ) ]
305312pub 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