#43053·bun

fs.watchFile: previous.atime is older than in node after a poll that found no change

Author: robobunCreated Sep 17, 2026Updated Sep 17, 2026

What happens

The previous stat that fs.watchFile passes to the listener can have an older atime than the one node passes.

Node's previous is the stat from the last successful poll. libuv saves it after every successful stat(), also when nothing changed and no callback fired (ctx->statbuf = *statbuf): https://github.com/libuv/libuv/blob/5152db2cbfeb5582e9c27c5ea1dba2cd9e10759b/src/fs-poll.c#L211-L218

Bun's previous is the Stats object from the last callback (the prevStat slot in src/runtime/node/node_fs_stat_watcher.rs). A poll that finds no change returns early in restat() and does not refresh it. The comparison ignores atime, so a read between two changes moves atime without a callback, and the next previous still has the old atime.

Repro

Needs a mount that updates atime on read. relatime, the Linux default, is enough here.

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";

const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wf-atime-"));
const f = path.join(dir, "f.txt");
fs.writeFileSync(f, "abc");
const old = new Date(Date.now() - 3600_000);
fs.utimesSync(f, old, old); // atime = mtime = one hour ago
const before = fs.statSync(f);
const sleep = ms => new Promise(r => setTimeout(r, ms));

const log = [];
fs.watchFile(f, { interval: 10 }, (cur, prev) => log.push({ curSize: cur.size, prevAtimeMs: prev.atimeMs }));
await sleep(150);
fs.readFileSync(f); // moves only atime: no callback
await sleep(150);
const afterRead = fs.statSync(f);
fs.writeFileSync(f, "abcdef"); // first callback: what is previous.atime?
await sleep(300);
fs.unwatchFile(f);

const which = ms => (ms === afterRead.atimeMs ? "atime after the read" : ms === before.atimeMs ? "atime before the read" : String(ms));
console.log(`first callback: cur.size=${log[0]?.curSize} previous.atime = ${which(log[0]?.prevAtimeMs)}`);
fs.rmSync(dir, { recursive: true, force: true });
node v26.3.0: first callback: cur.size=6 previous.atime = atime after the read
bun 1.4.3:    first callback: cur.size=6 previous.atime = atime before the read

Notes

A fix needs the pool thread to keep the last successful stat and refresh it on every successful poll. The change callback then has to build previous from that stat, taken at the moment the change is detected, and not from the cached JS object.

Found while working on #43050, which does not change this. Related: #43051.