Skip to content

Give up a promise wait on an idle loop: unsettled top-level await exits 13 - #47553

Open
robobun wants to merge 8 commits into
robobun/e1aae3a3/wasm-streaming-keep-alivefrom
robobun/e1aae3a3/unsettled-tla-idle-wait
Open

robobun wants to merge 8 commits into
robobun/e1aae3a3/wasm-streaming-keep-alivefrom
robobun/e1aae3a3/unsettled-tla-idle-wait

Conversation

@robobun

@robobun robobun commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

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 prints Warning: Detected unsettled top-level await and exits with code 13.
  • EventLoop::wait_for_promise (src/jsc/event_loop.rs) loops tick() and auto_tick() while its promise is pending. With nothing ref'd, that is a zero-timeout poll.

Fix

  • wait_for_promise takes a WhenIdle policy. GiveUp returns Err(Unsettled::Idle) when nothing is queued and nothing refs the loop: the condition that ends the run loop.
  • The entry then runs 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.
  • Verified: 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

Downsides

  • Decision needed: a top-level await that only unref'd work settles exits with code 13, as in Node. Main resolved it, at 100% CPU. Notes list 36 shapes.
  • Each pending turn of a GiveUp wait runs 4 to 45 more instructions. Release .text: +13,492 bytes.
  • As on main: waits beneath script still poll (see Notes), and a --parallel worker on Windows hangs. Under bun test --aot with one global, a failed file can go on later.
Notes

Base: main a47a315dc4 (#47316, which moves WebKit to 4d45755fed and makes bun test --aot run 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

Wait Policy Result
entry point: bun file, -e, -p, stdin, --compile and --compile --aot executables GiveUp beforeExit, then the warning and exit code 13
test file GiveUp the file fails with the same text and its stalled modules are rejected, the next file runs
--preload of bun run, bun test and a Worker GiveUp the preload fails with the same text, like a preload that throws: bun run exits with code 1, bun test fails each file, a Worker emits 'error'
lint plugin GiveUp the message it had. Its private copy of the loop is gone
bun repl -e GiveUp an error and exit code 1
entry point and preload under --watch and --hot KeepWaiting no change
matchers (3), macros (2), plugin setup() (2), bake (3) KeepWaiting no change: script that called in, or a test, keeps the loop turning above them

A 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 p and as p.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.

  • Node also exits 13 (23 shapes): setTimeout().unref() and setInterval().unref(), timers/promises setTimeout with { ref: false }, AbortSignal.timeout(), AbortSignal.any() of one, Atomics.waitAsync() with a timeout or notified by an unref'd Worker, 'exit' of a child.unref() or worker.unref(), a message of a forked child whose channel is unref'd, 'data' of a socket.unref() (net, http.get), an unref'd UDP socket, fs.watchFile() with unref(), 'data' of process.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 stream pull() or a thenable that only an unref'd timer settles.
  • No Node equivalent (10 shapes): Bun.spawn() with unref(), then .exited, onExit, an IPC message or the first read of proc.stdout (and the disposal of await using for such a child). A Worker made with { ref: false }. An unref'd Bun.connect() or Bun.udpSocket(). A plugin onLoad that only an unref'd timer settles.
  • Node resolves (3 shapes): child_process.spawn() with piped stdio and unref(), for a child that writes to its stdout, then 'close' or 'exit' (10 of 10 runs). fork() and unref(), 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 after unref(), 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:

  • An idle Bun.RedisClient or Bun.SQL connection whose onclose settles 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 prints closed 1006, this branch exits 13 at that line. The close timer is unref'd (src/js/internal/sql/pool.ts:955). In the then() form main exits before the timer too. A fix of its own is in work: the timer must hold the loop.
  • A thread-safe bun:ffi JSCallback that another thread calls. It holds no loop ref (a N-API thread-safe function does). A ref from creation to close() keeps every program alive that never closes its callback, so that is a decision of its own.
  • A child that unrefs process.channel and 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), a beforeExit listener that starts an unref'd timer, an 'exit' listener that resolves, a FinalizationRegistry callback.

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, and then() 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.
  • An fs.watch() event (persistent: false, or unref()) that a ref'd timer causes: the await exits 13 in 4 of 5 runs. Node: 5 of 5.
  • Unref'd work on another thread (a Worker, a thread-safe function) settles the await if its completion is queued before the loop goes idle.

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, and then() is not called. That is so on main too.

Existing test programs that changed, and the PR that added each

Fixed on the way, because a top-level await relied on the polling

  • A second 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.
  • A listener of 'unhandledRejection' or 'uncaughtException' that settles the awaited promise: the wait checks that the next checkpoint has nothing to run before it gives up (Bun__hasJobsForCheckpoint).

Measurements (release builds, Linux x64, base = #47552. The sizes of the whole binary are on main a47a315dc4. Every other number is from main 34cbc70fa1, before the rebase: that code did not change.)

  • Size: size text 139,711,643 to 139,725,135 bytes (+13,492), data +64 bytes. Stripped bun: 139,901,216 to 139,913,504 bytes (+12,288). wait_for_promise grows from 478 to 1,167 bytes. The largest new symbols are cold: describe_unsettled_module_graph (3,294 bytes), Bun__forEachStalledTopLevelAwait (2,714) and Bun__rejectStalledTopLevelAwait (2,153).
  • Disassembly of wait_for_promise: the KeepWaiting loop is the loop of main, 0 instructions added. A pending turn of a GiveUp wait adds 4 to 6 instructions when an immediate is queued, and 30 to 45 when the loop parks on ref'd work (the reads of has_pending_loop_work() and one call of Bun__hasJobsForCheckpoint).
  • instructions:u from perf_event_open, medians of 9 interleaved runs. An entry wait that setImmediate continues 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).
  • Poll calls in 3 s once the loop is idle: a.mjs 971,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.
  • A live await costs the same: epoll_pwait2 calls 1 and 1 for a timer, 3 and 3 for a matcher test. Full collections: 0 and 0. mi_malloc calls of bun -e 0: 4,201 and 4,201.
  • No startup work, no host function, no allocation on a path that settles.

Not changed, or not as in Node

  • The matcher waits (bun test waits at 100% cpu usage for a promise that will resolve after expect() #14950), the run loop of bun:test, the wait for a mock.module() factory, macros, plugin setup(), 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 around auto_tick() of a KeepWaiting wait only: a ref over a GiveUp wait makes Idle unreachable.
  • --watch and --hot are not changed (hot: replace a generation that is parked on a top-level await #38613).
  • A test file that failed this way is over: JSC's asyncExecutionRejected gives its stalled modules the error, and a record with an error does not run its body again. That takes in what the file reached with import(): 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 plugin onLoad that never settles): it has no suspended module to reject. The other is a file of bun test --aot with 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. --isolate ends it with the realm of the file. A todo in test/cli/test/unsettled-top-level-await.test.ts states it. It needs a change in the engine.
  • A later file that imports a module that was given up on fails with the same error.
  • A rejection that nothing handles and that comes during 'beforeExit' ends the run with the error and exit code 1, as in Node, but the 'exit' listener sees code 0 (Report unhandled rejections made in the event loop's last turn #45582, process: 'exit' listeners see exit code 1 after an unhandled rejection #45575). A Worker drops such a rejection, on main too and with or without a top-level await: Report unhandled rejections made in the event loop's last turn #45582 is the open change for it.
  • bun -p prints the result of a module with a top-level await when the module finishes, also when a 'beforeExit' listener lets it finish. Node refuses --print for ESM input.
  • The line of the warning comes from the last expression that ran before the await. Where that does not lead to an await (the disposal of await using), the warning names the module without a line. An --aot executable has no line either, and names the entry point for an await in a module that it imports.
  • In a bun test --parallel worker 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.
  • A reader that a program takes again from Bun.stdin.stream() after it released one does not ref stdin again. Only the console iterator does, and not for a stdin that the program unref'd: process.stdin.unref() records that on the stream under a private name.
  • Deno ended a top-level await on unref'd work from 1.17 to 1.41.2 and waits for it since 1.41.3 (fix(core): don't error out with unref'd ops in TLA denoland/deno_core#647). It still ends a dead one.
  • An off switch (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:

  • Promote the WebAssembly ticket inside JSC: see Keep the process alive while a streaming WebAssembly compilation is pending #47552. It needs a WebKit change.
  • Add Node's test-esm-tla-unfinished.mjs unchanged: it calls describe() of node:test outside a runner, and a test file from Node is not edited. Its fixtures are in the tree and a test.each runs 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 --eval input (Bun says [eval]) or Error: Xyz (Bun prints error: Xyz).
  • An off switch: not asked for.

The review also asked for tests that pin exit code 13 for an idle Bun.SQL connection and for sql.close({ timeout }). They are not here: the first is the Bun.RedisClient case 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), beforeExit and '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 as then() and as await.
  • 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 an import(), 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 --aot case above.
  • Work that must stay pending cannot come due in these tests: an unref'd timer or timeout takes a minute, and an unref'd child reads its stdin to the end. With 20 ms, one run on a loaded debug build settled the await before the wait looked at the loop.
  • 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 --compile executable, 2 fail without the fix. test/bundler/compile-aot-run-time.test.ts: three programs in its shared --compile --aot build, 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.
  • The 10 changed test files together, with the debug build: 587 pass, 33 skip, 2 todo, 0 fail. The 9 of them that a debug build runs, with a release build of Keep the process alive while a streaming WebAssembly compilation is pending #47552: 76 fail.
  • The validated files also ran with the settings of the ASAN lane (BUN_DESTRUCT_VM_ON_EXIT=1, LeakSanitizer, exception checks) on the debug build. The first push aborted there: a stalled preload left bun test through the exit for errors before JavaScript starts, with no teardown.
  • Also run with the debug build, before the rebase: 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: the test/aot/parallel suite (4,390 pass, 4 skip, 0 fail) and the 170 programs of test/bundler/trim-by-use/programs (all exit 0).
  • Not passing here, and not caused by this change: child_process.test.ts ("more than 10240 fds" needs a higher ulimit, and the GC case takes 5.4 s on a debug build) and process.test.js ("node version on the host"). Both were run before the rebase.
  • After the rebase, with a release build: compile-aot-run-time.test.ts (12 pass), the five changed programs of test/aot/parallel, the source lints (194 pass), process-stdin.test.ts, stdin-fixtures.test.ts, tty.test.ts and ctrl-c.test.ts. This release build has no --trim, so the two changed programs of test/bundler/trim-by-use ran plain and with --compile --aot: same output.

Other open PRs

Credit: the walk for the stalled modules and the first test file come from #29739.

@robobun

robobun commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:16 PM PT - Oct 31st, 2026

✅ @robobun, your commit cd52f8d20994bf494f3b64e8101c14db9f752905 passed in Build #136242! 🎉


🧪   To try this PR locally:

bunx bun-pr 47553

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

bun-47553 --bun

@robobun

robobun commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. It is stacked on #47552. Both branches are on main a47a315dc4 now.

How I reproduced it, with a release build of main on Linux x64:

// a.mjs
console.log("before");
await new Promise(() => {});
console.log("after");

bun a.mjs prints before and then holds one core at 100% until it is killed. Node 26.3 prints Warning: Detected unsettled top-level await at file:///.../a.mjs:2 and exits with code 13. With this PR, Bun prints the same warning and exits with code 13.

CI on cd52f8d209 (build 136242): every lane that ran is green. The tests that failed there passed on a retry, and none of them is a case that this PR adds or changes. The macOS x64 test lane has not run yet. Its jobs wait behind a backlog of other builds: they expire without an agent and are queued again.

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 bun test --parallel worker on Windows still hangs on such a file, and under bun test --aot with one global a file that failed can go on later.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/runtime/cli/test_command.rs Resolved
Comment thread src/jsc/event_loop.rs Resolved
Comment thread src/runtime/cli/run_command.rs Resolved
Comment thread src/runtime/api/bun/subprocess.rs Resolved
Comment thread src/jsc/event_loop.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/unsettled_top_level_await.rs Resolved
Comment thread src/js/builtins/ConsoleObject.ts Resolved
Comment thread src/jsc/event_loop.rs Resolved
@robobun
robobun force-pushed the robobun/e1aae3a3/unsettled-tla-idle-wait branch from 74ea549 to 206edce Compare October 31, 2026 17:26
@robobun

robobun commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator Author

The pushes up to 5ad4ba2d0e take the review and the first CI run. Each thread has its answer. In short:

Fixed

  • A test file that failed as unsettled went on under a later file. The runner now rejects the stalled modules of the file, so nothing of it runs later.
  • 'beforeExit' fired without end when its listener left a rejection for an 'unhandledRejection' listener. It now comes again only behind work of the event loop, as in Node.
  • await using no longer refs a child that the program unref'd. async_dispose is as on main.
  • The console iterator takes back only the ref that its own earlier loop dropped.

Changed because of CI

  • A --preload that cannot finish now fails like a preload that throws. The first push returned an error that made bun test leave through the exit for errors before JavaScript starts, with no teardown, and LeakSanitizer aborted that run on the ASAN lane.
  • The three --compile --aot cases are programs 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 failed for bundler_compile.test.ts).

Not changed, with the evidence in the thread

Open for a maintainer

  • await sql.close({ timeout }) with an idle reserved connection exits with code 13. The close timer is unref'd. A separate fix is in work.
  • A thread-safe bun:ffi JSCallback holds no loop ref. A ref from creation to close() is a decision of its own.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/runtime/cli/test_command.rs Resolved
Comment thread src/runtime/cli/run_command.rs Resolved
Comment thread test/js/bun/spawn/spawn.test.ts Resolved
Comment thread src/jsc/web_worker.rs Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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__rejectStalledTopLevelAwait sweep over moduleMap() for dynamically imported modules: it filters on EvaluatingAsync before asyncExecutionRejected, so a module already rejected through its importer is not rejected twice.
  • The --print result 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=1 not silencing the new warning (Bun's other warning printers also only honour --no-warnings here), 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.

@robobun

robobun commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator Author

6ffddbe8c1 takes the second review and the CI run of 5ad4ba2d0e.

  • Fixed: a module that a failed test file reached with await import() went on under a later file. The rejection now takes in every module that is suspended on its own await and began to evaluate after the first stalled module of the file.
  • Fixed: bun -p printed nothing when a 'beforeExit' listener let the module finish. The result prints once, when the module finishes.
  • Tests: the console case for a stdin that the program unref'd is not for Windows. The CI run showed that the process stays alive there while the read is pending. The spawn cases that moved into a test assert that the runner reported no warning and no error.
  • Not reproduced: a stalled test file behind a symlink. Six ways to reach it are in the thread, and each names the file and line.
  • Same on main: a Worker drops a rejection that its 'beforeExit' listener leaves. Report unhandled rejections made in the event loop's last turn #45582 is the open change for it.

The two threads on sql.close({ timeout }) and on the thread-safe JSCallback are resolved but not fixed here. They are in the PR notes for the maintainer who decides on the rule.

robobun and others added 6 commits October 31, 2026 20:16
…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.
@robobun
robobun force-pushed the robobun/e1aae3a3/wasm-streaming-keep-alive branch from e6a31ce to 477380c Compare October 31, 2026 21:59
@robobun
robobun force-pushed the robobun/e1aae3a3/unsettled-tla-idle-wait branch from 6ffddbe to 4af423d Compare October 31, 2026 21:59

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/js/builtins/ConsoleObject.ts Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
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`.
@robobun
robobun requested a review from alii as a code owner October 31, 2026 22:43

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

@robobun

robobun commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator Author

This push has three parts.

Rebase. #47552 and this PR are on main a47a315dc4 (#47316) now. The rebase changed no line of the diff. I built the new base again, debug and release, and ran the changed test files on it.

The two review comments on 4af423d.

  • console and a stdin that the program unref'd: fixed in cd52f8d. The reply is in the thread.
  • bun test --parallel on Windows: not fixed. I cannot run Windows here. A new case pins that a worker fails the file on Linux, and it is a todo on Windows. Downsides names the case.

What the new base showed.

  • Since AOT: bun test --aot on large suites, module loading fixes, fuzzer fixes #47316, bun test --aot runs test files as compiled code. A file that cannot finish fails there too. With one global, the rest of that file still runs if its await settles later. asyncExecutionRejected gives the record the error, but a compiled module is suspended in a saved frame of machine code, and the rejection does not reach that frame. --isolate is not affected. A todo in test/cli/test/unsettled-top-level-await.test.ts states the case. It needs a change in the engine.
  • One run on a loaded debug build settled an unref'd timer of 20 ms before the wait looked at the loop. The tests do not depend on that anymore. Work that must stay pending takes a minute, or is a child that reads its stdin to the end. Where a later file must settle the await of a failed file, it sends a request to an unref'd server of that file.

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.

2 participants