From 538ea267ed02a4efaf0be53fb2e83d8caa70c096 Mon Sep 17 00:00:00 2001 From: Franz Hemmer Date: Sat, 7 Nov 2026 05:21:56 -0500 Subject: [PATCH] fix: cap the refresh interval read from settings.json at a year (#322) A hand-edited refreshIntervalSeconds too long for chrono panicked the dashboard's tick (TimeDelta::seconds out of bounds) and its next-refresh countdown on every start. Read it capped at a year, as the provider backoffs cap Retry-After; the file keeps its value. Co-Authored-By: Claude Opus 5.5 --- crates/codexbar-app/src/ui_tests.rs | 34 +++++++++++++++++++++++++++ crates/codexbar-store/src/settings.rs | 34 +++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/codexbar-app/src/ui_tests.rs b/crates/codexbar-app/src/ui_tests.rs index f1995b7..5e457a3 100644 --- a/crates/codexbar-app/src/ui_tests.rs +++ b/crates/codexbar-app/src/ui_tests.rs @@ -2692,6 +2692,40 @@ fn minute_ticks_refresh_compact_history_and_smart_order_without_a_fetch(cx: &mut ); } +#[gpui_kit::test] +fn a_refresh_interval_too_long_for_a_date_waits_instead_of_panicking(cx: &mut TestAppContext) { + // settings.json can be edited by hand: an interval no date can hold still shows and ticks, and doesn't fetch again. + let settings = TempSettings::new( + "huge-refresh-interval", + r#"{ "refreshIntervalSeconds": 9000000000000000000 }"#, + ); + let base = chrono::Utc::now(); + let account = AccountSnapshot::new( + AccountId::new("claude-huge-interval"), + Provider::Claude, + vec![Metric::Window { + label: "Weekly".into(), + used: 0.4, + resets_at: base + chrono::Duration::days(3), + pace: None, + length: None, + }], + base, + ); + let dashboard = open_live_fixed(cx, &settings, vec![Arc::new(FixedProvider(vec![account]))]); + cx.run_until_parked(); + let fetched = cx.update(|cx| dashboard.read(cx).last_refresh_for_test()); + assert!(fetched.is_some(), "the first refresh runs"); + cx.executor().advance_clock(Duration::from_secs(2)); + cx.run_until_parked(); + assert_eq!(shown_ids(cx, &dashboard), ["claude-huge-interval"]); + assert_eq!( + cx.update(|cx| dashboard.read(cx).last_refresh_for_test()), + fetched, + "no second fetch inside the interval" + ); +} + #[gpui_kit::test] fn smart_order_puts_urgent_accounts_first_and_manual_comes_back(cx: &mut TestAppContext) { let settings = TempSettings::new("smart-order", "{}"); diff --git a/crates/codexbar-store/src/settings.rs b/crates/codexbar-store/src/settings.rs index 82771d2..239d55b 100644 --- a/crates/codexbar-store/src/settings.rs +++ b/crates/codexbar-store/src/settings.rs @@ -29,6 +29,8 @@ const LOCK_FILE: &str = "settings.write.lock"; /// Zoom range and step shared with the WPF app (`ZoomHelper`). pub const MIN_ZOOM: f64 = 0.5; pub const MAX_ZOOM: f64 = 3.0; +/// The longest automatic refresh interval: a year, as the longest Retry-After a provider backoff honors. +pub const MAX_REFRESH_INTERVAL_SECS: u64 = 365 * 24 * 3600; pub const ZOOM_STEP: f64 = 0.1; /// Provider names as the WPF app stores them (`ProviderId` enum names). @@ -463,11 +465,13 @@ impl Settings { .unwrap_or_default() } - /// Refresh interval in seconds; `None` means automatic refresh is off. + /// Refresh interval in seconds; `None` means automatic refresh is off. A longer interval than + /// [`MAX_REFRESH_INTERVAL_SECS`] (only a hand edit writes one) reads as that year, so the next refresh is + /// still a date the dashboard can count down to; the file keeps what it holds. pub fn refresh_interval_secs(&self) -> Option { match self.doc.get("refreshIntervalSeconds").and_then(Value::as_i64) { Some(secs) if secs <= 0 => None, - Some(secs) => Some(secs as u64), + Some(secs) => Some(secs.unsigned_abs().min(MAX_REFRESH_INTERVAL_SECS)), None => Some(120), } } @@ -1438,6 +1442,32 @@ mod tests { assert_eq!(settings.refresh_interval_secs(), None); } + #[test] + fn refresh_interval_past_a_year_reads_as_a_year_and_stays_in_the_file() { + let year = MAX_REFRESH_INTERVAL_SECS; + for (stored, expected) in [ + (3600, 3600), + (year as i64, year), + (year as i64 + 1, year), + (i64::MAX, year), + ] { + let settings = Settings::from_json(&format!(r#"{{"refreshIntervalSeconds": {stored}}}"#)); + assert_eq!(settings.refresh_interval_secs(), Some(expected), "{stored}"); + } + // Whatever it reads as, the next refresh is a date the dashboard can count down to. + let interval = chrono::Duration::try_seconds(i64::try_from(year).unwrap()).unwrap(); + assert!(chrono::Utc::now().checked_add_signed(interval).is_some()); + // Reading never rewrites the hand-edited value; another setting's save keeps it. + let dir = TempDir::with(PRIMARY_FILE, r#"{"refreshIntervalSeconds": 9000000000000000000}"#); + let mut settings = Settings::load(&dir.0).unwrap(); + settings.set_zoom_level(1.5); + settings.save().unwrap(); + assert_eq!( + dir.read(PRIMARY_FILE)["refreshIntervalSeconds"], + json!(9_000_000_000_000_000_000i64) + ); + } + #[test] fn load_missing_file_applies_wpf_defaults() { let dir = TempDir::with(PRIMARY_FILE, "");