Skip to content

test(hot): remove two races from the entry-point promise tests - #44666

Open
robobun wants to merge 1 commit into
mainfrom
robobun/638c6aac/hot-entry-promise-test-saves
Open

robobun wants to merge 1 commit into
mainfrom
robobun/638c6aac/hot-entry-promise-test-saves

Conversation

@robobun

@robobun robobun commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • does not take a promise of the program's, %s, for that of the entry point fails on Linux in 75 of the last 400 CI builds: "collected" twice on stdout (47), or error: Expected ";" but found ")" on stderr (30).
  • The test saves main.js in place. fs.writeFileSync writes over the file, then calls ftruncate (src/runtime/node/node_fs.rs:7376): two watcher events. A reload between them runs the second load twice, or parses the new file plus the old tail.
  • A build from before --hot: fix a use-after-free of the entry point's promise #44350 must fail the rejected and handled case. It fails only 6 of 20 runs: the promise catch() returns can get the collected cell.

Fix

  • The three saves differ in one digit, so every read gets one whole version.
  • The second load does its work once.
  • The 20,000 promises are made before the first catch().
  • Verified: bun bd test test/cli/hot/hot.test.ts passes. With 50 ms between the two calls, the old tests fail 8 of 8 and the new pass 8 of 8. A build from before --hot: fix a use-after-free of the entry point's promise #44350 fails the new tests 6 of 6.

Background

Notes

This PR changes tests only. No file under src/ changes.

Source of the CI numbers. Annotations of the 400 most recent finished builds, 122998 to 123628 (2026-10-02 to 2026-10-06). The two tests fail in 97 of them. Every hit passed on the retry.

signature builds lanes
"collected" twice 47 debian 13 aarch64 17, debian 13 x64 13, ubuntu 25.04 x64 9, ubuntu 25.04 aarch64 8, alpine 3.23 aarch64 5, alpine 3.23 x64 1
Expected ";" but found ")" 30 debian 13 x64 13, ubuntu 25.04 x64 8, alpine 3.23 aarch64 5, ubuntu 25.04 aarch64 3, debian 13 aarch64 1
timeout after 90 s, stdout has only first load 25 windows 11 aarch64 14, windows 2019 x64 12

The Windows timeouts are not changed by this PR. There the first save, made right after first load, never reloads. #40017 (open) describes a change that Windows misses right after the entry point first runs. I did not run Windows, so I did not confirm that it is the same failure.

What a reader sees during fs.writeFileSync. One process rewrites a file in a loop with a 201-byte and a 44-byte content. A second process reads it in a loop. With Bun as the writer, 82,652 of 727,000 reads returned the 44 new bytes followed by the last 157 old bytes, and none returned an empty file. With Node 26.3.0 as the writer (O_TRUNC), 459,284 of 860,000 reads returned an empty file and 2 returned such a mix.

The stderr of the second signature is that content. The second load is longer than the third. console.log("third load"); process.exit(0); is 43 bytes, and byte 43 of the second load is the "fs") of require("fs").readFile(__filename, () => {.

Both signatures from a delay between the two calls. A probe did the syscalls of fs.writeFileSync by hand (openSync without O_TRUNC, writeSync, a busy wait, ftruncateSync). Release build (367d939), the same three saves with a second load that has no collections and no promises, 40 runs per row:

delay passes collected twice parse error
0 38 2 0
200 µs 19 21 0
1 ms 0 38 2
5 ms 0 0 40

The parse error is the one from CI, character for character. The same probe around the real tests, debug build of main, 8 tests per cell:

delay old tests fail new tests fail
0 0 0
1 ms 3 0
5 ms 0 0
50 ms 8 0

The debug build starts a reload late, so a second event that is a few milliseconds behind often still joins the first reload.

Without the delay I could not make the old tests fail on this machine (ext4, 12 cores): 560 runs of the write pattern on a release build, pinned to one CPU next to busy loops for some of them, all passed. In CI the second event comes late often enough.

Why one digit. Two versions of the same length that differ in one byte have no state in between: a read that sees part of a write still sees one of the two. That holds for the write strategy of today (no O_TRUNC) and for a truncate followed by a write, where the state in between is an empty file that loads and prints nothing.

Why the second load has a guard. --hot can still reload twice for one save. The older tests in this file accept that too (expect(reloadCounter).toBeGreaterThanOrEqual(3), and driveErrorReloadCycle saves again when it sees the previous error). #30617 (open) makes the watcher wait longer before it posts a reload. This PR does not depend on it.

Why a rename does not remove the second reload. A rename over the entry point gives a MOVED_TO event on the directory and a DELETE_SELF event on the replaced file. src/jsc/hot_reloader.rs:1051 handles the case that they land in separate reads, and each posts a reload.

The catch() change. Promise.reject(e).catch(() => {}) makes two promises: the rejected one and the one that catch() returns, which is fulfilled a moment later. The old loop made them in turn, so the collected cell went to either kind. A fulfilled promise in that cell looks like an entry point that loaded, and the unfixed build passes. "Rejected and handled" case, release build from before #44350 (367d939), 20 runs per row:

test promises made fails
old in turn 6
new in turn 9
old all 20,000, then the catch() calls 20
new all 20,000, then the catch() calls 20

The pending case fails every run in all four rows: both of its promises stay pending.

Other runs. The two new tests under 16 busy loops on 12 cores, debug build: 16 of 16 pass. The whole file on the debug build: 16 pass, 0 fail.

Not changed. holds the promise of the entry point itself, which it looks at on every tick also saves in place. Its second version is longer than the first, it waits only for the line of the second load, and it has no failure in the 400 builds.


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/hot/hot.test.ts

The two tests save main.js in place. fs.writeFileSync writes over the
file and then resizes it, which is two watcher events. When --hot
reloads between them, the second load runs twice and prints "collected"
twice, or the third load parses the new file followed by the tail of
the old one.

The three saves now have the same length and differ in one digit, so a
read always gets one whole version. The second load does its work once.
The 20,000 promises are made before the first catch(), so the collected
cell goes to one of them and not to a promise that catch() returns.
@coderabbitai

coderabbitai Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 1 billable file and costs up to $0.25.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 22 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: b1c878dc-4730-415b-840a-af5c2845bc8a
📥 Commits

Reviewing files that changed from the base of the PR and between bbdc5a5 and e33e707.

📒 Files selected for processing (1)
  • test/cli/hot/hot.test.ts
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@github-actions github-actions Bot added the claude label Oct 6, 2026
@robobun

robobun commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. This PR changes tests only.

How the failure was reproduced: a probe did the syscalls of fs.writeFileSync by hand around the two tests (openSync without O_TRUNC, writeSync, a delay, ftruncateSync).

  • Debug build of main, 50 ms delay: the old tests fail 8 of 8 with "collected" twice on stdout. The new tests pass 8 of 8.
  • Release build, 5 ms delay: 40 of 40 runs print error: Expected ";" but found ")" for console.log("third load"); process.exit(0);"fs").readFile(__filename, () => {, the same text as in CI.
  • Release build from before --hot: fix a use-after-free of the entry point's promise #44350: the new tests fail 6 of 6, so they still catch the bug they were written for.

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

Code review found no issues

No high-confidence issues detected in this change.

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