Skip to content

FileSystemRouter: reload() re-reads dir as the caller wrote it - #45584

Open
robobun wants to merge 1 commit into
mainfrom
robobun/d388d4bf/fsrouter-reload-dir-as-written
Open

robobun wants to merge 1 commit into
mainfrom
robobun/d388d4bf/fsrouter-reload-dir-as-written

Conversation

@robobun

@robobun robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Since 1.4.0, with dir written through a symlink, reload() lists the old target after the link moves, and throws Unable to find directory: <old real path> when that target is gone.
  • With an index route it never returns (100% CPU). A failed relative Bun.resolveSync from dir plus a router over a subdirectory does it without a symlink. A debug build aborts: assertion failed: bun_paths::resolve_path::is_sep_any(entry_dir[base_dir.len() - 1]).
  • Cause: the constructor overwrites config.dir with the resolver's path (src/runtime/api/filesystem_router.rs:330). In 1.3.14 that statement wrote a stack copy and did nothing. The port made it live. No user has reported this.

Fix

  • Delete the statement. reload() again evicts, reads and names routes against dir as written.
  • Correct because 1.3.14 used that value: 21 of 21 probe lines equal 1.3.14.
  • Verified: seven new tests in test/js/bun/util/filesystem_router.test.ts fail on 1.4.3-canary and on main. All 44 in the file pass here.

Background

  • config.dir is the directory reload() reads. The resolver's path is the real path under a symlink, else the spelling cached first.
  • The loader names a route by slicing config.dir's length off each entry's directory, so another path gives wrong names.
  • Considered a second field for the caller's path (the overwrite stays) and FileSystemRouter: derive route names from the directory walk #41166's walk loader (right names, wrong directory).

Downsides

  • None found for callers. The constructor makes one allocation less (34 to 33 on a flat tree, ASAN statistics). reload() is unchanged (35).
  • As on 1.3.14, match().src stays relative to the first real path after a link moves.
Notes

Root cause

bun-v1.3.14, src/runtime/api/filesystem_router.zig, end of constructor:

fs_router.* = .{ ..., .router = router, ... }; // copies the local `router`
router.config.dir = fs_router.base_dir.?.slice(); // writes the local copy

The second line has written a stack copy since 4f41c3f (2022-11-22), so fs_router.router.config.dir stayed the path the constructor was given. The port (23427db) writes the router object itself:

fs_router.router.with_mut(|r| r.config.dir = Box::from(base_dir.leak()));

base_dir is abs_real_path when the resolver has one, else abs_path. reload() uses config.dir three times: to evict the directory cache, to read the directory, and as the prefix the loader slices off.

Who can hit it

No user has reported this. It was found by automated testing. A project under a symlinked ancestor that passes its real path as dir is not affected. One of these has to be true, and then reload() has to be called:

  • dir is written through a symlink (current/pages, with current a release link), or the routes directory is itself a symlink.
  • A relative resolve from dir failed earlier in the process, and a router over a subdirectory of dir was made.

Repro

// v1/pages/old.tsx, v2/pages/new.tsx, current -> v1
const router = new Bun.FileSystemRouter({ style: "nextjs", dir: root + "/current/pages" });
fs.unlinkSync(root + "/current");
fs.symlinkSync(root + "/v2", root + "/current");
router.reload();
Object.keys(router.routes); // 1.3.14 and this PR: ["/new"]. 1.4.3: ["s/old"]
case 1.3.14 1.4.3-canary 367d939d9 this PR
link moved, then reload() /new s/old /new
old target deleted, then reload() /new throws Unable to find directory: ROOT/v1/pages /new
link removed, then reload() throws Unable to find directory: ROOT/current/pages/ returns s/old throws, as 1.3.14
dir under or being a symlink, index route, reload() returns never returns returns
failed relative resolve, router over a subdirectory, reload() returns never returns returns
second router after reload(), file added under a symlinked dir /a /b /a /a /b

Tests

Each of the seven tests runs one fixture in its own process, because the resolver's directory cache lives as long as the process. The link is made before that process starts. A link made after the resolver has cached its parent directory is not seen as a link, and the bug does not show. Two of the fixtures (dir through a symlink, and the failed resolve) come from #41166.

  • USE_SYSTEM_BUN=1 (1.4.3-canary 367d939d9): 7 of 7 fail. Four print wrong output or throw, three never return.
  • Debug build of main 21620e2be3: 7 of 7 fail. Six abort with the assertion above, one throws for ROOT/v1/pages. The other 37 pass.
  • Debug build of this PR: 44 of 44 pass.

Measurements (Linux x64)

Allocator calls per call, debug build, ASAN_OPTIONS=atexit=1:print_stats=1, BUN_JSC_useJIT=0. Each number is the count for 600 calls minus the count for 300, divided by 300. Two runs gave identical counts.

tree call main mallocs / frees this PR
flat (3 routes) constructor 34 / 14 33 / 13
flat (3 routes) reload() 35 / 33 35 / 33
nested (5 routes, 3 subdirectories) constructor 43 / 21 42 / 20
nested (5 routes, 3 subdirectories) reload() 62 / 48 62 / 48

The statement allocated a copy of the path and freed the one it replaced. Instruction counts and binary size were not measured for this revision. It deletes one statement and adds no code.

Parity with 1.3.14, one case per process: 21 of 21 output lines equal. The cases are those above, a new router and src after a link move, a link that becomes a plain directory, and plain-directory controls (trailing and doubled separators, a relative dir after chdir, a deleted directory). 1.4.3-canary differs in 12 of the 21.

Not changed, as on 1.3.14

  • reload() does not refresh base_dir. After a link moves, match().src is relative to the first real path (../../v2/pages/new.tsx). filePath is right.
  • A constructor evicts nothing. A new router made after a link moved, with no reload() in between, lists the old target.
  • The loader still names a route by slicing entry.dir() at config.dir.len() - 1. Its debug assertions hold the premise, and they now catch a re-added overwrite.
  • reload() ignores route-parse errors that a constructor throws for (FileSystemRouter: surface route-load errors from reload() like the constructor does #34614).

#41166

#41166 rewrote the loader to build route names from the directory walk. It took the slice for the cause. With this change its two tests pass without its loader change. It keeps the overwrite, so reload() still reads the old link target there. It is still open. Once this PR merges, #41166 is loader hardening with no failing test, and it should not merge before this one.

After this change base_dir always equals config.dir and route_dirname_len is always 0 in the loader. Removing them is a possible follow-up. It is not part of this PR.


no test proof · iteration 4 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/util/filesystem_router.test.ts

The constructor replaced `config.dir` with the resolver's path for the
directory: the real path when `dir` is or passes through a symlink, else
the spelling the resolver cached first. `reload()` evicted, read and
named routes against that path. After a link above `dir` moved it still
listed the old target, after the old target was deleted it threw for a
path the caller never passed, and a path without a trailing separator
made it loop forever on an index route.

The Zig constructor had the same statement, but it wrote the local
`router` after the struct had been copied into the router object
(4f41c3f), so it never took effect. The port made it live.

Delete the statement. `config.dir` stays the path `Router::init` got.
@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:05 PM PT - Oct 16th, 2026

❌ @robobun, your commit 741871a has 1 failures in Build #126880 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 45584

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

bun-45584 --bun

@robobun

robobun commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on the released 1.4.3-canary (367d939) and on a debug build of main 21620e2. With dir written through a symlink, reload() lists the old target after the link moves, throws when the old target is deleted, and never returns when the routes directory has an index route. A failed relative Bun.resolveSync from dir plus a router over a subdirectory gives the same hang without a symlink. 1.3.14 does none of this.

Each case is one of the seven new tests in test/js/bun/util/filesystem_router.test.ts. All seven fail on both unfixed builds and pass on this branch.

@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 500b1c0f-beca-4321-9291-6c89d592e146
📥 Commits

Reviewing files that changed from the base of the PR and between f8bc171 and 741871a.

📒 Files selected for processing: 2
  • src/runtime/api/filesystem_router.rs
  • test/js/bun/util/filesystem_router.test.ts
💤 Files with no reviewable changes: 1
  • src/runtime/api/filesystem_router.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

Walkthrough

The filesystem router now keeps its configured directory path after loading. New subprocess tests cover reload behavior when symlink targets change or disappear, route retention after resolution failures, and route discovery after files are added.

Changes

Filesystem router reload

Layer / File(s) Summary
Retain configured path during reload
src/runtime/api/filesystem_router.rs, test/js/bun/util/filesystem_router.test.ts
The router retains its configured directory after route loading. Subprocess tests check route listings when symlink targets change or disappear, when resolution fails, and after files are added. They also check reloads through symlinked directories.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 74187

No actionable merge-blocking risk is established for the reload change. Normal checks can determine merge readiness.

Pre-merge checks: Passed 4
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.
Description check Passed The description clearly explains the problem, fix, scope, reproduction cases, test coverage, verification results, and known limitations. It does not use the template headings exactly, but it provides…
Title check Passed The title is concise, specific, and accurately summarizes the primary change: FileSystemRouter.reload() now uses the directory path as provided by the caller.
Full details: Description check

Explanation

The description clearly explains the problem, fix, scope, reproduction cases, test coverage, verification results, and known limitations. It does not use the template headings exactly, but it provides the required information in equivalent sections.

✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • 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.

@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 finding, I also checked that dropping the Box::from(base_dir.leak()) statement leaves base_dir's refcount balanced: claim takes the +1 at src/runtime/api/filesystem_router.rs:317 and finalize releases it at line 671, and leak() only borrowed the bytes for the copy, so the deleted block never touched the count.

Extended reasoning...

The change deletes one post-construction statement in src/runtime/api/filesystem_router.rs so config.dir keeps the caller's normalized path instead of the resolver's realpath, and adds seven subprocess tests in test/js/bun/util/filesystem_router.test.ts. It touches no security-sensitive surface. The inline finding (reload() not refreshing base_dir, so match().src stays relative to the old real path) is why a human should weigh whether that pre-existing gap should land with this PR or as the follow-up the description proposes.

.router
.with_mut(|r| r.config.dir = Box::from(base_dir.leak()));

Ok(fs_router)

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 (partial fix): after a release link is repointed and reload() runs, match().src still names the old real path, so asset URLs built from src break. reload() installs the new router at src/runtime/api/filesystem_router.rs:502 but never refreshes base_dir, the prefix get_script_src strips. Fix: reload() should re-derive base_dir from the directory it just read (deref the old +1, claim the new), using a fresh realpath rather than root_dir_info.abs_real_path, which inherits the unbusted parent's stale value. The PR calls this unchanged; the base had stale routes too, so fresh routes with a stale src is a new mixed state.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: dir written through a release link (current -> v1, later current -> v2), a FileSystemRouter constructed before the flip, then reload() and match("/new").src. base_dir is claimed once in the constructor at src/runtime/api/filesystem_router.rs:317 from root_dir_info.abs_real_path (/root/v1/pages). reload() at filesystem_router.rs:453-503 reads config.dir again and replaces the router and arena but leaves this.base_dir untouched. match() hands this.base_dir to MatchedRoute at filesystem_router.rs:616, and get_script_src at filesystem_router.rs:898-913 passes it as dir to get_public_path_with_asset_prefix (src/runtime/api/BunObject.rs:20-62). The route's file_path is now the fresh real path /root/v2/pages/new.tsx, which does not start with /root/v1/pages, so src becomes "../../v2/pages/new.tsx" with no origin, or "/abs:/root/v2/pages/new.tsx" with an origin, instead of "new.tsx" under the assetPrefix. On the base branch reload re-read the stale /root/v1/pages, so src and routes were consistently stale; after this change routes and filePath are fresh but src is not.

Verification: pre-existing (partial fix); acknowledged in diff: the PR description states "reload() does not refresh base_dir" and that bound is accurate per the code. reload() (src/runtime/api/filesystem_router.rs:425-507) installs the new router at lines 502-503 but never touches this.base_dir, so match().src is relative to the first real path. On the base, /new never matched at all. The new tests do not assert src after reload.

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.

2 participants