process.exit(): throw `process.reallyExit is not a function`, pass process as this

Merged
RO
1mo ago
+51-1

Problem

  • process.exit() calls process.reallyExit(code), as Node.js does. When process.reallyExit is not callable, Bun throws a TypeError with an empty message. Node.js throws TypeError: process.reallyExit is not a function.
  • A build with assertions aborts: ASSERTION FAILED: !message.isEmpty() at vendor/WebKit/Source/JavaScriptCore/runtime/Error.cpp(71) in JSC::createTypeError.
  • The cause is JSC::call(globalObject, reallyExitVal, args, ""_s) at src/jsc/bindings/BunProcess.cpp:913. The last argument is the message for a value that is not callable. This overload also passes the function as its own this. Node.js passes process.

Fix

  • The call passes process as this and the message process.reallyExit is not a function.
  • Correct because Node.js runs process.reallyExit(process.exitCode || 0) in JavaScript. That is a method call on process, and V8 gives that message.
  • Verified: two new tests in test/js/node/process/process.test.js. Both fail on release 1.4.3-canary.1. Also ran the other process.exit() tests and 12 exit tests from test/js/node/test/parallel/.

Background

  • process.reallyExit is the raw exit of Node.js: it emits no 'exit' event. Libraries such as signal-exit replace it.
  • JSC::call is the JavaScriptCore helper that calls a JS function from C++.
  • No other design applies: the two wrong values are arguments of one call. No other JSC::call in src/ has an empty message.

Downsides

  • A function that replaces process.reallyExit now sees this === process when process.exit() calls it. Before, this was the function itself.
  • Size: +53 bytes in the BunProcess.cpp object (-O3, no LTO): 37 for the message, 16 of code. Per call: no new allocation, branch, or syscall.
Notes

Repro:

process.reallyExit = "str";
process.exit(0);
stderrexit code
Bun 1.4.3-canary.1+367d939d9 (release)TypeError: 1
Bun 1.4.3-debug+367d939d9ASSERTION FAILED: !message.isEmpty()134
this branch (debug)TypeError: process.reallyExit is not a function1
Node.js v26.3.0TypeError: process.reallyExit is not a function0

The same result for each value tried: "str", undefined, null, 1, {}, Symbol("x"), and delete process.reallyExit.

The this value, with process.reallyExit = function () { console.log(this === process) } and process.exit(3): false before, true on this branch and on Node.js v26.3.0.

Not changed by this PR: when nothing catches the TypeError, Node.js exits with the code given to process.exit() (0 above, 5 for process.exit(5)). Bun exits 1. A replaced process.reallyExit that throws shows the same difference. For an entry point that throws, uncaught_exception (src/jsc/VirtualMachine.rs:2161) and exit_with_unhandled_note (src/runtime/cli/run_command.rs:1638) both set exit code 1. Related to #42032, which changes the first place for an 'exit' listener that throws. The new test catches the error in the child, so it does not depend on that exit code.

History: #16026 added the call with the comment // process.reallyExit(exitCode);.

Search for other call sites: JSC::call, call, profiledCall and construct with an empty message literal in src/ and packages/. BunProcess.cpp:913 is the only one.

Size measurement: compiled BunProcess.cpp from origin/main and from this branch with the release flags of the build (-O3 -march=nehalem), without -flto=thin so that the object is native code. size -A: .text of Bun::Process_functionExit 712 to 728 bytes, .rodata.str1.1 10594 to 10631 bytes, .data and .bss unchanged.

On a debug build without the fix, the first new test fails: the child exits with code 134 and the assertion is in its stderr.

Tests run on the debug build of this branch:

  • test/js/node/process/process.test.js -t "process.exit\(\)": 6 pass.
  • test/js/node/process/process.test.js, whole file: 179 pass, 5 skip, 3 fail. process fails because the container sets no USER. signal > simple case works and signal > process.emit will call signal events time out at 5 s in the whole-file run, with and without the fix. Each passes in 2.5 s when it runs alone.
  • test/js/node/test/parallel/: test-process-exit-code-validation.js, test-process-exit-code.js, test-process-exit-from-before-exit.js, test-process-exit-handler.js, test-process-exit-recursive.js, test-process-exit.js, test-process-really-exit.js, test-worker-exit-code.js, test-worker-nested-on-process-exit.js, test-worker-on-process-exit.js, test-worker-process-exit-async-module.js, test-crypto-op-during-process-exit.js: all exit 0.

Not run: macOS, Windows. The changed line has no platform-specific code.


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