Skip to content

dev server: map a router type's server entry point to the root route of that type - #42840

Open
robobun wants to merge 2 commits into
mainfrom
robobun/fdb3b818/dev-server-route-lookup-type-index
Open

robobun wants to merge 2 commits into
mainfrom
robobun/fdb3b818/dev-server-route-lookup-type-index

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • The dev server aborts with panic: index out of bounds: the len is 1 but the index is 1. The trigger is a build error in a bundle that holds a router type's server entry point. Example: the first request to a page with a syntax error. It needs a router type with a missing root directory before one whose root exists. Stable builds reach it: each routes: { "/a/*": { dir, style } } mount is one router type.
  • A save of the server entry point with a browser connected does the same.
  • The cause is the router init loop in DevServer::init (src/runtime/bake/DevServer.rs:912). It skips a type with a missing root, but stores the enumerate() index in route_lookup, not the type's router index.

Fix

  • Take the type index from types.len() before the push. Store Type::root_route_index(type_index) in route_lookup.
  • Correct because FrameworkRouter::init_empty makes one root route per pushed type, in order.
  • Verified: two new tests in test/bake/dev/bundle.test.ts, both panic without the fix (release and debug). Also framework-router, hot, esm.
  • Self-reviewed: 9 concerns raised, 7 addressed (Notes).

Background

  • A router type is one framework.fileSystemRouterTypes entry: a root directory of route files plus a server entry point.
  • FrameworkRouter keeps accepted types in types and all routes in routes. Route i is the root route of type i.
  • route_lookup maps a server graph file to its route. IncrementalGraph::trace_dependencies copies entries into framework_routes_affected. index_failures and finalize_bundle pass each one to router.route_ptr().
Notes
  • Top frames of the build-error abort (debug build): FrameworkRouter::route_ptr (FrameworkRouter.rs:1270), DevServer::index_failures (DevServer.rs:3403), finalize_bundle (DevServer.rs:4206).
  • The silent skip of a missing root stays. production.rs skips the same way. Dev server skips a router type with a missing root directory and prints no message #42842 tracks whether a skipped type should print a message.
  • Not a regression. The pre-port DevServer.zig had the same line: Route.Index.init(@intCast(i)) after orelse continue.
  • When no router type is skipped, the stored index is the same as before. The change has no effect on that case.
  • Why a page error is enough. process_chunk_dependencies traces every file of a bundle, so a bundle that holds the server entry point puts its index in framework_routes_affected. The first bundle of a route always holds it. index_failures reads the whole list when the same bundle has a failure in any file.
  • Three places read the index through router.route_ptr(): index_failures, the "List 1" block of finalize_bundle (only with a hot update subscriber and a file change), and the client components block of finalize_bundle. The first test covers "List 1": a browser on / must get a server-side reload, which also proves that the right route is marked. The second test covers index_failures two ways, with no browser: a page that is broken on the first request, then a syntax error saved into the server entry point. Each time the route must answer 500 and recover after the next save.
  • Stable reach, checked on release 1.4.3-canary.1 without BUN_FEATURE_FLAG_EXPERIMENTAL_BAKE: Bun.serve({ development: true, routes: { "/a/*": { dir: "./does-not-exist", style: "nextjs-pages" }, "/b/*": { dir: "./pages", style: "nextjs-pages" } } }) with the React packages installed and one pages/index.tsx that has a syntax error. The first GET / exits with SIGABRT. With the fix it answers 500 "Build Failed". The { dir, style } mount has no feature gate (src/runtime/server/server_body.rs:793). This PR does not change that.
  • The abort needs a short route list. With more routes than the bad index, nothing aborts, but the index names another route. Example: types [missing, A, B] give routes = [rootA, rootB, ...]. The old code mapped the server entry of A to route 1 (rootB) and the server entry of B to route 2 (the first scanned route, or out of bounds).
  • Limit that stays: route_lookup holds one route per file. When router types share a server entry point (all { dir, style } mounts do), the last accepted type wins the entry, so a save marks only that type's routes. That is a separate, older defect with no missing root needed. bake: serve routes behind symlinks, reload every route that shares a file #42813 makes the map hold several routes per file. It keeps the old index line, so the two changes compose: whichever lands second stores Type::root_route_index(type_index) through add_route_of_file.
  • Same pattern elsewhere. In src/runtime/bake/ the only other loop that uses an enumerate() index after a continue is production.rs:917. It stores an index into the list it enumerates, which is correct. src/runtime/server/server_body.rs:880 also makes a TypeIndex from the mount position, but nothing reads that payload (both matches are AnyRoute::FrameworkRouter(_)) and Remove dead code from bun_bundler, bun_install, bun_runtime, bun_parsers, and the JSC private host functions #41088 deletes the variant. It is excluded on purpose.
  • fileSystemRouterTypes is capped at 256 entries in Framework::from_js, and { dir, style } mounts at 255, so the u8 cast cannot fail. A 256th existing type would get TypeIndex 255, which GenericIndex::init rejects with a debug assertion. FrameworkRouter::init_empty already does the same for that input, so this PR does not change it.
  • The tests give the router type a clientEntryPoint, so that the page can load meta.modules[0] and run the real HMR client. That client is what subscribes to hot updates. Without a client entry point, the route's client bundle has no main module and the HMR client reports Failed to load bundled module 'null'. This PR does not change that.
  • bake: consolidate the open robobun dev-server, HMR runtime, production build and router fixes #39488 (claude/bake-consolidation) still has the old line, so it does not cover this.
  • Self-review, two passes. The first pass confirmed the shape of the fix and corrected this description: the reach through { dir, style } mounts, the three readers, the excluded server_body.rs site, and the one-route-per-file limit with bake: serve routes behind symlinks, reload every route that shares a file #42813 (4 concerns, all addressed). It raised one more execution concern that is not identified in my notes, so a second pass re-checked the final diff for execution problems. That pass found one should-fix (a 102-column comment, fixed) and three nits. Two nits are applied (helper names, a more exact test comment). The third is not: an extra test where the bad index is in bounds. Both tests already assert behavior that a bounds-check-only change would fail (the reload in the first, the 500 after the server entry point breaks in the second), and each dev server test costs about 2.5 s on a debug build.
  • Suites run with the debug build: test/bake/dev/bundle.test.ts (24 pass), test/bake/framework-router.test.ts, test/bake/app-options.test.ts, test/bake/deinitialization.test.ts, test/bake/dev/esm.test.ts, test/bake/dev/plugins.test.ts, test/bake/dev/vfile.test.ts, test/bake/dev/import-meta-inline.test.ts, test/bake/dev/hot.test.ts.

[human-review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bake/dev/bundle.test.ts
bun test v1.4.3 (09bb54630)

test/bake/dev/bundle.test.ts:
Dev server testing directory: /tmp/bun-dev-test-xI35fy
�[0;30mdev|�[0m Started development server: http://localhost:41589
�[0;30mdev|�[0m �[32mBundled page in 191ms�[0m�[2m:�[0m routes/index.ts �[2m+ 1 more�[0m
�[0;30mdev|�[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
�[0;30mdev|�[0m 
�[0;30mdev|�[0m Module "db.ts" is not accepted by routes/index.ts,
�[0;30mdev|�[0m �[32mReloaded in 64ms�[0m�[2m:�[0m db.ts
�[0;30mdev|�[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
�[0;30mdev|�[0m 
�[0;30mdev|�[0m Module "routes/index.ts" is a root module that does not self-accept.
�[0;30mdev|�[0m �[36m[x2]�[0m �[32mReloaded in 58ms�[0
... (truncated)

release without fix: 2 FAILED
bun test v1.4.3-canary.1 (09bb54630)

test/bake/dev/bundle.test.ts:
Dev server testing directory: /tmp/bun-dev-test-KvrjEF
�[0;30mdev|�[0m Started development server: http://localhost:35711
�[0;30mdev|�[0m �[32mBundled page in 4ms�[0m�[2m:�[0m routes/index.ts �[2m+ 1 more�[0m
�[0;30mdev|�[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
�[0;30mdev|�[0m 
�[0;30mdev|�[0m Module "db.ts" is not accepted by routes/index.ts,
�[0;30mdev|�[0m �[32mReloaded in 1ms�[0m�[2m:�[0m db.ts
�[0;30mdev|�[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
�[0;30mdev|�[0m 
�[0;30mdev|�[0m Module "routes/index.ts" is a root module that does not self-accept.
�[0;30mdev|�[0m �[36m[x2]�[0m �[32mReloaded in 1ms�[0m�[2m:�[0m routes/index.ts
(pass)  DEV:bundle-1: import identifier doesnt get renamed [217.89ms]
�[0;30mdev|�[0m Started development server: http://localhos
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bake/dev/bundle.test.ts
bun test v1.4.3 (09bb54630)

test/bake/dev/bundle.test.ts:
Dev server testing directory: /tmp/bun-dev-test-yUAQDk
�[0;30mdev|�[0m Started development server: http://localhost:41311
�[0;30mdev|�[0m �[32mBundled page in 187ms�[0m�[2m:�[0m routes/index.ts �[2m+ 1 more�[0m
�[0;30mdev|�[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
�[0;30mdev|�[0m 
�[0;30mdev|�[0m Module "db.ts" is not accepted by routes/index.ts,
�[0;30mdev|�[0m �[32mReloaded in 58ms�[0m�[2m:�[0m db.ts
�[0;30mdev|�[0m [Bun] Hot update was not accepted because it or its importers do not call `import.meta.hot.accept`. To prevent full page reloads, call `import.meta.hot.accept` in one of the following files to handle the update:
�[0;30mdev|�[0m 
�[0;30mdev|�[0m Module "routes/index.ts" is a root module that does not self-accept.
�[0;30mdev|�[0m �[36m[x2]�[0m �[32mReloaded in 59ms�[0
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     831878510f
  features     lto, baseline

23 deps, 131 codegen, 1176 objects in 990ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1254] mkdir codegen
[2/1254] mkdir stamps
[3/1254] mkdir pch
[4/1254] mkdir obj
[5/1254] install /workspace/bun
bun install v1.4.3-canary.1 (09bb54630)

Checked 22 installs across 61 packages (no changes) [25.00ms]
[6/1254] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (09bb54630)

Checked 1 install across 2 packages (no changes) [4.00ms]
[7/1254] gen ErrorCode+*.h
[8/1254] gen bindgenv2
[9/1254] fetch tinycc
[tinycc] up to date
[10/1254] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (09bb54630)

Checked 111 installs across 104 packages (no changes) [13.00ms]
[11/1254] fetch picohttpparser
[picohttpparser] up to date
[12/1254] gen node-fallbacks/react-refresh.js
Bundled 1 module in 5ms

  react-refresh.js  4.81 KB  (entry point)

[13/1254] gen bake.{client,server,error}.js
-> bake.client.js, bake
... (truncated)
diff hotspot
src/runtime/bake/DevServer.rs |  8 +++---
 test/bake/dev/bundle.test.ts  | 59 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 64 insertions(+), 3 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                           reads  edits  tests
src/runtime/bake/DevServer.rs      7      5     25
test/bake/dev/bundle.test.ts       7      5     25

…of that type

DevServer::init skips a file system router type whose root directory does
not exist. The route_lookup entry for the server entry point of a later
type used the position in framework.file_system_router_types, not the
index of the type in the router. After a skip the entry named the wrong
route, or one past the end of the route list.

A build error in a bundle that holds that server entry point aborted the
dev server with 'index out of bounds: the len is 1 but the index is 1' in
index_failures. The first request to a page with a syntax error is
enough. A save of the server entry point while a browser listened for hot
updates aborted the same way in finalize_bundle.

Take the index from the number of types pushed so far, and store
Type::root_route_index of it.
@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 5 days. After that, they cost $0.25 per reviewed file.

Or wait 18 seconds 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: e427bff7-178c-4b58-97b2-2b1594ba1f8b

📥 Commits

Reviewing files that changed from the base of the PR and between f5649a7 and 8318785.

📒 Files selected for processing (2)
  • src/runtime/bake/DevServer.rs
  • test/bake/dev/bundle.test.ts

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

@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduction (release 1.4.3-canary.1, Linux x64, and a debug build of main):

  1. Install the React packages (react, react-dom, react-server-dom-bun, react-refresh). Create pages/index.tsx with a syntax error.
  2. Start this server. No app option and no feature flag are necessary.
    Bun.serve({
      development: true,
      routes: {
        "/a/*": { dir: "./does-not-exist", style: "nextjs-pages" },
        "/b/*": { dir: "./pages", style: "nextjs-pages" },
      },
      fetch: () => new Response("fallback"),
    });
  3. Request /. The server exits with SIGABRT and panic: index out of bounds: the len is 1 but the index is 1.

With this PR the same request answers 500 "Build Failed", and the server stays up.

The original report used app: { framework } with two fileSystemRouterTypes (the first root missing), a socket on /_bun/hmr subscribed to hot updates, and a save of the second type's server entry point. That aborts the same way, and it is the first new test.

Verification

  • test/bake/dev/bundle.test.ts: with src/ from main, the two new tests fail with the panic above (22 pass, 2 fail). With this PR, 24 pass.
  • Both new tests also fail on release 1.4.3-canary.1.

@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 findings (both pre-existing limits, not introduced here), I also checked the fix itself: Type::root_route_index(type_index) matches FrameworkRouter::init_empty, which pushes one root route per accepted type in order (FrameworkRouter.rs:187-202), so route index == type index holds. The u8::try_from(types.len()).expect(..) is bounded by the 256-type cap in bake_body.rs:873 and mirrors the identical cast in init_empty. The other config-position TypeIndex at server_body.rs:880 is never read (both matches in server/mod.rs discard the payload), so it is unaffected.

Extended reasoning...

The diff replaces the enumerate() config-position index with the accepted-type index taken from types.len() before the push, and derives the route via root_route_index, which is RouteIndex::init(type_index as u32); init_empty lays out exactly one root route per type in push order, so the mapping is correct. The u8 cast is provably in range given the 256-entry cap enforced in Framework::from_js and the same cast already in init_empty. The two posted findings concern behavior that predates this PR (shared server entry across mounts and the silent skip of a missing root), which the author acknowledges in the description; they are worth a human's judgment on scope but do not indicate a defect in the change itself. Two new dev-server tests exercise both the hot-reload path and the index_failures path with a skipped first router type.

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

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

  • 🟣 src/runtime/bake/DevServer.rs — A user who mistypes the dir of a { dir, style } mount or a framework root gets a dev server that starts cleanly and answers 404 for that whole mount, with no message. DevServer.rs:924-928 turns a failed read_dir_info_ignore_error into a bare continue, dropping the router type without logging. Fix: report the missing root (a warning naming the joined path, or an error at startup) at DevServer.rs:928 and at the twin skip in production.rs:552, so the dropped mount is visible. The PR's own tests rely on this silent skip and lock it in as the supported shape.

    Extended reasoning...

    read_dir_info_ignore_error at DevServer.rs:926 swallows ENOENT and any other error and returns None. The continue at :928 skips types.push and route_lookup.put, so the type has no root route and scan_initial_routes never visits it. server_body.rs:880 still returns AnyRoute::FrameworkRouter for that mount, but both readers (server/mod.rs:185 and :2562) ignore the payload, so nothing on the server side notices the type is gone. Requests under the mount prefix fall through to the router with no matching route and get the dev server's not-found response. Population: any Bun.serve({ routes: { "/x/*": { dir, style } } }) user with a wrong relative dir, at every request. The dismissing finder called it pre-existing and out of scope; the diff adds a comment at :938 that documents the skip as intended and the new tests use routerType("missing") as fixture, so this PR is the point where the silent skip becomes a supported contract. Remedy: emit a diagnostic naming the joined root at both skip sites.

    Verification: pre-existing; acknowledged in diff: PR description says "The silent skip of a missing root stays. production.rs skips the same way." and the claim is accurate. Trigger: a { dir, style } mount or framework root pointing at a directory that does not exist. Mechanism verified: read_dir_info_ignore_error (src/resolver/resolver.rs:4118) is .ok().flatten(), so ENOENT and every other error…

Comment thread src/runtime/bake/DevServer.rs
@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Replies to the two pre-existing findings of the review. This PR changes neither of them.

Silent skip of a missing root. Confirmed on 1.4.3-canary.1: routes: { "/*": { dir: "./pagse", style: "nextjs-pages" } } starts with empty stdout and stderr, and GET / answers 404 Not Found. A diagnostic needs a decision first. A framework package can list roots that a project does not need to have (for example src/pages and pages), so a warning for each absent root is noise there. A path that the user wrote in routes is more likely a mistake. I filed #42842 to track it, with the options.

About the tests: they need a skipped router type, because the wrong index exists only after a skip. If a missing root becomes a startup error, this bug class goes away, and these two tests go with it.

Shared server entry point. Answered in the inline thread. #42813 makes route_lookup hold every route of a file, and it composes with this change.

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:25 PM PT - Sep 15th, 2026

✅ @robobun, your commit 831878510f46fcd287ef88b2e5193feaca1e10d3 passed in Build #116219! 🎉


🧪   To try this PR locally:

bunx bun-pr 42840

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

bun-42840 --bun

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