bunx: stop passing --force to the spawned install on every dist-tag run

Closed
RO
1mo ago
+487-213

Fixes #41211, fixes #23597

Problem

  • bunx <pkg>@latest spawns bun add <pkg>@latest --no-cache --force on every run (bunx_command.rs:1341 on main). --force links every cached package again. On Windows that costs 30 s or more.
  • A bare bunx <pkg> pays the same on every run after the first day: the age check (bunx_command.rs:1123) reads the hardlinked install cache file, whose mtime no install renews.

Fix

  • The spawned install passes --force in two cases only: the cached binary is not owned by the current user, or a first install without --force left no bin to run. Then bunx installs once more with --force and looks again. A healthy warm run runs one install, without --force.
  • Read the age of a tree from its .bin entry (lstat). Every install creates it again.
  • In the install bunx spawns (BUN_INTERNAL_BUNX_INSTALL), the bin linker (src/install/bin.rs) removes an existing .bin entry whose target file is gone. On Windows that entry is a shim that runs on its own, so without this the lookup found a bin and the forced install never ran. A project install keeps the entry: a build script can still create its target.
  • Correct because --no-cache fetches the manifest again, so bun add <pkg>@<tag> installs the version the tag names (#4981). A tree that an interrupted install left without some files still heals: the second, forced install links every package again.
  • Verified: test/cli/install/bunx.test.ts, block bunx cache (5 tests). Run on Linux against this change and against main.

Background

  • bunx keeps each package in <temp>/bunx-<uid>-<pkg>@<version>. For a dist-tag it spawns bun add there on every run.
  • --no-cache disables the manifest cache. --force also installs packages whose version already matches. Without it the installer keeps a package whose package.json name and version match, even when other files are missing.
  • Considered a forced install every 24 hours (a tool used once a day pays on every run) and an error that names the directory to remove (the user repairs by hand what main repaired on its own).

Downsides

  • A package whose bin names a file it does not ship runs two installs per run instead of one before it fails.
  • A bare bunx <pkg> on Linux and macOS follows latest once per 24 hours, not on every run after day one.
  • Binary: +256 bytes text, +0 data at ead653cbf6 (llvm-size, release, main 2722608f47). Not measured again for the later commits.
Notes

Measured on Linux x64, release builds, hardlink backend, loopback registry. The tree is one tool with 20 dependencies (21 packages, 83 files). The counts are syscalls of the spawned bun add (ptrace counter). The builds are main 2722608f47 and 00582846d1.

RunBuildlinkatrenameatunlinkatRegistry requests
warm bunx tool@latestmain83221271
warm bunx tool@latestthis PR0121
bare bunx tool, tree 25 h old, runs 1 / 2 / 3main83 / 83 / 831 / 1 / 1
bare bunx tool, tree 25 h old, runs 1 / 2 / 3this PR0 / 0 / 01 / 0 / 0
  • Earlier measurements on a 16 vCPU Windows VM with no antivirus, canary bun: warm bun x shadcn@latest --help 2.2 s vs 1.0 s on Linux. The spawned install in the warm cache dir: --no-cache --force 1.61 s, --no-cache alone 0.07 s. The cost is the forced re-link of 252 packages, which Defender amplifies on user machines.
  • On Windows the age check already reads the .bin shim, which every install writes again. There a stale bare-name run was one forced install per 24 hours. It is now one plain install per 24 hours.
  • The tag still moves at once in both directions. The test moves latest from 1.0.0 to 1.1.0 and back.
  • A package that declares no bin gets one install and the error: a forced install cannot add a bin.
  • The retry: the install and the two bin probes now sit in a loop that runs at most twice. The loop re-indents that block, so most of the diff in bunx_command.rs is whitespace. git diff -w shows the change itself (about 26 lines).
  • A file that another local user plants is a different case. bunx refuses a cache directory that is not owned by the current user or that group or others can write (is_trusted_cache_root).
  • Not changed: the second age check (get_bin_name_from_temp_directory, root package.json mtime). For a package whose bin is not named after the package, a bare-name or pinned run still deletes the tree once per 24 hours.
  • The tool no longer inherits BUN_INTERNAL_BUNX_INSTALL from the spawned install. On main that variable only changed argv dispatch, so the leak was harmless. Now the bin linker reads it, and an install the tool spawns in the user's project (shadcn runs bun add) must not. A test runs a bin that prints the variable.
  • The Windows CI lanes run the tests. The two retry tests and the marker test fail on the branch head before the retry commit and on main.

no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/install/bunx.test.ts