pack: build the tarball in memory and write it out only once it is complete - #38721
pack: build the tarball in memory and write it out only once it is complete#38721robobun wants to merge 3 commits into
Conversation
|
Warning Review limit reached
Next review available in: 5 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
Comment |
|
Updated 4:38 AM PT - Aug 15th, 2026
❌ @robobun, your commit 3511edd has some failures in 🧪 To try this PR locally: bunx bun-pr 38721That installs a local version of the PR into your bun-38721 --bun |
|
Reproduced on the current release (1.4.0-canary): the handoff's CI for 3511edd (build 97498): every test lane passed, including the darwin lane, which ran on its retry after a first attempt expired waiting for an agent; that expired attempt is the only reason the build shows as failed. Ready for review. |
There was a problem hiding this comment.
LGTM — focused fix that cleans up the truncated tarball on every pack failure path, and replaces the archive_write_data int-cast panic with a proper error.
What was reviewed
- Traced every
Global::crash()betweenwrite_open_filenameandwrite_closeinpack()— all converted totarball.crash(); failures after a successful close intentionally still leave the (now valid) tarball. OpenTarball::crash:write_fail→write_close→lstat-gatedunlinkis safe to call even from thewrite_closefailure path (libarchive treats a second close on a FATAL archive as a no-op); thelstatregular-file gate prevents deleting a symlink/device passed as--filename.bun_sys::lstat/unlinkexist on both POSIX and Windows (viasys_uv); the close-before-unlink ordering handles Windows' open-file restriction.- Tests:
ulimit -f 0is a clean way to force EFBIG regardless of uid; the newtest.concurrentcases use their owntempDirso they don't race the file-levelbeforeEachpackageDir.
Extended reasoning...
Overview
The PR fixes bun pm pack (and bun publish from a directory) leaving a truncated .tgz behind when packing fails after the output file has been created. It touches:
src/libarchive/lib.rs: adds thearchive_write_failFFI binding and awrite_fail()wrapper.src/runtime/cli/pack_command.rs: introducesOpenTarball { archive, path }and routes every failure betweenwrite_open_filenameand a successfulwrite_closethroughOpenTarball::crash(), which marks the archive fatal, closes it, unlinks the destination if it is a regular file, and exits 1. Also replaces theusize::try_from(archive.write_data(..)).expect("int cast")panic with a real error viawrite_entry_data(), and drops the now-vacuousResult<_, AllocError>returns fromarchive_package_json/add_archive_entry.- Tests in
bun-pack.test.tsandbun-publish.test.tscovering the default destination,--destination,--filename, mid-entry write failure (the former panic), an unreadable input file, and a symlink--filenamethat must survive.
Security risks
None identified. The only filesystem mutation added is an unlink of the exact path pack itself just opened with O_CREAT|O_TRUNC, gated on lstat reporting a regular file so a symlink or device passed as --filename is never removed. No user input flows into the unlink path beyond what already flowed into write_open_filename.
Level of scrutiny
Moderate. This is native Rust touching a CLI command's error paths and adding one libarchive FFI symbol, not a hot path or security boundary. The change is contained to pack() and its two helpers; archive_write_fail is a documented libarchive abort primitive. Calling write_close a second time from the write_close failure branch is safe because write_fail moves the archive to ARCHIVE_STATE_FATAL and libarchive's close short-circuits in that state. bun_sys::lstat / bun_sys::unlink both exist for POSIX and Windows (via libuv), and the fd is closed before the unlink so Windows can delete the file.
Other factors
- The PR description is unusually thorough — every design choice (why not a Drop guard, why close before unlink, why gate on
lstat, whyulimit -f) is justified, and cross-targetcargo checkplus clippy/fmt were run. - Tests are hermetic, drain both pipes concurrently, use
test.concurrentwith per-casetempDir(so no shared-state races with the file-levelpackageDir), and skip appropriately on Windows and when running as root. Theulimit -f 0approach is robust across uid. - Removing the dead
Result<_, AllocError>return type is the right call per the repo's "delete dead code in the same PR" rule; both call sites are updated. - The multi-agent bug hunt found nothing.
…mplete pack() streamed the archive straight into <name>-<version>.tgz, so every failure while adding entries or closing the archive exited 1 and left a truncated tarball at the destination. bun publish packed the same way and unlinked the file afterwards, so a failure in between (a failing postpack, for example) left it behind too, and --dry-run unlinked whatever file of that name the user had there. The archive now goes into a GrowingBuffer, the same sink Bun.Archive uses. bun pm pack hashes the buffer and writes it to the destination in one step, removing the file again if that single write fails; bun publish hands the bytes to the registry directly and no longer touches the disk, which also removes the read-back of the tarball, the unlink in publish_command.rs and Publish::Context.abs_tarball_path. bytes_in_last_block is set to 1, as archive_write_open_filename did for a regular file, so the produced tarballs are byte for byte the same as before. archive_write_data's return value is checked now instead of being cast with expect(), and GrowingBuffer reports an allocation failure through archive_set_error so that check has something to print.
ff69158 to
41621b6
Compare
|
Reworked since the first round (the LGTM above was for the earlier |
There was a problem hiding this comment.
I reviewed this PR and the bug hunting system found no issues. My earlier note about the description/title mismatch has been addressed. Because this is a substantial refactor of the pack/publish write path — switching from streaming-to-file to build-in-memory, with a subtle bytes_in_last_block requirement for byte-identical output and a changed memory profile for bun pm pack — a human look would still be worthwhile.
What was reviewed:
GrowingBuffer/WriteArchivelifetime ordering (tarball_bufferdeclared beforearchive, explicitdrop(archive)before consuming the buffer) — correct.pack_destination()simplification vs. the old NUL-byte dance —make_pathtakes&[u8], so the old dance was unnecessary; equivalent.File::createflags/mode (O_WRONLY|O_CREAT|O_TRUNC, 0666) match whatarchive_write_open_filenameused.write_tarball'slstat-gated unlink and the removed--dry-rununlink inpublish_command.rs— both covered by new tests.
Extended reasoning...
Overview
This PR changes bun pm pack and bun publish (from a directory) to build the tarball entirely in memory via libarchive's archive_write_open2 + the existing GrowingBuffer sink, instead of streaming to disk with archive_write_open_filename. bun pm pack then writes the finished bytes once via a new write_tarball(); bun publish uploads the bytes directly and never touches disk. This fixes three bugs: truncated .tgz files left behind on any mid-pack failure, bun publish --dry-run deleting a pre-existing tarball, and a panic on negative archive_write_data return. Files touched: src/libarchive/lib.rs (adds write_set_bytes_in_last_block and archive_set_error bindings, removes write_open_filename/write_free wrappers, makes GrowingBuffer::write_callback set an error string on OOM), src/runtime/cli/pack_command.rs (~200 lines net removed: the read-back-and-hash block, the write half of ArchivePtrExt, the destination-mkdir dance; adds pack_destination/write_tarball/write_entry_data), src/runtime/cli/publish_command.rs (removes abs_tarball_path field and its unlink), plus new tests in bun-pack.test.ts and bun-publish.test.ts.
Security risks
None identified. The change reduces filesystem interaction (publish no longer writes/unlinks a file) and the new write_tarball unlink on failure is gated by lstat → regular-file, so a --filename pointing at a symlink or device is not deleted. No new untrusted-input parsing.
Level of scrutiny
High. This is the code path that produces the bytes uploaded to package registries. The write_set_bytes_in_last_block(1) call is load-bearing for byte-identical output (without it, the last gzip block would be padded to 10 KiB and every tarball's shasum would change); the PR description says this was verified byte-for-byte at gzip levels 0/1/9, but that check is not automated. The memory-profile change (holding the full compressed tarball in RAM for bun pm pack) matches npm's behavior and bun publish already did this, but is a behavioral shift worth a maintainer's sign-off.
Other factors
The bug hunting system found no issues. Test coverage is strong: ulimit -f 0 for EFBIG on the final write across default/--destination/--filename, an unreadable input file, a symlink --filename that must survive, a read-only destination that must keep its contents, plus bun publish postpack-failure and --dry-run cases. I checked File::create (src/sys/file.rs:117) uses the same O_WRONLY|O_CREAT|O_TRUNC + 0666 as libarchive's file writer, and Dir::make_path (src/sys/dir.rs:99) takes &[u8] so the simplified pack_destination() is equivalent to the old NUL-byte dance. The comment-cop feedback (long comments) was addressed in 4f324ba, and my earlier note about the stale PR description was resolved. This is a well-executed change that implements two existing TODOs, but its scope and the criticality of the pack output put it past the threshold for auto-approval.
|
The one open point from the review above, that the byte-identical claim was only checked by hand, is now covered by a test (3511edd): the gunzipped tarball of a two-file package must be exactly 6 tar blocks. Without the |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs; my earlier note about the title/description being out of date has been resolved. Because this reworks how bun pm pack and bun publish do I/O (streaming → in-memory via GrowingBuffer/archive_write_open2, with bytes_in_last_block(1) needed for byte-identical output and a WriteArchive-Drop / tarball_buffer ordering invariant), a human look would still be worthwhile.
What was reviewed:
GrowingBufferoutlivesWriteArchive(declared first; explicitdrop(archive)beforeto_owned_slice());archive_write_freein Drop is a no-op after the explicitwrite_close.pack_destinationpassing&buf[..dir_end]tomake_path— that helper takes&[u8], so the old NUL-swap dance was unnecessary.write_tarballusesFile::create(.., true)=O_WRONLY|O_CREAT|O_TRUNC, mode 0666, matching whatarchive_write_open_filenameset;lstat-gated unlink leaves symlinks/devices alone.- Removed
write_open_filename/write_freewrappers have no remaining callers; the padding invariant is pinned by the newgunzipSyncbyte-count test.
Extended reasoning...
Overview
This PR changes bun pm pack from streaming the archive to disk via archive_write_open_filename to building it in a GrowingBuffer via archive_write_open2 (the same sink Bun.Archive uses) and writing it once complete via a new write_tarball(). bun publish from a directory now takes the bytes straight from the buffer and never writes a file, deleting the read-back, the abs_tarball_path field, and the post-publish unlink. A new archive_write_set_bytes_in_last_block binding is added and set to 1 to preserve byte-identical output (libarchive would otherwise pad the last 10 KiB record for a custom sink). The archive_write_data result is now checked (write_entry_data) instead of .expect("int cast")-panicking on a negative return, and GrowingBuffer::write_callback records ENOMEM via archive_set_error so the message reaches the user. archive_package_json/add_archive_entry drop their vestigial Result return, the raw *mut Archive becomes the crate's RAII WriteArchive, and the write half of the local ArchivePtrExt shim plus the crate's write_open_filename/write_free wrappers are removed as dead.
Security risks
None identified. No new untrusted-input parsing; the new file write goes to the same computed destination as before; the unlink on write failure is gated on lstat reporting a regular file so a --filename symlink/device is not deleted. Removing the unconditional unlink from bun publish is strictly safer (previously --dry-run would delete a user's existing tarball).
Level of scrutiny
Medium-high. This is an architectural change to the I/O path of two user-facing CLI commands, not a mechanical fix. The correctness hinges on (a) bytes_in_last_block(1) keeping output byte-identical (pinned by the new test and the description's manual verification at gzip 0/1/9), (b) the tarball_buffer/WriteArchive drop ordering (buffer declared first, archive dropped explicitly before to_owned_slice()), and (c) File::create matching the flags/mode libarchive used. All three check out on inspection, but the surface area and the fact that this PR was already reworked once from a different design suggest a maintainer should confirm the shape.
Other factors
- My earlier inline comment (title/description described the superseded
OpenTarballdesign) has been addressed — the current title and description accurately describe the in-memory approach, and the earlier shape is summarised in a<details>block. - The comment-cop bot's "paragraph-long comment" flags were addressed in 4f324ba (comments shortened to one line each); one flag on the pre-existing
ArchivePtrExtdoc comment was correctly noted as not introduced by this PR. - Test coverage is thorough: EFBIG under
ulimit -f 0for the default/--destination/--filenamecases, an unreadable input file, a symlink--filenamesurviving, a read-only pre-existing destination kept intact,bun publishwith a failingpostpackleaving no tarball,--dry-runleaving an existing tarball untouched, and the new no-padding assertion. Root-only skips are documented. - The one memory trade-off (holding the compressed tarball in memory for
bun pm pack) matches npm's behaviour andbun publishalready did this; the description calls it out explicitly.
Problem
bun pm packstreams the archive straight into<name>-<version>.tgz(write_open_filenameinpack(),src/runtime/cli/pack_command.rs). Every failure after that point, in the entry loops, inarchive_package_json/add_archive_entry, or atwrite_close, exits 1 and leaves that truncated file at the destination (tar -tzfreportsgzip: unexpected end of file), where a followingbun publish ./*.tgzor a re-run picks it up.bun publishfrom a directory packs the same way, reads the file back into memory, and unlinks it after publishing (publish_command.rs, the// TODO: read this into memoryunlink). So a failure between packing and that unlink, a failingpostpackfor example, leaves the tarball in the package directory, andbun publish --dry-run, which packs nothing, unlinks whatever file of that name the user had there.archive_write_datawas used asusize::try_from(..).expect("int cast"). When the write itself fails (disk full; libarchive buffers about 60 KiB, so any package bigger than that) the result ispanic: int cast: TryFromIntError(NegOverflow)and a crash report instead of an error.Fix
GrowingBufferthrougharchive_write_open2, the sinkBun.Archivealready uses (src/runtime/api/Archive.rs). This is what the// TODO: experiment with archive.writeOpenMemory()above the oldwrite_open_filenamecall asked for, and the shape npm uses (pacote builds the tarball in memory,npm packwrites it out afterwards).bun pm packhashes the buffer and writes it to the destination in one step (write_tarball). That write is now the only thing that can leave a partial file, and it unlinks the file when it fails. The unlink is gated onlstatreporting a regular file, the only thingFile::createcan have created or truncated; a--filenamethat is a symlink or a device is left alone, and a destination that could not be opened at all is not touched.bun publishtakes the bytes straight from the buffer intoPublish::Context.tarball_bytesand never writes a file. This deletes the read-back of the tarball inpack(), the unlink inpublish_command.rs, andPublish::Context.abs_tarball_path, whose only remaining use was a capacity hint. It fixes the failing-postpack leftover and the--dry-rununlink as well.bun pm packwith a failingpostpackstill keeps the tarball, as npm does (it also writes the file before runningpostpack).archive_write_open_filenamesetbytes_in_last_block = 1for a regular file; with a custom sink libarchive would otherwise pad the last block to 10 KiB, sopack()sets it explicitly (newwrite_set_bytes_in_last_blockbinding). Tarballs for a fixture with a scoped name, a bin, a 150 KB binary file and a README, at gzip levels 9, 1 and 0, are byte for byte identical to the ones the build before this change produced, with the same file mode (File::createuses the same flags and0666as libarchive did) and the same Shasum / Integrity / sizes output.bun publishalready read the whole tarball into memory;bun pm packnow holds the compressed bytes until it writes them instead of streaming them, which is what npm does too.write_entry_datachecks thearchive_write_dataresult and reportsfailed to write "<file>" to tarball: <reason>instead of panicking. The only way the in-memory sink fails is allocation, and libarchive leaves describing a failed client write to the client, soGrowingBuffer::write_callbacknow records the reason witharchive_set_error, the way libarchive's own file writer does.archive_package_json/add_archive_entryhadResultreturn types with no error path; they return the entry directly now. The*mut Archiveis replaced by the crate'sWriteArchiveowner, which makes the write half of the localArchivePtrExtshim and the crate'swrite_open_filename/write_freewrappers dead, so they are removed.test/cli/install/bun-pack.test.ts, "the archive is not padded to a full tar record": the gunzipped tarball of a two-file package is exactly 6 tar blocks (3072 bytes). This pins thebytes_in_last_blocksetting: the same sink without it, asBun.Archiveuses it, produces 10240 bytes for the same input (measured). Passes on the release too, as it should.test/cli/install/bun-pack.test.ts, newdescribe:bun pm packunderulimit -f 0, so the one write of the tarball fails withEFBIG, for the default destination,--destinationand--filename(no file is left); a file that cannot be opened (no file is created; skipped as root); a symlink--filenamesurvives; an existing read-only destination keeps its contents. All fail on the current release and pass with this change, also when run as a non-root user; whole file 83 pass, 2 skipped as root.test/cli/install/bun-publish.test.ts: a failingpostpackleaves no tarball in the package directory (release: exit 3 and the file is left);--dry-runleaves a tarball thatbun pm packhad put there untouched (release: deletes it). Whole file 41 pass.test/js/bun/archive.test.ts(sharesGrowingBuffer): 106 pass."bin"naming a directory,EISDIR) now leaves nothing behind; pack: skip bins reached through symlinks; publish: do not read the readme through a symlink #38707 separately makes that input stop erroring.cargo check -p bun_runtime -p bun_libarchiveforx86_64-pc-windows-msvcandaarch64-apple-darwin;cargo clippyon both crates;cargo fmt --check.Background
archive_write_open_filenameinstalls a sink that opens the path withO_CREAT | O_TRUNCand writes as it goes;archive_write_open2installs caller-provided callbacks, hereGrowingBuffer, which appends to aVec. Output is blocked in 10 KiB units;bytes_in_last_blocksays how the final, partial block is padded (1 = not at all).archive_write_closewrites the gzip trailer and the tar end-of-archive blocks; the bytes are not a readable.tgzbefore that.archive_write_datareturns the number of bytes taken or a negative status code. When a client write callback fails, libarchive sets no error text itself; the callback is expected to callarchive_set_error(libarchive's own file sink reports "Write error" that way).Global::crash()isexit(1)and does not unwind, so inpack()a destructor cannot be used for cleanup; the fix works by not having anything on disk to clean up until the single final write.RLIMIT_FSIZE(ulimit -f) caps the size of files a process may write; bun ignoresSIGXFSZat startup, so exceeding it makeswritefail withEFBIG. The tests use it to make the tarball unwritable regardless of the user they run as.Repro on the current release
With this change:
EFBIG: File too large: failed to write tarball: "/.../fsz-1.0.0.tgz" (write), exit 1, no file, in both of the first two cases; the publish case exits 3 with no file.Earlier shape of this PR
The first version kept streaming to disk and added an
OpenTarballhandle whosecrash()closed the archive and unlinked the destination, called from each of the twelve failure sites between open and close (plus anarchive_write_failbinding so that close did not pad out the unfinished entry). Review pointed out that the two TODOs quoted above describe the in-memory shape, which needs none of that, deletes the read-back code, and also covers the publish leftovers; this version implements that instead. The tests carried over with the error text adjusted, except for the large-package case that drove thearchive_write_datacheck into a failing disk: with the in-memory sink that check is only reachable through allocation failure, so it has no test.no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-pack.test.ts test/cli/install/bun-publish.test.ts