Skip to content

install: remove the same-filesystem probe from $TMPDIR when the rename into the cache fails - #38859

Open
robobun wants to merge 2 commits into
mainfrom
farm/715b1fc8/install-tmpdir-probe-leak
Open

install: remove the same-filesystem probe from $TMPDIR when the rename into the cache fails#38859
robobun wants to merge 2 commits into
mainfrom
farm/715b1fc8/install-tmpdir-probe-leak

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Every bun install leaves one empty $TMPDIR/.<hex>-N.hm file behind when $TMPDIR and the install cache are on different filesystems (tmpfs /tmp with the cache on disk, or a BUN_INSTALL_CACHE_DIR on another mount). They accumulate until the tmpfs is cleared; a long-running fuzzer left ~76,000 of them in three hours.
  • Present in 1.3.14 and on main, so not a recent regression.
  • Cause: get_temporary_directory_run in src/install/PackageManager/PackageManagerDirectories.rs creates the probe in the tempdir and renameats it into the cache directory (line 242). When the rename fails it switches tempdir to <cache>/.tmp and retries, and the only unlink in the loop (line 277) removes the retry's probe from the cache directory. The first probe is still in $TMPDIR and nothing removes it.

Fix

  • When the rename fails, unlink the probe from the directory it was created in before falling back (or crashing when the fallback has already been tried).
  • Correct because a failed rename leaves the source in place, so the probe is always still in tempdir on that path; the unlink result is ignored like the existing cache-directory unlink.
  • Test: test/cli/install/bun-install-tmpdir-exdev.test.ts points TMPDIR/BUN_TMPDIR at a directory on a different device (/dev/shm, or /tmp when the harness temp dir is elsewhere; skipped when neither differs, which covers Windows and the usual macOS layout), installs a file: dependency with the cache inside the test directory, and asserts that <cache>/.tmp was created (the fallback actually ran) and that the TMPDIR directory is empty afterwards. It fails on the released build with the leaked .hm file listed and passes with this change.
  • Also ran three installs in a loop with the debug build in both layouts: nothing left in $TMPDIR in the cross-device layout, and the same-filesystem layout still removes its probe and does not create <cache>/.tmp.
  • install: don't leak package extraction temp directories into $TMPDIR #33979 fixes a different install tempdir leak (extraction directories on cache-publish races and error paths); the two do not overlap.

Background

  • bun install extracts each package into a temp directory and then renames it into the cache, so the tempdir must be on the same filesystem as the cache (rename across filesystems fails with EXDEV). The probe described above is how bun detects this once per process; on failure it uses <cache>/.tmp as the tempdir instead.
  • FileSystem::tmpname produces the .<hex>-<counter>.<ext> names; the probe uses the hm extension.

[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/bun-install-tmpdir-exdev.test.ts
bun test v1.4.0 (24513432d)

test/cli/install/bun-install-tmpdir-exdev.test.ts:
57 |       expect(exitCode).toBe(0);
58 | 
59 |       // The cross-device rename failed, so bun switched to <cache>/.tmp ...
60 |       expect(existsSync(join(cache, ".tmp"))).toBe(true);
61 |       // ... and must have removed the probe it had created in $TMPDIR first.
62 |       expect(readdirSync(tmp)).toEqual([]);
                                    ^
error: expect(received).toEqual(expected)

- []
+ [
+   ".b31a253393dade1b-0.hm",
+ ]

- Expected  - 1
+ Received  + 3

      at <anonymous> (/workspace/bun/test/cli/install/bun-install-tmpdir-exdev.test.ts:62:32)
(fail) leaves nothing in $TMPDIR when it is on a different filesystem than the cache [496.33ms]

 0 pass
 1 fail
 5 expect() calls
Ran 1 test across 1 file. [4.88s]
error: script "bd" exited with code 1
__F:1:S:0

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (b7a043103)

test/cli/install/bun-install-tmpdir-exdev.test.ts:
57 |       expect(exitCode).toBe(0);
58 | 
59 |       // The cross-device rename failed, so bun switched to <cache>/.tmp ...
60 |       expect(existsSync(join(cache, ".tmp"))).toBe(true);
61 |       // ... and must have removed the probe it had created in $TMPDIR first.
62 |       expect(readdirSync(tmp)).toEqual([]);
                                    ^
error: expect(received).toEqual(expected)

- []
+ [
+   ".344bd0dcb8645fc4-0.hm",
+ ]

- Expected  - 1
+ Received  + 3

      at <anonymous> (/workspace/bun/test/cli/install/bun-install-tmpdir-exdev.test.ts:62:32)
(fail) leaves nothing in $TMPDIR when it is on a different filesystem than the cache [130.51ms]

 0 pass
 1 fail
 5 expect() calls
Ran 1 test across 1 file. [1178.00ms]
__F:1:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/bun-install-tmpdir-exdev.test.ts
bun test v1.4.0 (24513432d)

test/cli/install/bun-install-tmpdir-exdev.test.ts:
(pass) leaves nothing in $TMPDIR when it is on a different filesystem than the cache [420.54ms]

 1 pass
 0 fail
 5 expect() calls
Ran 1 test across 1 file. [4.60s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1534ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/4] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_install v0.0.0 (/workspace/bun/src/install)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_ast_jsc v0.0.0 (/workspace/bun/src/ast_jsc)
�[1m�[92m   Compiling�[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_jsc)
�[1m�[92m   Compiling�[0m bun_patch_jsc v0.0.0 (/workspace/bun/src/patch_jsc)
�[1m�[92m   Compiling�[0m bun_semver_jsc v0.0.0 (/workspace/bun/src/semver_jsc)
�[1m�[92m   Compiling�[0m bun_sys_jsc v0.0.0 (/workspace/bun/src/sys_jsc)
�[1m�[92m   Compiling�[0m bun_sql_jsc v0.0.0 (/workspace/bun/src/sql_jsc)
�[1m�[92m   Compiling�[0m bun_sourcemap_jsc v0.0.0 (/workspace/bun/src/sourcemap_jsc)
�[1m�[92m   Compiling�[0m bun_css_jsc v0.0.0 (/workspace/bun/src/css_jsc)
�[1m�[92m   Compiling�[0m bun_bundler_jsc v0.0.0 (/workspace/bun/src/bundler
... (truncated)
diff hotspot
.../PackageManager/PackageManagerDirectories.rs    |  2 +
 test/cli/install/bun-install-tmpdir-exdev.test.ts  | 67 ++++++++++++++++++++++
 2 files changed, 69 insertions(+)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                     reads  edits  tests
src/install/PackageManager/PackageManagerDirectories.rs      1      1      0
test/cli/install/bun-install-tmpdir-exdev.test.ts            0      1      0

…e into the cache fails

get_temporary_directory_run creates an empty .<hex>-N.hm file in the
tempdir and renames it into the cache directory to check that both live
on the same filesystem. When the rename fails (EXDEV when $TMPDIR and the
cache are on different mounts), it switches to <cache>/.tmp and retries,
but only ever unlinks the retry's probe from the cache directory. The
first probe stayed behind in $TMPDIR, one per bun install.

Unlink the probe from the directory it was created in before moving on.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f3494ea0-00a2-4def-8577-23048a2225e5

📥 Commits

Reviewing files that changed from the base of the PR and between 39fb3c1 and 8b2260e.

📒 Files selected for processing (2)
  • src/install/PackageManager/PackageManagerDirectories.rs
  • test/cli/install/bun-install-tmpdir-exdev.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 AM PT - Aug 15th, 2026

@robobun, your commit 8b2260e is building: #97832

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — two-line cleanup on the rename-failure path plus a targeted regression test.

What was reviewed:

  • Traced get_temporary_directory_run: on renameat_z failure the source stays in tempdir, and the new delete_file_z mirrors the existing success-path unlink of the same probe from cache_directory.
  • Test: confirmed findCrossDeviceDir compares against os.tmpdir() (where harness tempDir roots the cache), so the EXDEV precondition holds; asserts <cache>/.tmp exists so the fallback provably ran; hermetic file: dep, no network; mkdtempSync on /dev/shm is unavoidable here and is removed in finally.
  • Checked the create_file_z-failure branch above — no probe exists there, so no analogous unlink is needed.
Extended reasoning...

Overview

Two-line change to get_temporary_directory_run in src/install/PackageManager/PackageManagerDirectories.rs: when the renameat_z probe from $TMPDIR into the cache directory fails (typically EXDEV), unlink the probe from the directory it was created in before either falling back to <cache>/.tmp or crashing. Previously the only unlink was on the success path against cache_directory, so the first-iteration probe leaked in $TMPDIR. Adds test/cli/install/bun-install-tmpdir-exdev.test.ts, which forces the cross-device layout via /dev/shm (or /tmp when the harness tmpdir is elsewhere), runs an install with a local file: dependency, and asserts both that the fallback ran (<cache>/.tmp exists) and that the $TMPDIR directory is empty afterward.

Security risks

None. The change adds a best-effort unlinkat of a file bun itself just created, using the same fd and name it created with. No new inputs, paths, or trust boundaries.

Level of scrutiny

Low. A failed rename(2) leaves the source in place by contract, so the probe is guaranteed to still be at (tempdir, tmpname) on this branch — the unlink is the obvious symmetric cleanup and uses the identical let _ = <dir>.delete_file_z(tmpname) shape as the existing success-path cleanup ~30 lines below. The unlink runs before tempdir is reassigned to the fallback Dir, so it targets the correct directory. On the second loop iteration (fallback also fails to rename) the extra unlink is harmless — bun crashes immediately after. The other early-fallback branch (probe creation fails) needs no unlink because no file was created.

Other factors

The test is well-constructed against the repo's review rules: it asserts its own precondition (<cache>/.tmp exists proves the EXDEV path actually executed, so the test cannot pass vacuously on a same-device layout), it is hermetic (local file: dependency, no registry), it drains stdout/stderr/exit concurrently, asserts output before exit code, spreads bunEnv, and skipIfs cleanly on platforms without a writable cross-device mount (Windows, typical macOS). The mkdtempSync on /dev/shm deviates from the "use harness tempDir" guidance, but that is unavoidable — the directory must be on a specific other device — and it is removed in a finally registered before the assertions. The new file follows the established test/cli/install/bun-install-<topic>.test.ts pattern alongside a dozen siblings.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the probe cleanup is placed before tempdir is reassigned, so it unlinks from the directory the file was actually created in, and it mirrors the existing cache_directory.delete_file_z(tmpname) on the success path.

What was reviewed

  • Traced the 'brk loop: on rename failure the source file is guaranteed to still exist in tempdir; the new unlink runs before the .tmp fallback reassignment and also before the tried_dot_tmp crash path, so both failure exits are covered.
  • Checked the sibling create_file_z error arm — no probe exists there, so no cleanup needed.
  • Test: mkdtempSync on /dev/shm is a justified exception to the harness rule (needs a different device than tempDir), cleaned up in finally; the existsSync(cache/.tmp) assertion proves the EXDEV fallback actually ran; pipes drained concurrently; skipIf covers Windows/macOS where no cross-device candidate exists.
Extended reasoning...

Overview

Two-line fix in get_temporary_directory_run (src/install/PackageManager/PackageManagerDirectories.rs): when sys::renameat_z fails while probing whether $TMPDIR and the install cache share a filesystem, unlink the probe from the tempdir it was created in before either falling back to <cache>/.tmp or crashing. Previously only the success path unlinked (from the cache directory), so a cross-device $TMPDIR leaked one empty .hm file per bun install. A new test forces EXDEV via /dev/shm (or /tmp when it differs from the harness tempdir) and asserts the tempdir is empty afterward.

Security risks

None. The change adds a best-effort delete_file_z of a file bun itself just created, keyed by the same tmpname used for creation, in a directory bun already has an open fd to. No new inputs, no path construction from untrusted data.

Level of scrutiny

Low. This is a targeted resource-cleanup fix on an error path, using the same helper (delete_file_z) and same ignore-result pattern (let _ =) as the adjacent success-path cleanup three lines below. POSIX rename(2) guarantees the source is untouched on failure, so the file is always present to unlink; the result is discarded so a lost race is harmless. The unlink is placed before tempdir is reassigned to the .tmp fallback, so it targets the correct directory. On the second-iteration failure (rename fails inside .tmp too), the same line cleans up before Global::crash(), which is strictly better than before.

Other factors

The PR includes fails-without-fix / passes-with-fix evidence on both ASAN debug and release builds. The test follows harness conventions (bunEnv spread, tempDir, concurrent pipe drain, exit-code asserted last, cleanup in finally registered before assertions, no network). It uses mkdtempSync directly on /dev/shm rather than the harness tempDir — necessary here because the whole point is a directory on a different device than the harness tempdir, and it is removed in finally. The existsSync(join(cache, ".tmp")) assertion guards against a false pass by proving the EXDEV fallback branch actually executed. skipIf(!crossDeviceDir) means it only runs where a writable cross-device mount exists (Linux CI); the fix itself is platform-agnostic.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the red check is queue capacity, not a test failure.

  • Both builds for this PR (97256 for 2451343, 97832 for the retrigger) had every job that ran pass: 177 of 177 each.
  • The only non-passing jobs in each build are the two darwin 14 aarch64 - test-bun jobs, which expired in the queue without ever being assigned an agent (four attempts each). That pool is currently 6 agents with roughly 300+ jobs queued, and PR builds are waiting longer than the retry budget.
  • The macOS lane would not add coverage here: the change is a platform-independent unlink on an error path, and the new test skips on macOS because it needs a writable mount on a second filesystem (/dev/shm on Linux), so the fix is exercised by the Linux lanes, which passed.

Retrying the two expired jobs in Buildkite once the pool catches up should turn the check green; I am not going to push further retriggers since they only add to that queue.

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.

2 participants