Blob: read file-backed parts in multi-part new Blob([...])

RO
6d ago
+491-6

Fixes #25851

Problem

  • A file-backed Blob (Bun.file(p) or its slice) gives zero bytes as one part of new Blob([...]). No error occurs, and .size agrees with the wrong data.
  • from_js_without_defer_gc (src/runtime/webcore/Blob.rs) pushes blob.shared_view(), which is empty for a store that is not in-memory bytes.

Fix

  • An in-memory part keeps the inline code of main. A file part goes to one cold function, push_file_part: open with O_NONBLOCK, fstat, then pread.
  • A missing, unreadable or directory part throws ENOENT, EACCES or EISDIR. A pipe, device or S3 part throws a TypeError and is not opened. The null device is an empty part.
  • A part of half the RAM or more throws ENOMEM before it is read.
  • Verified: test/js/web/fetch/blob.test.ts (main fails each new test). Self-reviewed: 10 concerns raised, 8 addressed.

Background

  • A Blob has one store: in-memory bytes, a file, or S3. A file store holds no bytes.
  • The constructor is synchronous and makes one in-memory store, so it reads a file part at once.
  • Considered the lazy design of Node and browsers. It needs a multi-segment store (#36001) and is a follow-up.

Downsides

  • The constructor reads a file part on the JS thread: about 150 ms for 100 MB, 1.4 s for 1 GiB. Peak memory is 2 times the file.
  • A multi-part Blob is a snapshot. A part that main dropped in silence now throws, synchronously also from Bun.write(dest, [...]).
  • Each constructor call and each in-memory Blob part costs 1 instruction more. The binary grows by 8,192 bytes (dated in Notes).
Notes

What a user sees

const file = Bun.file(p); // 10 bytes: "ABCDEFGHIJ"
await new Blob([file]).text();          // "ABCDEFGHIJ" (one part: shares the lazy store)
await new Blob([file, "-tail"]).text(); // main: "-tail"   this PR: "ABCDEFGHIJ-tail"
await new Blob(["head-", file]).text(); // main: "head-"   this PR: "head-ABCDEFGHIJ"
await new Blob(["", file]).text();      // main: ""        this PR: "ABCDEFGHIJ"

Each way to get a file store is affected: Bun.file(path), Bun.file(fd), structuredClone(Bun.file(p)), a BunFile from postMessage, await new Response(Bun.file(p)).blob(), and slices of these.

Errors

partresult
missing fileENOENT, syscall open, with the path
unreadable fileEACCES, syscall open, with the path
directoryEISDIR, syscall read, with the path
FIFO, socket, device, Bun.stdin as a pipeTypeError: Blob parts backed by a pipe, socket or device cannot be read synchronously; await .bytes() or .arrayBuffer() first
S3TypeError: Blob parts backed by S3 cannot be read synchronously; await .bytes() or .arrayBuffer() first
the null device (/dev/null, an fd on it, a symbolic link to it, NUL on Windows)an empty part, no error
a part of half the RAM or moreENOMEM, syscall read, with the path

A path is checked with stat before it is opened. The open of a FIFO wakes a writer that waits for a reader, and the writer then fails or loses its bytes. The test for the FIFO runs a writer, lets the constructor throw, and then reads the bytes of the writer with cat.

The join holds a file part twice: in the buffer of the read and in the joined buffer. So a part of half the RAM or more cannot fit, and it throws ENOMEM before a reserve. fs.readFile has a rule of the same kind on Linux for a file as large as the RAM. The test makes a sparse file of 3/4 of the RAM: one copy fits and two copies do not.

The errors are thrown by the call that joins the parts. For new Blob, new File and new Response([...]) that is the constructor. Bun.write(dest, [...]) joins the array before it makes a promise, so it throws synchronously and does not reject:

callmainthis PR
Bun.write(out, Bun.file(missing))rejects ENOENTrejects ENOENT
Bun.write(out, ["h", Bun.file(existing)])resolves 1, writes hresolves 4, writes hSRC
Bun.write(out, ["h", Bun.file(missing)])resolves 1, writes hthrows ENOENT synchronously, out is not created
Bun.write(out, [{ toString() { throw } }])throws synchronouslythrows synchronously

The null device and Node (Linux x64)

part of new Blob(["x", part])mainthis PRNode v26.3.0
/dev/null by path, by fd, through a symbolic link, or as Bun.stdinsize 1size 1size 1
/dev/zerosize 1TypeErrorsize 1
Bun.stdin as a pipesize 1TypeError
a regular file of 3 bytessize 1size 4size 4

main gives size 1 in each row because it drops each file part. Node takes the length of a part from the stat size, so a device is an empty part there. bun throws for a device or a pipe so that no bytes are dropped without a signal. The null device has no bytes, so an empty part is exact.

On Linux the null device is found by its device number (major 1, minor 3). On macOS and Windows it is found by the st_rdev of /dev/null or \\.\NUL. A path is still not opened for it.

Instruction counts (release builds of main 8d36bff51 and of this branch, Linux x64, JIT off)

perf_event_open is not permitted in the build container and valgrind is not installed. The counts come from a ptrace single-step counter. It counts the instructions of the main thread between two marker syscalls. Repeated runs differ by less than 60 instructions in 6 million. Each value is the slope between 1,000 and 2,000 calls.

callmainthis PRdelta
new Blob(["a", "b"])3,135.73,136.7+1
new Blob([u8, "a"])3,208.53,209.4+1
new Blob([blob, "a"])3,230.53,232.4+2
new Blob([file, "a"]) (in-memory File)3,230.53,232.4+2
new Blob([slice, "a"])3,232.83,234.8+2
new Blob([16 in-memory blobs])8,800.28,817.2+17

The earlier head of this PR cost 30 more for each in-memory part.

Binary size: bun 80,840,224 to 80,848,416 bytes (+8,192). bun-profile 169,397,392 to 169,408,152 bytes (+10,760).

The counts, the sizes and the read times below are from Sep 24, before the merges with main of Oct 7, Oct 13 and Nov 4. The two call sites have the same code since then. The null device change measured +0 bytes for bun on Sep 25. The size was not measured again after the RAM limit, which is 3 lines in the cold function.

Synchronous read of a file part (release, warm page cache, one constructor call for each process, 15 runs each)

filetimepeak RSS
100 MB129 to 167 ms232 MB
1 GiB1.25 to 1.86 s2,080 MB

An empty script has a peak RSS of 28 MB. The peak is 2 times the file: the read buffer, and the joined buffer of the new Blob.

A part against the same part read alone

A grid of 912 cells compares await part.bytes() with await new Blob(["", part]).bytes(). It has 8 ways to make the part, 19 slice() argument lists, a nested slice, and a file that grows or shrinks after the part is made.

  • main: 650 cells differ. The part side is empty in each of them.
  • This PR: 129 cells differ. Each of them also differs on main.
    • 108 are negative-index slices of a file whose size was never read, for example Bun.file(p).slice(-3). The slice itself is wrong on main ("ABC" for the file above). #41257 corrects that at slice time.
    • 21 are an fd whose cursor was moved before the read. The lazy reader reads from the cursor. The part reads at the offset of the Blob, so two parts over one fd give the same bytes.

Review: not changed

  • A stat size that is stale. The part stops at the size that fstat gave after the open. A file that grows during the read gives a valid snapshot in both readers.
  • bun_sys::File does not close fds 0 to 2 on drop. If user code closed a stdio fd, the open for a part can get that fd and keep it. The limit is 3 fds, and the rule belongs to bun_sys::File.
  • A part below half the RAM but above the free memory. The read goes on until a reserve fails or the system stops the process. fs.readFile is the same.
  • joiner.done() aborts when its allocation fails. StringJoiner::done allocates with Vec::with_capacity for each caller, so this is not specific to file parts.
  • The RAM limit counts one part, not the total of the join. Two file parts of 40 % of the RAM each are both read, and the join then fails in joiner.done(). In-memory parts of the same total do the same on main. A limit for the whole join belongs with a fallible StringJoiner::done.
  • A first reserve of 64 MiB with doubling (a review suggestion, withdrawn by its author). It adds copies for each large file and does not make the read smaller.
  • The FormData serializer still reads a file entry with NodeFS::read_file: a FIFO entry blocks, an fd entry reads from the cursor, an S3 entry gives no bytes. It reads Bun.stdin as a pipe today, so a move to push_file_part changes behaviour and needs its own PR.

Platform notes

  • On POSIX, pread does not move the cursor of the fd of the caller. On Windows, a positioned ReadFile on a synchronous handle moves it, so the test asserts the cursor only on POSIX.
  • Bun.file() of a file that is embedded in a compiled executable has an in-memory store, so it never reaches push_file_part.
  • On Windows, Bun.file("/dev/null") maps to NUL. NUL, \\.\NUL and os.devNull are an empty part. CON and CONIN$ throw the TypeError.

Tests

  • bun bd test test/js/web/fetch/blob.test.ts (debug, ASAN, Linux x64) at e170412cc1, which merges main b9f2ec3635 of Nov 4: 174 pass, 1 skip. The skip is the EACCES test, which does not run as root. As uid 65534 the new block gave 14 pass on Oct 13.
  • With the source of main b9f2ec3635, the 13 tests of the new block that run as root fail.
  • With only the RAM limit removed, its test fails by its timeout.
  • The new block passed 12 of 12 runs. Its five tests that start a child process run concurrently.
  • Windows: the last run of the block on a Windows x64 debug build was on Sep 25, before the merges with main (9 pass, 4 skip). At e170412cc1, bun scripts/rust-check-all.ts is ok for x86_64-pc-windows-msvc, aarch64-pc-windows-msvc, aarch64-apple-darwin, aarch64-linux-android and x86_64-unknown-freebsd.
  • Alpine: the first test for the RAM limit made a sparse file of 4 TiB. The filesystem of the Alpine lanes refuses a file of that size (EFBIG, Buildkite build 125195). The file is now 3/4 of the RAM.
  • macOS: the first version of the FIFO test read the FIFO in the child with await Bun.file(fifo).text(), and the child never exited there (Buildkite build 120425, both macOS lanes). The constructor is not involved, so the test now reads with cat. The lazy reader on macOS is a separate problem.
  • Also ran at e170412cc1: blob-array-fast-path.test.ts, blob-cow.test.ts, structured-clone-blob-file.test.ts, bun-file.test.ts, body-clone.test.ts (171 pass).

Earlier shape of this PR

The first version sent each Blob part through one helper, push_blob_part_bytes. It read a path with NodeFS::read_file (a blocking open, and one more copy of the file) and an fd with the pread loop. Review rounds on that version added: pread for an fd so that a repeated part does not read from the end, the st_size == 0 case for virtual files such as procfs, a fallible allocation with a first reserve of 8 GiB at most, and a clamp of a slice to the size of the file.

The null device case was #43944, a PR on top of this branch. Its commits are in this branch now.


no test proof · iteration 11 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/blob.test.ts