Skip to content

Bun.serve: store the route parameter name range as 32 bits - #45174

Open
robobun wants to merge 1 commit into
mainfrom
robobun/db858b71/serve-route-params-range-u32
Open

robobun wants to merge 1 commit into
mainfrom
robobun/db858b71/serve-route-params-range-u32

Conversation

@robobun

@robobun robobun commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.serve({ routes }) gives req.params the names of another route once the routes before it hold 65,536 :name segments. /last/:id/:slug answers {"k0_0":"7","k0_1":"x"}.
  • IdentifierRange (src/jsc/bindings/ServerRouteList.cpp:41) stores a uint16_t start and count. finishCreation casts a server-wide name index into it (:152).
  • count wraps too: one route with 65,536 names gets req.params = {}.

Fix

  • start and count are now uint32_t, the index type of the vector. With no cast in the initializer, a narrowing conversion does not compile.
  • getParameter() takes a 16-bit index. A name past it gets "", not the value of the wrapped index.
  • The range table had two entries per route and used one. With one, it stays at 8 bytes per route.
  • Verified: test/js/bun/http/bun-serve-routes.test.ts (two new tests, bun 1.4.3-canary.1 fails both). Also serve-http3.test.ts.

Background

  • ServerRouteList is the C++ object behind routes. One vector holds the parameter names of all routes, with a range per route.
  • uWS, the HTTP router, matches at most 100 segments of a route. req.params lists a name past that as "", so the count of one route has no bound.
  • Considered an error from Bun.serve() past 65,535 names: it rejects a working table. Considered a wider getParameter() index: two uWS headers change to save one compare.

Downsides

  • One more instruction per route parameter per request: the loop head has 4, it had 3 (llvm-objdump, -O3, no LTO). The object code of the file is 58 bytes smaller.
  • A route with more than 65,535 names builds every name per request, not the count modulo 65,536.
Notes

Runs of the reported script. It has 1,024 routes of 64 names, then /last/:id/:slug. The table shows req.params of GET /last/7/x by the count of names in front of the last route.

names bun 1.4.3-canary.1 this PR (debug build)
65,535 ok ok
65,536 {"k0_0":"7","k0_1":"x"} ok
65,537 {"k0_1":"7","k0_2":"x"} ok
131,072 {"k0_0":"7","k0_1":"x"} ok

One route with N names. The request is /x/v0/.../v98. The table shows the key count of req.params. In each run of this PR the names a0 to a98 have a value and every other name is "".

N bun 1.4.3-canary.1 this PR (debug build)
65,535 65,535 65,535
65,536 0 65,536
65,537 1 65,537
70,000 4,464 70,000

The guard in the loop. A build with both fields widened and the unsigned short cast left alone answers a65536: "v0" in the second test. Index 65,536 wraps to 0.

Cost numbers. I compiled ServerRouteList.cpp at the merge base and at this PR with the release flags (-O3, without -flto=thin, against the headers of the debug build directory). I compared the two objects with llvm-nm -S and llvm-objdump -d.

  • Loop head per name at the merge base: movzx, cmp, jl. With this PR: cmp, ja, cmp, jl.
  • paramsObjectForRoute (each of its two instantiations): 1,325 to 1,309 bytes. create: 355 to 299 bytes. finishCreation: 1,717 to 1,747 bytes. Text of the object: 18,236 to 18,178 bytes.
  • Range table: two entries of 4 bytes per route before, one entry of 8 bytes per route after.
  • Not measured: a release build of this PR. perf and valgrind are not installed here, so the instruction count comes from the disassembly and not from a run.

Test shape. The tests use one route with 65,537 names, not 1,024 routes. Bun.serve() takes 16 s to register 1,024 routes of 64 names in a debug ASAN build (#35248 and #30410 are open for the registration time). The two tests add about 3.5 s to the file in a debug ASAN build.

Suites run with the debug ASAN build: test/js/bun/http/bun-serve-routes.test.ts (63 pass) and test/js/bun/http/serve-http3.test.ts (77 pass).


[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/js/bun/http/bun-serve-routes.test.ts
bun test v1.4.4 (367d939d9)

test/js/bun/http/bun-serve-routes.test.ts:
(pass) path parameters > handles single parameter [39.27ms]
RequestParams {
  postId: "456",
  commentId: "789",
}
(pass) path parameters > handles multiple parameters [26.59ms]
(pass) path parameters > handles encoded parameters [15.95ms]
(pass) path parameters > handles unicode parameters [14.94ms]
(pass) path parameters > decodes raw valid UTF-8 bytes in a parameter segment [482.13ms]
(pass) path parameters > decodes raw an invalid UTF-8 byte in a parameter segment [40.48ms]
(pass) HTTP methods > GET request [33.77ms]
(pass) HTTP methods > POST request [65.98ms]
(pass) HTTP methods > PUT request [8.86ms]
(pass) HTTP methods > DELETE request [12.43ms]
(pass) HTTP methods > PATCH request [21.48ms]
(pass) HTTP methods > OPTIONS request [29.87ms]
(pass) HTTP methods > HEAD request [7.64ms]
(pass) implicit HEAD for per-method route objects > HEAD is served by the GET handler when no HEAD handler is declared [72.45ms]
(pass) im
... (truncated)

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

test/js/bun/http/bun-serve-routes.test.ts:
(pass) path parameters > handles single parameter [11.33ms]
RequestParams {
  postId: "456",
  commentId: "789",
}
(pass) path parameters > handles multiple parameters [2.69ms]
(pass) path parameters > handles encoded parameters [1.36ms]
(pass) path parameters > handles unicode parameters [0.97ms]
(pass) path parameters > decodes raw valid UTF-8 bytes in a parameter segment [17.46ms]
(pass) path parameters > decodes raw an invalid UTF-8 byte in a parameter segment [3.43ms]
(pass) HTTP methods > GET request [11.05ms]
(pass) HTTP methods > POST request [3.86ms]
(pass) HTTP methods > PUT request [2.22ms]
(pass) HTTP methods > DELETE request [0.57ms]
(pass) HTTP methods > PATCH request [0.68ms]
(pass) HTTP methods > OPTIONS request [0.48ms]
(pass) HTTP methods > HEAD request [2.28ms]
(pass) implicit HEAD for per-method route objects > HEAD is served by the GET handler when no HEAD handler is declared [9.63ms]
(pass) implicit HEAD for per-method route objects > HEAD does not 404 when there is no later route to fall through to [7.06ms]
(pass) implicit HEAD for per-method route objects > the G
... (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/js/bun/http/bun-serve-routes.test.ts
bun test v1.4.4 (367d939d9)

test/js/bun/http/bun-serve-routes.test.ts:
(pass) path parameters > handles single parameter [101.59ms]
RequestParams {
  postId: "456",
  commentId: "789",
}
(pass) path parameters > handles multiple parameters [23.50ms]
(pass) path parameters > handles encoded parameters [18.13ms]
(pass) path parameters > handles unicode parameters [15.38ms]
(pass) path parameters > decodes raw valid UTF-8 bytes in a parameter segment [930.24ms]
(pass) path parameters > decodes raw an invalid UTF-8 byte in a parameter segment [55.70ms]
(pass) HTTP methods > GET request [59.71ms]
(pass) HTTP methods > POST request [8.93ms]
(pass) HTTP methods > PUT request [8.40ms]
(pass) HTTP methods > DELETE request [7.47ms]
(pass) HTTP methods > PATCH request [7.95ms]
(pass) HTTP methods > OPTIONS request [8.23ms]
(pass) HTTP methods > HEAD request [7.83ms]
(pass) implicit HEAD for per-method route objects > HEAD is served by the GET handler when no HEAD handler is declared [72.50ms]
(pass) impli
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 2660ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/1437] fetch picohttpparser
[picohttpparser] up to date
[2/1437] subst deps/zlib/zconf.h
[3/1437] fetch zstd
[zstd] up to date
[4/1437] fetch brotli
[brotli] up to date
[5/1437] fetch libdeflate
[libdeflate] up to date
[6/1437] fetch libarchive
[libarchive] up to date
[7/1437] subst deps/libjpeg-turbo/jversion.h
[8/1437] subst deps/libjpeg-turbo/jconfig.h
[9/1437] subst deps/libjpeg-turbo/jconfigint.h
[10/1437] fetch libspng
[libspng] up to date
[11/1437] fetch cares
[cares] up to date
[12/1437] fetch libwebp
[libwebp] up to date
[13/1437] fetch hdrhistogram
[hdrhistogram] up to date
[14/1437] fetch lshpack
[lshpack] up to date
[15/1437] fetch mimalloc
[mimalloc] up to date
[16/1437] gen BunObject.lut.h
Generating /workspace/bun/build/release/codegen/BunObject.lut.h from /workspace/bun/src/jsc/bindings/BunObject.cpp
[17/1437] gen ProcessBindingFs.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingFs.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingFs.cpp
[18/1437] fetch highway

... (truncated)
diff hotspot
src/jsc/bindings/ServerRouteList.cpp      | 13 ++++++-----
 test/js/bun/http/bun-serve-routes.test.ts | 39 +++++++++++++++++++++++++++++++
 2 files changed, 46 insertions(+), 6 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                       reads  edits  tests
src/jsc/bindings/ServerRouteList.cpp           2      3     13
test/js/bun/http/bun-serve-routes.test.ts      2      2     13

Each route reads its parameter names from one vector that holds the
names of every route of the server. The range into that vector had a
16-bit start and a 16-bit count, and both were cast down with no check.

With 65,536 names in the routes before it, a route built `req.params`
with the names of another route. A route with 65,536 names of its own
lost them.

Store the start and the count as `uint32_t`, the index type of the
vector. The brace initializer now takes both values with no cast, so a
narrowing conversion does not compile. `getParameter()` takes a 16-bit
index, so a name past that index gets the empty string and not the
value of a wrapped index.

The table of ranges had two entries per route and used one. It now has
one entry per route, so it stays at 8 bytes per route.
@robobun

robobun commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:40 PM PT - Oct 13th, 2026

❌ @robobun, your commit a375ba2 has 2 failures in Build #125203 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 45174

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

bun-45174 --bun

@robobun

robobun commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced on bun 1.4.3-canary.1 (Linux x64) with the reported script: 1,024 routes of 64 :name segments, then /last/:id/:slug.

  • 65,535 names in front of the last route: GET /last/7/x answers {"id":"7","slug":"x"}.
  • 65,536 names: it answers {"k0_0":"7","k0_1":"x"}.
  • One route with 65,536 names of its own: req.params is {}.

PR: #45174

@coderabbitai

coderabbitai Bot commented Oct 13, 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: 0fa2c36d-1016-4243-868c-519864b2504d
📥 Commits

Reviewing files that changed from the base of the PR and between c69a636 and a375ba2.

📒 Files selected for processing: 2
  • src/jsc/bindings/ServerRouteList.cpp
  • test/js/bun/http/bun-serve-routes.test.ts

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

Walkthrough

Route identifier ranges now use 32-bit start and count fields. Parameter lookup calls uWS only for indices at or below USHRT_MAX. Regression tests cover routes with 65,537 parameter names and a later parameterized route.

Changes

Route Parameter Indices

Layer / File(s) Summary
Store route identifier ranges
src/jsc/bindings/ServerRouteList.cpp
Identifier range fields and counts use 32-bit values. Each route allocates one range, and initialization stores the values without 16-bit casts.
Bound parameter lookup and test large routes
src/jsc/bindings/ServerRouteList.cpp, test/js/bun/http/bun-serve-routes.test.ts
Lookup calls uWS only for indices at or below USHRT_MAX; larger indices use an empty view. Tests check the large route’s names and values and verify that a later route retains its parameter names.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to a375b

No actionable merge-blocking risk is identified; the change is mergeable after normal checks.

Pre-merge checks: Passed 4
Passed 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 primary change: storing route parameter name ranges as 32-bit values.
Description check Passed The description clearly explains the problem, fix, trade-offs, and verification results. It does not use the template headings exactly, but it contains the required change summary and test information…
✨ 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.

I reviewed this PR and didn't find any bugs. Since it changes per-request native code in the Bun.serve routing path and the one remaining question (JSC's MarkedArgumentBuffer behaviour past 65,536 entries) can't be confirmed from this checkout, a human look would still be worthwhile.

What was reviewed:

  • The IdentifierRange widening and the dropped *2 sizing: m_pathIdentifierRanges is only ever indexed by the route index (same bound as m_routes), and finishCreation writes exactly paths.size() entries, so the old oversizing masked nothing.
  • The i <= numeric_limits<unsigned short>::max() guard against packages/bun-uws/src/HttpParser.h:560 and HttpRouter.h: add() truncates patterns at MAX_URL_SEGMENTS (100) and getParameter returns empty for index > paramsTop, so names past index 65,535 can never have a value and "" is the correct result.
  • The new test's shape: the 65,538-segment route registers at depth 100 in the uWS trie, so the 100-segment request does match it and a65536 is the first index past 16 bits; m_pathIdentifiers.size() is a WTF::Vector size and fits uint32_t.
  • Not verifiable here: that MarkedArgumentBuffer::ensureCapacity/append have no 0x10000 cap (JSC headers aren't in the checkout); the test asserts args.at(65536) directly, so a debug/ASAN CI run of it settles this.
Extended reasoning...

The diff touches src/jsc/bindings/ServerRouteList.cpp (widens IdentifierRange start/count from uint16_t to uint32_t, sizes the range table to paths.size(), and guards getParameter() calls to indices that fit unsigned short) and adds two tests in test/js/bun/http/bun-serve-routes.test.ts. It touches no auth, crypto, or injection surface; the untrusted-input surface is the request path, which uWS already caps at 100 segments. The logic is small and I confirmed the boundary semantics against the vendored uWS router and parser, and no CODEOWNERS entry covers the changed files. I chose defer over approve because the per-request loop now runs up to the full identifier count with no upper bound and the MarkedArgumentBuffer growth semantics at 65,537 entries could not be checked from the checkout, so the CI debug/ASAN run of the new test is the deciding evidence a human should see.

@robobun

robobun commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

The open question about MarkedArgumentBuffer has an answer from the pinned WebKit source and from a run.

  • MarkedVectorBase keeps unsigned m_size and unsigned m_capacity (MarkedVector.h). In MarkedVector.cpp at WebKit 0c06faadf65b, slowEnsureCapacity reports an overflow only when the capacity does not fit int32. expandCapacity reports one only when the byte size overflows or FastMalloc::tryMalloc fails. There is no bound at 0x10000.
  • args in paramsObjectForRoute is a rooted scratch vector. The code never passes it to a call, so Interpreter::maxArguments does not apply to it.
  • The second new test passes on a debug ASAN build. It reads a65536, which is args.at(65536). A build without the index guard answered a65536: "v0" there. So entry 65,536 is stored and read back as a real value.
  • One route with 70,000 names answers 70,000 keys on the same build, and 99 of them have a value.

paramsObjectForRoute does not check args.hasOverflowed(). This PR does not change that: the buffer overflows only when its allocation fails, as it does on main.

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