Found while investigating truecalc/pro#160 ("NOW()/TODAY() silently freeze after any later unrelated edit") — the actual root cause lives here, in the vendored engine, not in pro's own call chain.
The bug: crates/workbook/src/recalc.rs, RecalcContext::now_serial():
pub fn now_serial(&self) -> Option<f64> {
let utc = Utc.timestamp_millis_opt(self.timestamp_ms).single()?;
let local = utc.with_timezone(&self.timezone).naive_local();
let epoch = NaiveDate::from_ymd_opt(1899, 12, 30)?;
let days = local.date().signed_duration_since(epoch).num_days() as f64;
let secs = local.time().num_seconds_from_midnight() as f64; // whole seconds only
Some(days + secs / 86_400.0)
}
NaiveTime::num_seconds_from_midnight() returns a u32 count of whole seconds, discarding the sub-second portion of timestamp_ms (which itself carries millisecond resolution, per current_time_ms()). Two RecalcContexts whose timestamp_ms differ by less than 1000ms but fall in the same wall-clock second produce a byte-identical now_serial().
Real-world symptom (confirmed via pro's own MCP server, driven over real stdio JSON-RPC, not a unit test): a NOW()/TODAY() cell appears to "freeze" across several rapid, unrelated edits made within the same second, then correctly jumps forward once a second boundary is crossed. Confirmed directly:
- Edits ~50ms apart (same second): the cell's value stayed byte-identical across 3 consecutive reads.
- Edits ~1.1s apart (crossing a second boundary): the cell's value changed on every single edit, each step exactly
1/86400 (one second of serial-day) apart.
Confirmed NOT a caching/dedup issue anywhere in the call chain above this function — is_volatile's own doc treats NOW/TODAY/RAND/RANDBETWEEN/RANDARRAY identically, and every volatile cell is unconditionally reseeded into the dirty frontier on every incremental recalc regardless of what was actually edited. RAND() "looks" fresher only because it bypasses RecalcContext entirely and reads the system clock/RNG directly on every evaluation (per this same file's own module doc) — it proves nothing about now_serial()'s own precision.
Suggested fix: preserve the sub-second component already present in timestamp_ms, e.g.:
let secs = local.time().num_seconds_from_midnight() as f64
+ local.time().nanosecond() as f64 / 1e9;
Note Timelike::nanosecond() can return values ≥ 1_000_000_000 during a leap second — worth a defensive comment or clamp if that matters for this crate's own correctness bar.
Severity: minor/moderate — this is a real precision gap (a spreadsheet's NOW() should reflect the timestamp's actual resolution), not a permanent-freeze bug. It self-corrects every second. Filed as its own issue since the original report (pro#160) overstated the symptom based on a repro that never crossed a full second boundary.
Found while investigating
truecalc/pro#160("NOW()/TODAY() silently freeze after any later unrelated edit") — the actual root cause lives here, in the vendored engine, not in pro's own call chain.The bug:
crates/workbook/src/recalc.rs,RecalcContext::now_serial():NaiveTime::num_seconds_from_midnight()returns au32count of whole seconds, discarding the sub-second portion oftimestamp_ms(which itself carries millisecond resolution, percurrent_time_ms()). TwoRecalcContexts whosetimestamp_msdiffer by less than 1000ms but fall in the same wall-clock second produce a byte-identicalnow_serial().Real-world symptom (confirmed via pro's own MCP server, driven over real stdio JSON-RPC, not a unit test): a
NOW()/TODAY()cell appears to "freeze" across several rapid, unrelated edits made within the same second, then correctly jumps forward once a second boundary is crossed. Confirmed directly:1/86400(one second of serial-day) apart.Confirmed NOT a caching/dedup issue anywhere in the call chain above this function —
is_volatile's own doc treatsNOW/TODAY/RAND/RANDBETWEEN/RANDARRAYidentically, and every volatile cell is unconditionally reseeded into the dirty frontier on every incremental recalc regardless of what was actually edited.RAND()"looks" fresher only because it bypassesRecalcContextentirely and reads the system clock/RNG directly on every evaluation (per this same file's own module doc) — it proves nothing aboutnow_serial()'s own precision.Suggested fix: preserve the sub-second component already present in
timestamp_ms, e.g.:Note
Timelike::nanosecond()can return values ≥ 1_000_000_000 during a leap second — worth a defensive comment or clamp if that matters for this crate's own correctness bar.Severity: minor/moderate — this is a real precision gap (a spreadsheet's
NOW()should reflect the timestamp's actual resolution), not a permanent-freeze bug. It self-corrects every second. Filed as its own issue since the original report (pro#160) overstated the symptom based on a repro that never crossed a full second boundary.