Bun.file: make exists()/size/lastModified reflect the current filesystem state
The first stat a BunFile performs is memoized on the blob's store forever. exists(), .size and .lastModified never re-stat, so one BunFile answers from a snapshot of a past filesystem state for the life of the object. A file created after one "missing" answer reads as "" forever with no error.
Repro
import * as fs from "node:fs";
const dir = fs.mkdtempSync("/tmp/lzcache-");
// A: a deleted file still "exists"
const a = dir + "/a"; fs.writeFileSync(a, "abc");
const fa = Bun.file(a); await fa.exists();
fs.unlinkSync(a);
console.log(await fa.exists(), fa.size, "| truth:", fs.existsSync(a));
// true 3 | truth: false
// B: a file created after one "missing" answer is never seen again
const b = dir + "/b";
const fb = Bun.file(b); await fb.exists();
fs.writeFileSync(b, "content");
console.log(await fb.exists(), JSON.stringify(await fb.text()));
// false ""
// C: .size / .lastModified frozen at their first read
const c = dir + "/c"; fs.writeFileSync(c, "0123456789");
const fc = Bun.file(c); void fc.size;
fs.appendFileSync(c, "0123456789"); fs.utimesSync(c, 1000, 2000);
console.log(fc.size, fc.lastModified, "| truth:", fs.statSync(c).size, fs.statSync(c).mtimeMs);
// 10 1783424589067 | truth: 20 2000000
Cause
One shared root: the lazy stat result is memoized on the store the first time any getter needs it and never invalidated. get_exists_sync / get_size / get_last_modified guarded on the cached value, and resolve_file_stat was a no-op on stat failure. Case B is the worst: exists() cached self.size = 0 via resolve_size, and every later .text() passed max_length = 0 to the read path.
Fix
resolve_file_statresets the cached stat on failure (mode = 0,seekable = None,max_size = MAX_SIZE,last_modified = INIT_TIMESTAMP).get_exists_sync,get_last_modified,get_size,get_size_for_bindingsalways re-stat file stores via a sharedstat_file_sizehelper and never writeself.size.- A new
Blob.size_is_explicitflag (set byslice()) records whenself.sizeis a caller-supplied bound, soget_sizecan clamp a bound past EOF while leaving a whole-file view live. get_sliceskips itssize == 0early exit for file stores, and resolves negative start/end against the live stat size viaview_size()(positive indices and the default end still useself.size.get(), soslice(2)on a growing file stays unbounded).- Structured-clone serialize no longer calls
resolve_size()(which cached a stat on the source), and only puts a concrete size on the wire for an explicit slice.
Behaviour changes
Two observable changes a maintainer should sign off on:
.size/.lastModified/exists()issue astaton every access for file-backed blobs. This is the fix; hot loops that repeat.sizeshould read it once into a local.Bun.file(p).slice(-N, Infinity)now returns the last N bytes (the W3C slice semantics, matching in-memoryBlob.slice). Previously it saw the unresolvedMAX_SIZEsentinel and produced a past-EOF offset that read empty.serve.test.tsmoves that case frombadRangesto a dedicated "negative start resolves against the live file size" test.
Relationship to #33360
Both introduce the same size_is_explicit field at the same offset. This PR fixes the getters; #33360 fixes the read path being clamped by a stale stat size. Whichever lands second only conflicts on the field declaration hunk.
Verification
test/js/bun/util/bun-file.test.ts gains a BunFile exists()/size/lastModified reflect the current filesystem state describe with 13 tests: the three repro cases above, create/delete polling, slice-bound preservation (regular / non-seekable / missing / empty regular file), delete-then-recreate and truncate-then-grow read-poisoning guards, .slice() with no end staying unbounded, negative-index slice against the live size, expect().toHaveLength/toBeEmpty not poisoning, and structuredClone not poisoning (source and clone).
before / after
$ USE_SYSTEM_BUN=1 bun test test/js/bun/util/bun-file.test.ts -t 'BunFile exists'
(fail) exists() sees a file deleted after the first call
(fail) exists() sees a file created after the first call, and reads its contents
(fail) size and lastModified track changes to the underlying file
(fail) polling exists() observes create and delete
(fail) slice() size is preserved across re-stat
... 5 pass (pre-existing behaviour this guards), 8 fail
$ bun bd test test/js/bun/util/bun-file.test.ts -t 'BunFile exists'
13 pass, 0 fail
Also green: blob.test.ts, bun-file-exists.test.js, bun-file-read.test.ts, bun-file-fd-read.test.ts, serve.test.ts Content-Range suite, bun-serve-file.test.ts, structured-clone-blob-file.test.ts, FormData.test.ts. bun run rust:check-all passes on all 10 targets.
Fixes #22484
Fixes #4930
Fixes #23902
Fixes #22456
no test proof · iteration 7 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/http/serve.test.ts