Skip to content

node:fs: convert Stats times by the value, not by the class - #47233

Open
robobun wants to merge 1 commit into
mainfrom
robobun/2cfe25fb/fs-stats-date-and-bigint-ms
Open

robobun wants to merge 1 commit into
mainfrom
robobun/2cfe25fb/fs-stats-date-and-bigint-ms

Conversation

@robobun

@robobun robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • new BigIntStats(...) stores its *Ms fields as Numbers (2.5, Node.js 2n). Its date getters throw TypeError: Invalid argument type in ToBigInt operation.
  • The BigIntStats date getters cut *Ms to 64 bits: a file dated 18446744073709552 s reads mtime.getTime() === 384, not NaN. The other numeric type throws.
  • Cause: the class, not the value, picks the conversion (src/jsc/bindings/NodeFSStatBinding.cpp:237, :801, :884). No user reported it.

Fix

  • Both constructor paths call divideNsIntoMs: JS / with 1000000n, as in Node.js.
  • One getter per date field serves both prototypes and converts *Ms like Number(ms) (IDLLooseDoubleOrBigInt). No other file's date changes (0 of 17,820).
  • Verified: test/js/node/fs/fs-stats-constructor.test.ts (16 new tests fail on main) and the restored upstream test-fs-watchfile-bigint.js.
  • Self-reviewed: 22 concerns raised, 19 addressed, 3 in part.

Background

Downsides

  • Node.js rounds. This PR does not: a date stays 1 ms below Node's when the *Ms fraction is 0.5 or more. The round is one ready commit (costs in Notes) that moves dates of existing files. A maintainer must decide.
  • Each later date read on a Stats runs 3 to 8 more instructions. new BigIntStats(...) retains 4 BigInt cells and runs about 1,045 more.
Notes

Repro (Linux, line 3 needs a tmpfs):

const fs = require("node:fs");
const B = fs.statSync(".", { bigint: true }).constructor;
const s = new B(1n, 0o100644n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 2500000n, 0n, 0n, 0n);
console.log(typeof s.atimeMs, String(s.atimeMs));
try { console.log(s.atime.toISOString()); } catch (e) { console.log("threw:", e.message); }
// touch -d @18446744073709552 /dev/shm/far
console.log(fs.statSync("/dev/shm/far", { bigint: true }).mtime.getTime());
const n = fs.statSync("."); n.ctimeMs = 5n;
try { console.log(n.ctime.getTime()); } catch (e) { console.log("threw:", e.message); }
main Node.js v26.3.0 this PR
typeof atimeMs, value number 2.5 bigint 2 bigint 2
s.atime threw: Invalid argument type in ToBigInt operation 1970-01-01T00:00:00.002Z 1970-01-01T00:00:00.002Z
far-future file, { bigint: true } 384 NaN NaN
BigInt ctimeMs on Stats threw: Conversion from 'BigInt' to 'number' is not allowed. 5 5

The 62 values that the new tests assert differ from Node.js v26.3.0 in 37 values on main and in 0 with this PR. None of them is a fraction. A fractional *Ms still differs from Node.js (the round, below).

Who can reach the changed behaviour: a program that constructs BigIntStats by hand, a file dated past 2^63 ms on a filesystem with 64-bit times, or a program that stores the other numeric type in *Ms. Nothing in this repository calls the constructor.

Measurements (Linux x64, release builds of main 8d5672c and of this change on it, before the rebase over the WebKit upgrade)

  • User-space instructions per iteration, JIT off, N = 200,000, medians of interleaved runs. Runs of one binary differ by about 10.

    per iteration main this PR
    statSync(f), no date read 2,080.6 2,082.7
    statSync(f, { bigint: true }), no date read 5,150.9 5,159.1
    statSync(f).mtime 3,236.5 3,238.3
    statSync(f), then mtime and ctime 4,290.9 4,298.8
    statSync(f), then all four dates 6,387.9 6,398.1
    statSync(f, { bigint: true }).mtime 6,341.1 6,299.4
    statSync(f, { bigint: true }), then all four dates 9,638.3 9,518.8
    new BigIntStats(...) 6,728.6 7,773.7
  • The later date reads cost more on Stats because the shared getter compares two structures before it reads *Ms by name.

  • new BigIntStats(...) wall time, JIT on, 2,000,000 calls, 5 interleaved runs: 504 to 664 ns per call on main, 651 to 960 ns with this PR.

  • Release text: 138,010,898 to 138,007,314 bytes (3,584 fewer). Date getters: 9,704 bytes in 8 functions to 4,768 bytes in 4. The two BigIntStats constructor paths: 7,510 to 8,358 bytes with divideNsIntoMs.

  • Bun__createJSStatsObject, Bun__createJSBigIntStatsObject, the Stats constructor paths, the date setter and the 14 mode methods: 0 instructions differ (19 functions, addresses normalized).

  • new BigIntStats(...): 4 BigInt cells retained per call, plus the divisor. Main: 0.

  • A Number stat and a date read create 0 BigIntStats class objects, as on main.

  • Real files, main against this PR: 0 of 17,820 differ in any date of either class (/usr/include, /usr/lib/x86_64-linux-gnu, src/, test/js/node, node_modules, up to 5,000 files each).

  • Exception-check mode (BUN_JSC_validateExceptionChecks=1): 0 unchecked exceptions.

The round, left out

Node.js: new Date(MathRound(Number(ms))) (dateFromMs). Bun truncates. The change is one line plus tests, commit eec912d on branch robobun/2cfe25fb/fs-stats-date-round:

-    JSValue result = JSC::DateInstance::create(vm, globalObject->dateStructure(), internalNumber);
+    JSValue result = JSC::DateInstance::create(vm, globalObject->dateStructure(), JSC::jsRound(internalNumber));

A date moves by +1 ms when the *Ms fraction is 0.5 or more. How many files that is depends on how they were written. On this host (Linux 6.1, ext4):

tree files whose mtime date would move
/usr/include, /usr/lib/x86_64-linux-gnu (whole-second times) 0 of 7,204
node_modules 89 of 616
src/ of this checkout 3,732 of 5,000
test/js/node of this checkout 5,000 of 5,000

Linux before 6.13 stamps new files from a coarse clock whose sub-millisecond phase is constant for hours, so one write burst moves together.

Measured with a build of that commit, on 2,400 files generated with a uniformly random sub-millisecond mtime:

main and this PR with the round Node.js v26.3.0
mtime.getTime() differs from Math.trunc(mtimeMs) 0 1,200 1,200
Bun.file(f).lastModified === fs.statSync(f).mtime.getTime() 2,400 1,200 no such API
statSync(f).mtime differs from statSync(f, { bigint: true }).mtime 0 1,200 1,200
copy by utimesSync(dest, s.atime, s.mtime) is newer than its source 0 1,200 1,200
the same copy is older than its source 2,400 1,200 1,200
copy by fs.cpSync(src, dest, { preserveTimestamps: true }) is newer than its source 0 1,200 1,200
etag 1.8.1 tag (express.static, send) differs from the tag Node.js issued 1,200 0 0
the same tag differs from the tag Bun issued before 0 1,200 1,200
utimesSync(f, ms / 1000) then mtime.getTime() !== ms, 1,000 values in 2004-10 248 0 0
the same, 1,000 values in 2023-11 0 0 0
the same, 1,000 values in 2038-02 248 0 0

Not changed, still differs from Node.js

  • Bun.file().lastModified stays truncated. It is the File API field, and Chromium (TimeDelta::InMilliseconds()) and WebKit (millisecondsAs<int64_t>()) truncate it for a file on disk. It still reads 384 for the far-future file above. Bun.file: keep the sign of lastModified for files dated before 1970 #41748 rewrites that conversion.
  • The seven is*() mode methods (NodeFSStatBinding.cpp:142) keep one function per class and read mode by the class. Node.js also picks there by the class of the receiver.
  • Stats(...) and BigIntStats(...) without new return an object. Node.js throws.
  • A stat object that is frozen or not extensible: fs.Stats: make the date accessors define the property like Object.defineProperty #42518. Its re-check reads getStructure<isBigInt> in getDateField and has to move to hasStatsStructure after this PR.
  • The *Instant accessors and the 18-argument Stats of Node.js v26.2 (docs/runtime/nodejs-compat.mdx:44).
  • The own-key order of the ten base fields (Bun: dev, ino, mode, ..., Node.js: dev, mode, nlink, ...).

Windows and macOS: not run locally. CI build 134618 passes on the Windows and macOS lanes. The changed code has no platform branch.

Suites run with the debug build, after the rebase: fs-stats-constructor.test.ts (also in exception-check mode), and from test/js/node/test/parallel/: test-fs-watchfile-bigint.js, test-fs-watchfile.js, test-fs-stat.js, test-fs-stat-bigint.js, test-fs-stat-date.mjs, test-fs-utimes.js. Before the rebase, with the debug build: fs-stats-truncate.test.ts, fs-birthtime-linux.test.ts, cp.test.ts, vm.test.ts -t newTarget, test-fs-stat-abort-test.js (under the test runner), test-fs-statfs.js, test-fs-utimes-y2K38.js, test-fs-watchfile-ref-unref.js, test-fs-promises.js, test-fs-promises-file-handle-stat.js, test-worker-fs-stat-watcher.js, test-fs-cp-async-preserve-timestamps.mjs. With the release build: fs.test.ts (629 pass), bun-file.test.ts, and the 101 ahead-of-time programs whose path contains stats. fs.test.ts on the debug build: 4 recursive readdir tests exceed their 10 s limit in this container. They pass on the release build. tsc on test/: no errors.

Same file, other open PRs: #42518, #42558, #46832, #46388. None changes a conversion.

Self-review, the 3 concerns addressed in part: the package effects of the round list only the etag rows that were measured again here, the instruction counts are JIT-off only, and a checked-in differential test against Node.js is left for a follow-up.

The BigIntStats constructor and the date getters of Stats and BigIntStats
picked a conversion from the class of the object:

- `new BigIntStats(...)` divided `BigInt.asIntN(64, ns)` as a double and
  stored the four `*Ms` fields as Numbers, which its own date getters
  reject.
- The date getters used ToNumber for Stats and a ToBigInt cut to 64 bits
  for BigIntStats. A BigInt `*Ms` at or past 2n ** 63n wrapped to a valid
  date, and a value of the other numeric type threw.

Node.js divides with the JS `/` operator in the constructor and converts
with `Number(ms)` in one getter that both classes share. Do the same:

- `divideNsIntoMs` applies `jsDiv` with `1000000n` for both constructor
  paths.
- One getter per date field serves both prototypes. It converts `*Ms`
  with the existing `IDLLooseDoubleOrBigInt` converter, behind an inline
  path for a Number and for a BigInt that a double holds exactly.

The getters still do not apply Node's `Math.round`, so the date of a real
file does not change.

test-fs-watchfile-bigint.js gets its upstream assertion back: it compares
the stats of a missing file with `new BigIntStats(0n, ...)`.

This replaces #42563 and #42564.
@robobun

robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:41 PM PT - Oct 28th, 2026

❌ @robobun, your commit 2045e05 has 5 failures in Build #134618 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47233

That installs a local version of the PR into your bun-47233 executable, so you can run:

bun-47233 --bun

@robobun

robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: the change and the tests are pushed. This PR replaces #42563 and #42564.

Reproduction: the script in the Notes of the PR body, run with a release build of main on Linux x64.

  • new BigIntStats(...) stores atimeMs as the Number 2.5, and .atime throws TypeError: Invalid argument type in ToBigInt operation. Node.js v26.3.0 stores 2n and returns the date.
  • A tmpfs file dated 18446744073709552 s reads mtime.getTime() === 384 with { bigint: true }. Node.js gives NaN.
  • A BigInt ctimeMs on a Stats object throws. Node.js returns the date.

Proof for the tests (test/js/node/fs/fs-stats-constructor.test.ts): a release build of main fails the 16 new tests and passes the 7 existing ones. The debug build of this branch passes all 23, also with BUN_JSC_validateExceptionChecks=1. The upstream assertion in test/js/node/test/parallel/test-fs-watchfile-bigint.js fails on main and passes on this branch.

CI (build 134618) passed: 190 of 195 jobs pass, the Windows and macOS lanes included. The other 5 are test-aot-drawn jobs, which do not gate the build. They run test/aot/perf/generated-code-of-idioms-* and generated-code-of-operations-*, which compare counts of generated code with pinned values. Tests of the same lanes fail on main's builds 134576 and 134732, and this PR does not touch them.

Open question for a maintainer: should the date getters also apply Node's Math.round? It is not in this PR, because it moves mtime.getTime() by 1 ms for each file whose mtimeMs fraction is 0.5 or more. The commit is ready on branch robobun/2cfe25fb/fs-stats-date-round, and the Notes list its measured effects.

@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 6c3d1bf and 2045e05.

Files selected for processing: 3
  • src/jsc/bindings/NodeFSStatBinding.cpp
  • test/js/node/fs/fs-stats-constructor.test.ts
  • test/js/node/test/parallel/test-fs-watchfile-bigint.js

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Walkthrough

BigIntStats timestamp construction now uses BigInt division to convert nanoseconds to milliseconds. Stats and BigIntStats share date getters. Tests cover construction, date conversion, and watchfile callback stats.

Changes

Filesystem Stats

Layer / File(s) Summary
BigInt timestamp construction
src/jsc/bindings/NodeFSStatBinding.cpp, test/js/node/fs/fs-stats-constructor.test.ts, test/js/node/test/parallel/test-fs-watchfile-bigint.js
BigIntStats construction divides nanosecond timestamps by one million using BigInt division. Tests cover constructor forms, conversion behavior, and watchfile callback stats.
Shared Stats date getters
src/jsc/bindings/NodeFSStatBinding.cpp, test/js/node/fs/fs-stats-constructor.test.ts
Date getters now recognize both Stats structures and use shared conversion logic. Tests cover conversion behavior and shared prototype accessors.

Suggested reviewers: jarred-sumner, cirospaciari

Priority: Normal

Merge Risk: Minimal · up to 2045e

No blocking issue is established. Merge after normal build and test checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title "node:fs: convert Stats times by the value, not by the class" matches the main change. The objectives say both constructor paths now use value-based BigInt division, and that date getters ar…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description provides a detailed explanation of the problem, fix, scope, trade-offs, and verification results. It does not use the template headings exactly, but it includes the required informatio…
Full details: Title check

Explanation

The title "node:fs: convert Stats times by the value, not by the class" matches the main change. The objectives say both constructor paths now use value-based BigInt division, and that date getters are shared and convert *Ms by value (IDLLooseDoubleOrBigInt) instead of selecting the conversion by class. The title is specific, concise, and follows the node:fs prefix convention. It is 59 characters, which is within a normal length.

Full details: Description check

Explanation

The description provides a detailed explanation of the problem, fix, scope, trade-offs, and verification results. It does not use the template headings exactly, but it includes the required information for what the PR does and how the changes were verified.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked the shared getter's getDirect fast path: both class structures are built with atimeMs..birthtimeMs at offsets 10-13 (src/jsc/bindings/NodeFSStatBinding.cpp:562-569 and :591-598), so hasStatsStructure accepting either structure is sound. The isFile()/mode methods still convert by class rather than by value — pre-existing and outside this PR's scope. getStructure<> is still used by the native and constructor paths, so no helper went dead.

Extended reasoning...

The change reworks the native node:fs Stats/BigIntStats binding so that BigIntStats constructor *Ms fields are BigInt-divided via jsDiv and the four date getters are shared across both prototypes, converting by value type; it touches no security-sensitive surface. The one posted finding is a pre-existing truncate-vs-round divergence from Node that the author already documents as a maintainer decision, and the behavioral change to a node compat surface in C++ warrants a human look, so this is a ruled-out note rather than an approval.

Comment thread src/jsc/bindings/NodeFSStatBinding.cpp Resolved

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants