Browse / github.com/storytold/craft-launcher / patch
Timestamps saved to disk never wrap the schedule arithmetic (#774)
Saved timestamps can overflow: feed throttle and update-check schedule stop working ## Summary Timestamps loaded from disk are used in subtraction without being sanitised. Release builds don't check for overflow, so the result wraps, and the never-crash rule is broken in debug builds. ### 1. Feed throttle (`crates/native/src/feed_throttle.rs`) - `load` (`:94`) drops only values far in the future. - A saved `"not_before"` near `i64::MIN` makes `(at - now)` at `:115` wrap to a huge wait. In release builds the feed is then never asked again; in debug builds it panics. ### 2. Update-check schedule (`crates/ui-egui/src/app.rs:334-336`) ```rust && self.settings.last_check.is_n
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- SusieeTheLinuxUser (imported from pull request #840, open)
- Base
674b893f0e5dcaceb742ae22728b8bde8bd73911- Size
- +51 −6 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
11d77444900210b91142637e
How it went
No other operator has reported using it yet.
Diff
raw · crates/native/src/feed_throttle.rs crates/ui-egui/src/app.rs
@@ -90,8 +90,10 @@ impl FeedThrottle { /// changes. A missing or malformed file just means no backoff is pending. pub fn load(file: &Path, now: i64) -> Self { let mut saved = std::fs::read(file).ok().and_then(|b| serde_json::from_slice::<Saved>(&b).ok()).unwrap_or_default();- // A wait far in the future means a broken file or a clock that jumped back: drop it.- if saved.not_before > now + MAX_THROTTLE_BACKOFF * 2 {+ // A saved wait outside the sane range means a broken file, an edited value or a clock+ // that jumped: drop it. A `not_before` near `i64::MIN` would otherwise wrap the+ // subtraction in `wait` into a centuries-long wait (#774).+ if !(0..=now.saturating_add(MAX_THROTTLE_BACKOFF * 2)).contains(&saved.not_before) { saved.not_before = 0; } Self { saved, file: Some(file.to_path_buf()), ..Self::new() }@@ -112,7 +114,7 @@ impl FeedThrottle { let gap = WARM_GAPS.get(self.saved.failures as usize).or(WARM_GAPS.last()).copied().unwrap_or(WINDOW); at = at.max(last + gap); }- (at - now).max(0)+ at.saturating_sub(now).max(0) } /// Notes that a request is being sent now.@@ -375,4 +377,16 @@ mod tests { std::fs::write(&file, br#"{"unknown": true}"#).unwrap(); assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0); }++ #[test]+ fn an_absurdly_old_saved_not_before_is_no_wait() {+ let dir = tempfile::tempdir().unwrap();+ let file = dir.path().join("feed-throttle.json");+ // A hostile or corrupted `not_before` near i64::MIN once wrapped `at - now` into a+ // wait of centuries (a debug-build panic, #774).+ std::fs::write(&file, br#"{"not_before": -9223372036854775808, "throttles": 1}"#).unwrap();+ assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);+ std::fs::write(&file, br#"{"not_before": -60, "throttles": 1}"#).unwrap();+ assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);+ } }@@ -173,6 +173,13 @@ const RESCAN_EVERY: i64 = 5 * 60; /// Coming back to the window re-checks, but not more often than this. const RESCAN_ON_FOCUS_AFTER: i64 = 30; +/// A periodic update check is due `every` seconds after the last one. The saved time is+/// hostile (an edited `app.ron`, a clock that ran fast and was later corrected): a time in+/// the future or a corrupted `i64::MIN` never postpones the check (#774).+fn update_check_due(last: Option<i64>, now: i64, every: i64) -> bool {+ last.is_none_or(|t| t > now || now.saturating_sub(t) >= every)+}+ impl LauncherUi { pub fn new(ctx: &egui::Context, backend: Box<dyn Backend>, settings: Settings) -> Self { let lang = effective_lang(&settings.language);@@ -325,15 +332,15 @@ impl LauncherUi { self.last_detect = now; } let focused = ctx.input(|i| i.viewport().focused).unwrap_or(true);- let came_back = focused && !self.was_focused && now - self.last_detect >= RESCAN_ON_FOCUS_AFTER;+ let came_back = focused && !self.was_focused && now.saturating_sub(self.last_detect) >= RESCAN_ON_FOCUS_AFTER; self.was_focused = focused;- if came_back || now - self.last_detect >= RESCAN_EVERY {+ if came_back || now.saturating_sub(self.last_detect) >= RESCAN_EVERY { self.last_detect = now; self.backend.send(Command::Detect); } if let Some(every) = self.settings.check_every.seconds() && !self.checking- && self.settings.last_check.is_none_or(|t| now - t >= every)+ && update_check_due(self.settings.last_check, now, every) { self.backend.send(Command::CheckForUpdates { force: false }); }@@ -813,3 +820,27 @@ impl LauncherUi { }); } }++#[cfg(test)]+mod tests {+ use super::update_check_due;++ const NOW: i64 = 1_791_500_000;+ const EVERY: i64 = 24 * 60 * 60;++ #[test]+ fn a_check_is_due_after_its_interval() {+ assert!(update_check_due(None, NOW, EVERY));+ assert!(!update_check_due(Some(NOW - EVERY + 1), NOW, EVERY));+ assert!(update_check_due(Some(NOW - EVERY), NOW, EVERY));+ }++ #[test]+ fn a_saved_time_in_the_future_or_at_i64_min_never_postpones_a_check() {+ // A clock that ran fast and was later corrected, or an edited `app.ron` (#774): the+ // launcher would sit in the tray for weeks; `i64::MIN` panicked the subtraction.+ assert!(update_check_due(Some(NOW + 60), NOW, EVERY));+ assert!(update_check_due(Some(i64::MAX), NOW, EVERY));+ assert!(update_check_due(Some(i64::MIN), NOW, EVERY));+ }+}