Use chrono instead of hand-rolled UTC conversion (#844)

* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the civil_from_days copy behind the desktop app's log timestamp with
chrono as a workspace dependency. The output format is unchanged and pinned by
the existing test. AGENTS.md now says time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic logging on a clock outside chrono's range

chrono's From<SystemTime> unwraps internally, so a clock past year 262143
panicked inside the logger. timestamp now reads the duration since the epoch
and goes through the fallible DateTime::from_timestamp, reading an
out-of-range clock as the epoch, exactly as the pre-epoch clamp already did.
The year is written with {:04} rather than %Y, which would prefix a '+' past
year 9999. Tests cover the far-future and year-10000 cases.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* time: preserve old out-of-range log timestamp (no epoch fallback)

`DateTime::from_timestamp(...).unwrap_or_default()` silently reset any clock
past chrono's year range to 1970-01-01, changing output the old hand-rolled
`civil_from_days` converted exactly. Restore the old conversion domain with a
cycle-based helper that routes the calendar through chrono (the calendar
repeats every 400 years = 146_097 days, so month/day/time come from chrono and
only the year is carried in i128, keeping every i64 exact). Keep the pre-1970
epoch clamp; u64 seconds past i64::MAX saturate rather than falling back.

Pinned old outputs in the test (replacing the u64::MAX/2 -> epoch assertion):
  u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z
  10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
This commit is contained in:
Dylan M. Taylor
2026-10-23 11:35:06 -04:00
committed by GitHub
parent f431791c26
commit d47abe484d
5 changed files with 54 additions and 18 deletions
+1
View File
@@ -32,6 +32,7 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra
- The audio callback never blocks, allocates in steady state, or panics. - The audio callback never blocks, allocates in steady state, or panics.
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. - Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
- `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test. - `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
## Architecture (layers enforced by `cargo xtask layers`) ## Architecture (layers enforced by `cargo xtask layers`)
Generated
+10
View File
@@ -688,6 +688,15 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "clang-sys" name = "clang-sys"
version = "1.9.1" version = "1.9.1"
@@ -3668,6 +3677,7 @@ dependencies = [
name = "soundcraft" name = "soundcraft"
version = "0.12.1" version = "0.12.1"
dependencies = [ dependencies = [
"chrono",
"eframe", "eframe",
"egui 0.37.0", "egui 0.37.0",
"log", "log",
+2
View File
@@ -32,6 +32,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] } serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] } serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4" log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1" rayon = "1"
proptest = "1" proptest = "1"
+1
View File
@@ -22,6 +22,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
egui.workspace = true egui.workspace = true
serde_json.workspace = true serde_json.workspace = true
log.workspace = true log.workspace = true
chrono.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd.workspace = true rfd.workspace = true
+40 -18
View File
@@ -112,28 +112,36 @@ fn matches(name: &str, target: &str) -> bool {
} }
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. /// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String { pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs(); // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
let (days, rem) = (secs / 86_400, secs % 86_400); // past `i64::MAX` seconds saturates rather than falling back to the epoch.
let (y, m, d) = civil_from_days(days); let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) let (y, t) = utc_parts(secs, since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
} }
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's /// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd /// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
/// clock can't overflow). /// so month, day and time of day come from chrono and only the year is carried, which keeps every
fn civil_from_days(days: u64) -> (u64, u64, u64) { /// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
let z = days.saturating_add(719_468); fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
let era = z / 146_097; use chrono::Datelike;
let doe = z % 146_097; let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let mp = (5 * doy + 2) / 153; let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let d = doy - (153 * mp + 2) / 5 + 1; let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
let m = if mp < 10 { mp + 3 } else { mp - 9 }; match (date, time) {
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); (Some(d), Some(t)) => {
(y, m, d) let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
} }
fn numbered(dir: &Path, n: usize) -> PathBuf { fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -533,6 +541,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking. // Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
// SystemTime can't hold this instant, so it is only checked where it exists.
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
} }
#[test] #[test]