Repository navigation
Conversation
|
Updated 8:02 PM PT - Sep 23rd, 2026
❌ @robobun, your commit 940ecf4 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 43741That installs a local version of the PR into your bun-43741 --bun |
|
Status
|
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository: oven-sh/bun/.coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review. WalkthroughExit and beforeExit dispatch now enter no-checkpoint event-loop scopes. Uncaught-exception and unhandled-rejection handling use a shared scope outside bun:test. New subprocess tests check Promise job and nextTick timing across callback contexts. ChangesEvent-loop dispatch behavior
Suggested reviewers: Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to No actionable issue is established for the process callback timing changes; the PR is mergeable after normal checks. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
I re-reviewed the follow-up commits (2124b13, 3b92f75) and found no new bugs; since this changes when the event loop counts itself as entered for four process-level dispatchers and the description lists deliberate behavior tradeoffs under "Downsides", a human look is still worthwhile.
What was reviewed:
enter_error_handler_scopereturningNoneunderbun test: both error paths now hold no guard at all before handing off to the runner, so the nested-guard case from the previous round no longer applies.Process__dispatchOnBeforeExitfallback:vm.drainMicrotasks()matches whatJSNextTickQueue::draindoes on an empty queue, and the movedRETURN_IF_EXCEPTIONstill covers both branches. Checked a promise job that readsprocess.nextTickfor the first time inside that fallback — its tick is not drained afterwards, but the same program ran neither the job nor the tick on the base, so this is not a regression.- Guard balance in
uncaught_exception: the paths that skip the guard's drop are the main-threadprocess_exitcalls that never return; the worker paths all fall through to a normal return.
Extended reasoning...
The change wraps Process__dispatchOnExit, Process__dispatchOnBeforeExit, uncaught_exception and unhandled_rejection_owned in src/jsc/VirtualMachine.rs with an EventLoopEnterNoCheckpointGuard (skipped under bun test for the two error paths), adds a vm.drainMicrotasks() fallback in src/jsc/bindings/BunProcess.cpp when no nextTick queue exists, and adds seven subprocess tests to test/js/node/process/process.test.js. It touches no security-sensitive surface. Two of the four findings from the previous run were addressed by the follow-up commits; the Mode::Bun arm in unhandled_rejection_owned still returns without a drain, which the PR description frames as an accepted downside tracked elsewhere, and that open tradeoff plus the event-loop-semantics scope is why this is a defer rather than an approve.
|
A companion change is ready on What it covers. In Measured. Debug builds on main 6d504dd, with
The source files do not overlap, and the commits of this PR apply on the branch without conflict. On the combined build, Open point:
The two statements should follow one rule. Maintainers: I can push the commit to this PR, or open it as a PR stacked on this branch. Say which you prefer. |
|
@robobun #43826 merged, and it also changes the beforeExit checkpoint (beforeExit now ends with the same checkpoint that runs the tick pass and the microtasks). Please rebase this onto main. BunProcess.cpp and process.test.js changed there, so expect conflicts. Then check whether the Process__dispatchOnBeforeExit change here is still needed, and drop it if #43826 already covers it. Keep the rest: the four dispatchers that hold the event loop entered, and the 7 new cases. |
…ion listeners with the event loop entered Native code emits these process events between event loop turns, with entered_event_loop_count at 0. A native call made by a listener that dispatches a callback through enter()/exit() (socket.destroy() running the close callback, fs.watch().close()) was then the outermost pair, so EventLoop::exit() ran the nextTick queue and the promise jobs inside that call, before it returned. The onError of a Bun.ModuleGraph is reached through the same two error paths. Hold enter_event_loop_scope_without_checkpoint() in the four dispatchers, as the Bun.serve request paths do. What runs after the listeners return does not change. Under bun test the error paths release it before they call the runner, which can start the next test from there.
uncaught_exception and unhandled_rejection_owned take no entry under bun test. A Bun.ModuleGraph onError that throws re-enters uncaught_exception with the outer entry still held, and the runner could then start the next test with the count at 1. The tests now also check what runs once the listener has returned, and cover a 'beforeExit' listener in a program without a nextTick queue.
3b92f75 to
12be3dc
Compare
|
Rebased onto
|
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline test nits, I re-checked this push's new enter_error_handler_scope (src/jsc/VirtualMachine.rs:2087): under bun test neither uncaught_exception nor unhandled_rejection_owned takes an entry now, so the ModuleGraph onError-throws re-entry into uncaught_exception also reaches BunTest::run with the count unchanged. I also checked the guard held across the noreturn process_exit calls in uncaught_exception and across the Strict/Warn re-entry from unhandled_rejection_owned — nested guards only balance the count, and the beforeExit drain the earlier version relied on is now unconditional in the base (BunProcess.cpp:866, #43826).
Extended reasoning...
The Rust change is 12 lines: RAII no-checkpoint guards around the exit/beforeExit dispatchers and a bun-test-gated guard at the top of the two error dispatchers in src/jsc/VirtualMachine.rs, plus seven subprocess tests. No security-sensitive surface. The two earlier substantive concerns about post-listener drains on the 'exit' and default-mode 'unhandledRejection' paths are unchanged in this push and were rejected by the author as accepted downsides, so they are not restated; a maintainer is already engaged on the thread.
…ons mode The new cases pipe stderr and expect it to be empty. One more case runs with --unhandled-rejections=none: the nextTick that the listener queued runs in the explicit drain of that mode, inside the held entry.
Problem
'beforeExit','exit'or'unhandledRejection'listener,socket.destroy()runs the queued nextTicks and promise jobs before it returns:then(f); socket.destroy(); g()runsfbeforeg, unlike Node. An'uncaughtException'listener for an entry module throw and aBun.ModuleGraphonErrordo the same.entered_event_loop_countat 0 (dispatch_on_exit,dispatch_on_before_exit,uncaught_exception,unhandled_rejection_ownedinsrc/jsc/VirtualMachine.rs). Theenter()/exit()pair around the native close callback is the outermost one, soEventLoop::exit()drains.Fix
enter_event_loop_scope_without_checkpoint()across the listeners and their drains, so a nested pair does not drain.tick(), timers and sockets already call JS with the count above 0.bun testthe error paths take no entry: the runner can start the next test inside the report.'beforeExit'that is the checkpoint of process.nextTick: run the tick pass only when a tick is scheduled #43826, which this PR does not touch.test/js/node/process/process.test.js, all failing withsrc/atmain. Self-reviewed: 5 concerns raised, 3 addressed, 2 rejected (Notes).Background
entered_event_loop_count(src/jsc/event_loop.rs) is the depth of native entries into JS. Only the outermostEventLoop::exit()runs the microtask checkpoint (nextTicks, promise jobs), so each callback runs to completion.enter_event_loop_scope_without_checkpoint()(Bun.serve, node:http: run request handlers with the event loop entered #39826) is anenter()whose exit only balances the count, for a dispatcher that drains by itself.Downsides
'exit', and after an'unhandledRejection'that ends the loop, bun runs no jobs, so those never run. Node runs the promise jobs (Notes).Notes
Report. Found by a differential run against node v26.3.0: 24 entry points x 39 synchronous calls, each checked for a queued promise job or nextTick that ran before the call returned. 15 cells differ, all in
'beforeExit','exit'and'unhandledRejection'listeners. That run reports the same output on bun 1.4.2, 1.4.0 and 1.3.14. There is no user report.Repro.
bun d.mjs <timer|beforeExit|exit|unhandledRejection>:before destroy() -> after destroy().timeris correct.beforeExitandunhandledRejectionprintbefore destroy() -> NEXTTICK -> MICROTASK -> after destroy().exitprintsbefore destroy() -> MICROTASK -> after destroy()(process.nextTickis a no-op onceprocess._exitingis set).resetAndDestroy(),destroy()andresetAndDestroy()on the server side, andfs.watch().close(). That run also lists TLSdestroy(), which I did not run.end(),write(),destroySoon()andserver.close()never did it.Where the count is 0.
'beforeExit':VirtualMachine::on_before_exitruns after the loop went idle. The nextTick drain thatProcess__dispatchOnBeforeExitdoes after the emit ran at 0 too, so a nextTick or promise job that a listener queued had the same problem. The scope covers that drain.'exit':VirtualMachine::on_exit, after the loop ended, and the worker exit path ('beforeExit'and'exit'in aworker_threadsWorker had the defect too). Withprocess.exit()the listeners run inside the JS caller's frame and have that caller's count. That was already correct from a timer. From one of the other listeners it is correct now.'unhandledRejection': a rejection that the last callback of the loop made is reported by thehandle_rejected_promises()call inrun_command.rs, after the loop ended. This is the repro. The call at the end oftick()has the count at 1, so a rejection that is reported while the loop is still alive was not affected (checked with a second, pending timer). The explicit drains of the--unhandled-rejectionsmodes are inside the scope as well.'uncaughtException':uncaught_exceptionfor a rejected entry module (run_command.rs), and a throw in a'beforeExit'listener. From a timer or I/O callback the count was already 1.Bun.ModuleGraphonError: both error paths try it first. For an unhandled rejection that is reported after the loop ended it ran at 0.Variants checked against node, bun 1.4.3 and this PR (net
destroy()andfs.watch().close()as the nested call): timer, top level,'beforeExit', a second'beforeExit'listener, a nextTick and a promise job queued by'beforeExit','exit'on a natural exit,'exit'afterprocess.exit()from a timer, a nextTick, a promise job,'beforeExit'and'unhandledRejection','unhandledRejection'for a rejection from a timer, an immediate, a nextTick, an fs callback and the top level,'uncaughtException'from a timer, from'beforeExit', from a top-level throw before and after anawait,'uncaughtExceptionMonitor','rejectionHandled','warning', andonErrorfor a rejection and an exception. With this PR every variant printsbefore -> after.What runs after the listeners return is unchanged. I compared the three builds with and without the
destroy()call:'beforeExit'and'uncaughtException': the queued nextTick and promise job run after the listener returns, as before withoutdestroy(), and as in node.'exit': bun runs nothing after the listeners. Node runs the promise jobs on a natural exit. On 1.4.3 a promise job ran only when the listener happened to make such a native call. It now never runs, the same as without the call.'unhandledRejection'in the default mode: in the affected case the loop has ended, and bun runs nothing that the listener queued. A timer that the listener starts does not keep the process alive either. Run a microtask checkpoint after each batch of unhandledRejection listeners, and follow Node's captureRejections #42031 and Report a loop turn's rejections before the loop-alive check, and keep the exit code when an 'exit' listener throws #42032 cover that. On 1.4.3 a queued job ran only when the listener happened to make such a native call. It now never runs, the same as without the call.'uncaughtException'listener bun runs the nextTick before the promise job and node runs them in the other order. This PR does not change that.A native call that runs the event loop from inside a listener.
Bun.build()with an async pluginsetupwaits for that promise before it returns. Inside it, a timer, a promise job, an immediate and a nextTick run in the same order on 1.4.3 and with this PR, from a timer callback,'beforeExit','unhandledRejection'and'uncaughtException'. The wait callstick(), which drains unconditionally, and a timer callback already had the count at 1.bun test.uncaught_exceptionandunhandled_rejection_ownedhand the error to the test runner, andon_unhandled_rejectioncan callBunTest::run, which starts the next test inside that call. Fake timer controls and matcher waits give a checkpoint per callback when the count is 0 (#43356 has the details). ABun.ModuleGraphonErrorthat throws also re-entersuncaught_exceptionwhileunhandled_rejection_ownedis on the stack. So both paths take no entry underbun test(enter_error_handler_scope), andbun testbehaves as before. TheonErrorof a graph is not covered there.Self-review. Concerns raised: 5. Addressed: the
'beforeExit'checkpoint without a nextTick queue (now through #43826, see the rebase paragraph), the entry that was still held underbun teston a nested report, and the length of three comments. Rejected, with the reason:'unhandledRejection'listener. It is correct, but alone it makestest-event-capture-rejections.jsfail (details below). Run a microtask checkpoint after each batch of unhandledRejection listeners, and follow Node's captureRejections #42031 has it with thenode:eventschanges that the test needs.'exit'listeners. It must run only after a natural exit.dispatch_on_exitalso emits'exit'after a fatal error, where promise jobs from before the error are still queued and Node does not run them. It needs its own change and a differential run against Node.Review follow-up.
'beforeExit'without a nextTick queue. On 1.4.3 the checkpoint after the listeners drained only the nextTick queue, which exists only after something readsprocess.nextTick.process.once("beforeExit", () => Promise.resolve().then(f))then never ranf, and Node runs it. With the entry held, a job that a listener queued beforefs.watch().close()was lost in the same way. process.nextTick: run the tick pass only when a tick is scheduled #43826 fixed that checkpoint (next paragraph).'unhandledRejection'listener is not part of this PR. With it,test/js/node/test/parallel/test-event-capture-rejections.jscontinues past stage 3 (it chains its stages withprocess.nextTickfrom inside the listeners) intocaptureRejectionsgaps ofnode:events, and fails. Run a microtask checkpoint after each batch of unhandledRejection listeners, and follow Node's captureRejections #42031 has that drain together with thenode:eventschanges. For this listener the new test checks only the order insidedestroy().setImmediatecallback still runs with the count at 0 (maybe_drain_microtasks()after the fold), sofs.watch().close()inside it drains again. That is a different entry point. 1.4.3 and this PR behave the same there.Rebase onto #43826. #43826 merged while this PR was open. It ends
'beforeExit'with the full checkpoint (globalObject->drainMicrotasks()), with or without a tick queue. This PR had its own change inProcess__dispatchOnBeforeExitfor the same gap. The rebase dropped it, andBunProcess.cppis now identical tomain. Checked on debug builds at6d67b14b85: withsrc/atmainall new cases fail, and with this PR they pass. A'beforeExit'listener's promise job runs after the listener, with and withoutfs.watch().close(), in a program that never readsprocess.nextTick, as in Node.Second review. Two optional test points. The new cases now pipe stderr and expect it to be empty. An eighth case runs with
--unhandled-rejections=none: a nextTick that the'unhandledRejection'listener queued runs in the explicit drain of that mode, which is inside the held entry. Not added: a row for each other mode (the same guard, andwarnandstrictprint a warning) and worker cases (a worker reaches the same two dispatch functions, and this PR changes no worker code).Suites run on the debug ASAN build.
test/js/node/process/process.test.js(179 pass),process-nexttick.test.js,process-on.test.ts,test/js/node/net/node-net.test.ts,node-net-server.test.ts,test/js/bun/net/socket.test.ts,test/js/node/watch/fs.watch.test.ts,test/js/node/worker_threads/worker_threads.test.ts,test/js/web/workers/worker.test.ts,worker-terminate-lifetime.test.ts,test/js/bun/module-graph/module-graph.test.ts,module-graph-isolation.test.ts,module-graph-io.test.ts,test/js/node/events/event-emitter.test.ts,test/js/node/async_hooks/AsyncLocalStorage.test.ts,test/js/bun/cron/in-process-cron.test.ts,test/js/bun/util/filesink.test.ts,test/js/bun/repl/repl.test.ts,test/js/node/cluster.test.ts,child_process_ipc.test.js,spawn.ipc.test.ts,node-tls-connect.test.ts,console-write.test.ts,test/js/bun/test/bun_test.test.ts,test-test.test.ts,dots.test.ts,stack.test.ts,test/cli/test/bun-test.test.ts,isolation.test.ts,test/cli/run/require-cache.test.ts. Also 51 node tests fromtest/js/node/test/parallel(test-process-exit*,test-process-beforeexit*,test-promise-unhandled-*,test-process-uncaught*,test-event-capture-rejections,test-worker-*exit*,test-next-tick*,test-net-*destroy*): all exit 0. After the review follow-up: the 17 node tests that mentionbeforeExit(all exit 0),spawn-stdin-readable-stream.test.ts, andtest/js/node/process/stdin/stdin-fixtures.test.ts. After the rebase:process.test.js(180 pass),process-nexttick.test.js,fs.watch.test.ts,node-net-server.test.ts,filesink.test.ts,repl.test.ts,module-graph.test.ts,bun_test.test.ts,bun-test.test.ts,worker_threads.test.ts, the 51 node tests and the 17 node tests that mentionbeforeExit: no failure exceptprocess(noUSERvariable).Failures, all the same with
src/atmainor on bun 1.4.3:process.test.jsprocess: the container has noUSERvariable. It passes withUSERset.node-net.test.ts(10) andsocket.test.ts(1): no DNS or outside network in the container.worker-terminate-lifetime.test.tsterminate() while dns.lookup() is in flight: LeakSanitizer reports 16 bytes fromnode_fs_binding::Binding::new.AsyncLocalStorage.test.tsre-entering a storage inside run()and 7 leak tests oftest/cli/run/require-cache.test.ts: timeouts of the debug build.worker.test.tsandmodule-graph-isolation.test.ts: one 5 s timeout each in the full run. Both pass alone.stdin-fixtures.test.ts(3): the test kills the child after 1 s, and the debug build needs 1.2 s for the fixture. The fixtures print the same output as on 1.4.3 when I run them by hand.no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/process/process.test.js