Repository navigation
Conversation
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
Collaborator
Author
|
Updated 7:47 PM PT - Oct 23rd, 2026
✅ @robobun, your commit eabf4b15cf3c976835a756807e1d091c143246d3 passed in 🧪 To try this PR locally: bunx bun-pr 46661That installs a local version of the PR into your bun-46661 --bun |
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));
|
This was referenced Oct 24, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #28459, fixes #30234
Problem
Bun.file()value innew Bun.Archive({...})orBun.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.get_entry_data(src/runtime/api/Archive.rs:373) and each reader takeshared_view(), which is empty for a file store.Bun.write(path, gzipArchive)writes the raw tar:write_file_internal(Blob.rs:4936) ignorescompress.Fix
new Bun.Archive(obj)writes the tar up to the firstBun.file()and records the rest. The first method that needs bytes reads the files on the work pool, once.ENOENT) rejects that call. The next call reads again.Bun.file(fd)and S3 entries throw aTypeError.Bun.write(dest, archive)writes whatbytes()gives: a pool job reads and gzips first.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
Blobviews aStore, which holds bytes, a path (Bun.file()), or an S3 key.bytes()andextract().new(Bun.Archive: read Bun.file() input from disk instead of writing empty bytes #41193): it blocks the JS thread. Considered aTypeError: the docs and types acceptBun.file().Downsides
Bun.write(dest, gzipArchive)now writes gzip. Callers that relied on the raw tar break.Archive16 to 24 bytes, binary +19,456 bytes.new Blob([Bun.file(a), Bun.file(b)])(Blob constructor ignores File parts if more than one part is given #25851) and a stale cached file size (CallingBunFile.existsmakesBun.writewrite nothing #4930).Notes
What changed for callers
Bun.file()entry with a path, a.slice()of one, aBlobof oneBun.file(), and aBun.build()output with anoutdirgive 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 leavespathas it was.EISDIR. A FIFO or a device rejects withENODEV, asBun.Imagedoes: only a regular file is read. A file of more than 256 KB that gets smaller during its read rejects withEIO. 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.new, as for values in memory. Bun.Archive: support per-entry file modes when creating archives #33213 is about modes.Bun.file(fd),Bun.stdinand S3 files as an entry: aTypeError(ERR_INVALID_ARG_TYPE) fromnew. An fd reads once on POSIX, so it cannot be read again after a failed read. As the whole archive,blob()andBun.write()pass the Blob on as before, and the methods that read reject. Before: 0 bytes.Bun.file()given as the whole archive is read once and kept.files()andextract()see the same bytes.Bun.write(dest, archive)gzips when the archive hascompress: "gzip". Before: the raw tar. Bun.write(path, archive): honor Archive's compress option #30236 has the same change.console.log(archive)printsArchive { files: 2 }with no size for an object form with file entries, andArchive {}for aBun.file()given as the archive. Before:Archive (10.24 KB) { files: 2 }andArchive (0 KB) { files: 0 }.How it works
ArchiveholdsSource::Store(store)as before, orSource::Pending(Arc<Pending>).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_bytesgives its length before the close).PendingisInput -> 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_readis the open thatBun.Imagehad:O_NONBLOCKon POSIX only, thenfstat. Both use it.Bun.write(dest, archive):WriteArchiveinwrite_file.rsmakes the bytes on the pool and then calls the usual write. For an S3 destination it reads the options during the call toBun.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
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, andnewwould do I/O for an archive that nothing uses.Blob::read_bytes_to_handler, whichBun.Imageuses: 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_handleris the way to read S3 entries later.TypeErrorinnewwith files read only byBun.Archive.write(path, obj): the issue and the docs usenewwithBun.write.await Bun.file(p).bytes(), which gives the same tar on main. Against it this change stops the silent loss and lowers peak memory.Bun.Archivecreates empty files when usingBun.file()as input #28459, Windows, 3 thumbs-up).Self-review
O_NONBLOCKon Windows (now POSIX only, in one helper), aBun.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 overarchive_write_datafor entries above 2 GiB with no test (not in this change).forward_toinbun_libarchivethat can fail. The pool writer looks for an out-of-memory write after each 256 KB instead.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,straceandbloatyare not installed, so the tools are gdb breakpoint counts,perf_event_openand/proc.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.new Bun.Archive({a:"x"})13,729 -> 13,771 (+42, branches +12). A Blob entry +38. AUint8Arrayentry +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.Archive16 -> 24 bytes. Job contexts: blob 40 -> 48, write 96 -> 96, extract 56 -> 72, files 56 -> 64./proc/self/ioand/proc/self/task/<tid>/io), 2000Bun.file()entries of 4 KB:new Bun.Archive0 in the process. The firstbytes()2000 in the process (1 per file), 0 on the JS thread. The secondbytes()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.await archive.bytes()(VmHWM, median of 5): one 128 MBBun.file()entry 288 MB. The same bytes read first withawait 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).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,000new 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
new Blob([Bun.file(a), Bun.file(b)])is empty beforeBun.Archivesees it (Blob constructor ignores File parts if more than one part is given #25851, Blob: read file-backed parts in multi-part new Blob([...]) #33600).Bun.file()whose size was read and cached before the file changed gives the cached number of bytes (CallingBunFile.existsmakesBun.writewrite nothing #4930, Bun.file: a failed stat leaves the size unknown #39763, Bun.file: do not use a cached stat size as the byte budget of a read or a copy #43910).Bun.file(p).slice(-5)of a file whose size nothing read is empty (Bun.file().slice(): count a negative index back from the file's real end #41257).Bun.write(dest, Bun.file(src).slice(a, b))writes the whole file (Honor Bun.file().slice() bounds in Bun.write #41209).shared_view()outsideBun.Archiveget 0 bytes for a file or S3 store: formData(): read a file- or S3-backed Blob body before parsing it #45161 (formData()body), FormData: throw when serializing an S3-backed entry instead of sending an empty part #38364 (FormData with an S3 entry), redis: reject file-backed Blob arguments instead of sending the empty string #36836 (redis arguments), Throw DataCloneError when structured-cloning an S3File #37905 (structured clone of an S3 file).archive_write_datatakes at mostINT_MAXbytes per call. Not changed here.Bun.Archivecreation to avoid high memory usage #26665).Bun.Archivecannot be cancelled, soworker.terminate()waits for a slow read, as it does forextract()today.C:file) is opened throughopenat, not through libuv asBun.file(p).bytes()does. Not run.Other open pull requests that touch
Archive.rsArchive.rsandBlob.rshunks conflict with this change in text. The window of a sliced Blob fits onSource::Store.ExtractResultenum, where each side adds one arm, and at the imports of the test file.files()), webcore(Blob): share Blob-typed parts via a rope store instead of eager memcpy #36001, Blob: wrap blobs for JS through JsClass::to_js only #37656 (Blob): each conflicts with main today.git merge-treewith this change gives no conflict.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 threeBun.Archivecases oftest/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