Skip to content

test: hard-link the upgrade stand-in and run bun-upgrade.test.ts concurrently - #39740

Open
robobun wants to merge 2 commits into
mainfrom
farm/5e435b53/bun-upgrade-test-speed
Open

robobun wants to merge 2 commits into
mainfrom
farm/5e435b53/bun-upgrade-test-speed

Conversation

@robobun

@robobun robobun commented Aug 20, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun-upgrade.test.ts takes 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 to bunExe(), 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 behind bunExe(). test/cli/bun.test.ts:146 already 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 --stable unpacks 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>.outdated and runs completions, which recreates the bunx link (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 toContain lines, no exit code.
  • --help: stdout starts with the usage line, stderr is empty, exit code 0. Before: a not.toContain of 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 --profile was honored), stderr ends with the exact unpack error, exit code 1. Before: two not.toContain, exit code awaited but not checked.
  • Completing flow: one line names the version, stderr ends with the complete Upgraded banner, 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>.outdated and bunx.exe (bunx-debug.exe for debug builds). The non-canary branch (output ends with the exact Congrats! ... line, install dir unchanged) is derived from the source strings. CI and bun bd are canary builds and take the other branch, as before.
  • Staging test: one line names v9.9.9, stderr ends with the exact unpack error, readdirSync of the staging dir is [], the mode check is unconditional on POSIX. Before: toContain("9.9.9") and two existsSync checks.
  • 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: toContain on a fragment and toContain("9.9.8").
  • stdout of the release flows is still not asserted: debug builds print the asset search on stdout, and on Windows Expand-Archive writes 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.

…s concurrently

Every release flow in bun-upgrade.test.ts copied the test binary into
its own temp dir. On Windows the first launch of each new copy is slow
(about 2.4s for the 78 MB canary build on the arm64 lane, about 7s for
a debug build), and the three flows at the bottom of the file ran one
after another, each waiting on its own Expand-Archive.

The flows that fail before the binary is replaced now share one
stand-in, created once in beforeAll. The flow that replaces the binary
gets one of its own. Both are hard links to bunExe() and fall back to a
copy when the temp dir is on another volume. The upgrade only renames
directory entries, so the linked bunExe() is never modified. All eight
tests run in one concurrent batch. Each flow stages into its own
BUN_TMPDIR, except the one that tests the held-open default temp dir.

Assertions now pin the exact output and the state on disk: the inline
snapshots and exit codes of the argument checks, the announced version
and the exact last error line of every failing flow (digest mismatch
with the exact asset URL, unpack failure), the staging directory
contents after each run, and after a completed upgrade the exact
contents of the install directory (the fake release script on POSIX,
the parked .outdated image plus the recreated bunx link on Windows)
together with the full "Upgraded" banner.
@coderabbitai

coderabbitai Bot commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Your included review limit has been reached.

You’re in a promotional period — use the checkbox below to run this review for free:

  • Run review for free

On-demand reviews are free for the next 31 days. After that, they cost $0.25 per reviewed file.

How can I continue?

Run this review now using the option above, or comment @coderabbitai review --use-credits.

You can also wait for the limit to reset (next review available in 5 minutes), then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7b76a8cb-9238-4c21-ac1e-9452f3665c1a

📥 Commits

Reviewing files that changed from the base of the PR and between 6e906e4 and 728d0fd.

📒 Files selected for processing (1)
  • test/cli/install/bun-upgrade.test.ts

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

@robobun

robobun commented Aug 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review at 728d0fd. Test-only change, one file.

How the slow part was measured: the old file and this file were timed on the same machines with the same binaries. Windows 11 arm64 with the canary build: 17.2s before, 3.6s to 3.8s after (18 runs). Windows Server 2019 x64: 10.2s before, 3.0s to 3.3s after (10 runs). Linux x64 with bun bd test (debug+ASAN): 5.4s before, 3.0s after, also green in 3 runs pinned to one CPU. The per-copy first-launch cost that the fix removes was probed directly on the arm64 machine: a fresh copy of the binary takes about 2.4s to start the first time, a hard link to it takes 18ms. Details and the list of assertion changes are in the PR description.

@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.

I reviewed this PR and didn't find any bugs. It's a careful, well-documented test refactor, but it leans on several platform-specific claims (Windows hard-link semantics vs. the swap path, openTempDirWithoutSharingDelete running concurrently with the other flows, the exact .outdated/bunx install-dir contents) that I can't independently verify from Linux — a quick human look from someone with the Windows lanes in mind would be worthwhile.

Checked: the hard-link-is-safe argument against renameat_concurrently_without_fallback and the Windows .outdated rename path; that every concurrent flow using the shared standInExe fails before the swap and stages into its own BUN_TMPDIR; that the new exact-string assertions (Downgrading/is out!, the Upgraded banner, Unzip failed (exit code: 9), the checksum error, bunx-debug.exe) match the source in upgrade_command.rs / install_completions_command.rs; and that releaseAsset never needs a -baseline suffix because bun upgrade doesn't select one.

Extended reasoning...

Overview

Test-only change to test/cli/install/bun-upgrade.test.ts (~200 lines). Two orthogonal changes: (1) the per-test copyFile(bunExe(), ...) becomes a hard link with a copy fallback, and the flows that never reach the swap share one stand-in from beforeAll; (2) the last three release-flow tests move to it.concurrent, each with its own BUN_TMPDIR so staging dirs don't collide. Along the way every assertion is tightened — toContain fragments become inline snapshots / exact toEndWith / readdirSync equality, and exit codes are now checked everywhere.

Security risks

None. Test file only; no production code touched. The hard link is created in a temp dir and the upgrade path renames directory entries rather than writing through the inode, so bunExe() itself is never mutated (verified against the POSIX renameat_concurrently_without_fallback path and the Windows .outdated rename in source; the PR also confirms empirically via link counts / fsutil hardlink list).

Level of scrutiny

Medium. It's test-only and the PR description is unusually thorough (per-platform timings, first-launch probes, source line references, 8–13 green runs per lane), and the bug-hunting pass found nothing. But the diff is not mechanical: it introduces concurrency across flows that previously ran serially, shares one executable across concurrent subprocesses, changes what the FILE_SHARE_DELETE test runs alongside on Windows, and pins a lot of exact runtime output (the full Upgraded banner, the Windows install-dir contents including bunx.exe/bunx-debug.exe). Those are exactly the kinds of Windows-specific interactions where a maintainer sanity check is cheap insurance.

Other factors

I cross-checked the new exact strings against src/runtime/cli/upgrade_command.rs (Downgrading from Bun {}-canary, Bun v{} is out! You're on v{}, Unzip failed (exit code: {}), Failed to verify Bun (code: {}), the checksum error, the full Upgraded. banner) and install_completions_command.rs (bunx-debug.exe in debug builds) — they all match. bun upgrade never selects a -baseline asset, so the digest test's releaseAsset URL is correct on every lane. The concurrent digest runs and the --profile flow all use the shared stand-in but each set a distinct BUN_TMPDIR, and none reaches the swap. No existing test was weakened or removed; assertions are strictly stronger. Deferring only because the change is large enough and Windows-sensitive enough that it falls outside "simple/mechanical".

@robobun

robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

The three Windows-specific points from the review were checked on two Windows machines, not only from the source. For the record:

  1. Hard link vs. the replace path. After three bun bd test runs on Windows 11 arm64, fsutil hardlink list build\debug\bun-debug.exe listed the build's own entry, three stand-ins (bun.test.*\bun-debug.exe) and three parked entries (bun.test.*\bun-debug.exe.outdated). The upgrade renamed the launched link, not the build output, and the build output still worked afterwards. Renaming and deleting a link to the running test binary both succeed on Windows, so the temp dir cleanup is not affected.

  2. The held-open temp dir and the concurrent flows. In every Windows run the three failing flows finished (and their tempDir staging roots were removed from inside the held-open temp dir) while the completing flow still held the handle: on arm64 they finished at about 0.7s and the completing flow at about 3.6s, on Server 2019 at about 2.3s and 2.9s. 13 runs on arm64 and 10 runs on Server 2019 were all green. The flows never open the held-open directory themselves, because each one stages into its own BUN_TMPDIR.

  3. Install dir contents. The exact listing passed on Windows 11 arm64 with the canary build (bun.exe, bun.exe.outdated, bunx.exe) and with a debug build (bun-debug.exe, bun-debug.exe.outdated, bunx-debug.exe), and on Windows Server 2019 x64 with the canary build. On Linux the install dir held only the executable and its content was the fake release script.

… the first line

On POSIX a piped stderr receives the "Fetching version tags" progress
line when the version fetch takes longer than the progress bar's delay.
Pinned to one CPU, the debug build printed it in every run and three or
four tests failed. The announcement is now looked up among the lines,
and the non-canary branch is matched at the end of the output.

The stand-in is linked from the resolved executable path, as
test/cli/bun.test.ts already does.
@robobun

robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 728d0fd after a self-review of the first version.

The first version asserted that the version announcement is line 0 of stderr. That is not true on POSIX when the version fetch takes longer than the progress bar's 500ms delay: a Fetching version tags line comes first. With the debug build pinned to one CPU, 3 or 4 tests failed in 3 of 3 runs. The announcement is now looked up among the lines, and the non-canary branch is matched at the end of the output. 3 of 3 starved runs and 3 normal runs pass on Linux. The toEndWith assertions were not affected, because the progress bar ends its line before bun prints anything else.

The stand-in is now linked from realpath(bunExe()), the same idiom test/cli/bun.test.ts uses for its linked copy. Re-checked on Windows 11 arm64: 5 runs with the canary build green at 3.7s, and fsutil hardlink list grew by exactly 2 links per run, so the link path is still taken. The debug build passes there too with the CI per-test timeout.

The self-review also noted that the Windows unpack line pins a fallthrough (upgrade_command.rs does not check the Expand-Archive status, so a corrupt download is reported as a verify failure). This PR pins what bun prints today. A product fix there would change one constant here. The PR description has the details.

@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.

I reviewed this PR and didn't find any bugs. Because it restructures the test's concurrency (the openTempDirWithoutSharingDelete handle is now held while other flows run), switches to hard-linking the test binary, and tightens several assertions to exact platform-specific strings (Info-ZIP exit code 9, the Windows install-dir listing with bunx.exe/bunx-debug.exe), a human look would still be worthwhile.

What was reviewed:

  • Hard-link safety: bun upgrade only renames the launched entry (POSIX rename/exchange, Windows .outdated park) — bunExe() is never written through the link; cross-volume falls back to copyFile.
  • Concurrency isolation: each failing flow stages into its own BUN_TMPDIR, so the held-open default temp dir handle and the shared standInExe (never swapped) don't collide.
  • toContainAnyValues is a real jest-extended matcher already used in the suite; expectTargetsRelease covers both canary and release wording.
Extended reasoning...

Overview

Test-only change to test/cli/install/bun-upgrade.test.ts (~250 lines). Replaces per-test copyFile(bunExe(), ...) with a shared hard-linked stand-in from beforeAll (copy fallback for cross-volume), converts the three sequential release-flow tests to it.concurrent with per-test BUN_TMPDIR isolation, and strengthens assertions from toContain/not.toContain to inline snapshots, toEndWith on exact error strings, exit-code checks, and readdirSync disk-state checks. No production code touched.

Security risks

None. Test-only; the local TLS release server, NODE_TLS_REJECT_UNAUTHORIZED=0, and ASAN_OPTIONS handling are all pre-existing.

Level of scrutiny

Moderate-to-high for a test change. The correctness of running these flows concurrently rests on several platform-specific invariants: (1) bun upgrade only renames the launched directory entry and never writes into the inode behind bunExe(), so a hard link is safe; (2) holding the default temp dir open without FILE_SHARE_DELETE in the runner process does not block concurrent tests that create/delete subdirectories inside it; (3) the shared standInExe is only used by flows that fail before the swap. The PR description traces each of these to source (src/sys/lib.rs, self_exe_path()) and verifies empirically on Windows 11 arm64, Server 2019 x64, and Linux debug+ASAN with 8–13 green runs each, and the robobun follow-up confirms fsutil hardlink list and install-dir listings on real Windows machines. That is thorough, but the tightened assertions — exact Unzip failed (exit code: 9) (depends on Info-ZIP across all POSIX CI images), the exact Windows install-dir listing including the bunx/bunx-debug link created by completions, and the full Upgraded banner — are the kind of thing a maintainer familiar with the CI fleet should sanity-check.

Other factors

The bug-hunting system found nothing. I checked that toContainAnyValues is a real matcher (used in jest-extended.test.js and expect.test.js), that openTempDirWithoutSharingDelete opens fs::RealFS::get_default_temp_dir() (so the concurrent flows' BUN_TMPDIR redirection genuinely sidesteps it), and that closeTempDirHandle not being in a try/finally is pre-existing behavior, not a regression. The zip is now written to a separate tmpdirSync() so it doesn't appear in the readdirSync(installDir) assertion. Given the scope of the concurrency restructuring and the platform-specific assertion tightening, I'm deferring rather than auto-approving.

@robobun

robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

On the one new point in this review, the Unzip failed (exit code: 9) line: every POSIX CI image gets Info-ZIP's unzip. scripts/bootstrap.sh installs the unzip package for apt, dnf, apk and pkg (lines 706 to 755), and macOS ships Info-ZIP UnZip 6.00. Exit code 9 is Info-ZIP's code for "no zipfile found", which is what a non-zip body produces. Build 101753 runs this file on every one of those images, so the line is checked directly there as well. The Windows install-dir listing and the banner were covered in the two comments above and in the notes of the description.

This branch has not been deployed

No deployments
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.

1 participant