Skip to content

dev server: release the directory watch of a failed import by hash - #46916

Open
robobun wants to merge 1 commit into
mainfrom
robobun/2505b678/dev-server-release-directory-watch-by-hash
Open

robobun wants to merge 1 commit into
mainfrom
robobun/2505b678/dev-server-release-directory-watch-by-hash

Conversation

@robobun

@robobun robobun commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • After a failed import resolves, another file can stop hot reloading until a dev server restart.
  • DirectoryWatchStore::free_entry removes the directory watch of the failed import by a stored position in the watcher's list (src/runtime/bake/dev_server/directory_watch_store.rs:66). Watcher::flush_evictions moves the last item into a removed slot (src/watcher/Watcher.rs:450), so the position can name another file's watch.
  • A second failed import there, right after the first resolves, takes the watch queued for removal and is never retried.

Fix

  • Watcher::remove_directory(hash) finds the item under the watcher mutex and queues it. add_directory returns no position and keeps a queued item for the store.
  • The watcher opens the directory with O_DIRECTORY. The store holds no position and no descriptor.
  • Verified on Linux: 10 new tests in test/bake/dev/html.test.ts and bundle.test.ts (8 fail on main), plus css, hot, esm there, test/cli/hot, test/cli/watch.
  • Self-reviewed: 23 concerns raised, 21 addressed. Rejected: a kind check in add_directory (the release has one), a second trigger test (same code path).

Background

Downsides

  • free_entry scans the watchlist: 144 to 5,247 instructions at 1,000 watch items. A file's first watch: -21 to +54. Text: +256 B.
  • macOS and Windows were not run. On macOS the watcher opens the directory with O_RDONLY, not O_EVTONLY.
  • Not fixed: a release removes a directory item that two producers share.
Notes

Where it comes from. There is no issue and no user report. The wrong removal showed in the dev server's debug logs. free_entry has removed by a stored position since the store was written.

Who can hit it. Any failed relative import (also one left by a moved or deleted file), then the removal of a file's watch item, then the import resolves.

  • Linux: the watcher removes a file's item when the file is renamed away, which is how vim saves. A file in the project root keeps reloading, because the item of its directory also reports the save. A file outside the root has no such item and stops reloading.
  • macOS and Windows: read from the code, not run. Directory events carry no file name there, so a file in the project root has no second reporter.
  • macOS, also read and not run: main's store opened a descriptor for the directory, free_entry closed it, and flush_evictions closed the same number again for the watch item. The watch item is now the one owner.
  • Windows, read and not run: for a directory outside the project, main's store kept u32::MAX as its position and queued it at release. The store keeps no position now.
  • A failed import such as ./dep.ts/x names the watched file dep.ts as its directory. On main the release removed the watch item of that file. remove_directory only queues a directory item.
  • On Linux main, a failed import below a FIFO blocked the dev server in open(2). O_DIRECTORY makes that open fail.
  • The second case in steps: a file is created that a failed import names, and that file has a failed relative import of its own in the same directory. After the next watcher event, the creation of the second missing file reloads nothing. On Linux this shows only for a directory outside the project root.

Deleted. Entry.watch_index, Entry.dir, Entry.dir_fd_owned, the resolver probe and the descriptor code in insert, the FdExt import. From Watcher::remove_at_index: the LOCK generic, the unused kind, hash and parents parameters, the branch that cast a hash to a position, the sentinel assert. The position that append_directory_assume_capacity returned, with parent_watch_item in append_file_maybe_lock.

Other designs.

  • Stable slots in the watchlist stop the moving of items. They would add a check per event and a field per item for --hot and --watch too.
  • A store that never releases keeps one item and one descriptor per directory for the session.
  • A release after the rebuild (when a rebuilt importer stops reporting the import) removes the order of the second case. A bundle that a page request starts can still register a directory whose removal is queued, so the cancel stays needed.
  • remove_directory queues and does not remove. Each platform loop maps events to positions before it takes the mutex, so only the watcher thread can reorder the list. flush_evictions now asserts that thread in debug builds.
  • The Zig watcher had Watcher.remove(hash). Remove ~39k lines of dead Rust across the workspace #35002 removed it as dead code.

Tests. "main" is a release build of 179664e, one process per run.

Test Covers On main (Linux)
html: keeps the watch of a file that was saved by rename stale position, file outside the root fails
html: keeps the watch of a file in the project root stale position, file in the root (also runs on Windows) fails (trace)
html: releases the watch of its directory the release removes its own item fails (trace)
html: is retried when the watch of its directory was released just before cancel of a queued removal fails
html: keeps its directory watch and not another released watch the cancel keeps other queued removals fails
bundle: removing 'use client' releases a directory watch second caller of free_entry fails
bundle: below a watched file keeps the watch of that file kind check in remove_directory fails
bundle: recovers from importing a file below a FIFO O_DIRECTORY fails
bundle: a file created for a failed import reloads on its later saves macOS double close (guard) passes
bundle: keeps a save by rename in that directory the store's item is not the parent item of watched files (guard) passes
  • main: each of the 8 fails in 10 of 10 runs, and the 2 guards pass in 10 of 10.
  • This branch: 200 of 200 pass on a release build and 200 of 200 on the debug ASAN build (20 runs per test).
  • Each of these changes to the fix makes one test fail on the debug build: no kind check, a cancel that drops every queued removal, no cancel, no call of remove_directory, no O_DIRECTORY, a store directory named with a trailing separator.
  • cargo check -p bun_runtime passes for aarch64-apple-darwin, x86_64-pc-windows-msvc, x86_64-unknown-freebsd, aarch64-unknown-linux-musl and x86_64-linux-android.

Measurements. linux-x64, release builds of main 179664e and of this branch. perf and valgrind are not on this machine, so an instruction count is a gdb stepi count of one call.

  • watcher-thread functions: 5 of 6 identical after address normalisation, size delta 0 B (nm -S, objdump -d). The sixth, on_file_update_wrapped::<DevServer>, has the same size and instruction count and encodes one bound check differently.
  • resolver hook (get_resolve_watcher::wrap): -3 B, -1 instruction on the add_directory hash-hit path (objdump).
  • free_entry: 747 / 5,247 / 50,264 instructions at 100 / 1,000 / 10,000 watch items, 144 on main.
  • track_resolution_failure: -264 instructions with an empty eviction queue, -221 with one pending release (3,568 to 3,304 and 1,991 to 1,770 at 100 items).
  • first watch of a file (append_file_maybe_lock): 1,228 to 1,207 instructions at 1,000 items and 1,176 to 1,230 at 10,000 when the parent directory is watched. 6,855 to 2,934 and 51,804 to 12,480 when it is not.
  • release binary text: +256 B (size). free_entry 372 to 202 B, track_resolution_failure 2,906 to 2,421 B plus 452 B for add_directory::<true, true>, remove_directory 240 B.
  • size_of::<Entry>() 16 to 4 B. size_of::<Watcher>() 35,936 B, unchanged.
  • directory items left per cycle: reported order main 1 / this branch 0. Second failed import after a release: 1 item and 1 descriptor while it is pending, 0 after it resolves (readlink /proc/<pid>/fd, 20 cycles).
  • macOS opens and closes of the watched directory per cycle: not measured, no macOS machine.

Not fixed here.

  • remove_directory must not get the hash of a watched file's parent item. The store names its directory without the trailing separator and the parent item has one, so the two differ. One test pins this.
  • Two producers that share a directory item by hash (the resolver hook with the same spelling, or a 32-bit collision): a release removes the item for both. That needs an owner count.
  • On Windows an evicted item's handle stays open (flush_evictions closes descriptors on POSIX only).
  • On Windows the open of a directory item has no O_DIRECTORY, as on main. A failed import below a file that is not watched yet still registers that file as a directory item there (read, not run).
  • On Linux a file outside the root that is replaced by a rename over it is not reported again (watcher: report a file that was replaced by rename on Linux #36248, watcher: keep no descriptor for a watched file outside kqueue #39571). This branch was not built on top of those.
  • The watcher never calls inotify_rm_watch. The eviction queue is a fixed array of 8,096 entries.

Overlap. #46029 and #45723 cut the same remove_at_index parameters. #46029 lists this defect under "Not fixed here", and its Entry.watch_index hunks go away with this change. #46706 looks the item up by hash inside free_entry. #42129, #39834 and #46809 also change Watcher.rs.

Related. #29529 fixed the same kind of stale position for kqueue (#29524). #40640 fixed the same symptom for files outside the root from another cause (#42094).


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bake/dev/html.test.ts

`DirectoryWatchStore` kept the position of its watch item in the
watcher's list and removed the item by that position when a failed
import resolved. The watcher moves its last item into the slot of a
removed item, so the position could name the item of another file.
That file then stopped hot reloading until restart.

- `Watcher::remove_directory(hash)` finds the directory item under the
  watcher mutex and queues it for removal.
- `Watcher::add_directory` returns no position. For the store it also
  keeps an item that a release queued, so a failed import in the same
  directory right after a release is still retried.
- `Watcher::remove_at_index` is for `on_file_update` only. Its `LOCK`
  generic and its unused `kind`, `hash` and `parents` parameters are
  gone.
- The watcher opens the directory of a store watch on every platform,
  with `O_DIRECTORY`, and closes it if the watch fails. On macOS the
  store no longer opens a descriptor that it and the watch item both
  close.
@coderabbitai

coderabbitai Bot commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 13 minutes.

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
Commits

Reviewing files that changed from the base of the PR and between 179664e and 63d2804.

Files selected for processing: 7
  • src/jsc/hot_reloader.rs
  • src/runtime/bake/dev_server/directory_watch_store.rs
  • src/runtime/bake/dev_server/mod.rs
  • src/runtime/bake/dev_server/watch.rs
  • src/watcher/Watcher.rs
  • test/bake/dev/bundle.test.ts
  • test/bake/dev/html.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@robobun

robobun commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on linux-x64 with the dev server test harness. The first new test in test/bake/dev/html.test.ts has these steps:

  1. web/index.ts imports ../outside/dep. The dev server runs in web/.
  2. Save web/index.ts with an import of ./later, a file that does not exist.
  3. Save outside/dep.ts the way vim does: rename it away, then write a new file.
  4. Create web/later.ts.
  5. Save web/index.ts.
  6. Save outside/dep.ts.

On main, step 6 produces no hot update (10 of 10 runs on a release build of 179664e). With this change it does (20 of 20).

PR: #46916

@robobun

robobun commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:20 PM PT - Oct 25th, 2026

✅ @robobun, your commit 63d28045b9f0f5068b07cf90221321eed056456f passed in Build #132741! 🎉


🧪   To try this PR locally:

bunx bun-pr 46916

That installs a local version of the PR into your bun-46916 executable, so you can run:

bun-46916 --bun

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked two things in the watcher refactor and ruled them out: the parent-eviction loop deleted from remove_at_index was dead at every caller (all passed Kind::File and an empty parents slice), so dropping it is behavior-preserving; and the CANCEL_REMOVAL compaction in add_directory cannot discard watcher-thread evictions, because on_file_update flushes its queue under the same mutex before releasing it, so only remove_directory entries are ever in evict_list when the main thread takes the lock.

Extended reasoning...

The change replaces the dev server's stored WatchItemIndex/fd with a hash-keyed removal in src/watcher/Watcher.rs and src/runtime/bake/dev_server/directory_watch_store.rs, touches no security-sensitive surface, and has four confirmed inline findings (an unbounded evict_list write, hash-only removal evicting the resolver's shared directory watch, and two platform-flag issues) that warrant a human look.

Comment thread src/watcher/Watcher.rs
bun_sys::open_a(file_path, bun_sys::O::RDONLY | bun_sys::O::CLOEXEC, 0)?
// `O_DIRECTORY`: a FIFO or a file at this path fails here and is not opened.
#[cfg(not(windows))]
let flags = bun_sys::O::RDONLY | bun_sys::O::DIRECTORY | bun_sys::O::CLOEXEC;

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.

🟡 nit (optional): on macOS, directories watched for a failed import are now held open with a plain read handle instead of the event-only flag the rest of the watcher uses. src/watcher/Watcher.rs:622 builds O::RDONLY | O::DIRECTORY | O::CLOEXEC, while every other watch open in this file uses WATCH_OPEN_FLAGS (O_EVTONLY | O_CLOEXEC on darwin, Watcher.rs:44), and the store's removed open used O::DIRECTORY | WATCH_OPEN_FLAGS. Fix: open with WATCH_OPEN_FLAGS | bun_sys::O::DIRECTORY on non-Windows so the directory handle never blocks an unmount and matches the file-watch opens. The PR description names the O_RDONLY/O_EVTONLY difference as an unrun downside. [also at: src/watcher/Watcher.rs:624 - nit: on macOS the directory watched for a failed import is now held open with O_RDONLY instead of O_EVTONLY, which the base opened it with.]

Why this was flagged

A macOS dev server user has a failed relative import whose directory exists (for example on an external or network volume); DirectoryWatchStore::insert at src/runtime/bake/dev_server/directory_watch_store.rs:285 passes Fd::INVALID so the watcher opens the directory itself at src/watcher/Watcher.rs:625. The flags at Watcher.rs:622 are O_RDONLY|O_DIRECTORY|O_CLOEXEC, not the O_EVTONLY|O_CLOEXEC that WATCH_OPEN_FLAGS (Watcher.rs:44) provides and that add_file_by_path_slow (Watcher.rs:865) and the base-branch store open (O::DIRECTORY | WATCH_OPEN_FLAGS) used. An O_RDONLY directory handle counts as a vnode in use, so the volume cannot be unmounted until the watch is flushed, whereas the base branch's O_EVTONLY handle does not block unmount. The PR description lists this flag difference as an untested downside rather than an intended behaviour.

Verification: Trigger: a macOS dev server records a failed relative import whose directory exists. Mechanism verified. src/watcher/Watcher.rs:622 builds bun_sys::O::RDONLY | bun_sys::O::DIRECTORY | bun_sys::O::CLOEXEC. So for the failed-import directory, merging changes the macOS descriptor from O_EVTONLY to a plain O_RDONLY read handle, which blocks eject/unmount while the dev server runs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept O_RDONLY on purpose. This line opens every directory item that the watcher creates, not only the one of the store. On main it already opens the parent directory of each watched file with O_RDONLY, because the bundler passes no dir_fd (src/bundler/ParseTask.rs:2393). Each watched file also keeps the read descriptor of its parse. So a volume with dev server sources is busy with or without this one descriptor.

WATCH_OPEN_FLAGS here would change those parent items to O_EVTONLY on macOS, and --hot calls faccessat on the descriptor of a directory item there (src/jsc/hot_reloader.rs:982). I have no macOS machine to run that. The description names the flag difference under Downsides.

Comment thread src/watcher/Watcher.rs
Comment on lines +942 to +950
pub fn remove_directory(&mut self, hash: HashType) {
let _guard = self.mutex.lock_guard();
let Some(index) = self.index_of(hash) else {
return;
};
// `add_directory` matches by hash alone, so the item can be a file's.
if self.watchlist.items_kind()[index as usize] == WatchItemKind::Directory {
// Queued, not removed: events in flight name items by position until the watcher thread flushes.
self.remove_at_index(index);

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.

🟣 pre-existing, not blocking: Dev-server users whose failed import names a directory the resolver already watches lose the resolver's watch on that directory when the import resolves; the resolver's cached fd for it is closed at the next flush. remove_directory at src/watcher/Watcher.rs:944-950 looks up by hash alone and queues whichever Directory item owns that hash, including one the resolver registered through on_maybe_watch_directory with its own dir_fd. Fix: the store must only release an item it created (track that add_directory::<true,true> appended rather than hash-hit, or refcount the item), so a shared item survives the store's release. [also at: src/watcher/Watcher.rs:817 - Dev-server users can lose the resolver's directory watch for a project directory after a failed import there resolves, so the resolver's dir cache is never busted again for that directory. src/watcher/Watcher.rs:804-817 only cancels a queued eviction when CANCEL_REMOVAL is true; the resolver's add_directory::<false, false> at src/watcher/Watcher.rs:983 hash-hits the item that free_entry just queued and returns Ok without cancelling, and the next flush evicts it.]
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

The resolver registers directory D with its cached fd via src/watcher/Watcher.rs:976-984 (hash of D without trailing slash). A failed relative import under D then calls insert, which hash-hits at src/watcher/Watcher.rs:804 and returns Ok(()) with no new item (src/runtime/bake/dev_server/directory_watch_store.rs:283-295). When the import resolves, free_entry calls remove_directory (directory_watch_store.rs:66-67); index_of finds the resolver's item, kind is Directory, so it is queued at src/watcher/Watcher.rs:950 and flush_evictions closes its fd at :437 and drops it at :451. The fd was the resolver's DirEntry.fd (src/resolver/lib.rs:795), now EBADF, and the directory has no watch so bust_dir_cache (hot_reload_event.rs:109) never runs for it again. The base had the same consequence but this change rewrote the release and added a kind check while leaving ownership unchecked, which REVIEW.md's one-owner rule flags.

Verification: pre-existing — the base already released the same shared item by the same route. free_entry (directory_watch_store.rs:66-67) calls remove_directory(hash), which takes the first item with that hash and queues it if its kind is Directory (Watcher.rs:944-951) — there is no producer check, so the resolver's item is queued and flush_evictions closes its fd (Watcher.rs:436-451).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the shared item case that the description lists under "Not fixed here". It is older than this change: main releases the same item by the position that add_directory returned for the hash hit.

Two corrections for the dev server:

  • No resolver descriptor is closed. The dev server resolvers keep store_fd false (src/resolver/resolver.rs:969, the one = true is the completions helper at src/runtime/cli/run_command.rs:3727). So DirEntry.fd is invalid (resolver.rs:3512), and the watcher opens the descriptor of that item itself.
  • A directory event feeds the retry of the store and, on Linux, the names of changed files (src/runtime/bake/dev_server/hot_reload_event.rs:97-173). The store registers the directory again at the next failed import. A directory with watched files keeps its parent item (<dir>/, another hash), so the names still arrive.

A release of only the items that the store appended does not cover the other order: the store appends the item and the resolver hook hits it later. That needs an owner count on the item, and a write on the hash-hit path of the hook that --hot and --watch also run. I left it out of this change.

Comment thread src/watcher/Watcher.rs
Comment on lines +620 to +625
// `O_DIRECTORY`: a FIFO or a file at this path fails here and is not opened.
#[cfg(not(windows))]
let flags = bun_sys::O::RDONLY | bun_sys::O::DIRECTORY | bun_sys::O::CLOEXEC;
#[cfg(windows)]
let flags = bun_sys::O::RDONLY | bun_sys::O::CLOEXEC;
bun_sys::open_a(file_path, flags, 0)?

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.

🟣 pre-existing, not blocking: pre-existing: on Windows a failed import below a regular file still makes the file untrackable, so a dev-server user's later edits to it are not hot reloaded. The #[cfg(windows)] arm at src/watcher/Watcher.rs:624 omits bun_sys::O::DIRECTORY, so for import './dep.ts/x' the store's add_directory opens the file dep.ts and appends it as a Directory item. Fix: make the open fail for non-directories on every platform, e.g. use O::DIRECTORY in both arms (it is defined on Windows and routes to the directory NtCreateFile path), so the POSIX fix in this PR covers the sibling Windows branch too.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

On Windows, inside the project root, a file imports ./dep.ts/x; DirectoryWatchStore::insert (src/runtime/bake/dev_server/directory_watch_store.rs:285) calls add_directory::<true, true> with the dir …/dep.ts. append_directory_assume_capacity uses the Windows flags at src/watcher/Watcher.rs:624 (O::RDONLY | O::CLOEXEC, no O::DIRECTORY); openat_windows_impl (src/sys/lib.rs:7063) only takes the directory NtCreateFile path when O::DIRECTORY is set, so the regular file opens and a WatchItem of kind Directory with hash(…/dep.ts) is appended (src/watcher/Watcher.rs:683). When the bundler later watches dep.ts itself, add_file (src/watcher/Watcher.rs:903) finds that hash and returns Ok(FdOwnership::Caller) without appending a File item. The Windows watcher matches the edit of dep.ts to that Directory item (src/watcher/WindowsWatcher.rs:451) and the dev server routes it to append_dir (src/runtime/bake/dev_server/watch.rs:143); process_file_list only retries the still-failing ./dep.ts/x and never invalidates dep.ts, so the edit is lost. On POSIX the new O::DIRECTORY makes the open fail with ENOTDIR; the base branch behaved the same way on Windows, so this is pre-existing.

Verification: pre-existing (the base already fails the same way on Windows by the same route; this PR fixes the class with O_DIRECTORY on POSIX only and leaves the Windows arm out). src/watcher/Watcher.rs:621-625: the #[cfg(windows)] arm is O::RDONLY | O::CLOEXEC only. src/sys/lib.rs:7063 takes the directory branch only when (flags & O::DIRECTORY) != 0, so a regular file opens.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and older than this change. I left the Windows arm as it is on main because I cannot run it. With O::DIRECTORY, openat_windows_impl takes open_dir_at_windows_nt_path (src/sys/lib.rs:7063). That path asks for other rights than the file path does (FILE_TRAVERSE, FILE_LIST_DIRECTORY) and sets FILE_DIRECTORY_FILE, for all three producers of directory items. I added the Windows case to "Not fixed here" in the description.

Comment thread src/watcher/Watcher.rs
Comment on lines +955 to 958
pub fn remove_at_index(&mut self, index: WatchItemIndex) {
debug_assert!(self.mutex.is_held_by_current_thread());

self.evict_list[self.evict_list_i as usize] = index;

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.

🟣 pre-existing, not blocking: The dev server panics with an index out of bounds if more than 8096 directory watches are released between two watcher flushes. remove_at_index at src/watcher/Watcher.rs:958 writes self.evict_list[self.evict_list_i as usize] with no bound against MAX_EVICTION_COUNT (src/watcher/Watcher.rs:52), and the new public remove_directory (src/watcher/Watcher.rs:942) lets the main thread queue from free_entry at any time, while the queue is only drained by the watcher thread inside on_file_update. Fix: bound the write (flush or drop when full, or make the queue growable) so a burst of releases cannot index past the fixed array.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

free_entry (src/runtime/bake/dev_server/directory_watch_store.rs:66-67) runs on the main thread for every directory whose deps all resolved, and also from disconnect_and_delete_file (src/runtime/bake/dev_server/incremental_graph.rs:755) during a rebuild. Each call reaches remove_at_index and increments evict_list_i (src/watcher/Watcher.rs:958-959). evict_list is a fixed array of MAX_EVICTION_COUNT = 8096 (src/watcher/Watcher.rs:52,129) and is reset only in flush_evictions at :470, which runs only on the watcher thread when a batch of events is dispatched. A large rebuild that releases many entries (for example deleting a directory tree whose files each had failed imports, or a 'use client' sweep) can queue more than 8096 indices before the next batch; the array index then panics in release builds, taking the dev server down. The base is the same, but this change made the entry point public and hash-keyed, and the cancel path at :808-815 also assumes the list is well-formed. Remedy: guard the write against MAX_EVICTION_COUNT.

Verification: pre-existing. Trigger: more than 8096 free_entry calls on the main thread between two watcher-thread flushes. evict_list is a fixed [WatchItemIndex; MAX_EVICTION_COUNT] with MAX_EVICTION_COUNT = 8096 (src/watcher/Watcher.rs:52). remove_at_index (src/watcher/Watcher.rs:955-959) writes it with no bound check. On the base, free_entry called remove_at_index from the main thread.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Older than this change. On main free_entry queues from the same thread through remove_at_index::<true>, one entry per release, into the same fixed array. This change adds no producer and does not raise the rate. #46029 replaces the array with a Vec, which removes the bound. I left it to that PR so that the two changes do not collide on evict_list.

@robobun

robobun commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Cross-reference from #39571 (no stored descriptor for a watched file outside kqueue).

With #39571 a watched file that is removed on Linux reports DELETE on its own watch, and the dev server evicts its entry. That makes the stale position in DirectoryWatchStore::free_entry reachable in a sequence that main survives: a failed import is pending, a watched file outside the root is removed, the first failed import resolves, and the directory watch of the second failed import is released in its place. I reproduced it on the #39571 branch with a dev server run.

So #39571 needs this PR to merge first. Its body now says so. This PR's test "keeps the watch of a file that was saved by rename" covers the save side. The removal sequence above is not in either PR yet: a dev server test for it is on the branch robobun/980251b7/wip-watch-path-again (test/bake/dev/hot.test.ts, "a failed import that resolves does not release the directory watch of another one").

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