Skip to content

resolver: bust a directory under its real path and under each link to it - #47046

Draft
robobun wants to merge 11 commits into
mainfrom
robobun/bde8c9e0/bust-every-spelling
Draft

robobun wants to merge 11 commits into
mainfrom
robobun/bde8c9e0/bust-every-spelling

Conversation

@robobun

@robobun robobun commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A file of a package behind a link (node_modules/lib -> ../packages/lib) is renamed. The dev server serves the removed module. bun --hot prints error: ENOENT reading "<root>/packages/lib/c.ts" at every reload.
  • The resolver caches a directory once per path it walked. Resolver::bust_dir_cache (src/resolver/resolver.rs:2642) evicts one key, and the watcher names only the real path.

Fix

  • The resolver records each directory link it follows. A bust from the hot reloader or the dev server evicts the directory under its real path and under each link.
  • A retry after a miss and FileSystemRouter.reload() evict one key, as before.
  • The dev server handles a removed entry point in each graph that has the file.
  • Verified: 19 new cases (16 fail on main) in 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_cache makes the resolver read a directory again.
  • Considered one listing for every path of a directory: built, and require("lib/b") failed with no watcher.
  • Considered the wide eviction in the retry too: a loop of failed lookups read every link path again.

Downsides

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:

  1. A recheck of cached misses after a failed lookup. Resolver: run a missed lookup once more at a fresh Generation, with DirInfo and not-found marks under Generation #40279 does it (resolver: re-check a cached "not found" once the directory is known to exist #46805 is a narrower one). It must merge before this PR, see "Worse than main until then".
  2. This PR: the bust of every path of a directory.
  3. A dev server watch for a failed import by package name. DirectoryWatchStore::track_resolution_failure returns 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 tsconfig paths.

An earlier head had all three in one diff. It is kept on the branch robobun/bde8c9e0/miss-recheck-and-fan-out for 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 that Bun.build uses, so a build whose plugin probes for a generated file fails with Could not resolve in 10 of 10 runs.

What a user sees. Layout: node_modules/lib -> ../packages/lib, made before the process starts. index.ts imports lib/c. The process starts in the workspace root. Linux x64.

Step main this PR
dev server, c.ts renamed to c.tsx 200 with the module of the removed file, after every later save 200 with the new module
the same for a file that a server route and a client component import both graphs keep the removed module both graphs get the new module
dev server, c.ts deleted 200 with the module of the removed file Could not resolve: "lib/c"
bun --hot, c.ts renamed reload, then ENOENT reading at every reload reload with the new module
bun --hot, d.ts created, then imported Cannot find module 'lib/d' resolves
a retry after a failed lookup, FileSystemRouter.reload() one key one key, unchanged

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:

Step main this PR alone
dev server, c.ts deleted, then created again old module, then new module 500 Could not resolve: "lib/c" through two saves of the importer
the same with ./packages/lib/c, no link error, then new module the same as main
bun --hot, rm -rf dist, importer saved, dist built again, importer saved new module Cannot find package 'lib' through two saves
the same with ./packages/lib, no link Cannot find module './packages/lib' through two saves the same as main

Row 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 in apps/web (bun run --filter) watches the files of ../../packages/lib and 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 --hot on 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:

Which bust evicts what. RealFS::bust_entries_cache is the one place that removes a listing. Resolver::bust_dir_cache calls it for every key of the directory. Its callers are the notifications: the hot reloader (through VirtualMachine::bust_dir_cache and BundleV2::bust_dir_cache) and the dev server (on_file_deleted, process_file_list). Resolver::bust_dir_cache_key removes 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 from reload(), 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:u through perf_event_open. System calls are counted through ptrace. perf, strace and valgrind are not on this machine.

What main this PR
cached Bun.resolveSync("real/a"), ("real"), ("lib/a"), instructions per call, 3 runs 10,408 to 10,417, 14,628 to 14,629, 11,272 to 11,290 10,402 to 10,427, 14,627 to 14,650, 11,278 to 11,284
failed relative lookup (one bust, one re-read, one retry), instructions 23,033 23,101
20,000 failed ./missing from a linked package, one lookup by name per link between them: peak RSS, 1 link / 5 links 249 MB / 253 MB 247 MB / 251 MB
bun index.js, 5 packages behind 10 links, instructions, 3 runs 6,012,295 to 6,013,743 6,022,723 to 6,037,295
bun index.js, 200 packages behind 400 links, instructions, 3 runs 90.62M to 90.74M 91.72M to 91.83M
bun --hot, per save in a linked package of 50 entries: openat / getdents64 / RSS 7.7 / 4.0 / 55 KB 9.6 / 6.0 / 65 KB
the same with a package.json of 6 KB: RSS per save 62 KB 80 KB
bun --hot, per save in a plain directory: openat / getdents64 / RSS 8.7 / 4.2 / 53 KB 8.7 / 4.3 / 55 KB
release binary text / bss 138,011,510 / 2,155,048 +6,651 / +128

One 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 DirInfo without freeing them, on main too (#36675 is open for that). A linked directory now drops them once per path, and each path parses the package.json again: that is the RSS per save. One host function is new: bustResolverDirCache in bun: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 for apps/web and 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: the bool that bust_dir_cache returns and no caller reads. It is the signature of main, through three wrappers.

Order against other open PRs.

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 file b.js, then require("lib/b") gave Cannot find module 'lib/b', where main and node find the file.

Tests. The dev server harness has a new symlinks option, 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 in resolve.test.ts (they call the new test hook), 2 of 4 in hot.test.ts, 8 of 9 in bundle.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, and bundle, html, hot, esm, css, plugins in test/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

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

robobun commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:01 PM PT - Oct 28th, 2026

❌ @robobun, your commit 87d8092 has 5 failures in Build #134547 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47046

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

bun-47046 --bun

@robobun

robobun commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

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):

  1. A project with packages/lib/{package.json,a.ts,c.ts} and the link node_modules/lib -> ../packages/lib. The link exists before the process starts, as after an install of a workspace.
  2. index.ts imports lib/a and lib/c. An HTML route serves it with development: true.
  3. Remove packages/lib/c.ts and write packages/lib/c.tsx.
  4. The served script still holds the module of c.ts, after the rebuild and after two more saves of index.ts. The same steps with ./packages/lib/c and no link serve the new module at once.
  5. bun --hot index.ts with the same layout prints error: ENOENT reading "<root>/packages/lib/c.ts" at every reload after the rename.

The debug log shows Bust <root>/packages/lib three times, and then resolve(lib/c) = <root>/packages/lib/c.ts from the listing that is cached under <root>/node_modules/lib.

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 test-aot-drawn lanes (test/aot/). They also fail on main (builds 134092 and 134576), and this diff does not touch them.

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.
Comment thread src/jsc/hot_reloader.rs Resolved
Comment thread src/resolver/lib.rs Resolved
Comment thread src/resolver/lib.rs Resolved
Comment thread src/resolver/lib.rs Resolved
Comment thread src/resolver/lib.rs Resolved
Comment thread src/resolver/resolver.rs Resolved
Comment thread src/resolver/resolver.rs Resolved
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.

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