Skip to content

Windows watcher: treat every watched path as changed after a ReadDirectoryChangesW overflow - #42951

Open
robobun wants to merge 5 commits into
robobun/6c16103f/windows-hot-reload-wakeupfrom
robobun/6c16103f/windows-watcher-overflow-resync
Open

robobun wants to merge 5 commits into
robobun/6c16103f/windows-hot-reload-wakeupfrom
robobun/6c16103f/windows-watcher-overflow-resync

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • On Windows, --hot, --watch, bun build --watch and the dev server read file changes with ReadDirectoryChangesW. The kernel records changes between two reads in a 64 KiB buffer. When that buffer overflows, the next read completes with zero bytes and every record since the last read is lost. An edit of the entry point that lands in that window never reloads. Fixes Windows watcher: a ReadDirectoryChangesW buffer overflow drops the changes and nothing reloads #42936.
  • WindowsWatcher::next (src/watcher/WindowsWatcher.rs, the nbytes == 0 branch) logged the overflow and re-armed the read. The context never learned that records were lost.
  • The same loop scanned the watchlist without Watcher.mutex. A process that starts inside a burst (a --watch restart) reads a column the JS thread frees while it grows the watchlist, and crashes in is_parent_or_equal.

Fix

  • next returns Next::Overflow on a zero byte completion. watch_loop_cycle then calls Watcher::resync_after_overflow (src/watcher/Watcher.rs), at most once per second. It stats every watched path and dispatches one batch with a synthetic WRITE for each path whose mtime is newer than the previous resync (or the start), or which no longer exists. --watch restarts, --hot reloads, bun build --watch rebuilds, and the dev server invalidates its graph. Unwatched writes, including the process's own output, produce no events.
  • The record scan takes Watcher.mutex per item, so the JS thread's own appends keep flowing during a burst.
  • The helper is platform neutral so watcher: report metadata and move_from events, and recover from queue overflow #36416 can call it for inotify IN_Q_OVERFLOW. stat goes through libuv, which has no long path support, so a drive letter path gets the \\?\ form.
  • Verified: test/cli/hot/hot.test.ts "--hot reloads an edit that lands in a ReadDirectoryChangesW buffer overflow". Canary 1.4.3 fails 3 of 3 runs (the edit is lost, 60 s timeout). A debug build of this branch passes 5 of 5 in about 19 s. Also test/cli/watch/watch.test.ts and test/js/node/timers/node-timers.test.ts on Windows.

Background

Notes

Reproduction. Windows Server 2019 x64, 16 vCPUs. A bun --watch index.js project with 15000 imported modules and 8 writer processes that call utimes on files under the project root (320000 calls, 23 s). An edit of index.js after the burst did not reload on the 1.4.3 canary, 2 of 2 runs. The same edit with no burst reloads. A debug build with BUN_DEBUG_watcher=1 shows 8 overflow lines, 2498 records seen of 80000, and no record for index.js until the next edit.

Test design. The project puts 3000 modules under a 2000 byte directory prefix that the burst files share, so every record costs 3000 long compares and the watcher thread falls behind 8 writers. Each round: start the writers, wait for their "ready", edit the entry, wait for the writers to exit, then wait for the new output. On the canary the edit is lost in 98 of 100 mid-burst writes (54 writes were needed before one got through), so two rounds give a reliable failure. The test cannot assert that an overflow happened without a src hook, so a runner whose watcher keeps up passes on the base too. It never fails with the fix.

Why the resync stats instead of reporting every path. The first version reported every watchlist entry. Under a sustained burst --hot reloaded several times per second, --watch restarted into the burst, and a bun build --watch whose output lands under the root would restart on its own writes. With the mtime filter an unrelated burst produces an empty resync. A deferral until the root went quiet was tried in between and dropped: inside an overflowing burst completions arrive in groups about 55 ms apart with gaps up to 123 ms, quiet detection either fired early or held real edits back, and a steady background writer would hold them forever.

Probes on a debug build of this branch (3000 modules, 8 writers, one edit 1.5 s into a 30 s burst, one edit after): --hot and --watch both print the mid-burst edit within about 11 s (one debug reload) and the later edit at once, with no crash or restart loop. Without the #42938 commit the --watch process that restarts inside the burst never reports the later edit. Without the per item lock it crashes in is_parent_or_equal on every restart.

Related. #42938 keeps one ReadDirectoryChangesW outstanding. #36416 adds the inotify resync. #31695 item 3 locks the same scan. #40017 and #39820 touch the same function.

Suites run on Windows (debug build of this branch): test/cli/hot/hot.test.ts "overflow" 5 of 5 and "hundreds" 1 of 1, test/cli/watch/watch.test.ts (6 pass, 7 skip), test/js/node/timers/node-timers.test.ts (23 pass). cargo check -p bun_watcher is clean for linux and windows-msvc.


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/hot/watch-many-dirs.test.ts, test/cli/hot/hot.test.ts

@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

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

Or wait 8 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 0d837d54-1de0-4421-a3e4-b4ef78ba8f76

📥 Commits

Reviewing files that changed from the base of the PR and between a8e4e90 and be8c53b.

📒 Files selected for processing (4)
  • src/runtime/jsc_hooks.rs
  • src/watcher/Watcher.rs
  • src/watcher/WindowsWatcher.rs
  • test/cli/hot/hot.test.ts

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

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/watcher/WindowsWatcher.rs — pre-existing, made routine by this PR: Windows bun --watch users whose project sees a write burst (for example bun install into node_modules) now get a restart straight into that burst, and the restarted process can crash. watch_loop_cycle reads this.watchlist.items_file_path() at WindowsWatcher.rs:447-455 without this.mutex while the new process's JS thread appends under the mutex, so ensure_unused_capacity (Watcher.rs:706-708) can free the column the compare is reading. Fix: take this.mutex around the watchlist scan in watch_loop_cycle (or snapshot the paths under it), as resync_after_overflow already does. The PR calls this crash out of scope, but its restart-on-overflow is what puts a fresh process under the burst.

    Extended reasoning...

    Population: any Windows --watch (or bun test --watch) project where a burst of writes under the recursively watched root overflows the 64 KiB buffer; bun install writing thousands of files into node_modules is the everyday case, since every record costs the watcher thread one compare per watched path (WindowsWatcher.rs:447-456). On the base, the overflow branch re-armed and continued; the running process's watchlist was static (add_file dedupes via index_of, Watcher.rs:876), so no concurrent growth occurred. With this PR, next returns Next::Overflow (WindowsWatcher.rs:349), resync_after_overflow dispatches WRITE for the first 128 entries (WindowsWatcher.rs:540), Task::enqueue with RELOAD_IMMEDIATELY calls reload_process (hot_reloader.rs:568-576), which is TerminateProcess plus parent respawn on Windows (util.rs:4316-4329). The respawned process starts its watcher in enable_hot_module_reloading before the entry point loads, while the burst is still running. Its JS thread resolves modules and calls append_file_maybe_lock::<_, true> (Watcher.rs:691-750), which…

    Verification: pre-existing (the race lives in untouched code) but this PR adds a new, everyday route into it; acknowledged in diff: the PR description says "a --watch restart under a sustained burst starts a process whose watcher scans the watchlist without the mutex while the JS thread grows it (#31695 item 3). That crash is pre-existing and out of scope here" — the "pre-existing" claim is accurate,…

Comment thread src/watcher/WindowsWatcher.rs Outdated
Comment thread test/cli/hot/hot.test.ts
Comment thread test/cli/hot/hot.test.ts Outdated
Comment thread src/watcher/WindowsWatcher.rs Outdated
Comment thread src/watcher/WindowsWatcher.rs Outdated
@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked in 1242f17478 after the review. The resync no longer runs inside the burst: after an overflow the watcher waits until no read completes for 250 ms, then dispatches one batch. So --watch restarts after the burst, not into it, and the unlocked watchlist scan is not reached by a restart that this PR causes. The lock for that scan stays with #31695. The other findings are answered in their threads.

@robobun
robobun force-pushed the robobun/6c16103f/windows-watcher-overflow-resync branch from be8c53b to 871e545 Compare September 16, 2026 19:37
@robobun
robobun changed the base branch from main to robobun/6c16103f/windows-hot-reload-wakeup September 16, 2026 19:37
Comment thread src/watcher/Watcher.rs Outdated
Comment thread src/watcher/Watcher.rs Outdated
Comment thread src/watcher/WindowsWatcher.rs Outdated
Comment thread src/watcher/WindowsWatcher.rs Outdated
Comment thread src/watcher/WindowsWatcher.rs Outdated
Comment thread src/watcher/WindowsWatcher.rs Outdated
Comment thread src/watcher/WindowsWatcher.rs Outdated
Comment thread src/watcher/Watcher.rs Outdated
Comment thread src/watcher/WindowsWatcher.rs
Comment thread src/watcher/WindowsWatcher.rs Outdated
@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:43 PM PT - Sep 16th, 2026

✅ @robobun, your commit debddb1a733f3d0218be8018b93dfe7cfebc88fb passed in Build #116739! 🎉


🧪   To try this PR locally:

bunx bun-pr 42951

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

bun-42951 --bun

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Restructured after a self-review. The Windows event loop wakeup is now its own PR, #42968, with a direct test, and this PR is stacked on it. resync_after_overflow moved to src/watcher/Watcher.rs as a platform neutral Watcher method, so #36416 can call it for inotify. The PR body is updated for the current shape.

Comment thread src/watcher/Watcher.rs
Comment thread src/watcher/WindowsWatcher.rs Outdated
Comment thread src/watcher/Watcher.rs
Comment thread src/watcher/Watcher.rs
Comment thread src/watcher/Watcher.rs
Comment thread src/watcher/WindowsWatcher.rs
Comment thread src/watcher/WindowsWatcher.rs Outdated
Comment thread src/watcher/WindowsWatcher.rs

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

No new blocking issues. 3 optional suggestions (nits or notes on pre-existing code) were found and not posted.

robobun and others added 3 commits September 16, 2026 20:56
…it, and scan the watchlist under the mutex
WindowsWatcher::next issued a new read before every wait, also while
the read of an earlier, timed out wait was still queued. Every watcher
cycle left one more read queued on the directory handle.

All reads share one buffer. A read that finds changes already recorded
completes inside the call and fills the buffer at once. A read that
completes later fills it only when its packet is dequeued. With two
reads in flight, the dequeue of the older packet overwrote the records
of the newer read. The watcher parsed the older records twice and never
reported the newer changes.

Issue a read only when none is outstanding.

(cherry picked from commit a9a3b37)
@robobun
robobun force-pushed the robobun/6c16103f/windows-watcher-overflow-resync branch from c764d96 to debddb1 Compare September 16, 2026 20:57

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

Windows watcher: a ReadDirectoryChangesW buffer overflow drops the changes and nothing reloads

1 participant