test: hard-link the upgrade stand-in and run bun-upgrade.test.ts concurrently
Problem
bun-upgrade.test.tstakes 20s on Windows 11 arm64 and 13s on Windows 2019 x64 (build 101560), 2s elsewhere.- Four release flows each copied
bunExe(). On Windows the first launch of a new copy costs 2.4s (canary build, arm64) or 7s (debug build). A hard link starts in under 20ms. - The last three flows ran one after another.
Fix
- The flows that fail before the swap share one stand-in from
beforeAll. The flow that swaps gets its own. Both are hard links tobunExe(), with a copy as the cross-volume fallback. Five copies per run become two links. - The links are safe because the upgrade only renames directory entries (
src/sys/lib.rs:9060, cross-device path at:8982). It never writes into the file behindbunExe().test/cli/bun.test.ts:146already runs a linked stand-in the same way. - All eight tests run concurrently. Each failing flow stages into its own
BUN_TMPDIR. The completing flow keeps the default temp dir, the one it holds open. - Every run now pins its output and its result on disk (notes). No test was removed. Verified: arm64 17.2s to 3.7s, x64 10.2s to 3.1s, Linux debug+ASAN 5.4s to 3.0s, 8 to 13 green runs each.
Background
bun upgrade --stableunpacks the asset into<tmpdir>/<version>/, runs it with--version, then moves it over its own executable. The test serves the release locally.- Only the completing flow needs a real archive. On Windows the verify step needs a real PE image, so the archive holds the test binary, built once per run as before.
- Windows cannot delete a running image. The upgrade parks it as
<name>.outdatedand runscompletions, which recreates thebunxlink (install_completions_command.rs:106).
Notes
Timings (same machine, same build, old file vs this file)
| Machine | Binary | Old | New |
|---|---|---|---|
| Windows 11 arm64 | canary build, as CI runs it | 17.2s, 17.2s, 17.3s | 3.6s to 3.7s (13 runs) |
| Windows 11 arm64 | bun bd test (debug, 216 MB) | 52.5s, 51.8s, 52.1s | 16.3s, 16.9s, 16.8s |
| Windows Server 2019 x64 | canary build | 11.3s, 10.2s, 10.0s | 3.0s to 3.3s (10 runs) |
| Linux x64 | bun bd test (debug+ASAN, 820 MB) | 5.3s, 5.4s, 6.3s | 3.0s to 3.1s (8 runs) |
| Linux x64 | release build | 0.4s to 1.3s | 0.2s |
The old file was run with --timeout=90000 on Windows, which is what the CI runner passes: on the arm64 machine two of the old tests did not fit into the default 5s per test. The new file was also run 10 times per Windows machine with the default timeout. What remains is the completing flow: the first launch of the extracted image (2.4s on arm64, which is part of what the flow verifies) plus Expand-Archive (0.7s on arm64, about 2s on Server 2019). A deflated archive was slower (0.4s to compress, 1.3s to expand), so the archive stays stored.
Per run: executable materializations 5 copies -> 2 hard links, zip builds 1 -> 1, bun upgrade processes 9 -> 9, sequential phases 4 -> 1. The zip is not built in beforeAll because only one test needs it and it is on that test's own critical path either way.
First-launch probe on the arm64 machine (78 MB canary build): fresh copy 2431ms and 2371ms, the same copy again 7ms, a hard link 18ms, a fresh copy of the small cmd.exe 26ms. The cost is per new file and grows with its size. On Server 2019, where the bootstrap removes Defender, a fresh copy costs 273ms and a hard link 6ms. The arm64 image still reports real-time protection on (it is tamper protected), which may be why that lane is the slowest.
Why the links are safe: POSIX installs the new binary with a NOREPLACE rename, then an EXCHANGE, then delete plus rename (renameat_concurrently_without_fallback). Windows renames the old image to .outdated and moves the new one in. The cross-device fallback unlinks the destination before it writes. self_exe_path() (src/bun_core/util.rs:2745) is /proc/self/exe on Linux, _NSGetExecutablePath plus realpath on macOS and GetModuleFileNameW plus GetFinalPathNameByHandle on Windows. All of them return the link that was launched, because a hard link is an ordinary name and not a symlink. Confirmed on Linux (link count of the debug binary was 3 after a run, the swapped-out link sat in the staging dir) and on Windows (fsutil hardlink list showed the stand-ins and the parked .outdated entries, bunExe() unchanged). Renaming and deleting a link to the running test binary works on Windows (probed), so temp dir cleanup is unaffected.
Assertion changes
- Three invalid-argument tests: stdout is empty, stderr is an exact inline snapshot, exit code 1. Before: two
toContainlines, no exit code. --help: stdout starts with the usage line, stderr is empty, exit code 0. Before: anot.toContainof a message spelled differently from the real one (bun itself), so it could not fail.--stable --profile: one line names v9.9.9 (only profile assets are served, so this also proves--profilewas honored), stderr ends with the exact unpack error, exit code 1. Before: twonot.toContain, exit code awaited but not checked.- Completing flow: one line names the version, stderr ends with the complete
Upgradedbanner, exit code 0. On disk: on POSIX the install dir holds exactly the executable and its content is the fake release script. On Windows it holds exactly the executable,<name>.outdatedandbunx.exe(bunx-debug.exefor debug builds). The non-canary branch (output ends with the exactCongrats! ...line, install dir unchanged) is derived from the source strings. CI andbun bdare canary builds and take the other branch, as before. - Staging test: one line names v9.9.9, stderr ends with the exact unpack error,
readdirSyncof the staging dir is[], the mode check is unconditional on POSIX. Before:toContain("9.9.9")and twoexistsSyncchecks. - Digest test: both runs in parallel. Mismatch: stderr ends with the exact error, including the exact asset URL for this target, plus the note line, and the staging root is
[]. Match: one line names v9.9.8, stderr ends with the exact unpack error, the staging root is["9.9.8"]. Before:toContainon a fragment andtoContain("9.9.8"). - stdout of the release flows is still not asserted: debug builds print the asset search on stdout, and on Windows
Expand-Archivewrites to the inherited stdout.
The version line is looked up among the lines (toContainAnyValues), not pinned to line 0. On POSIX a piped stderr receives a Fetching version tags progress line first when the version fetch takes longer than the progress bar's 500ms delay. The first version of this PR pinned line 0: with the debug build pinned to one CPU (taskset -c <cpu> bun bd test ...) three or four tests failed in 3 of 3 runs. With the lookup, 3 of 3 starved runs and 3 normal runs pass. The toEndWith assertions are not affected by progress output, because the progress bar ends its line before bun prints anything else.
The exact unpack line differs by platform. POSIX: Unzip failed (exit code: 9) (Info-ZIP, which scripts/bootstrap.sh installs on every Linux image and which macOS ships). Windows: error: Failed to verify Bun (code: ENOENT), because upgrade_command.rs does not check the Expand-Archive exit status and notices the failure at the verify step.
The Windows unpack line pins what bun upgrade prints today. The Windows block in upgrade_command.rs (around line 957) does not look at the Expand-Archive status, unlike the POSIX block, so a corrupt download is reported as a verify failure. That is a product question outside this test-only change. A fix there changes one constant in this file.
The stand-in is linked from realpath(bunExe()), the same idiom as test/cli/bun.test.ts:146. A shared harness helper, and the other tests that copy the binary on Windows (bun-install-registry.test.ts makes seven copies around line 9073), are candidates for a follow-up and are out of scope here. NTFS allows 1023 links per file. A machine that runs this file hundreds of times without clearing its temp dir hits that limit, and the helper then falls back to a copy.
With a Windows debug build the completing flow takes about 14s, of which about 7s is the first launch of the extracted 216 MB image, so a local bun bd test on Windows still needs --timeout as the CI runner passes it. Before this change two tests needed it even with the release build.
Open PRs #39379, #37674 and #31743 touch this file. The helpers keep their names and signatures, the argument tests keep their order, and the startReleaseServer comment that #37674 edits is untouched. The two flows that #39379 adds can use standInExe when either side rebases. test/expected-durations.json is left for CI to regenerate.
no test proof · iteration 0 · Platform-specific test-only change; deferring to CI.