Repository navigation
Conversation
|
Updated 3:31 AM PT - Nov 8th, 2026
✅ @robobun, your commit a180822528a09f74bcd624cd0a1a85cadebe42e7 passed in 🧪 To try this PR locally: bunx bun-pr 32228That installs a local version of the PR into your bun-32228 --bun |
|
Found 3 issues this PR may fix:
🤖 Generated with Claude Code |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughProcess lifecycle events (beforeExit, exit) are dispatched through the JS-visible process.emit via a new helper that resolves and calls process.emit (falls back to internal emitter). Native dispatch runs the helper under a top exception scope, reports/clears thrown exceptions, and tests validate monkey-patched process.emit behavior via subprocesses. ChangesProcess lifecycle event dispatch via process.emit
Pre-merge checks:
|
There was a problem hiding this comment.
Actionable comments posted: 2
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)
856-873:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDon't snapshot
beforeExitlisteners before calling overriddenprocess.emit.
hadListenersis captured before JS runs, but the whole point of this change is thatprocess.emitis now user-overridable. An override can add the firstbeforeExitlistener and then delegate to the original emit; that listener will fire and may queuenextTickwork, but Lines 869-873 will still skip draining because the stale pre-call snapshot stayed false. Base the drain decision on the actualprocess.emitresult instead of the pre-callwrapped()state.Suggested direction
-static void emitProcessExitEvent(JSC::JSGlobalObject* globalObject, Process* process, ASCIILiteral eventName, int exitCode) +static bool emitProcessExitEvent(JSC::JSGlobalObject* globalObject, Process* process, ASCIILiteral eventName, int exitCode) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ - JSC::call(globalObject, emitValue, callData, process, arguments); - RETURN_IF_EXCEPTION(scope, ); + JSValue didEmit = JSC::call(globalObject, emitValue, callData, process, arguments); + RETURN_IF_EXCEPTION(scope, false); + return didEmit.toBoolean(globalObject); } @@ - bool hadListeners = process->wrapped().hasEventListeners(Identifier::fromString(vm, "beforeExit"_s)); - auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - emitProcessExitEvent(globalObject, process, "beforeExit"_s, exitCode); + bool hadListeners = emitProcessExitEvent(globalObject, process, "beforeExit"_s, exitCode); if (auto* exception = scope.exception()) [[unlikely]] { (void)scope.tryClearException(); Bun__reportUnhandledError(globalObject, JSValue::encode(exception)); return; }🤖 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 856 - 873, The code captures hadListeners before calling emitProcessExitEvent, but because process.emit can be overridden the decision to drain nextTickQueue must be based on the actual post-emit state; remove the pre-call snapshot (hadListeners) and instead either (A) read the listener state after emit by calling process->wrapped().hasEventListeners(Identifier::fromString(vm, "beforeExit"_s)) and use that result to decide whether to drain nextTickQueue, or (B) change emitProcessExitEvent to return a bool indicating whether the emit had listeners and use that return value to gate nextTickQueue->drain; update the code around emitProcessExitEvent, hadListeners, and nextTickQueue->drain accordingly (referencing hadListeners, emitProcessExitEvent, process->wrapped().hasEventListeners, and nextTickQueue->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.
Inline comments:
In `@src/jsc/bindings/BunProcess.cpp`:
- Around line 292-294: The current check after calling
JSC::getCallData(emitValue) silently returns when callData.type ==
CallData::Type::None; instead, mirror Process_functionEmitHelper by creating a
pending "not a function" exception so the caller can surface it during shutdown:
when callData.type == CallData::Type::None, invoke the same
createNotAFunctionError path used by Process_functionEmitHelper (i.e.,
produce/throw the not-a-function exception for emitValue) rather than returning
silently, leaving the pending exception for the caller to report.
- Line 289: The code creates a new Identifier for "emit" on each lifecycle
dispatch by calling Identifier::fromString(vm, "emit"_s); replace those
constructions with the cached name vm.propertyNames().emit (the same cached
property used in processGet) so that process->get(globalObject,
Identifier::fromString(vm, "emit"_s)) becomes process->get(globalObject,
vm.propertyNames().emit) (apply the same replacement at all beforeExit/exit
dispatch sites in BunProcess.cpp to avoid per-dispatch allocations).
---
Outside diff comments:
In `@src/jsc/bindings/BunProcess.cpp`:
- Around line 856-873: The code captures hadListeners before calling
emitProcessExitEvent, but because process.emit can be overridden the decision to
drain nextTickQueue must be based on the actual post-emit state; remove the
pre-call snapshot (hadListeners) and instead either (A) read the listener state
after emit by calling
process->wrapped().hasEventListeners(Identifier::fromString(vm, "beforeExit"_s))
and use that result to decide whether to drain nextTickQueue, or (B) change
emitProcessExitEvent to return a bool indicating whether the emit had listeners
and use that return value to gate nextTickQueue->drain; update the code around
emitProcessExitEvent, hadListeners, and nextTickQueue->drain accordingly
(referencing hadListeners, emitProcessExitEvent,
process->wrapped().hasEventListeners, and nextTickQueue->drain).
🪄 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: 90c4acc7-f07f-43af-beb0-b6230ed02e0a
📒 Files selected for processing (2)
src/jsc/bindings/BunProcess.cpptest/js/node/process/process.test.js
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
I checked the three suggested issues against their repros on a current build before adding any
Keeping |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 `@src/jsc/bindings/BunProcess.cpp`:
- Around line 880-887: The call to JSNextTickQueue::drain(vm, globalObject) can
enter JS and leave a pending exception, so after the call in BunProcess.cpp add
the standard JSC binding exception check: wrap the call site with a ThrowScope
(or ensure a ThrowScope is active) and invoke RETURN_IF_EXCEPTION(vm, ... ) /
RETURN_IF_EXCEPTION(vm) as per project convention immediately after
nextTickQueue->drain(vm, globalObject); so any pending exception is detected and
returned (matching the existing handling used for
emitProcessExitEvent/Process__dispatchOnBeforeExit).
- Around line 316-318: The wrapper currently uses the patched emit's return
value directly (JSValue result in BunProcess.cpp) which lets a monkey-patched
process.emit returning undefined/false suppress draining; change the return
logic so only an actual boolean false from the emit prevents draining: after
calling emitOverride in the function that dispatches beforeExit, check
result.isBoolean() and if true return result.toBoolean(globalObject), otherwise
return true (so undefined/non-boolean values do not block
m_nextTickQueue->drain()); update the code around the JSValue result /
RETURN_IF_EXCEPTION call to implement this boolean-only interpretation.
🪄 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: 70ea78d8-4fa1-4dea-887e-ba4fa3565f4b
📒 Files selected for processing (2)
src/jsc/bindings/BunProcess.cpptest/js/node/process/process.test.js
There was a problem hiding this comment.
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)
300-321:⚠️ Potential issue | 🟠 MajorFix accessor
process.emitoverrides: avoidgetDirect
emitProcessExitEventreadsprocess.emitviaprocess->getDirect(..., "emit"_s), which bypasses JS accessor/getter invocation; ifprocess.emitis an accessor own-property, this can treat the stored GetterSetter as “not a function” instead of calling the getter result. Keep the own-property detection, but fetch the override through normal JS property access semantics on the override branch, thenJSC::call.🤖 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 300 - 321, The code uses process->getDirect to read process.emit which bypasses JS accessor getters; instead, keep the own-property detection but fetch the override with normal property access so getters run: check ownership (e.g. process->hasOwnProperty or getOwnProperty) for "emit" to decide whether to use the override branch, and when you need the override value use process->get(globalObject, Identifier::fromString(vm, "emit"_s)) (not getDirect) to obtain emitOverride, then use JSC::getCallData/call as before (preserve process->wrapped().emit fallback logic and the call error handling).
🤖 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 300-321: The code uses process->getDirect to read process.emit
which bypasses JS accessor getters; instead, keep the own-property detection but
fetch the override with normal property access so getters run: check ownership
(e.g. process->hasOwnProperty or getOwnProperty) for "emit" to decide whether to
use the override branch, and when you need the override value use
process->get(globalObject, Identifier::fromString(vm, "emit"_s)) (not getDirect)
to obtain emitOverride, then use JSC::getCallData/call as before (preserve
process->wrapped().emit fallback logic and the call error handling).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: b6b062c8-add6-42f7-bf96-42f9651e6b59
📒 Files selected for processing (1)
src/jsc/bindings/BunProcess.cpp
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 `@src/jsc/bindings/BunProcess.cpp`:
- Around line 300-327: The current logic only calls process->getDirect(...) and
ignores prototype-level overrides; change the resolution so you perform a full
property lookup (process->get(...)) whenever getDirect() returns no own property
(i.e., emitOverride is empty), or when getDirect() yields an accessor
(isGetterSetter/isCustomGetterSetter) — but keep the current behavior that if
getDirect() returns a non-callable own property you fall back to the internal
emitter (wrapped().emit). Concretely: call process->getDirect(...) into
emitOverride; if emitOverride is empty OR
emitOverride.isGetterSetter()/isCustomGetterSetter() then set emitOverride =
process->get(globalObject, Identifier::fromString(vm, "emit"_s)) and check
RETURN_IF_EXCEPTION; then obtain callData from emitOverride and if callable
invoke JSC::call(...) as currently done; only when the original getDirect()
returned a non-callable own property should you skip the prototype lookup and
fall through to wrapped().emit(Identifier::fromString(vm, eventName),
arguments). Ensure you reference emitOverride, process->getDirect, process->get,
callData, and wrapped().emit when applying the change.
In `@test/js/node/process/process.test.js`:
- Around line 658-685: Update the test "invokes a process.emit override defined
as an accessor property" to exercise the accessor override for both lifecycle
events: beforeExit and exit. Specifically, in the inline script that defines
Object.defineProperty(process, "emit", ...) (the accessor-based override of
process.emit), make the getter-returned function detect both "beforeExit" and
"exit" and log distinct markers (e.g., "accessor:beforeExit" and
"accessor:exit"), then update the test assertions that inspect stdout/exitCode
to expect both markers; this ensures the accessor [[Get]] path is covered for
process.emit when invoked for beforeExit as well as exit.
🪄 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: eac4eec1-8675-4ec5-aa22-6c81ea54aa65
📒 Files selected for processing (2)
src/jsc/bindings/BunProcess.cpptest/js/node/process/process.test.js
There was a problem hiding this comment.
All my earlier feedback has been addressed and the bug hunter found nothing new on f4efbd2, but this reworks process shutdown dispatch in the JSC bindings and went through several design iterations (getDirect vs full [[Get]], drain-timing, non-callable fallback) — worth a human sign-off on the final trade-offs.
Extended reasoning...
Overview
This PR routes the beforeExit and exit lifecycle events through a user-replaceable process.emit so monkey-patches (signal-exit etc.) observe them, matching Node. It adds a ~50-line emitProcessExitEvent helper in src/jsc/bindings/BunProcess.cpp, rewires dispatchExitInternal and Process__dispatchOnBeforeExit to use it under a top exception scope, removes the exit early-return on zero listeners, and adds six subprocess tests in test/js/node/process/process.test.js.
Security risks
None identified. The change does not touch auth, crypto, permissions, or untrusted input parsing. It changes which JS function is invoked at shutdown, but that function is already user-controllable (it's process.emit); Node already behaves this way.
Level of scrutiny
High. This is core process-lifecycle dispatch in C++ JSC bindings — every Bun process runs this code on shutdown. The PR went through six substantive revisions during review, each fixing a real regression caught by CI or reviewers: an ASAN unchecked-exception abort after drain(), a worker_threads.test.ts regression from throwing on non-callable emit, and a test-event-capture-rejections.js drain-timing regression from using a full [[Get]]. The final design encodes deliberate, non-obvious trade-offs: getDirect (own-property only) instead of full [[Get]] so the no-override path keeps the native "a listener fired" signal that gates the post-beforeExit drain; accessor overrides resolved with a targeted full [[Get]]; non-callable overrides falling through to the internal emitter rather than throwing; prototype-level emit replacements intentionally not honored. These are well-reasoned and documented in code comments and the PR description, but they are semantic judgment calls a maintainer should ratify.
Other factors
All seven of my prior inline comments across three review rounds are resolved, and the bug-hunting system found nothing on the current head. The two CI failures on f4efbd2 (streams-leak, fetch-abort-slow-connect) are known flakes unrelated to this change, per the a348996 retrigger commit. Test coverage is solid (six new subprocess tests covering the override matrix plus existing worker_threads.test.ts for the non-callable fallback). I'm not approving because the change is neither simple nor mechanical and the iteration history shows the semantics here are easy to get subtly wrong.
Node dispatches the beforeExit and exit lifecycle events by calling process.emit(event, code), so a user-replaced process.emit is invoked. Bun emitted them through the internal EventEmitter (process->wrapped().emit(...)), bypassing any user override, so libraries that monkey-patch process.emit (e.g. signal-exit) never observed these events. Add an emitProcessExitEvent helper that detects an own-property process.emit override with getDirect and, when callable, invokes it (resolving an accessor via a full [[Get]]); otherwise it emits on the internal EventEmitter, preserving the native "a listener fired" signal that gates the post-beforeExit nextTick drain. A non-callable process.emit falls back to the internal emitter instead of throwing at shutdown. The exit early-return on "no listeners" is removed and _exiting is set unconditionally, both matching Node. Fixes #32227 Fixes #29194
a348996 to
e2ae14f
Compare
|
Rebased onto current |
|
CI status on |
|
Stale PR review: keep open, rework. The fix is still wanted. Node v26.3.0 calls a replaced The current diff is not the shape to land:
|
Node emits both events with process.emit(event, code), so an emit that a program replaced is what runs. The runtime emitted them on the native emitter, which a replaced emit never saw (signal-exit's onExit did not run at a natural exit). The two dispatch sites now read emit from process for each event. While it is the built-in emit, they emit on the native emitter as before, with no call into JS. A replaced emit is called with (event, code). At a natural exit a non-callable emit is not called and a throw is reported as an uncaught exception. Under process.exit() a non-callable emit throws a TypeError and a throw goes to the caller.
…emit The split of the exit latch ran 'exit' listeners twice when an 'exit' listener threw after process.exit() in programs that never replaced emit. The latch is the one main has again. emitThroughReplacedEmit takes the event and its arguments. A throw that a handler takes at 'beforeExit' is followed by the checkpoint. The 'worker' helper is as it was.
…-emit-lifecycle-events
|
Status: reworked on current How to reproduce #12918. Copy import { onExit } from "signal-exit";
process.on("SIGINT", signal => console.log("Native signal handler got " + signal));
onExit(exitCode => console.log("onExit called with exit code " + exitCode));
setTimeout(() => console.log("bye!"), 600);
setTimeout(() => process.kill(process.pid, "SIGINT"), 300);
What changed against the earlier version of this PR. The lookup is the full |
There was a problem hiding this comment.
Beyond the inline findings, two more candidates were checked and ruled out: the top exception scope that emitThroughReplacedEmit opens beneath Process_functionExit's throw scope leaves a throwing or non-callable emit pending for the caller's RETURN_IF_EXCEPTION rather than tripping the exception-check validator, and a non-callable process.emit skipping the native 'exit' listeners at a natural exit matches node, whose MakeCallback also emits nothing in that case.
Extended reasoning...
The change routes 'beforeExit' and 'exit' through a user-replaced process.emit in src/jsc/bindings/BunProcess.cpp, adds JSEventEmitter::isBuiltInEmit, and adds ~30 subprocess cases; it touches no security-sensitive surface. Seven inline findings were posted, so a human look is already signalled; this note only records the two further candidates examined and ruled out this run.
Findings marked 🟡 are optional suggestions and need no follow-up push.
…and pin the exit codes The two worker cases started a bun child that started a worker. They needed 30 s in a debug build. The worker now runs in the test process and records each event in a SharedArrayBuffer, so the cases take under 1 s and no timeout. The two cases that pinned no exit code now pin 1, with a comment that says what node does.
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 finding, I also checked the two refactor hazards in src/jsc/bindings/BunProcess.cpp: Process_functionExit calling dispatchExitInternal directly only skips the hasProcessObject guard, which is already true there (process is dereferenced on the line above), and the m_isExitCodeObservable latch now runs before the m_isExiting early return exactly as it did in Process__dispatchOnExit. The tryClearException in emitThroughReplacedEmit is under its own DECLARE_TOP_EXCEPTION_SCOPE and hands the exception to Bun__reportUnhandledError only on the MakeCallback shape, so nothing is swallowed and the ProcessExit shape still propagates to the caller.
Extended reasoning...
The change routes 'beforeExit' and 'exit' through a user-replaced process.emit in native process-lifecycle code (BunProcess.cpp, JSEventEmitter) and adds ~30 spawned test cases; it touches no auth, crypto, or input-parsing surface. A human look is still warranted because the exit-path semantics (latch ordering, exception reporting, microtask drain) are subtle and one test-wiring finding remains inline.
There was a problem hiding this comment.
I re-reviewed this push and found no new bugs; the two test-only commits since my last review close the nits I had open (pinned exit codes, no per-test timeout, Worker error wired to reject). A maintainer should still weigh the documented tradeoffs before merging, since the C++ path is unchanged and the PR deliberately accepts some divergences from node.
What was reviewed:
replacedProcessEmitfast path: own-propertygetDirectfalls through to a full[[Get]]for accessors, and the prototype check is gated onoverridesGetOwnPropertySlot, so Proxy prototypes take the slow path.- Exception flow in
emitThroughReplacedEmit: MakeCallback shape clears and reports, ProcessExit shape leaves the throw pending forProcess_functionExit'sRETURN_IF_EXCEPTION; thebeforeExitsite drains microtasks thenRELEASE_AND_RETURNs. - The moved
m_isExitCodeObservablelatch: now set before them_isExitingearly return, which is the same orderingProcess__dispatchOnExithad for the natural-exit path. - Test block:
-espawns drain stdout/stderr/exited together and assert a combined object; the in-process worker cases now reject onerror.
Extended reasoning...
The change routes 'beforeExit' and 'exit' through a user-replaced process.emit in src/jsc/bindings/BunProcess.cpp, adds JSEventEmitter::isBuiltInEmit, and rewires process.exit() to call dispatchExitInternal directly; it touches no auth, crypto, or injection surface. It is a behavior change to process lifecycle with several documented divergences from node (forwarders through EventEmitter.prototype.emit lose listeners, exit code 1 on a throw at 'exit', no microtask drain after 'exit'), which the author has chosen to accept rather than fix here. The C++ has not changed since my prior inline findings, which the author self-resolved with replies and by documenting the tradeoffs in the description, so a human maintainer should make that product call; the test-only follow-ups fully addressed my remaining nits.
|
I checked this branch against
Merge order. After an unhandled rejection, a signal-exit handler that calls The description named #42031, which is closed in favor of #45597. It now names #45597. With #45597 merged on top, |
|
Merged
|
Fixes #12918, fixes #32227, fixes #29194
Problem
process.emitnever gets'beforeExit'or'exit'.signal-exitreplaces it, soonExit()does not run at a natural exit.dispatchExitInternalandProcess__dispatchOnBeforeExit(src/jsc/bindings/BunProcess.cpp) emit on the native emitter. Node callsprocess.emit(event, code).Fix
process.emitfor each event. The built-in emit still runs natively.emitgets(event, code). The microtask checkpoint runs after it at'beforeExit'.emit, and the main thread reports a throw as uncaught.process.exit()throwsTypeError: process.emit is not a function, or passes the throw to its caller.test/js/node/process/process.test.js, pinned to node v26.3.0 (25 fail on main). Alsoworker_threads.test.tsand 235 nodeparalleltests. Self-reviewed: 11 concerns raised, 11 addressed, two proposals not adopted (Notes).Background
processis a nativeEventEmitter. The runtime called the C++ emitter belowprocess.emit.emitthrough JS (+1 VM entry, +2 allocations per exit for every program), and an own-property check (misses a prototypeemit).Downsides
emit: +421 instructions per'beforeExit'round, +460 at'exit', no allocation..textgrows 1,280 bytes.emitnow runs at exit and reaches two open defects.onExit(code => process.exit(code))after an unhandled rejection exits 0, where main exits 1 (process: 'exit' listeners see exit code 1 after an unhandled rejection #45575). Jobs left by an'unhandledRejection'listener run at'beforeExit'(Run the ticks and microtasks that 'unhandledRejection' listeners queue #45597).emitthat forwards torequire("events").prototype.emitno longer reaches the listeners.'warning', the uncaught-exception events, signals and IPC still bypassemit(Notes).Notes
Result against node v26.3.0
signal-exitpackage misbehaves with Bun (node compatibility) #12918 (onExit1.mjs,onExit2.mjs,writeFileAtomically1.mjs,writeFileAtomically2.mjs) with signal-exit 4.1.0 and write-file-atomic 7.0.1 print the same lines on node and on this branch. Main does not printonExit called with exit code 0for the first two.{ alwaysLast: true },onExitafter an uncaught error (onExit 1 null), a removed handler, and the repros of signal-exit onExit callback not triggered in Bun during process shutdown #29194 and process lifecycle events (beforeExit/exit) are not emitted via process.emit as in Node.js #32227.lockfile1.0.4 uses signal-exit to remove its lock file. AfterlockSync()and a natural exit, node and this branch remove the file. Main leaves it on disk.process.emit, so there is no upstream test to port. The 29 new scripts arebun -echildren. 26 of them print the same lines and exit with the same code on node. The other 3 say in a comment what node does.How the lookup works
replacedProcessEmitfirst looks, without a[[Get]], for the common state:processhas no ownemitand its prototype holds the built-in function.JSEventEmitter::isBuiltInEmitcompares the host function pointer.process.emitread that node does. Anemiton the prototype, on an inserted prototype, or behind an accessor is found. An accessor is read once for each event.Answers to the review comment of 2026-09-23
emit: a natural exit emits nothing and throws nothing.process.exit()throws the TypeError.'beforeExit': it is not gated on the value thatemitreturns. Node runs the ticks and microtasks after the call whatever it returned, and the case "the ticks and microtasks that emit queues run before 'exit', whatever emit returns" pins that. Theerror: kaboomoftest/js/node/test/parallel/test-event-capture-rejections.jswith a replacedemitpreloaded has another cause. On main that test runs 3 of its 13 stages and exits 0, because a tick that an'unhandledRejection'listener queues last never runs. A preload of onlyprocess.on("beforeExit", () => {})gives the sameerror: kaboomon main. Run the ticks and microtasks that 'unhandledRejection' listeners queue #45597 corrects both defects behind it (Run a microtask checkpoint after each batch of unhandledRejection listeners, and follow Node's captureRejections #42031 is closed in its favor). With no preload the test passes here as on main.One exit latch
process.exit()its own latch, so that the exit after aprocess.exit()that threw emits'exit'again, as node does. That ran the'exit'listeners twice when an'exit'listener threw afterprocess.exit(), in programs that never replaceemit. The latch is the one main has again (m_isExiting), and two cases guard it.process.exit()whoseemitthrew (or was not callable), the exit that follows emits no'exit'.Measurements
Release
bun-profile, Linux x64, mainbd599f5afagainst this PR ate38058be8. Instructions of the main thread, counted with gdbstepifrom the entry ofProcess__dispatchOnBeforeExitto its return, and ofVirtualMachine::on_exitto its return (LTO inlinesProcess__dispatchOnExitthere). Counts repeat exactly unless the row says otherwise.'beforeExit'main'exit'main1 + 1(noprocessobject)process.exitCode = 0emitput back as an own propertyonExit(), then removedprocess.exitCode = 0; Bun.gc(true)processrebuilds its property table (about 10,000 instructions, one allocation). Main does that at'exit', where it writes_exiting. This PR does it at'beforeExit'. The sum of both events grows by about 950.mi_mallocandmi_free1 and 1 on both builds, no entry into JS, no call of the built-inemithost function, no checkpoint.emit(the pass-through of process lifecycle events (beforeExit/exit) are not emitted via process.emit as in Node.js #32227): 1 entry into JS and 1JSStringfor each event, and 1 checkpoint for each'beforeExit'. The first'exit'costs 7,746 instructions. The first'beforeExit'compiles the function and is not repeatable..text: 65,453,397 to 65,454,677 bytes (+1,280). The strippedbunis 88,938,056 bytes on both builds.Process::finishCreationis identical (2,413 bytes). No new builtin name.sizeof(Bun::Process)is 136 on both.BUN_JSC_validateExceptionChecks=1: no report for a throwingemit, a throwing getter and a non-callableemit, each at a natural exit and inprocess.exit().Differences from node that stay
'exit'sets the exit code to 1. Node keeps the code that was set. Two cases pin the 1 and say so. Report a loop turn's rejections before the loop-alive check, and keep the exit code when an 'exit' listener throws #42032 covers it.'exit'queues does not run. Node runs it after the'exit'emit (and drops the ticks, becausenextTickis a no-op while_exitingis set). Main does not run it for a native listener either, and a replacedemitmatches the native path here.'exit'gets the code 0 and the process exits with 1 (process: 'exit' listeners see exit code 1 after an unhandled rejection #45575, bun run: decide the exit code of a printed error before 'exit' listeners run #38116). This is also what a plain'exit'listener gets on main.emit(or from a listener) at the worker's'exit'does not reach the parent as an'error'event (Report a loop turn's rejections before the loop-alive check, and keep the exit code when an 'exit' listener throws #42032).emitwhose getter throws: the process exits with 1. Node exits with 7, because its fatal-exception path readsemitagain.processkeeps its listeners in the native emitter. A patch onrequire("events").prototype.emitis not seen, and a replacedemitthat forwards to that function reaches no listener. On main such a program still got its'beforeExit'and'exit'listeners, because the runtime did not callemit.'exit'listeners (trace events, worker stdio,process.finalization) are listeners like the others. A replacedemitthat does not forward'exit'skips them.Events that still bypass a replaced
emit'warning'(jsFunction_emitWarning). The pattern that hides one warning with a replacedemitstill prints the warning. The two helpers of this PR fit that site.'uncaughtExceptionMonitor','uncaughtException','unhandledRejection','rejectionHandled'.emitwhen the first listener of a signal is added.'message','internalMessage'and'disconnect'(fix(process): dispatch child IPC through JavaScript emit #44546),'memoryPressure','error','newListener'and'workerMessage'.Suites, debug build, this branch merged into main
5c6d4e24cd(2026-10-23)src/of main, 25 of them fail. The 4 that pass on main guard existing behavior. The two worker cases run the worker in the test process and read its events from aSharedArrayBuffer, under 1 s each in a debug build.test/js/node/process/process.test.js: 225 pass. 1 fails on a host that has node v26.3.0, because main wants v26.12.1 there (should be the node version on the host that we expect).test/js/node/worker_threads/worker_threads.test.ts: 161 pass.test/js/node/test/parallel: 235 files (test-process-*,test-worker-*,test-next-tick*,test-promise-unhandled*, and the exit, signal and uncaught-exception tests). All pass. An earlier run on this branch reported 215 of 220.The two defects of Downsides, with the open PRs on top (2026-10-23)
onExit(code => process.exit(code))after an unhandled rejection: the handler gets 0 and the process exits with 0. On main the handler does not run and the process exits with 1. With therun_command.rschange of process: 'exit' listeners see exit code 1 after an unhandled rejection #45575 applied on top, the handler gets 1 and the process exits with 1, as on node.test-event-capture-rejections.jswith a pass-throughemitpreloaded:error: kaboom. With Run the ticks and microtasks that 'unhandledRejection' listeners queue #45597 merged on top, it exits 0. It also exits 0 there with only a'beforeExit'listener preloaded.Self-review
'beforeExit'that a handler took and that skipped the checkpoint (fixed), the shape of the helper (one helper for any event, and the'worker'helper is untouched), missing cases (added), and downsides that the description did not name (added).An earlier version of this change was #29196.
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