Stop running the event loop inside JavaScript calls

Draft
DY
1d ago
+1362-356

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

APIBeforeAfter
Bun.build({ plugins }) with a setup() that returns a pending promiseran 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 connectedblocks 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 pending setup() is still thrown by the call. One after it rejects the returned promise.
  • Bun.build() options: with a pending setup(), or an onStart() that returns a promise (which makes the result of the last setup() pending), every option except target and plugins is read after Bun.build() has returned. They were always read after the plugins, so that setup() can change build.config, and build.config is the caller's own object (an existing test pins that). A caller that mutates one options object between two un-awaited Bun.build() calls now changes the first build too. Documented in docs/bundler/plugins.mdx.
  • Bun.serve({ app }) errors: a setup() that fails after Bun.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 --preload files 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 yes shell builtin under bun exec ticked the uSockets loop from inside its own task. MiniEventLoop gets the same "run after the next poll" queue that the JS loop has.
  • patchInternals.makeDiff (testing only) ran the thread's loop on Windows while git diff ran. It now uses the isolated Bun.spawnSync loop. I could only type-check this for Windows, and only before the merge of main.
  • EventLoop::tick_while_paused, Bun__tickWhilePaused and libuv_sys::Loop::tick have no caller left and are deleted.

How it works

  • Bun.build() (BuildStart in JSBundler.rs): the state up to the point where the bundle is scheduled is parked in one JS array that only the promise reaction holds. A setup() promise that never settles is collected together with it, and a test checks that.
  • Bun.serve({ app }): the options keep a queue of setup functions. DevServer holds 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 the setup() it calls can stop the server, and a drained server frees the DevServer. bun build --app waits between them at the top level, where no script is on the stack.
  • waitForDebugger() sleeps on the condition variable that runWhilePaused already 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 a setup() that never settles
  • test/bake/app-options.test.ts: Bun.serve() returns before a pending setup(), the three ways a setup() can fail later, and a setup() that settles after server.stop()
  • test/js/node/inspector/inspector.test.ts: nothing runs inside waitForDebugger()

New tests checked another way:

  • test/cli/run/preload-test.test.js compares the event loop depth that a timer sees in a reloaded preload and in the entry point. It reads a new function of bun: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 captured yes under bun exec lets 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: a setup() that runs after Bun.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 --app runs async setup() 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