Repository navigation
Conversation
The listing and DirInfo caches are keyed by the path as walked, so a directory behind a link has one key for the link and one for its real path. bust_dir_cache removed the one key it was given. A caller that knows only the real path, like the watcher, left the listing under the link in place, and an import through the link kept resolving to a file that was renamed or removed. The resolver now records each directory link it follows, on the cold fill that already computes the real path. bust_dir_cache uses the record to remove every key of the directory. A process that followed no link takes the old path after one load and one branch.
…has none A directory event names the changed entries. The reloader looked each name up in the cached listing of the watched directory and matched the entry's abs_path against the watch list. The resolver fills abs_path in the listing it resolved the import from. For a package behind a link that is the listing of the link, so the entry in the listing of the watched real path had no path, nothing matched, and a removed or replaced file caused no reload. Join the watched directory and the name in that case.
A failed import registers a directory watch only for a relative specifier. An import by package name or tsconfig path of a file that was deleted stayed an error until restart once the resolver stopped answering from a stale listing: nothing bundled the importer again when the file came back, and nothing read the directory again. on_file_deleted now registers the watch for each importer with the path of the deleted file. The watch is released when the importer bundles.
|
Updated 3:01 PM PT - Oct 28th, 2026
❌ @robobun, your commit 87d8092 has 5 failures in
🧪 To try this PR locally: bunx bun-pr 47046That installs a local version of the PR into your bun-47046 --bun |
|
Status: draft. This PR must merge after a recheck of cached misses (#40279). The reason is in Downsides. How I reproduced it (main 11c3671, Linux x64):
The debug log shows A link that is made after the dev server starts is a different case: the resolver does not follow it to its real path (#42781), and nothing in the package is watched. The tests make the link before the start for that reason. CI on 87d8092: the tests of this PR pass on the Linux and Windows lanes. The macOS lanes are still queued. The red jobs are the |
On Windows the rename fails with EPERM while the target exists. The step covers the name lookup of the POSIX reloader, which the Windows reloader does not have.
A bust for a directory that changed (a watcher event, or FileSystemRouter.reload()) evicts the directory under its real path and under each link to it. A retry after a miss only guesses a directory from the specifier, so it evicts that one cache key again, as main does. The lookup that runs again after a miss now checks each "not found" that it reads from the caches. It opens a directory that is cached as missing, and it reads each directory listing again in place. Before, a specifier that starts with a package name had no retry that could see a new file or a new directory. RealFS::bust_entries_cache is the one eviction of a listing. The key of a directory below a link to a file system root gets its separator. The generation of a listing comes from one clock for the process, so Bun.build still reads a listing again that a retry read before the build started.
The fallback is the general fix for a save by rename that inotify reports only to the directory watch. It does not depend on links, and #41617 replaces the code it patched. The tests keep what the resolver alone fixes: a rename and a new file in a linked folder. A new test removes and builds again the output directory of the folder.
track_resolution_failure returned early for a specifier that starts with a package name. It now asks the resolver for the real directory of a package that is linked from outside node_modules, and it watches that directory with the specifier as written. This replaces the watch that on_file_deleted added for each importer of a deleted file: a failed import registers the watch, with or without a deleted file before it. An entry point that does not resolve is a deleted file. Each graph that has the file now bundles its importers again. Before, only the client graph did when the file was in the client graph and in the server graph.
The retry read each directory listing again that it used, also where the
cached listing had the name it needed. A failed `require("pkg")` read all
of `node_modules` again (938,000 instructions with 1,500 packages), and a
directory that was built again read the listing of its parent.
The retry now reads a parent listing again only if it lacks the directory,
and it takes the listing of a `node_modules` directory as it is cached. A
package that appears there is found through its own directory, which the
retry opens.
This branch keeps the one change that is its own: a bust for a directory that changed evicts the directory under its real path and under each link that the resolver followed. - The retry after a miss leaves the branch. A retry site evicts one cache key, as main does, through Resolver::bust_dir_cache_key. - FileSystemRouter.reload() evicts one key again too. - The dev server watch for a failed import by package name leaves the branch. A deleted entry point is still handled in each graph that has the file. - A directory below a link gets no record of its own, and a link that goes back to an earlier target is recorded once. - bun:internal-for-testing gets bustResolverDirCache, so that a test can send the bust that a watcher sends.
Problem
node_modules/lib -> ../packages/lib) is renamed. The dev server serves the removed module.bun --hotprintserror: ENOENT reading "<root>/packages/lib/c.ts"at every reload.Resolver::bust_dir_cache(src/resolver/resolver.rs:2642) evicts one key, and the watcher names only the real path.Fix
FileSystemRouter.reload()evict one key, as before.test/bake/dev/bundle.test.ts,test/cli/hot/hot.test.ts,test/js/bun/resolve/resolve.test.ts. Self-reviewed: 9 concerns on this part, 8 addressed (Notes).Background
bust_dir_cachemakes the resolver read a directory again.require("lib/b")failed with no watcher.Downsides
Could not resolvein the dev server), andrm -rf distplus a build (Cannot find package 'lib').bun --hot: 10 to 18 KB more RSS per save in a linked package.Notes
State. This PR is a draft on purpose. Two self-reviews of earlier heads of this branch found that the work is three changes, and that this one cannot merge first:
DirectoryWatchStore::track_resolution_failurereturns early for it today (src/runtime/bake/dev_server/directory_watch_store.rs:161). dev server: resolve a tsconfig paths alias to a file created after start #43396 opens the same return for tsconfigpaths.An earlier head had all three in one diff. It is kept on the branch
robobun/bde8c9e0/miss-recheck-and-fan-outfor reference. Its retry after a miss is not a candidate for part 1: it reads a whole directory listing again for one missing name (763,097 instructions for a failed lookup into a package of 1,051 files, main 18,972), and it stamps the listing generation thatBun.builduses, so a build whose plugin probes for a generated file fails withCould not resolvein 10 of 10 runs.What a user sees. Layout:
node_modules/lib -> ../packages/lib, made before the process starts.index.tsimportslib/c. The process starts in the workspace root. Linux x64.c.tsrenamed toc.tsxc.tsdeletedCould not resolve: "lib/c"bun --hot,c.tsrenamedENOENT readingat every reloadbun --hot,d.tscreated, then importedCannot find module 'lib/d'FileSystemRouter.reload()Worse than main until then. On main the link path never loses its old listing. Two orders end well there only through that old listing, and they are the orders that a plain directory already gets wrong on main:
c.tsdeleted, then created againCould not resolve: "lib/c"through two saves of the importer./packages/lib/c, no linkbun --hot,rm -rf dist, importer saved,distbuilt again, importer savedCannot find package 'lib'through two saves./packages/lib, no linkCannot find module './packages/lib'through two savesRow 1 needs part 1 (the importer then finds the file at its next save) or part 3 (at once). Row 3 needs part 1.
Where the fix reaches. The watcher takes a directory only below the working directory and outside
node_modules(Watcher::is_eligible_directory,src/watcher/Watcher.rs:787). So the rows above hold when the real directory of the package is below the working directory. A process that starts inapps/web(bun run --filter) watches the files of../../packages/liband no directory there. One row changes in that layout: after a rename of a watched file, the importer finds the new name, and the server watches the file under that name (test: "renaming a file of a package linked from outside the working directory"). A removed file and a new file are not reported there, as on main. #9547 and #14183 describe that start directory and stay as they are. Under--hoton Linux, a removed file and a save by rename of a file in a linked folder start no reload, as on main: #41617 changes that lookup. No user reported the rows of this PR. They were found while another dev server fix was tested.What the record covers. One entry per directory link that the resolver followed when it built that directory (
dir_info_uncached,src/resolver/resolver.rs:6607). A directory below a link has no entry: the bust joins the link and the rest of the path. Not covered, and the same as on main:--preserve-symlinks: nothing is recorded.Which bust evicts what.
RealFS::bust_entries_cacheis the one place that removes a listing.Resolver::bust_dir_cachecalls it for every key of the directory. Its callers are the notifications: the hot reloader (throughVirtualMachine::bust_dir_cacheandBundleV2::bust_dir_cache) and the dev server (on_file_deleted,process_file_list).Resolver::bust_dir_cache_keyremoves one key, which is what main does. Its callers guess a directory from the text of a specifier (VirtualMachine::_resolve,bust_dir_cache_from_specifier,Transpiler::resolve_entry_point), or walk a tree by the paths they have (FileSystemRouter.reload()). No report asks for more fromreload(), and the hot reloader matches a directory event against the listing of the real path.The entry point of a removed file.
enqueue_entry_points_dev_server(src/bundler/bundle_v2.rs:3610) looks each changed file up as an entry point. When the directory of a removed file was just read again, that lookup fails, and the failure went to one graph: the client graph if the file is in it. The importers in the server graph then kept the removed module. Behind a link this is every removal, because the real path of the directory is first read at that lookup. With no link it happens on main when another failed import has a watch on the directory (test: "renaming a file in two graphs while another import waits for a file of its directory").Measurements. Release builds of main 11c3671 and of this branch, Linux x64. Instructions are
instructions:uthroughperf_event_open. System calls are counted throughptrace.perf,straceandvalgrindare not on this machine.Bun.resolveSync("real/a"),("real"),("lib/a"), instructions per call, 3 runs./missingfrom a linked package, one lookup by name per link between them: peak RSS, 1 link / 5 linksbun index.js, 5 packages behind 10 links, instructions, 3 runsbun index.js, 200 packages behind 400 links, instructions, 3 runsbun --hot, per save in a linked package of 50 entries:openat/getdents64/ RSSpackage.jsonof 6 KB: RSS per savebun --hot, per save in a plain directory:openat/getdents64/ RSStext/bssOne bust of a changed directory costs 517 instructions with no link recorded, with the call from JS of the test hook. With 400 links recorded it costs 3,268 for a plain directory and 5,752 for the real directory of a linked package. main has no such hook: its bust removes one key.
The record is two hash table entries per link. A bust drops a listing and a
DirInfowithout freeing them, on main too (#36675 is open for that). A linked directory now drops them once per path, and each path parses thepackage.jsonagain: that is the RSS per save. One host function is new:bustResolverDirCacheinbun:internal-for-testing. It sends the bust that a watcher sends, so that a test can name each path of a directory.Self-review. The first review (on a head with a hot reloader fallback and a dev server watch per deleted file) found that the bust alone loses the two recoveries above, and asked for the miss side. The second review (on the head with all three parts) asked for this cut. Its concerns on this part:
reload()must not get the wide eviction (done), a directory below a link got a record of its own (done), a link that goes back to an earlier target was recorded twice (done), the fix of the entry point is not only for links and needs a row with no link (done), one test row for each clause of the key walk, through a test hook (done), the rows forapps/weband for a save by rename were wrong in the body (done), the reach and the missing reporter were not stated (done), the split itself (done). Not done: theboolthatbust_dir_cachereturns and no caller reads. It is the signature of main, through three wrappers.Order against other open PRs.
bust_dir_cache_from_specifier, so the second of the two rebases on the other.on_file_deletedand the failure path of a deleted file. resolver: give each config-file load mode its own DirInfo map #46750 and resolver: key the directory caches natively on POSIX, so<dir>\and<dir>do not share an entry #47002 change the key of the directory caches. resolver: realpath a directory missing from the parent's cached listing #42781 changes the same lines ofdir_info_uncached. Take&selfin VirtualMachine, EventLoop and RuntimeState: no&mutacross re-entrant JS #47109 changesVirtualMachine.rs, where this PR renames one call. Each needs a small rebase on the other.Earlier designs. One listing shared by every path of a directory is on the branch
robobun/bde8c9e0/resolver-one-listing-per-real-directory(+1,512/-531 in 17 source files). It is not proposed: with no watcher,require("./packages/lib/a"), a new fileb.js, thenrequire("lib/b")gaveCannot find module 'lib/b', where main and node find the file.Tests. The dev server harness has a new
symlinksoption, because a link that is made after the server starts is not followed to its real path. 19 new cases. On main 16 fail: all 6 inresolve.test.ts(they call the new test hook), 2 of 4 inhot.test.ts, 8 of 9 inbundle.test.ts. The 3 that pass there are the plain directory controls. Also run on the debug build:test/js/bun/resolve/resolve.test.ts,test/js/bun/util/filesystem_router.test.ts,test/cli/hot/hot.test.ts,test/cli/watch/watch.test.ts,test/bundler/resolver/cache-invalidation.test.ts, andbundle,html,hot,esm,css,pluginsintest/bake/dev/.no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/resolve/resolve.test.ts, test/cli/hot/hot.test.ts