fix(native): read mtime via BigInt nanoseconds to match Rust truncation#1079
fix(native): read mtime via BigInt nanoseconds to match Rust truncation#1079carlos-alm merged 3 commits intomainfrom
Conversation
Closes #1075 Rust writes file_hashes.mtime via Duration::as_millis() (truncates), JS read via Math.floor(stat.mtimeMs) (rounds because mtimeMs is f64). At large epoch values the f64 representation rounds up — JS reads N+1 for what Rust stored as N — busting the fast-skip path on every native→JS handoff and producing spurious re-parses on no-op and 1-file rebuilds. Switch fileStat to fs.statSync(path, { bigint: true }) and compute mtime as Number(s.mtimeNs / 1_000_000n). Integer math truncates identically to Duration::as_millis() so writer and reader land on the same value. Updates all consumers (detect-changes, insert-nodes, pipeline backfill) and the FileStat interface to drop the now-redundant Math.floor wrapper. Test seeds use the same BigInt path so they don't regress on f64 round-up. docs check acknowledged: internal change-detection fix; no language, feature, command, architecture, or roadmap surface affected.
|
Claude finished @carlos-alm's task —— View job PR Review AnalysisConducting comprehensive review of PR #1079: fix(native): read mtime via BigInt nanoseconds to match Rust truncation Todo List:
Phase 1: Root Cause Analysis ✅Problem Being Solved: Legitimate precision drift bug where:
Why This Approach: Switching to BigInt nanosecond math ( Phase 2: Configuration Change Detection ✅No configuration or rule relaxation detected. This is a pure algorithmic fix with no changes to linting rules, test thresholds, or build configuration. Phase 3: Test Integrity ✅Test changes are justified and improve accuracy:
Phase 4: Code Quality ✅Excellent implementation quality: ✅ Consistent Change Pattern: All stat call sites updated identically
✅ Proper Documentation: ✅ Error Handling: All ✅ Type Safety: All consumers properly handle the integer mtime values Root Cause AnalysisProblem Being Solved: Critical performance regression caused by Rust/JS mtime precision mismatch that was blocking the v3.10.0 publish gate with 62-144% slower rebuilds. Backlog Compliance
Critical ConcernsNone. This is a well-implemented precision fix that addresses a real performance regression. Final Recommendation
|
Greptile SummaryFixes the Rust↔JS mtime precision mismatch that caused the fast-skip path to miss on every native→JS handoff:
Confidence Score: 5/5Safe to merge — the fix is narrow, well-reasoned, and correctly propagated across all callers. The BigInt truncation path is mathematically equivalent to Rust's No files require special attention. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["fs.statSync(path, { bigint: true })"] --> B["s.mtimeNs: BigInt (nanoseconds)"]
B --> C["s.mtimeNs / 1_000_000n\n(integer BigInt division — truncates)"]
C --> D["Number(result) → integer ms\nMatches Rust Duration::as_millis()"]
subgraph OLD ["Old path (broken)"]
E["fs.statSync(path)"] --> F["s.mtimeMs: f64 (float ms)"]
F --> G["Math.floor(s.mtimeMs)\nf64 ULP ~256 ns → rounds up at boundary"]
G --> H["N+1 instead of N\n→ fast-skip cache miss on every native→JS handoff"]
end
D --> I["fileStat() returns { mtime, size }"]
I --> J["detect-changes: mtime comparison\n stat.mtime === storedMtime"]
I --> K["insert-nodes: buildFileHashes / updateFileHashes"]
I --> L["pipeline: backfillNativeDroppedFiles"]
Reviews (3): Last reviewed commit: "test(builder): make #1075 mtime regressi..." | Re-trigger Greptile |
| it('fileStat mtime matches Rust Duration::as_millis() truncation (#1075)', () => { | ||
| const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-fileStat-trunc-')); | ||
| const file = seedFile(dir, 'a.js', 'export const a = 1;'); | ||
|
|
||
| const big = fs.statSync(file, { bigint: true }); | ||
| const expected = Number(big.mtimeNs / 1_000_000n); | ||
| expect(fileStat(file)?.mtime).toBe(expected); | ||
|
|
||
| fs.rmSync(dir, { recursive: true, force: true }); | ||
| }); |
There was a problem hiding this comment.
Regression test only catches the bug probabilistically
The test comment says "Reverting to Math.floor(stat.mtimeMs) re-introduces the bug visibly," but that's only true when the freshly-created file's mtimeNs happens to land near a millisecond boundary where f64 rounding flips. At the current epoch (~1.748 × 10¹⁸ ns), the f64 ULP is ~256 ns, so only values with a sub-ms remainder in the ~[999744 ns, 1000000 ns) window trigger the discrepancy — roughly a 0.026% chance per run. In the common case the test passes even with Math.floor.
A deterministic version would use fs.utimesSync to stamp a known-bad mtime (one where Math.floor(Number(badNs) / 1e6) !== Number(badNs / 1_000_000n)) and assert the two values diverge, then confirm fileStat returns the BigInt-truncated value. As written the test is still useful as a sanity check, but the regression-guard claim in the comment overstates it.
There was a problem hiding this comment.
Fixed in 1a44aa4 — replaced the probabilistic test with a deterministic vi.spyOn(fs, 'statSync') stub that injects a hand-picked mtimeNs (1748400000000999808n) where the BigInt path lands on N and the f64 path lands on N+1. Verified locally by reverting fileStat() to Math.floor(stat.mtimeMs) — the test now fails deterministically (1748400000000 vs 1748400000001) instead of mostly passing. Kept the original fresh-file-on-disk variant alongside as a sanity check that the helper still works against real filesystem stats.
Codegraph Impact Analysis8 functions changed → 17 callers affected across 7 files
|
Greptile noted that the previous regression test was probabilistic: the f64 ULP rounding bug only triggers on ~0.026% of fresh-file mtimes (the ~256 ns window where Number(mtimeNs)/1e6 rounds across a ms boundary), so a future revert to Math.floor(stat.mtimeMs) would usually pass. Replace with a vi.spyOn stub that injects a hand-picked BigInt mtimeNs known to diverge between the BigInt path (N) and the f64 path (N+1). The test now fails deterministically against the broken implementation — verified locally by reverting fileStat() and watching the assertion flip 1748400000000 → 1748400000001. The fresh-file disk variant is kept alongside as a sanity check that the helper still works against real filesystem stats.
Closes #1075
Summary
file_hashes.mtimeviaDuration::as_millis()(truncates) while JS read viaMath.floor(stat.mtimeMs)(rounds becausemtimeMsis f64). At large epoch values JS reads N+1 for what Rust stored as N — busting the fast-skip path on every native→JS handoff and producing spurious re-parses on no-op and 1-file rebuilds.fileStat()tofs.statSync(path, { bigint: true })and computemtime = Number(s.mtimeNs / 1_000_000n). Integer math truncates identically toDuration::as_millis() as i64so writer and reader land on the same value.detect-changes,insert-nodes,pipelinebackfill) and theFileStatinterface to drop the now-redundantMath.floorwrapper.fileStat().mtime === Number(stat.mtimeNs / 1_000_000n)so a future revert toMath.floor(stat.mtimeMs)re-introduces the bug visibly.Why this unblocks the publish gate
Pre-publish benchmark gatefailed v3.10.0 with:#1075 explicitly enumerated this as one of two residuals after #1074 unmasked the precision drift; the other residual was the query benchmark regression closed by #1076. The same root cause explains the 1-file rebuild blow-up: fast-skip is also walked over the unchanged neighbors of the edited file, and every one falls out due to the 1ms drift and gets re-parsed.
Notes for reviewers
file_hashes/fast-skip path are intentionally untouched:features/owners.ts,domain/graph/watcher.ts,domain/graph/journal.tsall compare JS-written values to JS-read values, so they have no Rust round-trip and no drift.(mtimeMs=...)since the float value is no longer consulted by the comparison.Test plan
npm run typechecknpm run lintnpx vitest run tests/builder/— 27 passednpx vitest run tests/integration/build.test.ts tests/integration/build-parity.test.ts— 23 passed1-file rebuildandNo-op rebuilddeltas back under thresholdDependency
Local Node 24 verification depended on #1078 (vitest
--strip-typesinjection fix). CI runs on Node 22 so this PR's tests run regardless of merge order.