Repository navigation
Conversation
|
Updated 8:16 PM PT - Oct 31st, 2026
✅ @robobun, your commit cd52f8d20994bf494f3b64e8101c14db9f752905 passed in 🧪 To try this PR locally: bunx bun-pr 47553That installs a local version of the PR into your bun-47553 --bun |
|
Status: ready for review. It is stacked on #47552. Both branches are on main How I reproduced it, with a release build of main on Linux x64: // a.mjs
console.log("before");
await new Promise(() => {});
console.log("after");
CI on One decision is open for a maintainer. It is the first bullet of Downsides: a top-level await that only unref'd work can settle exits with code 13 now, as in Node. Two cases remain, and the third bullet of Downsides names them: a |
74ea549 to
206edce
Compare
|
The pushes up to Fixed
Changed because of CI
Not changed, with the evidence in the thread
Open for a maintainer
|
There was a problem hiding this comment.
Beyond the inline findings, I also checked the index arithmetic in the new ModuleLoader.cpp helpers: resumePointBefore only runs after bytecodeIndexOfSuspension returned a nonzero offset, which it does only when numberOfUnlinkedSwitchJumpTables() > 0, so the - 1 cannot underflow; the backward scan from start and the forward skip to next in positionOfStalledAwait both stay inside source.length() via the end > source.length() check and isAwaitKeywordAt's own bound.
Extended reasoning...
The hunt stopped at its bug cap with four findings posted, so this note only records the new C++ await-locating code that was examined for out-of-bounds reads and found guarded.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Still open from earlier reviews (2):
- 🔴
src/jsc/VirtualMachine.rs:3860—Scripts whose top-level await is settled by a thread-safe bun:ffi JSCallback now print the unsettled-await warning and… - 🔴
src/jsc/event_loop.rs:1273—Scripts that end with await sql.close({ timeout }) while a reserved connection is idle now exit with code 13 and the un…
If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.
There was a problem hiding this comment.
I reviewed the latest push (6ffddbe) and found no new bugs; because this changes the core wait policy of the event loop and asks for a decision on unref'd-work semantics, a human look is still worthwhile.
What was reviewed:
- The new
Bun__rejectStalledTopLevelAwaitsweep overmoduleMap()for dynamically imported modules: it filters onEvaluatingAsyncbeforeasyncExecutionRejected, so a module already rejected through its importer is not rejected twice. - The
--printresult path in run_command.rs: the print now runs once, either before 'beforeExit' or after the post-'beforeExit' loop settles the entry, and the loop re-dispatches 'beforeExit' only behind loop work. - Ruled out:
NODE_NO_WARNINGS=1not silencing the new warning (Bun's other warning printers also only honour--no-warningshere), and a worker's 'beforeExit' listener resuming a stalled await (pre-existing worker shutdown behaviour, not introduced by this change).
Extended reasoning...
The change touches the event loop's promise wait (src/jsc/event_loop.rs), VirtualMachine liveness predicates, ModuleLoader.cpp module-graph walking and rejection, every wait_for_promise call site (run, test, preload, worker, lint, repl, macros, bake), and a ConsoleObject builtin, with ~2000 added lines and new tests. It touches no auth, crypto, or injection surface. Approval is not appropriate because the PR is large and intentionally changes user-visible exit behaviour for programs whose top-level await only unref'd work settles, a decision the author explicitly leaves to maintainers; several inline threads from earlier runs (sql.close timeout, thread-safe FFI callbacks, the bun test root registry key) are also still open with no commit visibly addressing them, as test_command.rs:2929 still keys the graph on vm.main().
Still open from earlier reviews (4):
- 🔴
src/jsc/VirtualMachine.rs:3860—Scripts whose top-level await is settled by a thread-safe bun:ffi JSCallback now print the unsettled-await warning and… - 🔴
src/jsc/event_loop.rs:1273—Scripts that end with await sql.close({ timeout }) while a reserved connection is idle now exit with code 13 and the un… - 🔴
src/runtime/cli/test_command.rs:2936—Under bun test, a stalled test file reached through a symlink (a symlinked file or directory) is reported without its a… - Also unresolved: 1 minor or pre-existing.
If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.
|
The two threads on |
…ts 13
EventLoop::wait_for_promise repeats tick() and auto_tick() while its
promise is pending. With nothing ref'd on the loop, auto_tick() polls with
a zero timeout, so a promise that nothing can settle held one core for
ever: `await new Promise(() => {})` in the entry module, in a test file,
in a preload.
The wait now takes a WhenIdle policy. With GiveUp it returns
Err(Unsettled::Idle) once the promise is pending, nothing is queued,
nothing refs the loop and the next checkpoint has nothing to run: the
condition that ends the run loop. Ok(()) still means settled.
- The entry point returns to Run::start. After 'beforeExit', an entry that
is still pending gets Node's warning ("Detected unsettled top-level
await at <url>:<line>", the source line and a caret, once for each
stalled module) and exit code 13, unless an exit code is already chosen.
A Worker prints the same warning.
- `bun test` fails the file with that text and runs the next file.
- A preload that cannot finish is a load error.
- A lint plugin and `bun repl -e` get an error.
- The waits beneath script or a test keep waiting: matchers, macros,
plugin setup(), bake, and the entry under --watch and --hot.
Three places relied on the polling and hold the loop themselves now: a
second loop over `console` refs stdin again, the disposal of a subprocess
waits for the exit that its signal brings, and the wait does not give up
while a listener of 'unhandledRejection' has queued jobs.
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
…ly behind loop work - bun test: when a file fails because its top-level await cannot settle, its stalled modules are rejected with that error (AsyncModuleExecutionRejected). Unref'd work that settles the await while a later file runs resumes nothing: a record with an error does not run its body again. A later file that imports such a module fails with the same error. - bun run: after 'beforeExit', the run went back to 'beforeExit' whenever a tick had jobs to run. A 'beforeExit' listener that leaves a rejection for an 'unhandledRejection' listener then repeated for ever. As in Node, 'beforeExit' comes again only behind work of the event loop. - The console iterator takes back only the ref that its own earlier loop dropped, so a stdin that the program unref'd stays unref'd. - Subprocess disposal is as on main again: `await using` does not ref a child that the program unref'd.
…ses move to compile-aot-run-time - A stalled `--preload` returned `error.UnsettledPreload`, and `bun test` left through the exit for errors before JavaScript starts: no 'exit' listeners, no summary, no teardown of the VM (LeakSanitizer aborted the run on the ASAN lane). It now rejects the preload's stalled modules and returns a promise rejected with that error. Every caller already handles a preload that throws: `bun run` prints the error and exits with code 1, `bun test` fails each file with it, a Worker emits 'error'. - The three `--compile --aot` cases are programs of the shared build of test/bundler/compile-aot-run-time.test.ts, which skips itself where the build has no compiler (test/aot/test-files-self-skip.test.ts).
… result; Windows console case - bun test: the rejection of a failed file's stalled modules now takes in the modules that they reached with import(). Such a module is in no list of its importer, so the sweep takes every module that is suspended on its own await and began to evaluate after the first stalled module of the file. - bun -p: a module that a 'beforeExit' listener lets finish has its result printed. It exited with code 0 and printed nothing. - console-iterator.test.ts: the case for a stdin that the program unref'd is not for Windows, where the pending read keeps the process alive. - spawn.test.ts: the cases that moved into a test assert that the runner reported no warning and no error.
…'d work a minute The rows in which unref'd work must stay pending used a timer of 20 ms. On a loaded machine that timer could be due before the wait looked at the loop, and the await settled. That work cannot come due now: a timer or a timeout of a minute, and a child that reads its stdin to the end. The rows in which a later file settles the await of a file that failed no longer wait for a timer. The file waits for a request to an unref'd server, and the later file sends it. The row for a later importer imports the module after its await is settled, so it fails only if the module has failed for good. A todo states what `bun test --aot` does not do: with one global, the rest of a compiled file that was given up on runs once its await settles.
e6a31ce to
477380c
Compare
6ffddbe to
4af423d
Compare
A loop over `console` after the first takes back the ref that the release of the first reader dropped. It took that ref also when the program had called `process.stdin.unref()`, and then the process stayed alive until stdin ended. `process.stdin.unref()` now records its request on the stream of stdin, under a private name, where it kept it in a local before. The loop reads it and takes no ref. Tests: a second loop on an unref'd stdin lets the process end, and a file that cannot finish fails in a worker of `bun test --parallel`.
|
This push has three parts. Rebase. #47552 and this PR are on main The two review comments on
What the new base showed.
|
Fixes #14951, fixes #19049, fixes #33283, fixes #45262
Stacked on #47552.
Problem
await new Promise(() => {})in the entry module spins at 100% CPU for ever. Node printsWarning: Detected unsettled top-level awaitand exits with code 13.EventLoop::wait_for_promise(src/jsc/event_loop.rs) loopstick()andauto_tick()while its promise is pending. With nothing ref'd, that is a zero-timeout poll.Fix
wait_for_promisetakes aWhenIdlepolicy.GiveUpreturnsErr(Unsettled::Idle)when nothing is queued and nothing refs the loop: the condition that ends the run loop.beforeExit, prints the warning and exits with code 13. A test file or a preload fails with that error, and its stalled modules are rejected.test/js/node/process/unsettled-top-level-await.test.ts(51 of 69 cases fail without the fix). Self-reviewed: 22 concerns raised, 19 addressed.Background
then()on the same promise is never called.load_entry_pointonly: test files and preloads still spin. Considered waiting on unref'd work (Detect unsettled top-level await in entry-point loading instead of hanging #30551, Deno): that needs a count of it.Downsides
GiveUpwait runs 4 to 45 more instructions. Release.text: +13,492 bytes.--parallelworker on Windows hangs. Underbun test --aotwith one global, a failed file can go on later.Notes
Base: main
a47a315dc4(#47316, which moves WebKit to4d45755fedand makesbun test --aotrun test files as compiled code). The rebase changed no line of the diff. The test counts and the sizes below are from builds on that base.What each wait does when the loop is idle
bun file,-e,-p, stdin,--compileand--compile --aotexecutablesGiveUpbeforeExit, then the warning and exit code 13GiveUp--preloadofbun run,bun testand a WorkerGiveUpbun runexits with code 1,bun testfails each file, a Worker emits 'error'GiveUpbun repl -eGiveUp--watchand--hotKeepWaitingsetup()(2), bake (3)KeepWaitingA Worker already exited with code 13. It prints the warning now, as in Node.
The decision this PR needs
The wait ends on the condition that ends the run loop: nothing queued, nothing ref'd. So unref'd work does not keep a top-level await alive, as in Node. On main it did, because the wait polled until the work came. The other rule is to keep waiting while any unref'd work exists (#30551, and Deno since 1.41.3, denoland/deno_core#647). #32014 has tests that expect exit code 0 for such awaits, and its review expects a later parity PR to change them.
Top-level awaits that change (172 shapes, each as
await pand asp.then(), on the release build 1.4.3-canary, this branch and Node 26.3, Linux x64)36 shapes resolved before and exit with code 13 now. In all 36 the
then()callback is never called, before or now: only the top-level await resolved.setTimeout().unref()andsetInterval().unref(),timers/promisessetTimeoutwith{ ref: false },AbortSignal.timeout(),AbortSignal.any()of one,Atomics.waitAsync()with a timeout or notified by an unref'd Worker, 'exit' of achild.unref()orworker.unref(), a message of a forked child whose channel is unref'd, 'data' of asocket.unref()(net,http.get), an unref'd UDP socket,fs.watchFile()withunref(), 'data' ofprocess.stdin.unref(), a signal that an unref'd child sends, an import of a module whose own await only an unref'd timer settles, a streampull()or a thenable that only an unref'd timer settles.Bun.spawn()withunref(), then.exited,onExit, an IPC message or the first read ofproc.stdout(and the disposal ofawait usingfor such a child). A Worker made with{ ref: false }. An unref'dBun.connect()orBun.udpSocket(). A pluginonLoadthat only an unref'd timer settles.child_process.spawn()with piped stdio andunref(), for a child that writes to its stdout, then 'close' or 'exit' (10 of 10 runs).fork()andunref(), then 'message' (child_process.unref() is causing main process to exit even if IPC is established #23686, node:child_process: IPC channel keeps the parent alive; implement channel ref/unref/refCounted/unrefCounted #36405). Bun holds no ref for those pipes or that channel afterunref(), with or without this PR. For a child that writes nothing, Node 26.3 exits 13 too (8 to 10 of 10 runs).Found outside that table:
Bun.RedisClientorBun.SQLconnection whoseonclosesettles the await (idleTimeout, or the server closes). An idle connection holds no loop ref on purpose (PostgresSQLConnection::update_poll_ref: "only while a connected connection has something in flight").await sql.close({ timeout: 1 })while a reserved connection is idle: main printsclosed 1006, this branch exits 13 at that line. The close timer is unref'd (src/js/internal/sql/pool.ts:955). In thethen()form main exits before the timer too. A fix of its own is in work: the timer must hold the loop.bun:ffiJSCallbackthat another thread calls. It holds no loop ref (a N-API thread-safe function does). A ref from creation toclose()keeps every program alive that never closes its callback, so that is a decision of its own.process.channeland awaits a message (A child process waiting on an unref'd IPC channel busy-spins a full core until a message arrives #46050): main polls at 100% CPU until the message comes. This branch exits 13, and so does Node 26.3.8 shapes hung before and exit 13 now: a promise that nothing settles,
setImmediate().unref()(2), a request to an unref'd HTTP server (2), abeforeExitlistener that starts an unref'd timer, an 'exit' listener that resolves, aFinalizationRegistrycallback.Races, where work comes due as the last ref'd turn ends:
setImmediate(() => {}); await new Promise(r => setTimeout(r, 0).unref()): the await exits 13 in 5 of 5 runs, andthen()is called in 4 of 5. The first turn of the run loop starts about 1.3 ms after the module returns, so the 1 ms timer is due. The first turn of the wait starts after 0.2 ms. Node: 5 of 5 exit 13,then()called in 1 of 5.fs.watch()event (persistent: false, orunref()) that a ref'd timer causes: the await exits 13 in 4 of 5 runs. Node: 5 of 5.The wait runs
tick()before it reads the loop, and the run loop reads it first. So a signal that the last ref'd timer sends to the own process settles the await, andthen()is not called. That is so on main too.Existing test programs that changed, and the PR that added each
test/js/bun/spawn/spawn.test.ts, "unref() + .exited with nothing else ref'd", 3 cases (windows: make us_loop_pump poll IOCP for unref'd handles (POSIX parity) #34478). They moved into the body of a test, where the loop of the runner still sees the exit. 3 new cases assert the exit code 13.test/js/web/abort/abort.test.ts(AbortSignal.timeout: keep the timer armed when the signal loses its observers #37666): the program waited on a secondAbortSignal.timeout(). It waits on a ref'd timer. 1 new case asserts the exit code 13.test/js/valkey/reliability/connection-failures.test.ts(valkey: reject subscribe() on a failed client before storing the listener #39547): a ref'd interval holds the process untilonclose. 1 new case asserts the exit code 13 for an idle client.test/aot/parallel/errors/frames-exceptions-host-compiled.mjs,test/aot/parallel/workers/options-of-a-worker.ts,test/bundler/trim-by-use/programs/abort-signal.tsandatomics.ts(AOT: fixes for modules, classes and Workers, WebKit upgrade, shorter compiler docs #47129): a ref'd interval stands around the wait on unref'd work.test/aot/run-parallel.tscompares the warning without its position: an--aotexecutable has no line for it.Fixed on the way, because a top-level await relied on the polling
for await (const line of console)did not keep the process alive: the first loop unref'd stdin when it released its reader. The second loop takes that ref again, and only that one: a stdin that the program unref'd stays unref'd. Without a top-level await this ended the process on main too.Bun__hasJobsForCheckpoint).Measurements (release builds, Linux x64, base = #47552. The sizes of the whole binary are on main
a47a315dc4. Every other number is from main34cbc70fa1, before the rebase: that code did not change.)sizetext 139,711,643 to 139,725,135 bytes (+13,492), data +64 bytes. Strippedbun: 139,901,216 to 139,913,504 bytes (+12,288).wait_for_promisegrows from 478 to 1,167 bytes. The largest new symbols are cold:describe_unsettled_module_graph(3,294 bytes),Bun__forEachStalledTopLevelAwait(2,714) andBun__rejectStalledTopLevelAwait(2,153).wait_for_promise: theKeepWaitingloop is the loop of main, 0 instructions added. A pending turn of aGiveUpwait adds 4 to 6 instructions when an immediate is queued, and 30 to 45 when the loop parks on ref'd work (the reads ofhas_pending_loop_work()and one call ofBun__hasJobsForCheckpoint).instructions:ufromperf_event_open, medians of 9 interleaved runs. An entry wait thatsetImmediatecontinues 1,000,000 times: 4,005.9 to 4,006.5 per turn (+0.6). The same loop without a wait differs by +2.4 between the two builds, so the delta is inside the noise. A turn that parks on a ref'd 1 ms timer: 8,828 to 8,814 (noise about 100).a.mjs971,167 to 0 (exit 13). A test file: 951,573 to 2. A preload: 963,853 to 1.--watch: 961,665 to 948,464, not changed on purpose.epoll_pwait2calls 1 and 1 for a timer, 3 and 3 for a matcher test. Full collections: 0 and 0.mi_malloccalls ofbun -e 0: 4,201 and 4,201.Not changed, or not as in Node
bun testwaits at 100% cpu usage for a promise that will resolve after expect() #14950), the run loop ofbun:test, the wait for amock.module()factory, macros, pluginsetup(), bake and the interactive REPL still poll while they wait. Have condition-gated drive loops ref the event loop so unref'd timers fire without spinning #32014 parks such loops. If it lands after this PR, its loop ref belongs aroundauto_tick()of aKeepWaitingwait only: a ref over aGiveUpwait makesIdleunreachable.--watchand--hotare not changed (hot: replace a generation that is parked on a top-level await #38613).asyncExecutionRejectedgives its stalled modules the error, and a record with an error does not run its body again. That takes in what the file reached withimport(): every module that is suspended on its own await and began to evaluate after the first stalled module of the file. Two kinds of file can still go on later. One is a file whose load stalls before a module runs (a pluginonLoadthat never settles): it has no suspended module to reject. The other is a file ofbun test --aotwith one global: its record gets the error, but a compiled module is suspended in a saved frame of machine code, and the rejection does not reach that frame.--isolateends it with the realm of the file. A todo intest/cli/test/unsettled-top-level-await.test.tsstates it. It needs a change in the engine.bun -pprints the result of a module with a top-level await when the module finishes, also when a 'beforeExit' listener lets it finish. Node refuses--printfor ESM input.await. Where that does not lead to anawait(the disposal ofawait using), the warning names the module without a line. An--aotexecutable has no line either, and names the entry point for an await in a module that it imports.bun test --parallelworker on Windows the channel to the coordinator is a ref'd pipe (src/runtime/cli/test/parallel/Channel.rs), so the wait for a test file cannot go idle there and the run hangs as on main. On Linux the worker fails the file (0.4 s, where main hangs). A case pins that, and it is a todo on Windows. I could not run Windows, so the pipe is not changed here.Bun.stdin.stream()after it released one does not ref stdin again. Only theconsoleiterator does, and not for a stdin that the program unref'd:process.stdin.unref()records that on the stream under a private name.BUN_FEATURE_FLAG_...) is one term on the give-up path. It is not in this PR: nobody asked for it.Self-review
The review raised 80 concerns. 52 survived its own attempts to refute them, in 22 groups. 19 groups are addressed in this diff. The 3 others:
test-esm-tla-unfinished.mjsunchanged: it callsdescribe()ofnode:testoutside a runner, and a test file from Node is not edited. Its fixtures are in the tree and atest.eachruns its cases over them. Run by hand, the file passes 10 of 15 cases (0 of 15 before, they hang). The other 5 expect[eval1]for--evalinput (Bun says[eval]) orError: Xyz(Bun printserror: Xyz).The review also asked for tests that pin exit code 13 for an idle
Bun.SQLconnection and forsql.close({ timeout }). They are not here: the first is theBun.RedisClientcase that a test pins, and the second needs a fix, not a pin.Tests
test/js/node/process/unsettled-top-level-await.test.ts: 69 cases, 51 fail without the fix. They cover the entry point (file,-e,-p, stdin),beforeExitand 'exit' listeners,process.exitCode,--no-warnings, listeners of 'unhandledRejection', the line and caret (several lines, non-ASCII text,for await, a 100 KB line), a module that imports the stalled one, a preload, Node's fixtures, and one table that runs 13 kinds of work asthen()and asawait.test/cli/test/unsettled-top-level-await.test.ts: 13 cases and 1 todo, 11 fail without the fix. Four of them settle an await of a failed file while a later file runs: with and without--isolate, behind animport(), and for a later file that imports the stalled module. The failed file waits for a request to an unref'd server, and the later file sends it, so no case waits for a timer. One case runs a worker of--parallel(a todo on Windows). The todo is the--aotcase above.test/js/node/worker_threads/worker-top-level-await.test.ts: 11 cases, 4 fail.test/js/bun/console/console-iterator.test.ts: 4 new cases, 1 fails. The case for a stdin that the program unref'd is not for Windows: the first CI run showed that the pending read keeps the process alive there.test/bundler/bundler_compile.test.ts: the three cases of the report in a--compileexecutable, 2 fail without the fix.test/bundler/compile-aot-run-time.test.ts: three programs in its shared--compile --aotbuild, 2 fail without the fix. That file skips itself on a build that is no canary or has no compiler.test/aot/parallel/process/top-level-await-*.mjs: the three programs of the report, compared with the interpreter.BUN_DESTRUCT_VM_ON_EXIT=1, LeakSanitizer, exception checks) on the debug build. The first push aborted there: a stalled preload leftbun testthrough the exit for errors before JavaScript starts, with no teardown.expect.test.js,bun-test.test.ts,macro-test.test.ts,plugins.test.ts,js-plugins.test.ts,hot.test.ts,watch.test.ts,run-eval.test.ts,node-timers.test.ts,worker_threads.test.ts,worker.test.ts,wasm-streaming.test.ts,node-http-backpressure.test.ts,bake/dev/production.test.ts,test/regression/issue/39900.test.ts. With a release build of the first push: thetest/aot/parallelsuite (4,390 pass, 4 skip, 0 fail) and the 170 programs oftest/bundler/trim-by-use/programs(all exit 0).child_process.test.ts("more than 10240 fds" needs a higherulimit, and the GC case takes 5.4 s on a debug build) andprocess.test.js("node version on the host"). Both were run before the rebase.compile-aot-run-time.test.ts(12 pass), the five changed programs oftest/aot/parallel, the source lints (194 pass),process-stdin.test.ts,stdin-fixtures.test.ts,tty.test.tsandctrl-c.test.ts. This release build has no--trim, so the two changed programs oftest/bundler/trim-by-useran plain and with--compile --aot: same output.Other open PRs
wait_for_promiseor its callers for other defects (a wait that parks after its promise settled, a fatal error under a wait, macros). They need a rebase on whichever lands second.run_command.rsthat this PR also changes.Credit: the walk for the stalled modules and the first test file come from #29739.