Stop running the event loop inside JavaScript calls
What does this PR do?
A few JavaScript-callable APIs run the thread's event loop inside the call until a promise settles. Timers, I/O callbacks and microtasks of unrelated code then run underneath the caller's stack frame. That is what a long list of re-entrancy guards in sockets, WebSocket, SQL, node:vm, spawn and DNS exists for.
This PR removes those nested runs. Each API now returns before the promise settles, or blocks without running the loop. This is a breaking change.
Scope changed on 2026-11-06. This PR used to make the bun:test matchers (.resolves / .rejects, async toThrow(), async expect.extend() matchers) asynchronous as well. #44833 did that on main with its own design, so the merge of main takes main's test runner, types, docs and bindgen as they are. The matcher work, the bindgen before option and the roughly 530 awaits added to tests are gone from this PR.
Still not part of this PR: a macro that returns a promise waits in a nested loop (Macro.rs, _load_macro_entry_point). That needs a different design and comes later. A Windows N-API addon that calls uv_run can also still nest the loop, which Bun cannot prevent.
Breaking changes
| API | Before | After |
|---|---|---|
Bun.build({ plugins }) with a setup() that returns a pending promise | ran the event loop inside Bun.build() | Bun.build() returns its promise at once. The next setup() and the rest of the option parsing run when that promise fulfills |
Bun.serve({ app }) with such a setup() | ran the event loop inside Bun.serve() | Bun.serve() returns at once. The remaining setup() calls still run one after the other, in order. Requests wait until the last one has fulfilled |
inspector.waitForDebugger(), inspector.open(port, host, true) | ran the event loop until a frontend connected | blocks the thread and handles only inspector messages, as while paused at a breakpoint. Node v22 gives the same result for the script under "How did you verify" |
Consequences worth knowing before merging:
Bun.build()errors: an error before the first pendingsetup()is still thrown by the call. One after it rejects the returned promise.Bun.build()options: with a pendingsetup(), or anonStart()that returns a promise (which makes the result of the lastsetup()pending), every option excepttargetandpluginsis read afterBun.build()has returned. They were always read after the plugins, so thatsetup()can changebuild.config, andbuild.configis the caller's own object (an existing test pins that). A caller that mutates one options object between two un-awaitedBun.build()calls now changes the first build too. Documented indocs/bundler/plugins.mdx.Bun.serve({ app })errors: asetup()that fails afterBun.serve()returned cannot make it throw. The error is printed, waiting requests get a 500, and later ones get the existing plugin error response.
Not visible to users
--hot: a reload that has to load the--preloadfiles again ran the loop from inside the file-watcher task. (The list is only kept after a preload failed.) The reload is now deferred to the top of the loop, through the flag that already existed for a reload during a preload.- The
yesshell builtin underbun execticked the uSockets loop from inside its own task.MiniEventLoopgets the same "run after the next poll" queue that the JS loop has. patchInternals.makeDiff(testing only) ran the thread's loop on Windows whilegit diffran. It now uses the isolatedBun.spawnSyncloop. I could only type-check this for Windows, and only before the merge of main.EventLoop::tick_while_paused,Bun__tickWhilePausedandlibuv_sys::Loop::tickhave no caller left and are deleted.
How it works
Bun.build()(BuildStartinJSBundler.rs): the state up to the point where the bundle is scheduled is parked in one JS array that only the promise reaction holds. Asetup()promise that never settles is collected together with it, and a test checks that.Bun.serve({ app }): the options keep a queue ofsetupfunctions.DevServerholds bundles back (PluginState::Pending, as for[serve.static]plugins) and runs the next one from the reaction. The reaction counts as a pending request while it runs, because thesetup()it calls can stop the server, and a drained server frees theDevServer.bun build --appwaits between them at the top level, where no script is on the stack.waitForDebugger()sleeps on the condition variable thatrunWhilePausedalready uses, connects the frontends that attach meanwhile, and dispatches what they send.
Tests that nest the loop on purpose
Main's tests of the re-entrancy guards nest the loop through Bun.build() with an async setup(). That stops working here. A macro can still nest the loop, so the guards stay, and those tests now use runNestedEventLoopUntilSettled from bun:internal-for-testing, which no public API reaches. Comments in src/ that named expect().resolves or Bun.build() as the trigger now name the macro.
How did you verify your code works?
All runs are debug + ASAN builds on Linux x64.
New tests that failed on a build of main from 2026-10-22 and pass here:
test/bundler/bundler_plugin.test.ts:describe("a setup() that returns a pending promise"). There the first three failed and the file then hung on asetup()that never settlestest/bake/app-options.test.ts:Bun.serve()returns before a pendingsetup(), the three ways asetup()can fail later, and asetup()that settles afterserver.stop()test/js/node/inspector/inspector.test.ts: nothing runs insidewaitForDebugger()
New tests checked another way:
test/cli/run/preload-test.test.jscompares the event loop depth that a timer sees in a reloaded preload and in the entry point. It reads a new function ofbun:internal-for-testing, so main cannot show the failure. With the deferral disabled it reports depth 2 against 1.test/js/bun/shell/commands/yes.test.ts: a capturedyesunderbun execlets the loop reap the other pipeline member. It also passes on main, whose nested tick polled too. With the poll removed from the new queue it times out.test/bake/app-options.test.ts: asetup()that runs afterBun.serve()returned can stop the server. This guards a use-after-free that an earlier version of this PR had (ASAN caught it), not a bug on main.test/bake/dev/production.test.ts:bun build --appruns asyncsetup()calls in order, and fails when one rejects. Behaviour that did not change, so it passes on main too.
After the first merge of main (c1ad8db55) I rebuilt from scratch and ran every test file this PR still changes, plus hot.test.ts, serve-plugins-dev-server.test.ts, test/bake/dev/plugins.test.ts and test/internal/source-lints. What fails is what failed on main here before: no IPv6 or public network in this container (socket.test.ts, node-dns.test.js) and 5 s timeouts on the debug build (vm.test.ts, one in production.test.ts). I did not build main again to compare those after the merge.
After the second merge (over #47942, head 9b82ff36c) I rebuilt again and ran 12 of those files: bundler_plugin, app-options, inspector, preload-test, yes, yield, pidfd-exit-nested-tick, tcp-server, node-tls-duplex-close-throw-uaf, websocket-client-short-read, hot and serve-plugins-dev-server. All pass.
The two testing functions are entries of the table of built-ins of the ahead-of-time compiler. runNestedUntilSettled is in the list of functions that test/aot/builtins does not call, because it does not come back for a promise that never settles.
waitForDebugger(): a script queues an immediate, a 0 ms timer and a microtask, then calls it while a child connects and sends Runtime.runIfWaitingForDebugger after 300 ms. Both this build and Node v22.22 block for the 300 ms and report that none of the three ran inside the call.
Generated by Claude Code