Skip to content

Bun.Archive: read Bun.file() input on the work pool at first use - #46661

Draft
robobun wants to merge 3 commits into
mainfrom
robobun/667efa65/archive-bun-file-entries
Draft

robobun wants to merge 3 commits into
mainfrom
robobun/667efa65/archive-bun-file-entries

Conversation

@robobun

@robobun robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #28459, fixes #30234

Problem

  • A Bun.file() value in new Bun.Archive({...}) or Bun.Archive.write() becomes a 0-byte entry, with no error. The docs recipe "Create Archive from Directory" does this. new Bun.Archive(Bun.file(tar)) is empty.
  • Cause: get_entry_data (src/runtime/api/Archive.rs:373) and each reader take shared_view(), which is empty for a file store.
  • Bun.write(path, gzipArchive) writes the raw tar: write_file_internal (Blob.rs:4936) ignores compress.

Fix

  • new Bun.Archive(obj) writes the tar up to the first Bun.file() and records the rest. The first method that needs bytes reads the files on the work pool, once.
  • A read error (ENOENT) rejects that call. The next call reads again. Bun.file(fd) and S3 entries throw a TypeError.
  • Bun.write(dest, archive) writes what bytes() gives: a pool job reads and gzips first.
  • Verified: test/js/bun/archive.test.ts (81 new tests, 73 fail on main), s3.test.ts (1 new). Self-reviewed: 18 concerns raised, 17 addressed (Notes).

Background

Downsides

Notes

What changed for callers

  • A Bun.file() entry with a path, a .slice() of one, a Blob of one Bun.file(), and a Bun.build() output with an outdir give the bytes of the file. Before: 0 bytes.
  • new Bun.Archive() does not throw for a file that does not exist. The first use rejects. Bun.Archive.write(path, obj) rejects and leaves path as it was.
  • A directory rejects with EISDIR. A FIFO or a device rejects with ENODEV, as Bun.Image does: only a regular file is read. A file of more than 256 KB that gets smaller during its read rejects with EIO. A file of at most 256 KB is read to its end, so a procfs file (size 0) and a sysfs file (size 4096) give what they hold.
  • Only the bytes are archived. Each entry is a regular file with mode 0644 and the time of new, as for values in memory. Bun.Archive: support per-entry file modes when creating archives #33213 is about modes.
  • Bun.file(fd), Bun.stdin and S3 files as an entry: a TypeError (ERR_INVALID_ARG_TYPE) from new. An fd reads once on POSIX, so it cannot be read again after a failed read. As the whole archive, blob() and Bun.write() pass the Blob on as before, and the methods that read reject. Before: 0 bytes.
  • A Bun.file() given as the whole archive is read once and kept. files() and extract() see the same bytes.
  • Bun.write(dest, archive) gzips when the archive has compress: "gzip". Before: the raw tar. Bun.write(path, archive): honor Archive's compress option #30236 has the same change.
  • console.log(archive) prints Archive { files: 2 } with no size for an object form with file entries, and Archive {} for a Bun.file() given as the archive. Before: Archive (10.24 KB) { files: 2 } and Archive (0 KB) { files: 0 }.

How it works

  • Archive holds Source::Store(store) as before, or Source::Pending(Arc<Pending>).
  • The property pass runs on the JS thread as before. At the first Bun.file() it stops writing. It records that entry and the ones after it: a copy of a string or a buffer, the store and window of an in-memory Blob, or the path and window of a file. The tar written so far is kept without its end marker (archive_filter_bytes gives its length before the close).
  • The read appends the recorded entries through a second libarchive writer and pads the end as one writer does. The result is byte for byte the tar of the same values in memory (tested for 8 head sizes and 10 file sizes on both sides of the 512-byte record, the 10240-byte block and the 256 KiB read).
  • Pending is Input -> Built(store) behind one lock that only pool threads take. The first job reads. A job that comes meanwhile waits on its own pool thread for that one read. A failed read leaves the input as it was.
  • bun_sys::File::open_for_read is the open that Bun.Image had: O_NONBLOCK on POSIX only, then fstat. Both use it.
  • Bun.write(dest, archive): WriteArchive in write_file.rs makes the bytes on the pool and then calls the usual write. For an S3 destination it reads the options during the call to Bun.write, as a write that starts at once does. An archive in memory with no gzip takes the path it took before, with no job.

Why this shape

  • Why not read in new (Bun.Archive: read Bun.file() input from disk instead of writing empty bytes #41193): a directory of files would be read on the JS thread, and new would do I/O for an archive that nothing uses.
  • Why not Blob::read_bytes_to_handler, which Bun.Image uses: it gives each file whole, so the file and its copy in the tar are in memory together. The read here goes into the tar in 256 KB pieces. The peak RSS numbers are below. read_bytes_to_handler is the way to read S3 entries later.
  • Why not record every entry and write one tar on the pool: an in-memory archive would hold a second copy of each entry until its first use.
  • Why not a TypeError in new with files read only by Bun.Archive.write(path, obj): the issue and the docs use new with Bun.write.
  • The yardstick is the workaround await Bun.file(p).bytes(), which gives the same tar on main. Against it this change stops the silent loss and lowers peak memory.
  • Reach: one report (Bun.Archive creates empty files when using Bun.file() as input #28459, Windows, 3 thumbs-up).

Self-review

  • 18 concerns. The ones that changed the design: parked jobs for first uses that overlap (now one lock), O_NONBLOCK on Windows (now POSIX only, in one helper), a Bun.file() archive read again by each method (now read once), S3 options read after the read of the files (now read at the call), the (store, offset, size) window of s3, Bun.Archive: use the bytes of an in-memory Blob slice, not its whole store #44779 (not in this change), a loop over archive_write_data for entries above 2 GiB with no test (not in this change).
  • Not taken: a forward_to in bun_libarchive that can fail. The pool writer looks for an out-of-memory write after each 256 KB instead.
  • Open question for a maintainer: an entry from a Bun.file() has mode 0644, not the mode of the file. The docs say so.

Measurements

Release builds of main 5c6d4e2 and of this change on that commit, linux x64. The later rebase onto 6292c65 changed one module path in this diff. perf, valgrind, strace and bloaty are not installed, so the tools are gdb breakpoint counts, perf_event_open and /proc.

  • Allocator calls per call (mimalloc entry points, JIT off): new Bun.Archive({a:"x"}) 41.01 -> 41.01 (frees 110 -> 110). A Blob entry 41.01 -> 41.01. 100 string entries 2623 -> 2623. new Bun.Archive(blob) 0.01 -> 0.00. Archive.write(path, obj) 46.40 -> 45.93. bytes() 5.62 -> 5.32. blob() 3.36 -> 3.45. files() 21.83 -> 22.85. Bun.write(path, archive) 6.16 -> 6.35. The reader calls count all threads, and the collector moves them by a few tenths between runs.
  • Instructions per call (user space, JS thread, JIT off): new Bun.Archive({a:"x"}) 13,729 -> 13,771 (+42, branches +12). A Blob entry +38. A Uint8Array entry +40. 100 Blob entries +38 per entry. 100 string entries +41 per entry. new Bun.Archive(blob) 390 -> 426. Reader calls: inside the run-to-run spread of about 300.
  • Sizes: Archive 16 -> 24 bytes. Job contexts: blob 40 -> 48, write 96 -> 96, extract 56 -> 72, files 56 -> 64.
  • Binary: text 107,635,854 -> 107,655,310 (+19,456). The archive module in the linker map: 29,409 -> 42,243 bytes.
  • File reads (/proc/self/io and /proc/self/task/<tid>/io), 2000 Bun.file() entries of 4 KB: new Bun.Archive 0 in the process. The first bytes() 2000 in the process (1 per file), 0 on the JS thread. The second bytes() 0. Bun.Archive.write(path, obj) 2000 in the process, 0 on the JS thread. One 128 MB entry: 512 reads of 256 KB. 2000 string entries: 0. The JS thread shows 1 to 5 reads of about 40 bytes in each step on both builds, also for an archive with no file.
  • Peak RSS after await archive.bytes() (VmHWM, median of 5): one 128 MB Bun.file() entry 288 MB. The same bytes read first with await file.bytes(): 465 MB (main: 465 MB). 64 MB in memory before a small file: 239 MB (all in memory: 266 MB). A 128 MB file after 64 MB in memory: 509 MB (read first: 678 MB, main: 673 MB).
  • Wall clock, 10 interleaved runs, this change against main, with main against main in parentheses: new Bun.Archive({a:"x"}) -7.31% (2.07%). 100 string entries -0.45% (1.44%). bytes() +2.51% (3.12%). blob() +1.45% (1.80%). files() -2.11% (1.59%). Bun.write(path, archive) +1.10% (0.13%), -0.41% (0.57%) and +1.26% (1.72%) in three rounds. Archive.write(path, obj) -9.90% (0.07%) and +3.86% (4.85%) in two rounds. The VM has about 18% CPU steal, and a round differs from the next by more than the numbers in parentheses. A loop of 300,000 new Bun.Archive({a:"x"}), 5 interleaved runs each: main 1057 to 1561 ms, this change 1089 to 1317 ms, peak RSS 1.80 GB on both. I claim no gain and I see no loss.

Still wrong after this, and where it is tracked

Other open pull requests that touch Archive.rs

Also ran: cargo clippy -p bun_runtime -p bun_libarchive -p bun_sys, cargo fmt --check, bun run rust:check-all x86_64-pc-windows-msvc aarch64-apple-darwin, test/internal/source-lints/, test/js/bun/image/image.test.ts, test/js/bun/s3/s3.test.ts, test/js/web/fetch/blob.test.ts, test/js/bun/io/bun-write.test.js, the three Bun.Archive cases of test/js/bun/module-graph/module-graph-isolation.test.ts. Windows and macOS were not run locally: this is a draft until CI is green there.


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

A Bun.file() value in new Bun.Archive({...}) or Bun.Archive.write() became a
0-byte entry with no error, and a Bun.file() given as the archive read as 0
bytes. get_entry_data and each reader took shared_view(), which is empty for
a file store.

new Bun.Archive(obj) now writes the tar up to the first Bun.file() entry and
records the rest. The first method that needs the bytes reads the files on
the work pool under one lock and keeps the result. A failed read rejects that
call and leaves the input for the next call. A Bun.file() given as the
archive is read the same way. A Bun.file() of a file descriptor or an S3 file
as an entry throws a TypeError.

Bun.write(dest, archive) now writes what bytes() gives. For an archive that
is gzipped or not read yet, a job makes the bytes on the pool and then calls
the usual write. Before, a gzip archive was written as the raw tar.

bun_sys::File::open_for_read is the open that Bun.Image had (O_NONBLOCK on
POSIX only, then fstat). Both use it.

Fixes #28459, fixes #30234
@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:47 PM PT - Oct 23rd, 2026

✅ @robobun, your commit eabf4b15cf3c976835a756807e1d091c143246d3 passed in Build #131566! 🎉


🧪   To try this PR locally:

bunx bun-pr 46661

That installs a local version of the PR into your bun-46661 executable, so you can run:

bun-46661 --bun

@robobun

robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Status: draft. The tests pass on the Linux debug build. Windows and macOS have to pass in CI before this leaves draft.

How I reproduced it (main at 5c6d4e2, release build, linux x64):

import fs from "node:fs";
const d = fs.mkdtempSync("/tmp/ar-");
fs.writeFileSync(d + "/a.txt", "hello from disk");

const archive = new Bun.Archive({ "a.txt": Bun.file(d + "/a.txt") }, { compress: "gzip" });
const entries = await new Bun.Archive(await archive.bytes()).files();
console.log("entry:", JSON.stringify(await entries.get("a.txt").text()));

await Bun.write(d + "/out.tar.gz", archive);
const head = new Uint8Array(await Bun.file(d + "/out.tar.gz").slice(0, 2).arrayBuffer());
console.log("Bun.write gzip magic:", head[0] === 0x1f && head[1] === 0x8b);

const missing = new Bun.Archive({ "b.txt": Bun.file(d + "/nope") });
console.log("missing:", await missing.bytes().then(b => b.length + " bytes", e => e.code));
main this PR
entry "" "hello from disk"
Bun.write gzip magic false true
missing 10240 bytes ENOENT

test/js/bun/archive.test.ts: 73 of the 81 new tests fail on main, and all 204 pass with this PR.

Comment thread src/libarchive/lib.rs Outdated
Comment thread src/libarchive/lib.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/api/Archive.rs Outdated
Comment thread src/runtime/image/Image.rs Outdated
Comment thread src/runtime/webcore/Blob.rs Outdated
Comment thread src/runtime/webcore/blob/write_file.rs Outdated
Comment thread src/sys/file.rs Outdated

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bun.Archive compress not working in constructor variant Bun.Archive creates empty files when using Bun.file() as input

2 participants