From b1062ad187d3c5af5bc9e911f24149eb1ca9c39f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:50:32 +0000 Subject: [PATCH 1/4] node:fs: convert Stats date fields like Node.js The atime, mtime, ctime and birthtime getters of Stats and BigIntStats now compute new Date(Math.round(Number(ms))), the same as Node.js. A value with a fraction of 0.5 ms or more rounds up instead of being truncated. A BigInt past 64 bits becomes an Invalid Date instead of a wrapped value. Both classes accept a Number or a BigInt *Ms field. Fixes #42562 --- src/jsc/bindings/NodeFSStatBinding.cpp | 8 +- test/js/node/fs/fs-stats-constructor.test.ts | 90 +++++++++++++++++++- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/NodeFSStatBinding.cpp b/src/jsc/bindings/NodeFSStatBinding.cpp index e962fcb7f9d8..80df313fd4d1 100644 --- a/src/jsc/bindings/NodeFSStatBinding.cpp +++ b/src/jsc/bindings/NodeFSStatBinding.cpp @@ -20,6 +20,8 @@ #include #include "ZigGlobalObject.h" #include "JavaScriptCore/DateInstance.h" +#include +#include #include #if !OS(WINDOWS) #include @@ -230,8 +232,12 @@ inline JSC::JSValue getDateField(JSC::JSGlobalObject* globalObject, JSC::Encoded RETURN_IF_EXCEPTION(scope, {}); } - double internalNumber = isBigInt ? value.toBigInt64(globalObject) : value.toNumber(globalObject); + // Node.js: `new Date(MathRound(Number(ms)))`. Either class accepts a Number or a BigInt. + JSValue numeric = value.toNumeric(globalObject); RETURN_IF_EXCEPTION(scope, {}); + if (numeric.isBigInt()) + numeric = JSC::JSBigInt::toNumber(numeric); + double internalNumber = JSC::jsRound(numeric.asNumber()); JSValue result = JSC::DateInstance::create(vm, globalObject->dateStructure(), internalNumber); if (!thisObject->structure()->mayBePrototype()) { diff --git a/test/js/node/fs/fs-stats-constructor.test.ts b/test/js/node/fs/fs-stats-constructor.test.ts index c63884f78f0d..594b754da67f 100644 --- a/test/js/node/fs/fs-stats-constructor.test.ts +++ b/test/js/node/fs/fs-stats-constructor.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; -import { Stats, statSync } from "node:fs"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { Stats, statSync, utimesSync, writeFileSync } from "node:fs"; +import path from "node:path"; // Node.js's Stats constructor signature (deprecated, DEP0180): // Stats(dev, mode, nlink, uid, gid, rdev, blksize, ino, size, blocks, atimeMs, mtimeMs, ctimeMs, birthtimeMs) @@ -56,6 +57,91 @@ test("Stats instances share Stats.prototype", () => { expect(bigint instanceof Object.getPrototypeOf(bigint).constructor).toBe(true); }); +// Node.js computes every date field as `new Date(MathRound(Number(this.Ms)))`, in one +// getter shared by Stats and BigIntStats. The Date constructor truncates, so without the round +// a real stat with a fraction of 0.5 ms or more comes out 1 ms early. +describe("Stats date getters convert *Ms like Node.js", () => { + test("a real stat rounds the fraction instead of truncating it", () => { + using dir = tempDir("stats-date", { "file.txt": "x" }); + const file = path.join(String(dir), "file.txt"); + utimesSync(file, 1700000000.7896, 1700000000.7896); + + const stats = statSync(file); + const bigint = statSync(file, { bigint: true }); + expect({ + mtimeMs: stats.mtimeMs, + mtime: stats.mtime.getTime(), + atime: stats.atime.getTime(), + bigintMtimeMs: bigint.mtimeMs, + bigintMtime: bigint.mtime.getTime(), + }).toEqual({ + mtimeMs: 1700000000789.5999, + mtime: 1700000000790, + atime: 1700000000790, + bigintMtimeMs: 1700000000789n, + bigintMtime: 1700000000789, + }); + }); + + test("a Number *Ms rounds like Math.round", () => { + const inputs = [0.5, 1.4999, -0.5, -1.5, -1.6, 8.64e15 + 0.4, 8.64e15 + 0.6, Infinity, NaN]; + const results = inputs.map(ms => { + // The getter caches the Date on the instance, so each input needs its own Stats. + const stats = statSync(import.meta.path); + stats.atimeMs = ms; + return stats.atime.getTime(); + }); + expect(results).toEqual([1, 1, 0, -1, -2, 8.64e15, NaN, NaN, NaN]); + }); + + test("a BigInt *Ms past 64 bits is an Invalid Date, not a wrapped value", () => { + const bigint = statSync(import.meta.path, { bigint: true }); + bigint.atimeMs = 2n ** 64n + 5n; + bigint.mtimeMs = -(2n ** 63n) - 1n; + bigint.ctimeMs = 8_640_000_000_000_000n; + bigint.birthtimeMs = 8_640_000_000_000_001n; + expect([ + bigint.atime.getTime(), + bigint.mtime.getTime(), + bigint.ctime.getTime(), + bigint.birthtime.getTime(), + ]).toEqual([NaN, NaN, 8.64e15, NaN]); + }); + + test("both classes accept a Number or a BigInt *Ms", () => { + const stats = statSync(import.meta.path); + const bigint = statSync(import.meta.path, { bigint: true }); + // @ts-expect-error Node.js accepts the other numeric type too + stats.ctimeMs = 5n; + // @ts-expect-error Node.js accepts the other numeric type too + bigint.ctimeMs = 5.5; + const statsCtime = Object.getOwnPropertyDescriptor(Stats.prototype, "ctime")!.get!; + const bigintCtime = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(bigint), "ctime")!.get!; + expect({ + numberClassWithBigInt: stats.ctime.getTime(), + bigintClassWithNumber: bigint.ctime.getTime(), + statsGetterWithBigInt: statsCtime.call({ ctimeMs: 7n }).getTime(), + bigintGetterWithNumber: bigintCtime.call({ ctimeMs: 7.5 }).getTime(), + }).toEqual({ + numberClassWithBigInt: 5, + bigintClassWithNumber: 6, + statsGetterWithBigInt: 7, + bigintGetterWithNumber: 8, + }); + }); + + test("a *Ms that is not numeric still throws from the coercion", () => { + const stats = statSync(import.meta.path); + const bigint = statSync(import.meta.path, { bigint: true }); + // @ts-expect-error a Symbol cannot convert to a number + stats.atimeMs = Symbol("x"); + // @ts-expect-error a Symbol cannot convert to a number + bigint.atimeMs = Symbol("x"); + expect(() => stats.atime).toThrow(TypeError); + expect(() => bigint.atime).toThrow(TypeError); + }); +}); + // A call resolved through a binding that a closure captures is compiled with the scope object // itself in the this slot, and native functions see it raw. isFile() and friends used to read // `mode` out of that scope object (the date getters, once pulled out of their property descriptor, From 48519f2dfc54fd1dfd0655264d324c434de2010b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:53:09 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- test/js/node/fs/fs-stats-constructor.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/node/fs/fs-stats-constructor.test.ts b/test/js/node/fs/fs-stats-constructor.test.ts index 594b754da67f..6f150267df18 100644 --- a/test/js/node/fs/fs-stats-constructor.test.ts +++ b/test/js/node/fs/fs-stats-constructor.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; -import { Stats, statSync, utimesSync, writeFileSync } from "node:fs"; +import { Stats, statSync, utimesSync } from "node:fs"; import path from "node:path"; // Node.js's Stats constructor signature (deprecated, DEP0180): From 08fa3e246df845a89bffda3e1ac9784af5f0f2b2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 05:03:30 +0000 Subject: [PATCH 3/4] test: do not assume a nanosecond timestamp resolution for mtimeMs --- test/js/node/fs/fs-stats-constructor.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/js/node/fs/fs-stats-constructor.test.ts b/test/js/node/fs/fs-stats-constructor.test.ts index 6f150267df18..9f244c1ed832 100644 --- a/test/js/node/fs/fs-stats-constructor.test.ts +++ b/test/js/node/fs/fs-stats-constructor.test.ts @@ -68,14 +68,16 @@ describe("Stats date getters convert *Ms like Node.js", () => { const stats = statSync(file); const bigint = statSync(file, { bigint: true }); + // The stored fraction depends on the filesystem's timestamp resolution + // (789.5999 with nanoseconds on Linux, 789.6 with 100 ns on Windows). + expect(stats.mtimeMs).toBeGreaterThanOrEqual(1700000000789.5); + expect(stats.mtimeMs).toBeLessThan(1700000000790); expect({ - mtimeMs: stats.mtimeMs, mtime: stats.mtime.getTime(), atime: stats.atime.getTime(), bigintMtimeMs: bigint.mtimeMs, bigintMtime: bigint.mtime.getTime(), }).toEqual({ - mtimeMs: 1700000000789.5999, mtime: 1700000000790, atime: 1700000000790, bigintMtimeMs: 1700000000789n, From b10313b155bb3940db2a09e7aa79fdcc16669ff3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 05:15:00 +0000 Subject: [PATCH 4/4] ci: retrigger