Skip to content

watch mode: re-run files replaced by an atomic save (write temp + rename) - #33071

Open
robobun wants to merge 4 commits into
mainfrom
farm/b0b51dda/watch-atomic-save
Open

watch mode: re-run files replaced by an atomic save (write temp + rename)#33071
robobun wants to merge 4 commits into
mainfrom
farm/b0b51dda/watch-atomic-save

Conversation

@robobun

@robobun robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Problem

With bun test --watch (or --watch / --hot) on Linux, saving a test file the way editors do (write a temp file, rename() it over the original: vim, VS Code safe-write, sed -i, git checkout) is never picked up when the directory has two or more test files plus any other write activity (a log file, editor temp files, bun's own terminal output). The watcher goes permanently quiet for that file: later atomic saves and even in-place appends to it do nothing until the watcher is restarted.

# dir with a.test.ts and b.test.ts; bun test --watch running, output redirected into the dir
printf '...modified...' > a.test.ts.tmp && mv a.test.ts.tmp a.test.ts
# nothing re-runs, and nothing ever will for a.test.ts

Reproduced on 1.4.0 and current main (0/5 re-runs with two files, 5/5 with one file or with an in-place append).

Cause

Two things combine:

  • The per-file inotify watch follows the inode. An atomic save replaces the inode, and the watcher holds an open fd to the old one, so the kernel never even delivers IN_DELETE_SELF; the watch is a silent zombie. The parent directory's IN_MOVED_TO is the only signal left.
  • The directory-event handler in NewHotReloader::on_file_update mapped the event's file name to a watched file through the resolver's directory-entry cache: dir_ent.entries().get(name) then abs_path. abs_path is populated lazily, only for files resolved through the current cache generation, and every directory event busts that cache. So when another test file loads after any unrelated write in the directory, the rebuilt generation has an empty abs_path for the files loaded earlier, the hash lookup misses, and the reload is dropped. Editing the last-loaded test file still worked, which is what made this look timing-dependent.

Fix

In the directory-event path, join the watched directory's path with the event's file name and look that path up in the watchlist (the watcher's own source of truth), instead of going through the resolver cache. The resolver cache is still consulted, but only to invalidate the entry's cached fd/stat. The matched entry is evicted as before so the reload re-arms a watch on the new inode.

This also removes the dead prev_entry_id bookkeeping and the unused constructed-path hash in the old not-in-cache arm (it overwrote the path separator and hashed a stale trailing byte, so it could never match anything).

Verification

New test in test/cli/hot/watch.test.ts: two test files where the first writes into the watched directory while it runs (forcing the cache regeneration), then the first-loaded file is replaced via write-temp-then-rename.

  • unfixed (USE_SYSTEM_BUN=1): fails 3/3, no re-run ever happens
  • fixed (bun bd test): passes

test/cli/hot/watch.test.ts, test/cli/hot/hot.test.ts, test/cli/hot/watch-many-dirs.test.ts, test/cli/watch/watch.test.ts, test/cli/watch/watcher-trace.test.ts, and test/cli/test/test-changed.test.ts all pass with the fix.

Rebase note

Rebased onto main to resolve a conflict with #33056, which added a per-entry mutex around the resolver-cache fd/stat invalidation inside the block this PR rewrites. The resolution keeps that locking: the set_cache_fd / need_stat writes in the new loop now take Entry.mutex exactly as #33056 does. The rebase also drops the autofix-ci docs commit (main is formatted now), so the PR no longer touches docs/.

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Rewrites the directory-event-to-watched-file mapping in hot_reloader.rs to handle atomic saves (write-then-rename), adds a watch mode test verifying atomic save detection on non-Windows, and makes minor documentation edits to the Base64 guide and Web APIs table.

Changes

Hot Reloader Atomic Save Support

Layer / File(s) Summary
Directory-event mapping rewrite
src/jsc/hot_reloader.rs
Replaces the old abs_path/prev_entry_id approach with path construction from directory + changed name, loader filtering, resolver cache reset, hash deduplication via last_file_hash, and fd-validity-gated eviction.
Atomic save watch mode test
test/cli/hot/watch.test.ts
Adds a skipIf(isWindows) test that spawns bun test --watch, simulates editor-style atomic saves via writeFileSync + renameSync, and asserts reruns are triggered for the affected file.

Documentation Cleanups

Layer / File(s) Summary
Base64 guide and web-apis table
docs/guides/util/base64.mdx, docs/runtime/web-apis.mdx
Moves the btoa()/atob() example into the <Warning> block as a standalone fenced code block, and reformats the Web APIs markdown table rows without changing listed APIs.

Possibly related PRs

  • oven-sh/bun#33040: Directly related — involves the same docs/guides/util/base64.mdx and docs/runtime/web-apis.mdx files with Base64 doc restructuring and Web API reference changes.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: watch mode now reruns files replaced via atomic-save renames.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description is detailed and covers the problem, fix, and verification, even though it uses custom headings instead of the template.

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

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

@mintlify

mintlify Bot commented Jun 29, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jun 29, 2026, 1:04 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed c7e0716 to fix the cargo clippy failure (clippy::undocumented_unsafe_blocks wants the SAFETY comment on the line directly above the unsafe block; rustfmt had wrapped them apart). The test still passes against the debug build.

Note for reviewers: the two docs/*.mdx files in the diff come from the autofix-ci bot formatting files that were already unformatted on main. They are not part of this change.

@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 didn't find any bugs, but this rewrites the directory-event → watchlist mapping in on_file_update (unsafe deref, manual path-buffer indexing, kqueue/inotify split), so it's worth a human look from someone familiar with the watcher.

Extended reasoning...

Overview

The substantive change is in src/jsc/hot_reloader.rs: the directory-event branch of NewHotReloader::on_file_update is rewritten (~120 lines). Previously it mapped a changed file name to a watchlist entry by going through the resolver's directory-entry cache (dir_ent.entries().get(name).abs_path), which returned an empty path for files loaded under a prior cache generation, silently dropping the reload. Now it joins the watched directory path with the event's basename, hashes that, and scans the watchlist directly. The resolver cache is still touched, but only to invalidate the entry's cached fd/stat — it no longer gates whether a reload fires. Dead prev_entry_id bookkeeping and a buggy never-matching path-hash arm are removed. A new regression test in test/cli/hot/watch.test.ts exercises write-temp-then-rename with two test files plus in-directory write activity. The two docs/*.mdx diffs are autofix-ci whitespace reflows unrelated to the fix.

Security risks

None apparent. The changed path is internal watcher bookkeeping; inputs are file-system event basenames from inotify/kqueue, joined into a fixed-size stack PathBuffer with an explicit length guard before the copy_from_slice calls. No new external input surface, auth, or crypto is involved.

Level of scrutiny

Medium-high. This is core --watch / --hot runtime behavior that every developer using watch mode hits, the code path is platform-split (kqueue vs inotify) with manual byte-buffer indexing and an unsafe { &*dir_ent } deref, and watcher logic in this file has a documented history of subtle cross-thread races (see the surrounding comments about flush_evictions ordering). The refactor changes control flow (the inner hash-match loop now breaks after the first hash match regardless of fd validity, and the resolver-cache lookup pattern-matches on Fs::EntriesOption::Entries where the old code called .entries() unconditionally). These look correct but are the kind of thing a maintainer who knows the watchlist invariants should eyeball.

Other factors

The bug-hunting system found no issues. The PR description is thorough, the new test is targeted and verifies the unfixed binary fails, and the author reports the broader watch/hot test suites pass. CI on commit 6516af4 failed on a clippy undocumented_unsafe_blocks lint, which c7e0716 says it fixes; the robobun status comment hasn't yet updated for the new commit. Given the subsystem's sensitivity I'm deferring rather than approving.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Two notes on the control-flow points raised above, for whoever picks this up:

  • The inner hash-match loop breaking after the first match regardless of fd validity is the pre-existing behavior: the old loop also broke at the first *hash == file_hash, with the fd check only gating the append. Watchlist hashes are unique per path, so there is at most one match.
  • Matching Fs::EntriesOption::Entries(..) instead of calling .entries() is deliberate. .entries() is generated by enum_unwrap! and panics on the Err variant, which a directory whose readdir failed can cache; the match just skips the fd/stat invalidation in that case instead of panicking on the watcher thread. For the Entries variant the behavior is unchanged.

CI is green so far on c7e0716 (clippy included); the remaining lanes are still running.

Comment thread test/cli/hot/watch.test.ts Outdated
Comment thread test/cli/hot/watch.test.ts Outdated
@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Status: the change and its regression test are done, all review threads are resolved, and the remaining CI red is unrelated to this diff.

Final state of build 67252 (post-rebase head ad81bf7): 282 jobs passed, 3 lanes red, each from tests this PR does not touch:

  • darwin 26 aarch64: an HTTP upload test ("uploads roundtrip with sendfile()") timed out; test/cli/hot/watch.test.ts ran on that lane and passed.
  • alpine 3.23 x64: test/js/node/test/parallel/test-net-connect-memleak.js failed all 4 attempts, exactly as it did on the pre-rebase head and on both musl lanes, so it looks broken on musl on main.
  • alpine 3.23 x64-baseline: the same memleak test, plus test/js/sql/sql-mysql.test.ts.

None of these involve watch mode or the hot reloader, and the new regression test passed on every lane that ran it. I used my one CI re-trigger earlier for an infra failure, so I'm not pushing more empty commits; this is ready for review.

Comment thread test/cli/hot/watch.test.ts Outdated

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

Thanks for addressing the test nits — those all look good now. The substantive change is the rewrite of the directory-event → watchlist mapping in on_file_update (path-join + hash lookup replacing the resolver-cache abs_path path), which is well-reasoned but touches core watcher-thread logic with unsafe blocks and eviction ordering, so it's worth a human pass on the Rust side.

Extended reasoning...

Overview

The PR fixes bun --watch / bun test --watch failing to detect atomic saves (write-temp + rename) on Linux when a directory has multiple watched files plus other write activity. Files touched:

  • src/jsc/hot_reloader.rs — rewrites the directory-event handler's mapping from changed-name → watched-file. Previously it went through the resolver's directory-entry cache (dir_ent.entries().get(name).abs_path), which returns an empty path for entries loaded under a prior cache generation. The new code joins the watched directory path with the event's filename, hashes that, and looks it up directly in the watchlist. It also removes dead prev_entry_id bookkeeping and a broken constructed-path hash arm. The resolver cache is still consulted for fd/stat invalidation but no longer gates the reload.
  • test/cli/hot/watch.test.ts — new regression test (skipped on Windows) that reproduces the bug with two test files + in-directory write noise, then atomically replaces the first-loaded file via writeFileSync + renameSync. Also adds a file-level setDefaultTimeout.
  • docs/guides/util/base64.mdx, docs/runtime/web-apis.mdx — autofix-ci formatting noise (whitespace/table reflow only, no content change), explicitly flagged by the author as not part of the change.

Security risks

None identified. The change is confined to the file-watcher's internal event-to-reload mapping; no new external input surfaces, no auth/crypto/permissions code. The one unsafe block in the diff narrows an existing &mut *dir_ent deref to &*dir_ent with the same SAFETY justification (points into rfs.entries or a tombstoned copy that outlives the iteration). Path construction writes into a fixed-size PathBuffer with an explicit length guard before copy_from_slice.

Level of scrutiny

Medium-high. This is core runtime watcher-thread logic that every --watch / --hot user hits on every save. The diff is ~120 changed lines of Rust replacing one lookup algorithm with another, interacting with: the watchlist hash table, remove_at_index eviction (which feeds flush_evictions → fd close + swap_remove ordering already documented as race-sensitive in this file), and the kqueue/inotify split. The PR description and inline comments are thorough and the regression test is targeted, but the correctness depends on the constructed path matching exactly what Watcher::add_file hashed when the entry was added — including trailing-slash handling via strings::trim_right and SEP. That's the kind of invariant a human familiar with bun_watcher should sign off on.

Other factors

  • All three of my earlier test-quality nits (vacuous assertion, per-test timeout, redundant manual cleanup alongside await using) were addressed in a7e01a2 and 905fefb; the inline threads are resolved.
  • The author preemptively explained two control-flow subtleties (single-match break semantics preserved from the old code; Fs::EntriesOption::Entries(..) match instead of .entries() to avoid panicking on a cached Err variant), which are reasonable.
  • CI: the only red lane reported is a darwin-aarch64 artifact-download infrastructure failure unrelated to this diff; the new test passes on the lanes that ran. One unrelated flaky (test-net-connect-memleak.js) on Alpine x64.
  • The two docs/*.mdx changes are pure autofix reformatting and don't need review.

Given the scope of the hot_reloader.rs rewrite and its position on the watcher thread, I'm deferring rather than approving.

robobun added 4 commits June 30, 2026 14:31
…ir cache

A file saved via write-temp-then-rename (vim, VS Code, sed -i, git checkout)
replaces its inode, so the per-file inotify watch goes permanently quiet: the
watcher holds an open fd to the old inode, so not even IN_DELETE_SELF is
delivered. The parent directory's IN_MOVED_TO is the only remaining signal.

The directory-event handler mapped the event's file name to a watched file
through the resolver's directory-entry cache (`dir_ent.entries().get(name)`,
then `abs_path`). `abs_path` is populated lazily, only for files resolved
through the current cache generation, and any unrelated write in the directory
(a log file, editor temp files, test output) busts that cache. Once a later
module load rebuilds it, previously-loaded files hash to an empty path, the
watchlist lookup misses, and the reload is silently dropped; the file is never
watched again.

Join the watched directory's path with the event's file name and look that up
in the watchlist instead. The resolver cache is still consulted, but only to
invalidate the entry's cached fd/stat.
clippy's undocumented_unsafe_blocks requires the comment on the line
immediately preceding the unsafe block; rustfmt had wrapped the if-let so
the block landed on its own line. Bind the deref first instead.
…eanly

Drop the per-test timeout argument in favor of setDefaultTimeout like the
sibling watch suites, and replace the assertion that restated the awaited
condition with one that checks the re-run had no failing tests.

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

All my earlier nits are addressed and I didn't find any new issues, but this rewrites the directory-event → watchlist mapping in the hot reloader (path construction, hash lookup, eviction, resolver-cache invalidation under the per-entry mutex) across both the kqueue and inotify paths, so it's worth a human pass on the core logic.

Extended reasoning...

Overview

The PR rewrites a ~100-line block inside NewHotReloader::on_file_update in src/jsc/hot_reloader.rs — the path that maps a directory-level filesystem event to a specific watched file — and adds a regression test in test/cli/hot/watch.test.ts. The old code looked the changed name up in the resolver's directory-entry cache and used the cached abs_path; the new code joins the watched directory path with the event's file name, hashes that, and looks it up directly in the watchlist. The resolver cache is still consulted, but only to invalidate the cached fd/stat (under the per-entry mutex introduced by #33056). Dead bookkeeping (prev_entry_id, the broken not-in-cache path-construction arm) is removed.

Security risks

None apparent. The change is local to the file-watcher's reload-trigger path and does not touch auth, network, process spawning, or untrusted input parsing. The one unsafe block (dereferencing the *mut Fs::EntriesOption tombstone pointer) is narrower than before (&* instead of &mut *) and keeps the pre-existing SAFETY justification.

Level of scrutiny

Medium-high. This is core runtime behavior for --watch, --hot, and bun test --watch on Linux and macOS. The new path-construction + hash-lookup logic must agree exactly with how watchlist entries were hashed when added (trailing-slash trimming, separator handling), and the eviction/break ordering interacts with the watcher-thread vs JS-thread flush_evictions sequencing documented elsewhere in the file. The author's analysis is detailed and the regression test reproduces the failure on the unfixed binary, but a human familiar with the watcher should confirm the watchlist-hash equivalence and the kqueue branch (which the new test skips on Windows but does exercise on macOS CI).

Other factors

All three of my earlier inline nits (vacuous assertion, explicit per-test timeout, redundant manual proc.kill() alongside await using) have been addressed and the threads are resolved. The bug-hunting pass found nothing this round. The author reports the full hot/watch test suite passes and CI is green except for unrelated node-compat flakes. Given the change is a real logic rewrite in a concurrency-sensitive subsystem rather than a mechanical fix, I'm deferring rather than auto-approving.

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