Skip to content

Evaluate entry points in an event-loop task, outside the module loader's checkpoint - #45551

Draft
robobun wants to merge 2 commits into
mainfrom
robobun/7347e1ed/evaluate-entry-under-runner
Draft

robobun wants to merge 2 commits into
mainfrom
robobun/7347e1ed/evaluate-entry-under-runner

Conversation

@robobun

@robobun robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #34115

Problem

  • A program that queues process.exit(0) and then throws exits 0 with empty stderr: Promise.resolve().then(() => process.exit(0)); throw new Error("fatal"). Node v26.12.0 prints the error and exits 1.
  • The module loader evaluates the entry point inside its microtask checkpoint. Run::start (src/runtime/cli/run_command.rs) reads the entry promise after that checkpoint ran the exit.
  • The same checkpoint causes Writable.toWeb() breaks when a preload script requires node:stream #34115: after a preload that used process.nextTick, a CommonJS entry point runs promise jobs before process.nextTick callbacks.

Fix

  • begin_entry_load (src/jsc/VirtualMachine.rs) loads the graph with JSC::loadModule. An event-loop task, finish_entry_load, links and evaluates it outside any checkpoint and reports a failure at once.
  • The entry point, preloads, workers and test files use it. report_entry_failure replaces three reporters.
  • Verified: exit-code.test.ts, process-nexttick.test.js, vm.test.ts and node-module-module.test.js fail on main. The vendored Node suite matches main.

Background

Downsides

  • Tick and promise job order changes for some programs that do not throw (see Notes).
  • Per entry load: 2 more checkpoints, 1 promise, 1 reaction, 1 task. Release .text: +10,432 bytes.
  • Still lost: an imported CommonJS module that queued the exit in a promise job.
Notes

A maintainer must decide

  1. The target Node version for ES module entry points. This PR follows v26.12.0. Bun reports v26.3.0. In v26.3.0 an .mjs or .ts entry with a queued promise-job exit loses the error, as on main.
  2. The order changes below ship with no flag.
  3. Should every CommonJS module that the ES module loader creates use SyntheticSourceProvider::createDeferred ([JSC] Run a CommonJS module's generator before its importing graph links, not when its fetch completes WebKit#662)? Today only the entry-like module does. The switch fixes the last "Still lost" shape and removes the isEntry code (Bun__VM__setEntryModuleKey, Bun__VM__isEntryModuleKey).

How it works

  • begin_entry_load creates the promise that the runners wait for, as they did for the promise of loadAndEvaluateModule. load_entry_point, the preload loop, the worker wait and the test runner keep their code.
  • A reaction on the load promise queues the task (EntryLoadEvaluate). The task runs in the same turn of the event loop, before the unhandled-rejection pass.
  • Evaluation throws: uncaught exception at once. In a worker the report is a tick, because Node rethrows from a tick (emitUncaughtException, lib/internal/event_target.js).
  • Evaluation is pending on a top-level await: the promise jobs run before the ticks, as in Node. A rejection is reported from a reaction, with origin unhandledRejection.
  • The graph does not load: the reaction reports it inside the checkpoint.
  • A CommonJS entry-like module uses createDeferred, so its body runs when the task links the graph. moduleLoaderFetch names the module that bun:main imports.
  • report_entry_failure runs the deferred sink flush before it prints, so output that the program wrote stays before the error.
  • JSC::loadModule takes a key, so JSC no longer asks the host to resolve bun:main before the load. bundler_compile_prelinked.test.ts counts the host calls of the loader: resolve goes from 5 to 4 with the graph, and from 252 to 251 by name.

Order changes for programs that do not throw

  1. A CommonJS entry-like module when the tick queue exists already (after a preload that used process.nextTick, in a worker, a later test file): ticks before promise jobs. Node does the same. This is Writable.toWeb() breaks when a preload script requires node:stream #34115.
  2. An ES module entry point with no top-level await whose graph made the tick queue (it imports node:fs, or runs after a preload): ticks before promise jobs. Node v26.12.0 does the same. Node v26.11 and older run the promise jobs first.
  3. An entry point with a top-level await: promise jobs before ticks. Node does the same. Main ran the first tick first.
  4. The first process.nextTick of a process, when a promise job queues it, runs after the remaining promise jobs. Node does the same. This includes a node:vm context with microtaskMode: "afterEvaluate".
  5. bun:main reads the default export of a CommonJS entry point when its body finishes. Main ran the ticks and promise jobs of the entry point first. A CommonJS server config that a tick completes starts no server now. ES module entry points always behaved this way. process-nexttick.test.js pins it.
  6. A Module.runMain override that calls the original and then throws: the failure of the program is reported first, then the rejection of the override.
  7. Origin: what an ES module entry point throws, and a graph that does not load, is an uncaughtException. Main said unhandledRejection.

Measurements

Tools: lldb breakpoint hit counts on debug builds, llvm-size on release builds. valgrind, perf, strace and bloaty are not installed here.

main this PR
GlobalObject::drainMicrotasks calls, console.log("hi") 1 3
VM::drainMicrotasks calls, same program 3 5
calls from JSC into the per-microtask hook, same program 19 0
the same, a loop of 10,000 await null 168 0
release .text bytes (merge base 5f5799e, before a rebase) 106,006,545 106,016,977
300 runs of console.log("hi"), .cjs, release, 5 interleaved rounds 3533 to 4321 ms 3502 to 4132 ms
the same, .mjs 3535 to 3937 ms 3515 to 3739 ms
  • Added: 7 C exports, 2 promise reaction functions, 1 task tag. Removed: 6 C exports and the hook. One more host function is created only when a worker's entry point fails.
  • Removed per entry load: 1 call of the host resolve hook, for bun:main.
  • Per CommonJS module that the ES module loader creates: 1 call to Bun__VM__isEntryModuleKey. Per module fetch: 1 string compare of the referrer with bun:main.
  • A matrix of 36 shapes (entry, -e, stdin, preload, --import, dependency, top-level await, each with a queued promise job or tick): 33 equal to Node v26.12.0. Main: 7.

Still different from Node v26.12.0

  • An ES module entry point that imports a CommonJS module, where that module queued the exit in a promise job and a module throws: exit 0. An imported CommonJS module runs when it is fetched, inside the loader's checkpoint. See decision 3. exit-code.test.ts has an it.todo for it.
  • -e with ES module source and an --import preload, with a queued promise job: Bun reports the error. Node runs the promise job first.
  • A preload that queued the exit and an entry point that throws: exit 0. A checkpoint runs between two entry-like loads.
  • bun test: the error of a test file is printed now. The exit code is still the one that the queued process.exit(0) asked for.
  • A top-level await that never settles still spins on the main thread (awaiting top level no-op promise that never resolves is missing an error/warn, causing 100% cpu usage #14951).
  • require() of an ES module that imports the CommonJS entry point back: Node throws ERR_REQUIRE_CYCLE_MODULE. Here the cycle works, and the error that the entry point throws after it is reported (main lost it). Which exports such a cycle sees changed in 3 of 40 probed shapes.

Not routed

Vendored Node tests

  • test-runner-tags-experimental-warning.mjs: three cases are todo under Bun. Their child processes throw from the top level in Bun, and the process now exits before the tick that prints the warning, as Node does for such a script.
  • test-event-capture-rejections.js: on main the chain stops after its third step, because a tick queued in an unhandledRejection listener is dropped. It now runs further and reaches two steps that node:events does not support (a global captureRejections on an emitter whose constructor was not called, and a returned thenable). Line 125 skips those two under Bun.

Found and not fixed here

  • A .cjs file with no CommonJS syntax is transpiled as an ES module when it is preloaded or imported.
  • A worker's unhandled rejection after its entry point loaded exits 0, and the last one is not reported.
  • A tick queued in an unhandledRejection listener in the last turn of the loop is dropped.
  • A program that a Module.runMain override runs with require() loses the ticks and promise jobs that it queued.
  • Evicting the root key of a load while it loads (mock.module("bun:main") from a dependency) now gives Module "bun:main" has not been loaded. Main ran the program. The fix belongs in the fork: link and evaluate the record that the load produced.

Earlier PRs

#38141, #38159, #43366 and #33088 each fix one part of this. They are redundant if this lands. #43366 alone fixes #34115 and is the small alternative.

Suites

Debug build against a debug build of main: the changed test files, bun-main-entry-point.test.ts, hot.test.ts, watch.test.ts, process.test.js, worker_threads.test.ts and 10 more worker files, 9 test/cli/test files, 15 test/cli/run files, 5 test/cli/inspect files, AsyncLocalStorage.test.ts, plugins.test.ts, macro-test.test.ts, mock-module.test.ts. No failure that main does not have. Release build: all 3,661 parallel and 85 sequential vendored Node tests, same results as main after the deviation above. The two new test files also pass with BUN_JSC_validateExceptionChecks=1 and the LeakSanitizer settings of the CI runner. bun build --compile with the default format, --bytecode and --format=cjs was checked by hand: 8 of 8 variants report the error (main: 0). After the rebase onto 017b243: the changed test files, all 79 cases of bundler_compile_prelinked.test.ts, and 924 runs of a --hot process through four reloads (up to 40 in parallel, Linux debug build) with no lost reload. hot.test.ts › does not take a promise of the program's… fails in this container in about half of the runs, with main's build (4 of 8) and with this branch (3 of 8). Nothing was run on Windows or macOS here.


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/vm/vm.test.ts, test/js/node/module/node-module-module.test.js, test/bundler/bundler_compile_prelinked.test.ts

@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:34 PM PT - Oct 17th, 2026

❌ @robobun, your commit 2e1070a has 3 failures in Build #127627 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 45551

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

bun-45551 --bun

@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft. The change is complete and tested. It waits for the three decisions that the Notes block of the description lists.

How I reproduced the problem, on main (5866b5f, debug build) and on bun 1.4.3:

// entry.cjs or entry.mjs
Promise.resolve().then(() => process.exit(0));
throw new Error("fatal");

bun entry.cjs exits 0 and prints nothing. Node v26.12.0 prints the error and exits 1. The same happens with process.nextTick(() => process.exit(0)).

For #34115: bun --preload ./preload.js repro.js with the two files of the issue prints RESOLVED (wrong) on main and rejected: ABORT_ERR on this branch.

CI on 2e1070a: one test fails, and only on alpine 3.23 aarch64. It is vm.test.ts › a FinalizationRegistry cleanup job is dropped when its context dies before the job runs (deadContextCleanups: 5, expected 0). This PR does not change that code path. The same failure, on the same lane, is in the CI of #45255, #45340, #43600, #45654 and #45177.

The failure reproduces with the canary binary of main for linux-aarch64-musl (1.4.4-canary.1+5f5799ed1, run under qemu-aarch64). Change the last line of the fixture to console.log(JSON.stringify({ deadContextCleanups })) and it prints 5 on every run. What I found there, with a heap snapshot and a debugger session:

  • The test expects that a collection frees every context that nothing references. The collector scans the stack conservatively, so it does not promise that.
  • One stack slot of JSC::VM::drainMicrotasks() holds a one-byte flag. The function does not write the other seven bytes, so they keep an old pointer. The scan reads the word as a pointer into a Function cell of one dead context. That context stays alive and its five cleanups run.
  • In the snapshot that context has no root, and no incoming edge from outside its realm. That word is the only one on the stack that points into the realm.
  • Which cell the word points into depends on the order of small allocations. An unrelated change can move it.

I could not run the alpine binary of this branch. That it fails for the same reason is an inference from the same lane and the same output. #44544 is open and makes this test exact: it counts only the cleanups of the contexts that a WeakRef shows as collected. Its fixture passes on the canary binary for linux-aarch64-musl (6bb5dc1). Its checks also pass in 13 allocation layouts of that binary that keep one context alive. After #44544 lands, a rebase of this PR is the next step for that lane.

The two other red tests (node-net.test.ts, sql-close-pending-connection.test.ts) are on the darwin 27 aarch64 lane. That lane is allowed to fail, and the two tests fail there on other branches too (builds 127593, 127611, 127628).

…before what they queued

An entry-like load (the entry point, a preload, a worker entry, a test
file, the original Module.runMain under a runner-called override) now
has two steps. The module loader loads the graph. A task of the event
loop then links and evaluates it, outside any microtask checkpoint,
and reports a failure before the ticks and promise jobs that the
program queued.

A failure of the synchronous evaluation is an uncaught exception at
the throw (from a tick in a worker). A rejection after a top-level
await is reported from a promise job, before the ticks. One reporter,
report_entry_failure, replaces the three in run_command.rs,
web_worker.rs and test_command.rs.

The one-shot per-microtask hook that drained a new process.nextTick
queue, the evaluated_as_cjs flag and the module-evaluate hook that
tracked the start of a worker's entry are removed.
…main serve cases

JSC::loadModule takes a key, so an entry load no longer asks the host
to resolve bun:main before the load. bundler_compile_prelinked.test.ts
counts the host calls of the loader: 5 to 4 with the graph, 252 to 251
by name.

The two cases about the default export of the entry point move from
bun-main-entry-point.test.ts to process-nexttick.test.js, next to the
other order cases of the entry point.

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.

Writable.toWeb() breaks when a preload script requires node:stream

2 participants