Skip to content

Exit on unsettled top-level await instead of hanging (@inquirer/prompts)#30601

Open
robobun wants to merge 7 commits into
mainfrom
farm/2d046bb5/inquirer-tla-hang
Open

Exit on unsettled top-level await instead of hanging (@inquirer/prompts)#30601
robobun wants to merge 7 commits into
mainfrom
farm/2d046bb5/inquirer-tla-hang

Conversation

@robobun

@robobun robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17636
Fixes #14951
Fixes #29194
Fixes #12918

Repro

import { search } from "@inquirer/prompts";

await search({
  message: "Select a spec",
  source: input => (input ? [{ name: input, value: input }] : []),
}).catch(() => process.exit(0));
$ echo "" | bun index.mjs
# before: hangs forever at 100% CPU
# after:
warn: Detected unsettled top-level await in .../index.mjs
CAUGHT: User force closed the prompt with 13 null
# exit 0 — matches Node

Root cause

When stdin closes, readline closes, and nothing is left in the event loop that could settle the prompt's top-level await. Three things then go wrong compared to Node:

  1. loadEntryPoint busy-spins forever. It waits on the entry module's evaluation promise via waitForPromise, which loops tick(); autoTick(); until the promise settles. With an idle loop autoTick falls into tickWithoutIdle() (zero-timeout poll) → 100% CPU spin. Node instead stops waiting, fires beforeExit, and maps the still-pending promise to exit code 13.

  2. process.emit overrides are bypassed for beforeExit/exit. signal-exit (pulled in by inquirer) monkey-patches process.emit to observe shutdown and reject the pending prompt. Bun dispatched these two events through the C++ EventEmitter::emit on process->wrapped() directly, so the override never ran and signal-exit never fired its callback.

  3. Microtasks aren't drained after 'exit'. Even once signal-exit rejects the prompt, the .catch(() => process.exit(0)) is a microtask that Bun never ran before dying. Node drains once after 'exit'.

Fix

  • src/jsc/VirtualMachine.zigloadEntryPoint: replace the unconditional waitForPromise with a loop that also breaks when isEventLoopAlive() is false, so an idle loop returns to the caller with a still-pending promise instead of spinning.
  • src/bun.js.zig — main run loop: fold onBeforeExit() into the drain loop so a beforeExit listener that settles the TLA can resume it; after the loop fully drains, map a still-.pending entry promise to a warning + exit 13 (or report a late .rejected).
  • src/jsc/bindings/BunProcess.cppdispatchExitInternal / Process__dispatchOnBeforeExit: if process.emit was replaced as an own property, call it (matching Node). Set process._exiting unconditionally. Drain microtasks once after 'exit' so shutdown-time promise reactions reach their handlers.
Case Node Bun before Bun after
await new Promise(() => {}) warn + exit 13 hang, 100% CPU warn + exit 13
process.exitCode = 42; await new Promise(() => {}) exit 42, no warn hang exit 42, no warn
beforeExit handler settles the TLA resumes, exit 0 hang resumes, exit 0
process.emit = fn override observes beforeExit/exit yes no yes
microtask queued from 'exit' listener runs yes no yes
@inquirer/prompts search() on stdin close .catch() runs, exit 0 hang .catch() runs, exit 0

Verification

  • bun bd test test/regression/issue/17636.test.ts — 6 pass
  • USE_SYSTEM_BUN=1 bun test test/regression/issue/17636.test.ts — 6 fail (hang/timeout on unfixed bun)
  • Real @inquirer/prompts repro now exits 0 with the same CAUGHT: message Node prints
  • Node's test/fixtures/es-modules/tla/{resolved,unresolved,unresolved-withexitcode,unresolved-with-listener,rejected,rejected-withexitcode,process-exit}.mjs — all match Node's exit codes and listener output
  • bun bd test test/js/node/process/process.test.js -t onBeforeExit — 3 pass
  • bun bd test test/js/node/process/process.test.js -t exitCode — 22 pass
  • bun bd test test/js/bun/resolve/{require-esm-transitive-tla,dynamic-import-tla-cycle}.test.ts — 7 pass
  • bun bd test test/cli/run/run-eval.test.ts — 33 pass
  • bun bd test/js/node/test/parallel/test-process-beforeexit*.js — pass
  • bun run zig:check-all — pass

Related

Overlaps with / supersedes the stale #29196 (conflicts with main) and the TLA portion of #29739 / #29549 / #27215. This PR bundles the three pieces that together produce Node-equivalent behaviour for the @inquirer/prompts flow and adds a regression test for the end-to-end signal-exit pattern.

Fixes the @inquirer/prompts hang in #17636. Three pieces, all needed
for Node parity on the reported flow (stdin closes -> readline closes
-> top-level await never settles -> signal-exit rejects the prompt):

1. loadEntryPoint(): break out of the TLA wait once the event loop
   has nothing left that could settle it, instead of busy-spinning
   on tickWithoutIdle at 100% CPU forever.

2. Run.start(): fold onBeforeExit into the drain loop so a beforeExit
   listener that settles the TLA can resume it; otherwise map a
   still-pending entry promise to exit code 13 with a warning.

3. BunProcess.cpp: when the user has replaced process.emit as an own
   property (signal-exit does this), dispatch 'beforeExit' and 'exit'
   through that override so it can observe shutdown. Drain microtasks
   once after 'exit' so a promise rejected from an exit handler
   reaches its .catch() before the process dies.
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 391424ef-1adf-4c39-aef1-9a65805395e7

📥 Commits

Reviewing files that changed from the base of the PR and between abb9c53 and 74f9aa4.

📒 Files selected for processing (1)
  • test/regression/issue/17636.test.ts

Walkthrough

Reworks VM shutdown to tick-drain unsettled top-level await, inspects pending/rejected TLA to set exit codes (13/1), routes beforeExit/exit through a user-installed process.emit override when present, drains microtasks after exit, and adds regression tests for the behaviors.

Changes

Top-Level Await Shutdown Behavior

Layer / File(s) Summary
VM event loop tick-based TLA drain
src/jsc/VirtualMachine.zig
Replaces blocking waitForPromise with a tick loop that advances the VM only while the internal TLA remains pending and the event loop is alive, preventing busy-spinning on never-settling promises.
Run.start event loop re-entry and TLA exit code handling
src/bun.js.zig
After onBeforeExit, ticks once and conditionally re-enters the loop if vm.pending_internal_promise is still pending and the loop is alive; after eval_and_print inspects vm.pending_internal_promise to warn and set exit_handler.exit_code = 13 for pending TLA, surface .rejected via uncaughtException and set exit code 1 when appropriate.
Process lifecycle events with user emit override support
src/jsc/bindings/BunProcess.cpp
Adds userEmitOverride and callUserEmitOverride helpers, sets process._exiting during shutdown, prefers calling a user-installed process.emit override for beforeExit/exit (falling back to wrapped emitter when applicable), and drains microtasks after exit with exception reporting.
Regression test coverage for TLA and lifecycle events
test/regression/issue/17636.test.ts
Adds tests that spawn Bun with inline scripts/modules to verify unsettled TLA exit-code 13 behavior, process.emit monkey-patching observing beforeExit/exit, prompt-rejection ordering on stdin close, microtask ordering during exit, suppression by user process.exitCode, and beforeExit settling TLA.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: preventing unsettled top-level await from hanging and causing busy-spin, with specific reference to the @inquirer/prompts context.
Description check ✅ Passed The PR description comprehensively covers what the PR does (root causes, fixes, behavior matrix) and verification steps, but does not follow the repository's template structure.
Linked Issues check ✅ Passed All code changes directly address the linked issues: #17636 (prompt hang on Ctrl+C), #14951 (unsettled TLA with 100% CPU), #29194 (signal-exit callbacks), and #12918 (signal-exit compatibility with process.emit overrides and microtask draining).
Out of Scope Changes check ✅ Passed All changes are tightly scoped to the three files identified in the fix: VirtualMachine.zig, bun.js.zig, and BunProcess.cpp, plus a regression test, with no unrelated modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:27 PM PT - May 12th, 2026

@autofix-ci[bot], your commit 74f9aa4 has 3 failures in Build #53948 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30601

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

bun-30601 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 5 issues this PR may fix:

  1. signal-exit package misbehaves with Bun (node compatibility) #12918 - signal-exit monkey-patches process.emit to intercept shutdown events; PR fix Fix calling #private() functions in classes #2 makes Bun call own-property overrides on process.emit, directly addressing why signal-exit callbacks were never invoked
  2. control + c (^c) doesn't exit inquirer interactive prompt #6592 - @inquirer/prompts uses signal-exit; Ctrl+C triggers both the bypassed process.emit override (fix Fix calling #private() functions in classes #2) and the TLA busy-spin when stdin closes (fix Fix ?? operator  #1)
  3. Bun run process exits early for CLI scripts with more than 2 user input prompts #6052 - CLI scripts using @clack/prompts (which depends on signal-exit) exit early due to shutdown events bypassing the JS-level process.emit override
  4. AbortSignal.timeout() abort event never fires when signal is passed to a plain JS Promise at top-level #29546 - AbortSignal.timeout() abort event never fires at top-level because waitForPromise busy-spins instead of recognizing the event loop is no longer alive (fix Fix ?? operator  #1)
  5. @inquirer/prompts fails in postinstall hook #11077 - @inquirer/prompts in a postinstall hook hangs at 100% CPU when piped stdin closes, directly addressed by the loadEntryPoint busy-spin fix

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #12918
Fixes #6592
Fixes #6052
Fixes #29546
Fixes #11077

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Detect unsettled top-level await in entry-point loading instead of hanging #30551 - Detects unsettled TLA in entry-point loading, touches same files (src/bun.js.zig, src/jsc/VirtualMachine.zig) for the same TLA exit detection fix
  2. runtime: exit 13 on unsettled top-level await instead of hanging #29739 - exit code 13 on unsettled top-level await instead of hanging, same files and same purpose
  3. Exit unsettled top-level await instead of hanging / busy-looping #29549 - Fixes busy-looping and unsettled TLA exit in src/jsc/VirtualMachine.zig
  4. fix: detect unsettled top-level await and exit instead of busy-waiting #27215 - Detects unsettled TLA and exits instead of busy-waiting (older PR for same core issue)
  5. process: emit beforeExit and exit through the JS-level process.emit #29196 - Routes process.emit('exit'/'beforeExit') through user-visible emit, fixes signal-exit onExit callback not triggered in Bun during process shutdown #29194 which is also fixed by this PR
  6. Fix forgetting to drain microtasks in EventLoop.waitForPromise(...) #21141 - Fixes drainMicrotasks in EventLoop.waitForPromise, which overlaps with the busy-spinning fix in src/jsc/VirtualMachine.zig

🤖 Generated with Claude Code

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/BunProcess.cpp (1)

892-905: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Drain nextTick whenever a user process.emit override runs for "beforeExit"

wrapped().emit() returning false means no listeners ran, but an arbitrary JS override does not carry that guarantee. A monkey-patched process.emit can enqueue process.nextTick(...) during "beforeExit" and still return false/undefined, so the current if (fired) gate can skip queued work and prevent the pending TLA from ever resuming on shutdown.

Suggested change
-    bool fired;
+    bool shouldDrainNextTick = false;
     if (JSC::JSValue userEmit = userEmitOverride(vm, process)) {
-        fired = callUserEmitOverride(globalObject, process, userEmit, "beforeExit"_s, jsNumber(exitCode));
+        callUserEmitOverride(globalObject, process, userEmit, "beforeExit"_s, jsNumber(exitCode));
+        shouldDrainNextTick = true;
     } else {
         MarkedArgumentBuffer arguments;
         arguments.append(jsNumber(exitCode));
-        fired = process->wrapped().emit(Identifier::fromString(vm, "beforeExit"_s), arguments);
+        shouldDrainNextTick = process->wrapped().emit(Identifier::fromString(vm, "beforeExit"_s), arguments);
     }
-    if (fired) {
+    if (shouldDrainNextTick) {
         if (globalObject->m_nextTickQueue) {
             auto nextTickQueue = globalObject->m_nextTickQueue.get();
             nextTickQueue->drain(vm, globalObject);
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/BunProcess.cpp` around lines 892 - 905, The current logic
only drains globalObject->m_nextTickQueue when the emit call reports listeners
ran (fired), but a user-provided override (userEmitOverride /
callUserEmitOverride) can enqueue nextTick callbacks even when it returns
false/undefined; change the control flow so that when userEmitOverride(vm,
process) is used (i.e., you call callUserEmitOverride), you always drain the
nextTick queue afterwards regardless of the returned fired value, while
preserving the existing behavior for the wrapped().emit path; use the same
nextTickQueue->drain(vm, globalObject) call and m_nextTickQueue check to perform
the drain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/jsc/bindings/BunProcess.cpp`:
- Around line 892-905: The current logic only drains
globalObject->m_nextTickQueue when the emit call reports listeners ran (fired),
but a user-provided override (userEmitOverride / callUserEmitOverride) can
enqueue nextTick callbacks even when it returns false/undefined; change the
control flow so that when userEmitOverride(vm, process) is used (i.e., you call
callUserEmitOverride), you always drain the nextTick queue afterwards regardless
of the returned fired value, while preserving the existing behavior for the
wrapped().emit path; use the same nextTickQueue->drain(vm, globalObject) call
and m_nextTickQueue check to perform the drain.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c744462e-ec44-4931-930a-02eed28f2bbe

📥 Commits

Reviewing files that changed from the base of the PR and between 3bf4b33 and 6195111.

📒 Files selected for processing (4)
  • src/bun.js.zig
  • src/jsc/VirtualMachine.zig
  • src/jsc/bindings/BunProcess.cpp
  • test/regression/issue/17636.test.ts

Comment thread test/regression/issue/17636.test.ts Outdated
Comment thread src/jsc/VirtualMachine.zig
robobun added 2 commits May 13, 2026 01:48
Node drains Promise microtasks after the 'exit' event (so that
.catch()/.finally() reactions on promises rejected by exit handlers
run), but never process.nextTick — nextTick() is a no-op once
_exiting is set and anything already queued is dropped.

The previous commit used GlobalObject::drainMicrotasks() which also
drains the nextTick queue, causing a nextTick queued from
handleRejectedPromises() (which runs at the very end of tick()) to
fire with _exiting=true. That broke
test/js/node/test/parallel/test-event-capture-rejections.js whose
common.mustCall() guards on process._exiting.

Also: when a user process.emit override runs for 'beforeExit', drain
nextTick regardless of its return value — an override's falsy return
does not mean it did no work.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/regression/issue/17636.test.ts`:
- Around line 71-73: The test currently uses loose contains checks on the parsed
stdout array (`seen` from JSON.parse(stdout.trim())) which allows wrong order or
extra events; replace the two expect(...toContain(...)) assertions with a single
exact equality assertion that `seen` equals the precise ordered array of
shutdown events (e.g., ["beforeExit:0","exit:0"]) so the test enforces both
order and no extra emissions; update the assertions around the `seen` variable
accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 620e09b7-4211-44b3-89b3-305c4047fd6f

📥 Commits

Reviewing files that changed from the base of the PR and between 6195111 and ccc82e6.

📒 Files selected for processing (2)
  • src/jsc/bindings/BunProcess.cpp
  • test/regression/issue/17636.test.ts

Comment thread test/regression/issue/17636.test.ts Outdated
robobun and others added 3 commits May 13, 2026 01:58
Six concurrent ASAN debug-build subprocesses contend enough that the
readline/stdin cases exceed the default 5s timeout. Use 30s under
debug builds, 10s otherwise.
Comment thread src/bun.js.zig
Comment on lines +498 to +518
while (true) {
while (vm.isEventLoopAlive()) {
vm.tick();
vm.eventLoop().autoTickActive();
}

vm.onBeforeExit();

// loadEntryPoint() may have returned with the module's
// evaluation promise still pending (nothing left in the
// event loop that could settle it). A beforeExit listener
// may have just scheduled work that will settle it —
// drain once and re-enter if so. Otherwise fall through
// and the still-pending promise becomes exit code 13.
if (vm.pending_internal_promise) |p| {
if (p.status() == .pending) {
vm.tick();
if (vm.isEventLoopAlive()) continue;
}
}
break;

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.

🟡 Moving vm.onBeforeExit() into the outer while (true) (and removing the trailing call after the eval_and_print block) flips the output order for bun --print when a beforeExit listener writes to stdout: bun -p '(process.on("beforeExit", () => console.log("BE")), 42)' now prints BE\n42 instead of Node's / pre-PR Bun's 42\nBE. Very minor (--print + a beforeExit listener is uncommon and run-eval.test.ts doesn't cover it), but worth noting in case you want to keep a trailing onBeforeExit() after the eval_and_print block.

Extended reasoning...

What changed

Pre-PR ordering in the non-watcher branch of Run.start() was:

  1. main drain loop (while (vm.isEventLoopAlive()) { tick; autoTickActive; })
  2. eval_and_print block (prints the -p result, with its own drain loop for a pending result promise)
  3. vm.onBeforeExit()

The PR moves vm.onBeforeExit() inside the new outer while (true) loop (src/bun.js.zig:504) and deletes the trailing call after the eval_and_print block (the old line at the end of that block was replaced by the late-TLA / exit-13 check). So beforeExit now fires before the eval_and_print block runs.

Observable consequence

For bun --print <expr>, any beforeExit listener that writes to stdout now interleaves before the printed result instead of after. This diverges from Node's -p semantics, where the expression result is printed synchronously, then the event loop runs, then beforeExit fires on natural shutdown.

Step-by-step proof

bun -p '(process.on("beforeExit", () => console.log("BE")), 42)'

Node / pre-PR Bun:

  1. Expression evaluates synchronously → result is 42, beforeExit listener registered.
  2. Main drain loop: nothing alive → exits immediately.
  3. eval_and_print block: vm.entry_point_result.value is 42 (not a promise) → to_print.print(...) writes 42\n.
  4. vm.onBeforeExit() fires → listener writes BE\n.
  5. Output: 42\nBE\n.

Post-PR Bun:

  1. Expression evaluates synchronously → result is 42.
  2. Outer while (true): inner drain loop exits immediately, then vm.onBeforeExit() fires → listener writes BE\n. pending_internal_promise is fulfilled (the -e source has no TLA), so the loop breaks.
  3. eval_and_print block prints 42\n.
  4. Output: BE\n42\n.

Why existing code/tests don't catch it

run-eval.test.ts (33/33 pass per the PR description) doesn't combine --print with a beforeExit listener, and the new regression tests in 17636.test.ts all use -e, not -p. The reordering is a side-effect of folding onBeforeExit() into the drain loop (which is necessary for the TLA fix) without re-adding a trailing dispatch after eval_and_print.

Impact and fix

Impact is very low — bun --print + a registered beforeExit listener is an uncommon combination, and one could argue beforeExit-before-print is semantically defensible since the print is a runtime post-shutdown step. But it is an undocumented Node-parity ordering change introduced by this PR. If you want to preserve the old ordering, the simplest fix is to add a single trailing vm.onBeforeExit() after the eval_and_print block (before the late-TLA check), so that any side effects of printing — and the print itself — happen before the final beforeExit round. Not a blocker.

@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author

CI status (build #53948): the diff is green — my regression test test/regression/issue/17636.test.ts passes on every lane, and test-event-capture-rejections.js (which the first iteration regressed) passes too.

The two red lanes are pre-existing Windows flakes unrelated to anything this PR touches:

Test Lane Also failing in
test/js/bun/test/parallel/test-http-should-emit-close-when-connection-is-aborted.ts win 2019 x64 / x64-baseline builds 53801, 53804, 53806, 53807, 53812–53815, 53818, 53821–53822, 53829, 53832, 53845, 53847… across ali/workflow-lint, farm/c0fedab7/*, farm/0622636e/*, farm/e6cda499/*, etc.
test/cli/hot/hot.test.ts "should work with sourcemap generation" win 11 aarch64 builds 53806, 53814, 53821, 53845, 53851, 53885, 53900, 53902, 53920, 53928, 53929…

This PR only touches loadEntryPoint's non-watch TLA wait, the Run.start non-watch shutdown path, and process.emit routing for 'exit'/'beforeExit' — none of which intersect hot-reload sourcemap counting or the HTTP request-close path. Both tests pass locally on Linux with this build (5/5 runs each).

Ready for review/merge.

@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author

Verified this also fixes #6592 (@inquirer/prompts hanging on Ctrl+C).

Repro: in a PTY, run await input({ message: '...' }) and send Ctrl+C. readline's keypress handler closes the interface (no SIGINT listener on rl), the event loop drains, and the prompt's top-level await is left pending — same unsettled-TLA path as the stdin-close case.

before this branch node
Ctrl+C at inquirer prompt hangs forever warn: Detected unsettled top-level await, exit 13 exit 13

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant