Skip to content

uws: key HttpRouter by method id - #45400

Draft
robobun wants to merge 1 commit into
robobun/5627effc/serve-method-id-rustfrom
robobun/5627effc/uws-router-method-trees
Draft

robobun wants to merge 1 commit into
robobun/5627effc/serve-method-id-rustfrom
robobun/5627effc/uws-router-method-trees

Conversation

@robobun

@robobun robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • The parser stores methodIdFromWire() on the request. HttpRouter keeps one tree per method id, and the any-method tree as a member. route(id, url) indexes.
  • route() refuses an id that is no method, so Request::method() asserts it in release builds too.
  • HTTP/2 and HTTP/3 register under the uppercase names, with a static_assert on each. HttpRequest::getMethod() and the lowercase copy in Http3Request go.
  • Self-reviewed: 33 concerns raised, 32 addressed (Bun.serve: answer 501 to an unknown method and 404 to a router miss, not a closed socket #45396).

Behaviour change: none

Tests that cover this code: test/js/bun/http/bun-serve-routes.test.ts, test/js/bun/http/serve-http2-protocol.test.ts, test/js/bun/http/serve-http3.test.ts, test/js/node/http/node-http.test.ts.

Background

  • route() and cullNode() are uWebSockets code. Bun registers an any-method route under 36 method names, which leaves the any-method node empty, so a removal erases it.
  • Considered one clause that keeps the any-method node. The name scan stays with it.

Downsides

Notes

Open question. Open question before this PR leaves draft: the first ask of #6556, a method outside the 36 that reaches fetch. #44718 (open) lets a Request carry any method token. #45399 and this PR remove the method name from the path between uWS and Rust, so a request with no method id has no name to deliver. If maintainers want such a request delivered, the name must stay readable for it. That needs a maintainer decision.

Why no answer changes. After #45396 and #45357 a request without a method id does not reach route(). Without #45357 an unknown :method over HTTP/3 would get the 404 of a router miss, so this PR is a draft until that PR lands. After #45397 every server has an any-method "/*" registration (the default handler, or a user route), so on HTTP/1.1 each of the 36 methods has a tree, and on HTTP/2 and HTTP/3 the any-method tree is not empty. The fallback line then only ran a second time over any-method handlers that had already yielded. The id-keyed router runs them once.

The routers compared. A differential driver ran random registration programs against the router on main and this one: 1.89 million route() calls in the HTTP/1.1 shape. In every call where the old router stayed in the trees of the request's method, both routers gave the same handlers in the same order. The old router ran a handler that was registered for another method in 61,677 calls (47,709 in the HTTP/2 shape, 87,391 in the HTTP/3 shape). This one: 0. HttpRouter.h in this PR is the file that run measured.

#44323 and #35248. #44323 adds a test hook that calls route(name, url) and add(names, ...) on this router and pins what the router on main does. One of its tests is the fallback this PR deletes. If #44323 lands first, this PR changes the hook to pass the id, makes it reject a name that is no method before add() can terminate, and updates the expectations that pin the fallback. The expectations for tables that keep an any-method route must not change. #35248 rewrites the storage of the same file and keeps the fallback. It needs a rebase after this PR, or this PR after it.

Provenance. route(), cullNode() and the sort of the method nodes are uWebSockets code. uWebSockets registers an any-method route under "*" and gives an app a default any-method handler, so its any-method node is not empty. Bun's HTTP/1.1 app registers an any-method route under the 36 method names and has no such default, which leaves the node empty.

std::terminate() in add(). A route name that is no method and not "*" terminates at registration. On main such a route was registered under a tree that no request could reach. The names come from string literals in App.h, Http2App.h and Http3App.h. The HTTP/2 and HTTP/3 literals have a static_assert. #45397 added a test that registers a route for each of the 9 route-object methods over each protocol.

Measurements.

  • Instructions per request for parse, route and method read, server: read the method id uWS resolved, not the method name #45399 then this PR. GET to fetch: 2412, 2373. POST to fetch: 2710, 2387. PUT to fetch: 2624, 2379. PROPFIND to fetch: 2644, 2427. UNSUBSCRIBE to fetch: 2709, 2460. GET to a GET route: 2881, 2842. POST to a POST route: 3171, 2848. PUT to a PUT route: 3093, 2848. node:http, GET: 2570, 2513. POST: 2860, 2535. On main (f4d755a) the first two rows are 2435 and 2750. A standalone driver runs the real HttpParser.h and HttpRouter.h (clang -O3) under qemu-x86_64 -one-insn-per-tb -d exec,nochain. Repeat runs differ by at most 4 instructions in 2.6 million.
  • Instructions per request end to end: not measured. perf is not available in the build container (perf_event_paranoid is 4), and valgrind is not installed.
  • sizeof: HttpRouter 3,360 -> 6,528 bytes. HttpRequest 6,472 -> 6,472 (the id goes in padding). Http3Request 152 -> 104.
  • Allocations (operator new calls) to register 100 any-method routes and the default handler: HTTP/1.1 7,711 -> 7,669. With "/*": { GET: fn } added: 7,713 -> 7,671. HTTP/2 and HTTP/3: 319 -> 319 and 325 -> 323.
  • Release binary (llvm-size): text 80,693,075 -> 80,695,123 bytes (server: read the method id uWS resolved, not the method name #45399, then this PR, on base f4d755a; measured while the stack had its own HTTP/3 check). data and bss do not change. Against main (80,705,363) the whole stack is 10,240 bytes smaller.

Suites (debug build with ASAN): bun-serve-routes (86 tests), node-http-pipeline-callbacks, node-http, node-http-displaced-response, serve-http2, serve-http2-protocol, serve-http3, request-smuggling, bun-serve-static, bun-serve-file, serve-directory-routes, bun-serve-html, three test-http-*-client-error Node tests, and serve.test.ts. In serve.test.ts two tests fail in this container on the base too. In node-http, tests of one group hit their 5 s timeout in the whole-file run. The group has 11 tests that run at once, each with a child process, and they pass when the group runs alone.

@robobun

robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:17 AM PT - Oct 17th, 2026

❌ @robobun, your commit 6005a00 has 1 failures in Build #127498 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 45400

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

bun-45400 --bun

@robobun

robobun commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status

This PR changes no behaviour once #45357 is in. It is a draft: it needs #45357, and an order with #44323 and #35248. #45396 has the reproduction of the bug that this stack fixes.

The stack: #44510, #45396, #45397, #45398, #45399 (draft), #45400 (draft).

@robobun
robobun force-pushed the robobun/5627effc/uws-router-method-trees branch from add24f5 to d0bb702 Compare October 15, 2026 20:32
@robobun
robobun force-pushed the robobun/5627effc/serve-method-id-rust branch 2 times, most recently from 9e5913c to ed1a9e9 Compare October 15, 2026 21:57
@robobun
robobun force-pushed the robobun/5627effc/uws-router-method-trees branch 2 times, most recently from d5f50df to 0ee2f8e Compare October 17, 2026 10:06
@robobun
robobun force-pushed the robobun/5627effc/serve-method-id-rust branch from ed1a9e9 to 41e84b1 Compare October 17, 2026 10:06
The parser stores methodIdFromWire() on the request, and HttpRouter
keeps one tree per method id plus the tree of the any-method routes.
route() indexes the tree of the request's method and then tries the
any-method tree. It no longer scans the method names, and it has no
positional fallback: a removal cannot make another method's tree take a
request.

The HTTP/2 and HTTP/3 apps register their routes under the uppercase
method names, like HTTP/1.1. Nothing reads the method name of a request
any more, so HttpRequest::getMethod() and the lowercase copy in
Http3Request go.

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