Skip to content

resolver: strip the trailing slash before busting the dir cache for a specifier - #39211

Open
robobun wants to merge 5 commits into
mainfrom
farm/4ec55b6d/bust-dir-cache-trailing-slash
Open

resolver: strip the trailing slash before busting the dir cache for a specifier#39211
robobun wants to merge 5 commits into
mainfrom
farm/4ec55b6d/bust-dir-cache-trailing-slash

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • In builds with assertions enabled (debug builds and the release-asan lane CI tests on), the dev server and bun build --watch abort when a bundled file has an unresolvable import whose specifier ends in a slash, such as import "./missing/" or import "/abs/dir/":
    panic: Internal Assertion Failure: Invalid cache key "/tmp/repro/missing/"
    See Resolver.assertValidCacheKey for details.
    
    Expected (and what release builds print): error: Could not resolve: "./missing/", with the watcher or dev server staying up.
  • Cause: Resolver::bust_dir_cache_from_specifier (src/resolver/resolver.rs:2466), which the bundler calls on every ModuleNotFound while a watcher or dev server is attached, passed its keys to bust_dir_cache as is. join_abs preserves a trailing separator, so ./missing/ became the key /tmp/repro/missing/; the absolute branch passed the user's specifier verbatim; and an absolute specifier with a doubled separator (/tmp//missing) has the dirname /tmp/. bust_dir_cache asserted on each of these without normalizing first.
  • bust_dir_cache was the only one of the resolver's cache key sites that asserted without normalizing: read_directory_with_iterator (src/resolver/lib.rs:1220), dir_info_for_resolution (resolver.rs:3331) and dir_info_cached (resolver.rs:4247) all strip the trailing separator and then assert, so every other caller of bust_dir_cache (hot reloader, VM resolve retry, dev server, FileSystemRouter, transpiler) open-codes the strip at its call site, and this caller was the one that did not.

Fix

  • bust_dir_cache now strips the trailing separator itself (strings::without_trailing_slash_windows_path, the helper the assertion's doc comment names) before asserting, the same shape as the three lookup sites above. That covers every caller, present and future, instead of adding one more call-site strip; the existing call-site strips elsewhere become redundant and are left alone here.
  • bust_dir_cache_from_specifier routes both branches through one helper that strips before taking the dirname. The strip before dirname is what matters on Windows: dirname of C:\x\missing\ is C:\x\missing (the directory itself), so the parent C:\x, whose listing is what changes when missing gets created, was never busted there. POSIX dirname already skips a trailing separator.
  • What changes per build type: in assertion-enabled builds the abort goes away. In release builds on POSIX nothing observable changes: both maps hash keys with trailing native separators trimmed (BSSMapInner::key_hash, instantiated with REMOVE_TRAILING_SLASHES in src/resolver/dir_info.rs:398 and src/resolver/lib.rs:943), so these busts already hit. On Windows release builds the parent directory is now the one busted for a trailing-slash specifier.
  • Verified:
    • test/bundler/cli.test.ts, "bun build --watch" block, three specifiers (relative with a trailing slash, absolute with a trailing slash, absolute with a doubled separator): each fails on a debug build without the src change (stderr ends with the panic above) and passes with it. Each test then rewrites the entry point and checks that the watcher rebuilds.
    • test/bake/dev/bundle.test.ts, "importing a directory with a trailing slash before it is created": the dev server process died before serving the error overlay without the fix, passes with it. The test then fixes the import and expects a reload, to show the server survived.
    • These tests assert behavior release builds already have, so they pass on release lanes unchanged; the assertion that makes them fail before the fix is live in debug and ASAN builds, which is where the gate and the CI asan lane run them.
    • Full test/bundler/cli.test.ts, test/bake/dev/bundle.test.ts, test/js/bun/util/filesystem_router.test.ts and test/cli/hot/watch.test.ts (other bust_dir_cache callers) pass on the debug build; cargo clippy -p bun_resolver and cargo fmt are clean.
  • Left out on purpose, tracked separately: without_trailing_slash_windows_path does not strip a drive root followed by an extra separator (C:\\, from root_len + 1 in src/paths/string_paths.rs), so on Windows a literal import "C://" still reaches the assertion. That is a fix in the shared helper.
  • bundler: report an unresolvable relative import longer than the path buffer instead of panicking in watch mode #39203 changes the join in bust_dir_cache_from_specifier (specifier longer than the path buffer). The two fixes are independent; whichever lands second needs a trivial rebase of the few lines after the join.

Background

  • Directory cache: the resolver caches one DirInfo per directory (dir_cache) plus the directory listing it read (fs.entries), both keyed by the directory's absolute path. assert_valid_cache_key documents the key convention (native separators, no trailing separator except for the roots / and C:\) and is compiled in whenever Rust debug assertions are on, which includes the release-asan CI build (scripts/build/config.ts sets assertions = debug || asan). The maps themselves additionally trim trailing native separators when hashing, so the convention is enforced at the sites that build keys rather than relied on by the maps.
  • Cache busting in watch mode: when an import fails to resolve and a watcher is attached (bun build --watch, or the dev server), bundle_v2 calls bust_dir_cache_from_specifier to drop the cached state for the path the import would have resolved to and for its parent (a new hello.ts shows up in the parent's listing, a new hello/index.ts in the path itself), and retries the resolution once if anything was cached. That is what lets a build pick up a file created after an earlier build cached its absence; the dev server additionally watches the parent directory (DirectoryWatchStore) so the rebuild is triggered when the file appears.
Earlier revisions of this PR

The first revision stripped the separator inside bust_dir_cache_from_specifier only (then also its dirname, after review pointed at /a//b), and described the release-build effect as a silent cache miss; that was wrong on POSIX because the maps trim trailing separators when hashing (see above). Review of that revision suggested normalizing inside bust_dir_cache instead, which is the current shape. The tests are unchanged across revisions.


no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/cli.test.ts

… specifier

bust_dir_cache_from_specifier passed the joined path (or an absolute
specifier) straight to bust_dir_cache. A specifier ending in a slash
keeps that slash through the join, so the key failed the cache key
assertion and aborted the dev server and bun build --watch in builds
with assertions enabled; release builds silently missed the cache.

Normalize the key the same way the resolver's lookup sites do, so the
bust hits the entries the failed resolution populated.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 36983adf-ddc3-49b7-a656-aac8b709dfd5

📥 Commits

Reviewing files that changed from the base of the PR and between 8f8695f and 7689109.

📒 Files selected for processing (3)
  • src/resolver/resolver.rs
  • test/bake/dev/bundle.test.ts
  • test/bundler/cli.test.ts

Walkthrough

The resolver now shares trailing-separator cache invalidation for absolute paths. Development-server and watch-mode tests cover unresolved relative and absolute imports, continued process activity, and successful rebuilds after source changes.

Changes

Trailing-separator resolution

Layer / File(s) Summary
Shared resolver cache invalidation
src/resolver/resolver.rs
Absolute specifiers use bust_dir_cache_and_parent, which normalizes trailing separators before clearing path and directory cache entries.
Watch and development-server recovery tests
test/bake/dev/bundle.test.ts, test/bundler/cli.test.ts
Regression tests cover unresolved imports with trailing or doubled separators. They verify error reporting, continued watch activity, and successful rebuilds after source updates. The CLI tests add outputUntil for stream assertions.

Possibly related PRs

  • oven-sh/bun#38365: Both changes modify resolver directory-cache invalidation and path handling.
  • oven-sh/bun#39203: Both changes update resolver cache invalidation and add watch or development-server regression tests for unresolved imports.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely describes the main resolver change: stripping trailing slashes before directory-cache invalidation.
Description check ✅ Passed The description explains the problem, cause, fix, scope, verification, and known limitations in sufficient detail.

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix and tests pushed (latest d693207), waiting on CI.

Reproduced on a debug build of main (2f941ed) with bun build --watch index.ts --outdir dist where index.ts is import "./missing/";, and with a Bun.serve({ development: true }) HTML route importing the same file. Both abort with panic: Internal Assertion Failure: Invalid cache key ".../missing/"; the release binary prints Could not resolve: "./missing/" instead.

Tests: test/bundler/cli.test.ts ("bun build --watch" block, three specifier shapes) and test/bake/dev/bundle.test.ts ("importing a directory with a trailing slash before it is created") fail on the debug build without the src/resolver/resolver.rs change and pass with it.

Current shape (after self-review): the trailing separator is stripped inside bust_dir_cache itself, like the resolver's lookup sites already do, plus a strip before the dirname in bust_dir_cache_from_specifier for the Windows parent directory. The PR description explains what changes per build type; the Windows drive-root case in without_trailing_slash_windows_path is being fixed separately.

Related: #39203 touches the same function for a different failure (specifier longer than the path buffer); the two changes are independent.

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

LGTM — the fix applies the same without_trailing_slash_windows_path normalization the assertion's own doc comment names and that the other cache-key sites in this file already use, and both branches now share one helper. The inline nit is pre-existing behavior on pathological doubled-separator specifiers, not a regression.

Extended reasoning...

Overview

Normalizes the cache keys bust_dir_cache_from_specifier derives from a user-written import specifier by stripping the trailing separator before calling bust_dir_cache. The absolute and relative branches are folded into a new bust_dir_cache_and_parent helper (net ~12 lines in src/resolver/resolver.rs). Two tests: a bun build --watch test in test/bundler/cli.test.ts covering both relative and absolute trailing-slash specifiers via test.each, and a dev-server test in test/bake/dev/bundle.test.ts. Both assert the resolution error is reported and that the process survives to rebuild after the entry is fixed.

Security risks

None. This is path normalization for an internal directory-cache key; the input is an import specifier that already goes through the resolver, and the change only strips a trailing separator before a map lookup/removal.

Level of scrutiny

Low-to-medium. The fix is a one-line normalization at the layer that owns the invariant, using strings::without_trailing_slash_windows_path — the exact helper assert_valid_cache_key's doc comment names and the same one used at the three other cache-key sites in this file (resolver.rs:3332, :4248, :5806). The refactor into bust_dir_cache_and_parent is behavior-preserving for inputs without a trailing slash (identical call sequence) and correcting for inputs with one. On Windows it also fixes which parent gets busted (dirname of C:\x\missing\ was returning the same directory), which the PR description calls out.

Other factors

  • Tests await observable conditions (outputUntil reads the stream until the needle appears and throws with accumulated output on early end; the dev test uses the harness's expectReload/expectMessage), and verify the watcher/server survives by triggering and asserting a subsequent rebuild — not just "no panic in output".
  • The single finding is a Nit about pre-existing pathological inputs (/tmp//missing, C://) that panicked identically before this PR; it's a same-class hardening suggestion, explicitly non-blocking.
  • Checked that the first outputUntil(proc.stderr, ...) while stdout is unread cannot deadlock: the failing build writes nothing to stdout, so the pipe buffer stays empty until the second write triggers the rebuild.
  • No CODEOWNERS for these paths; PR description notes the independent overlap with #39203 in the same function.

Comment thread src/resolver/resolver.rs
dirname of an absolute specifier with a doubled separator (/a//hello)
is /a/, which is not a valid cache key either. Mirrors load_as_file.
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:56 PM PT - Aug 15th, 2026

@robobun, your commit fad9901 is building: #99034

Comment thread src/resolver/resolver.rs Outdated
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review: 3d4e505 also normalizes the parent key (covers absolute specifiers with a doubled separator, now a third row in the cli.test.ts block) and 7689109 trims the helper's doc comment. The Windows drive-root quirk in the shared helper is out of scope here and being handled separately; see the PR description.

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

LGTM — the follow-up commits addressed both prior review threads (parent key now normalized via without_trailing_slash_windows_path, doc comment trimmed).

What was reviewed:

  • bust_dir_cache_and_parent normalizes both keys the same way as the sibling site at resolver.rs:5808; drive roots and / are preserved by the helper.
  • The doubled-separator absolute case from the earlier review is now covered and has a test.each row; the remaining Windows C:\\\\ case lives in the shared helper and is scoped out with rationale.
  • Tests await observable output (no sleeps), use await using for the watch process, and verify the watcher survives and rebuilds.
Extended reasoning...

Overview

The PR fixes a debug/ASAN-only assertion abort in Resolver::bust_dir_cache_from_specifier when a bundled file has an unresolvable import whose specifier ends in (or, for absolute paths, contains a doubled) path separator. The fix extracts a shared helper bust_dir_cache_and_parent used by both the absolute and relative branches, and passes both the path key and its dirname result through strings::without_trailing_slash_windows_path — the same normalization every other cache-key site in this file already applies. Net Rust change is ~10 lines. Tests are added to both entry points that reach this code: bun build --watch (three specifier shapes via test.each) and the dev server (test/bake/dev/bundle.test.ts, mirroring the neighboring "importing a file before it is created" test).

Security risks

None. This is cache-key string normalization on the failed-resolution recovery path; no new inputs are trusted, no filesystem writes are gated on it, and the assertion it satisfies is a debug-only internal invariant check.

Level of scrutiny

Low-to-moderate. The Rust change is small, mechanical, and converges on the exact pattern already used at resolver.rs:5808 (load_as_file's dirname handling). The only behavioral change is that keys previously passed with a trailing separator are now stripped — which is what the cache expects, so in release builds this turns silent cache misses into hits. Nothing else calls the new helper.

Other factors

My earlier review flagged two same-class edge cases. The author addressed the actionable one (POSIX doubled-separator dirname) in 3d4e505 exactly as suggested, added a test row for it, and documented why the second (Windows C:\\\\ from root_len + 1 in the shared string_paths.rs helper) belongs in a separate change against that helper — I agree with that scoping. The comment-cop feedback on the doc comment was also addressed (7689109). Both review threads are resolved. The tests follow harness conventions (tempDir, bunEnv, await using, test.each, awaiting stream output rather than sleeping) and each asserts the process stays alive and rebuilds after the entry is fixed, so they can fail for the right reason on debug/ASAN lanes while passing on release.

Every lookup and populate site strips the trailing separator before
asserting; make the bust side do the same so no caller has to.
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Restructured in d693207 after a self-review pass: the strip now lives inside bust_dir_cache (the lookup sites read_directory_with_iterator, dir_info_for_resolution and dir_info_cached already strip before asserting; the bust side was the one that did not), and bust_dir_cache_from_specifier only keeps the strip that has to happen before the dirname for Windows. The description was also corrected: in release builds on POSIX the maps already trim trailing separators when hashing (BSSMapInner::key_hash), so this is an assertion-build fix there, with a Windows-only parent-directory correction on top. Tests are unchanged.

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

LGTM — my earlier review feedback (normalize the parent key too) was addressed, and the follow-up commit moves the strip into bust_dir_cache itself so every caller is covered.

What was reviewed:

  • Traced the doubled-separator absolute case (/tmp//missing) through bust_dir_cache_and_parentdirname_platform returns /tmp/, now stripped inside bust_dir_cache before the assertion.
  • Checked all other bust_dir_cache call sites (hot_reloader, dev_server, filesystem_router, transpiler, VirtualMachine) — they already wrap with without_trailing_slash_windows_path, so the added strip is idempotent for them and newly covers incremental_graph.rs:1562 and dev_server/mod.rs:461.
  • Tests await observable output (outputUntil polls the pipe, throws with the crash report if the process dies), and the dev-server test mirrors the neighboring "importing a file before it is created" case.
Extended reasoning...

Overview

Fixes a debug/ASAN-only assertion abort in Resolver::bust_dir_cache_from_specifier when a bundled file has an unresolvable import whose specifier ends in a slash (./missing/, /abs/dir/) or, for absolute specifiers, contains a doubled separator. The fix is ~15 lines in src/resolver/resolver.rs: both branches of bust_dir_cache_from_specifier now route through a shared bust_dir_cache_and_parent helper that strips the trailing separator before taking the dirname, and bust_dir_cache itself now strips before asserting. Three test.each rows in test/bundler/cli.test.ts cover bun build --watch, and one devTest in test/bake/dev/bundle.test.ts covers the dev server.

Security risks

None. This is path-string normalization for an internal cache-key lookup; no auth, no external I/O, no data exposure surface. The input is a user-written import specifier that was already being processed — the change only affects which key is used to remove an entry from an in-memory map.

Level of scrutiny

Low-medium. The mechanism is well-understood (the assertion doc comment names without_trailing_slash_windows_path as the canonical normalizer, and this PR applies exactly that). The change is additive normalization at the same layer as the assertion it satisfies; without_trailing_slash_windows_path is idempotent, so existing callers that already normalize are unaffected. The two callers that previously did not normalize (incremental_graph.rs and dev_server/mod.rs) now get it for free, which is strictly safer.

Other factors

  • I previously flagged two residual edge cases in the same class. Case 1 (POSIX doubled internal separator) is now covered by normalizing inside bust_dir_cache. Case 2 (Windows C:\\\\ drive-root off-by-one in the shared helper) is explicitly and reasonably deferred to a separate fix in src/paths/string_paths.rs.
  • The comment-cop flag on the long doc comment was addressed in 7689109, and d693207 went further by moving the normalization into bust_dir_cache (fixing the code rather than commenting the workaround).
  • CI passed on 7689109; d693207 on top is a one-line relocation of the strip call to a strictly-earlier point in the same call chain.
  • Tests follow harness conventions: tempDir, bunEnv, await using proc, poll for observable output rather than sleep, and the outputUntil helper surfaces the crash report in the failure message so a regression is diagnosable from CI output alone.

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

LGTM — the restructure in d693207 (stripping inside bust_dir_cache itself, mirroring the lookup sites) addresses the doubled-separator case I flagged earlier, and the third test.each row covers it. The remaining Windows C:// edge case is correctly deferred to the shared helper.

What was reviewed:

  • Traced /tmp//missingdirname_platform"/tmp/" through the new bust_dir_cache strip — assertion now passes.
  • Checked that moving the strip into bust_dir_cache is safe for existing callers (they already pre-strip; now redundant, not wrong).
  • Tests: outputUntil awaits the actual stream output and errors with the crash report if the process dies; no sleeps; describe.concurrent for the three subprocess variants.
Extended reasoning...

Overview

~15 net Rust lines in src/resolver/resolver.rs: bust_dir_cache now calls strings::without_trailing_slash_windows_path before assert_valid_cache_key (matching read_directory_with_iterator, dir_info_for_resolution, and dir_info_cached), and bust_dir_cache_from_specifier factors its two branches into a shared bust_dir_cache_and_parent helper that strips before the dirname (needed on Windows where dirname of a\\hello\\ is a\\hello, not a). Two test additions: a three-row test.each in test/bundler/cli.test.ts for bun build --watch with trailing-slash / doubled-separator specifiers, and a dev-server variant in test/bake/dev/bundle.test.ts.

Prior review resolution

My earlier review flagged two same-class edge cases. Case 1 (POSIX /tmp//missing → dirname returns /tmp/ with trailing slash) is now handled because the strip lives inside bust_dir_cache — I re-traced the path and the "/tmp/" result of dirname_platform is stripped to "/tmp" before the assertion. The third test row exercises exactly this. Case 2 (Windows C://root_len + 1 off-by-one in without_trailing_slash_windows_path) is a bug in the shared helper at src/paths/string_paths.rs used at 20+ sites; the PR description and author reply both defer it to a separate fix, which I'd already agreed was the right scope. The comment-cop paragraph-comment flag was addressed by trimming to one line. All inline threads are resolved.

Security risks

None. This is normalization of an internal cache-invalidation key derived from a user-written import specifier. On POSIX release builds nothing observable changes (the maps already hash with trailing separators trimmed via REMOVE_TRAILING_SLASHES); on debug/ASAN builds an assertion abort becomes a normal resolution error; on Windows release builds the correct parent directory is now busted for a trailing-slash specifier. No filesystem writes, no path traversal surface — the key is only used to remove entries from two in-memory maps.

Level of scrutiny

Low-to-medium. The Rust change is small, uses the exact helper the assertion's own doc comment names, and moves the fix to the layer that owns the invariant (bust_dir_cache) rather than one call site — REVIEW.md's "fix the whole class" guidance. Existing callers that already strip become harmlessly redundant. No CODEOWNERS entry covers src/resolver/.

Other factors

Tests follow harness conventions: tempDir, bunEnv/bunExe(), no sleeps — outputUntil polls the stream for the actual output and throws (with the accumulated crash report as the message) if the process dies before the needle appears, so the failure mode is diagnosable. describe.concurrent keeps the three subprocess variants parallel. The dev-server test mirrors the neighboring "importing a file before it is created" test with only the specifier changed. The PR description is thorough and correctly characterizes the per-build-type effect after the author's self-correction.

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