Bun.serve, node:http: run request handlers with the event loop entered - #39826
Conversation
The request dispatch paths called the user's handler with entered_event_loop_count at 0 and drained microtasks explicitly afterwards. A native call made from inside the handler that dispatches another callback through enter()/exit() (server.upgrade() running open(), ws.close() running close()) was therefore the outermost pair, and its exit() drained the handler's own nextTick and promise callbacks before the handler's next statement. The same happened to the continuation of an async handler, which runs from the explicit drain, and to a request's abort listeners. Add EventLoop::enter_scope_without_checkpoint, an enter() whose exit only balances the count, and hold it across the handler call and the explicit drains in every dispatch path: fetch(), routes, both WebSocket upgrade paths, node:http, the HTTP/3 paths, the bake framework path, and RequestContext::on_abort. The drain points do not change, so a request still performs the same number of checkpoints.
|
Warning Review limit reachedYour included review limit has been reached. You’re in a promotional period — use the checkbox below to run this review for free:
On-demand reviews are free for the next 31 days. After that, they cost $0.25 per reviewed file. How can I continue?Run this review now using the option above, or comment You can also wait for the limit to reset (next review available in 1 minute), then comment An organization admin can change what happens after included review limits in Billing. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (8)
Comment |
|
Status Reproduced on bun 1.4.0 and on a debug build of main. A The 9 new test cases fail on a debug build without the CI was green (Buildkite build 101895, 179 of 179 jobs). Merged as 0d34bf2. |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes microtask-checkpoint semantics on every Bun.serve / node:http request-dispatch path via a new event-loop primitive, a human look at the design (the enter_scope_without_checkpoint variant and its guard placement) would still be worthwhile.
What was reviewed:
exit_without_checkpointonly decrements the count; the explicitdrain_microtaskscalls inon_response/on_abortstill run under the held count, so no checkpoint is skipped.- Guard drop order in
on_abort: thescopeguard::defer!drain and_ref's release both run before_entereddecrements — matches the comment. - Every changed dispatch path (
on_request, route, node:http, H3, upgrade, bake) has a matching test excepton_saved_request, which the PR notes was verified against the bake suites.
Extended reasoning...
Overview
Adds a new EventLoop::enter_scope_without_checkpoint() RAII guard (increments entered_event_loop_count on construction, only decrements on drop — no microtask drain) plus a VirtualMachine wrapper, and holds it across nine request-handler dispatch sites in src/runtime/server/{mod.rs, server_body.rs, RequestContext.rs}. This makes nested enter()/exit() pairs inside a request handler (e.g. server.upgrade() running open(), ws.close() running close()) non-outermost, so their exit() no longer drains microtasks in the middle of the user's handler. The dispatchers' existing explicit drains still run, now with the count held above zero. Nine new test cases cover fetch/route/abort/async-continuation over HTTP/1, HTTP/3, and node:http+ws.
Security risks
None identified. This is a task-ordering fix; no auth, crypto, permission, or input-validation surface is touched.
Level of scrutiny
High. entered_event_loop_count gates every microtask checkpoint in the runtime, and the changed sites are the per-request hot path for Bun.serve and node:http. A misplaced guard could either skip a required checkpoint (leaked microtasks / hung promises) or leave the count permanently elevated on an early-return path. I traced each insertion: guards are placed after early-return validation (503 on stopped server, upgrade-context checks) and before the JS call, and Rust's drop-at-scope-end covers every remaining exit including ? / return. In on_abort the guard is declared before _ref and the scopeguard::defer! drain, so it drops last and both drains run under the held count — consistent with the doc comment.
Other factors
- The mechanism is sound and the PR explains it precisely, but adding a second enter/exit variant to the event loop is a design decision (versus, say, making the existing dispatchers use
enter_scopeand dropping their explicit drains, or makingdrain_microtasks_with_globalcount-aware). The description explicitly rules the latter out as too invasive; a maintainer should confirm that call. - User-observable ordering change: code that (accidentally) depended on microtasks running inside
server.upgrade()/ws.close()will see different behavior. The new behavior matches Node and run-to-completion semantics, so this is a correctness fix, but it's still a runtime-wide behavior change. - Test coverage is thorough (fetch, route, abort listener, async continuation, H3 twin, node:http twin) and the description enumerates the suites re-run; the one uncovered path (
on_saved_request/ bake) was verified against existing bake tests.
|
On the two alternatives named in the review above, for whoever looks at the design:
|
Jarred-Sumner
left a comment
There was a problem hiding this comment.
This will cause a performance hit. We deliberately drain microtasks multiple times inside request handlers. Can we continue to do that?
|
Yes. This PR does not remove or gate any of those drains. They all still run, at the same points, unconditionally:
These call The one drain the count removes is the one a nested The cost added per request is the guard: If you want numbers, I can run the HTTP benchmarks on a release build. |
|
Codegen delta on a release build (
The function contains two calls to |
With request handlers running inside the event loop (#39826) the 'close' event deferred from the close callback no longer fires before close() returns when the socket is closed from the 'connection' handler, so pin that case too.
With request handlers running inside the event loop (#39826) the 'close' event deferred from the close callback no longer fires before close() returns when the socket is closed from the 'connection' handler, so pin that case too.
Problem
fetch(), route, ornode:httprequest handler,server.upgrade()(which runsopen()before it returns) orws.close()(which runsclose()) drains the microtask queue in the middle of the handler:then(f); ws.close(); g()runsfbeforeg. Node runsfafter the handler returns. Async continuations andabortlisteners are affected too.src/runtime/server/(list in Notes) call into JS withentered_event_loop_countat 0 and drain explicitly afterwards. Theenter()/exit()pair aroundopen()orclose()(ServerWebSocket.rs) is then the outermost one, soEventLoop::exit()drains. The explicit drains ran at 0 too.Fix
EventLoop::enter_scope_without_checkpoint(): anenter()whose exit only balances the count. Each dispatch path holds it across the handler call and its explicit drains.tick(), timers, sockets). Under the scope the nested pair is not the outermost one and does not drain. The queued callbacks run at the drain the dispatcher performs anyway, so the checkpoints per request are unchanged. Matches Node.websocket-server.test.ts,node-http-with-ws.test.tsandserve-http3.test.ts, all failing on current bun. Also ran the websocket, http, node/http, ws and bake suites.Background
entered_event_loop_count(src/jsc/event_loop.rs) is the depth of native entries into JS.exit()runs the microtask checkpoint (nextTick callbacks, then promise jobs) only for the outermost exit. This gives each callback run-to-completion.RequestContext::on_responsedrains before it looks at the returned value, so a promise the drain settled is unwrapped without a tick. Hence a scope that exits without a checkpoint.Notes
Dispatch paths changed:
mod.rson_request,on_user_route_request,on_node_http_request_with_upgrade_ctx,on_saved_request(bake);server_body.rson_web_socket_upgrade,upgrade_web_socket_user_route,on_request_forandon_user_route_request_for(HTTP/3);RequestContext::on_abort.Cost: on a release build, the difference in the disassembly of
NewServer::on_requestis two loads, anincofentered_event_loop_countbefore the handler call and adecafter it, plus a push/pop of the register that holds the pointer. The node:http dispatch gets the same and still contains its two calls todrain_microtasks_with_global. No drain call is added or removed on any path. Details in the comments below.Tests: 6 cases in
test/js/bun/websocket/websocket-server.test.ts(fetch and route, each callingupgrade()and closing a socket; an abort listener; an async continuation), 2 intest/js/node/http/node-http-with-ws.test.ts(awsconnectionhandler run from the upgrade request, and a request handler closing a socket), 1 intest/js/bun/http/serve-http3.test.ts(fetch and a route over HTTP/3).Repro on bun 1.4.0 (prints
["microtask","after"], expected["after"]):The same code in a
setTimeoutcallback prints the expected order, because timers run underenter()/exit(). The async variant (await nullfirst, then the same statements) also printed the wrong order: the continuation ran from the explicit drain inon_response, with the count at 0. The two node:http cases print["rest of handler","nextTick","microtask"]on Node 26 and printed["nextTick","microtask","rest of handler"]on bun.Out of scope, unchanged:
server.stop(true)called from inside a handler still runson_abort, and its drain, inside the handler. Making every explicit drain observe the count would be a change todrain_microtasks_with_global, the hottest function of the request path, and would affect every subsystem that drains explicitly.on_saved_requestpath gets the same one-line change but has no test here, since it needs a framework app. The bake framework suites (react-response,request-cookies,response-to-bake-response) pass.Suites run with the debug build:
test/js/bun/websocket/,test/js/bun/http/serve.test.ts,bun-serve-routes.test.ts,bun-server.test.ts,serve-http3.test.ts, the abort and stream relatedserve-*.test.tsfiles,test/js/node/http/,test/js/first_party/ws/, thewsregression tests, and the three bake files above. The failures seen were the same with and without this change: thesend() (benchmark)test inwebsocket-server.test.tsstarves its concurrent neighbours on a debug build in this container, and a few tests depend onlocalhostresolution, on not running as root, or on an empty proxy environment. They fail on stock bun here too.