Skip to content

RecalcContext::now_serial() truncates to whole seconds, discarding timestamp_ms's sub-second precision #998

Description

@hhimanshu

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions