Bun.file: make exists()/size/lastModified reflect the current filesystem state

Closed
RO
3mo ago
+373-92

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_stat resets 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_bindings always re-stat file stores via a shared stat_file_size helper and never write self.size.
  • A new Blob.size_is_explicit flag (set by slice()) records when self.size is a caller-supplied bound, so get_size can clamp a bound past EOF while leaving a whole-file view live.
  • get_slice skips its size == 0 early exit for file stores, and resolves negative start/end against the live stat size via view_size() (positive indices and the default end still use self.size.get(), so slice(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 a stat on every access for file-backed blobs. This is the fix; hot loops that repeat .size should read it once into a local.
  • Bun.file(p).slice(-N, Infinity) now returns the last N bytes (the W3C slice semantics, matching in-memory Blob.slice). Previously it saw the unresolved MAX_SIZE sentinel and produced a past-EOF offset that read empty. serve.test.ts moves that case from badRanges to 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