Skip to content

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

Merged
Jarred-Sumner merged 1 commit into
mainfrom
robobun/c0daf32a/process-exit-reallyexit-not-callable
Sep 27, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
robobun/c0daf32a/process-exit-reallyexit-not-callable

Conversation

@robobun

@robobun robobun commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

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);
stderr exit code
Bun 1.4.3-canary.1+367d939d9 (release) TypeError: 1
Bun 1.4.3-debug+367d939d9 ASSERTION FAILED: !message.isEmpty() 134
this branch (debug) TypeError: process.reallyExit is not a function 1
Node.js v26.3.0 TypeError: process.reallyExit is not a function 0

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

…en it is not callable

process.exit() looks up process.reallyExit and calls it. The call passed
an empty string as the message for a value that is not callable, so the
TypeError had no message, and a build with assertions aborted on
`!message.isEmpty()` in JSC::createTypeError. The call also passed the
function itself as the this value.

The call now passes process as the this value and throws
`TypeError: process.reallyExit is not a function`, as Node.js does.
@robobun

robobun commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

Status: the fix and the tests are pushed.

How to reproduce:

// repro.js
process.reallyExit = "str";
process.exit(0);
  • bun repro.js on 1.4.3-canary.1+367d939d9 (release, Linux x64) prints TypeError: with an empty message.
  • A debug build at the same revision aborts with ASSERTION FAILED: !message.isEmpty().
  • node repro.js (v26.3.0) prints TypeError: process.reallyExit is not a function.

On this branch, bun bd test test/js/node/process/process.test.js -t "process.exit\(\)" passes. The two new tests fail on the release build above.

@coderabbitai

coderabbitai Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

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 configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: ab7e8afe-2269-4baf-9b9a-a3d617d17b3e

📥 Commits

Reviewing files that changed from the base of the PR and between 36cd151 and 7850782.

📒 Files selected for processing (2)
  • src/jsc/bindings/BunProcess.cpp
  • test/js/node/process/process.test.js

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.


Walkthrough

Process_functionExit now calls a callable process.reallyExit with process as its receiver. Tests check the error for non-callable replacements and confirm receiver binding and exit-code delegation.

Changes

Process exit behavior

Layer / File(s) Summary
Validate and call process.reallyExit
src/jsc/bindings/BunProcess.cpp, test/js/node/process/process.test.js
Process_functionExit reports process.reallyExit is not a function for non-callable values and uses process as the receiver for callable values. Tests cover both behaviors and check delegation with exit code 3.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 78507

No merge-blocking issue is established for the receiver-binding and error-message change. Merge after normal checks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies both main changes: the TypeError message and the process receiver passed to process.reallyExit.
Description check ✅ Passed The description explains the problem, fix, verification steps, test results, known limitations, and platform coverage. It does not use the exact template headings, but it includes the required informa…

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

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/jsc/bindings/BunProcess.cpp
@Jarred-Sumner
Jarred-Sumner merged commit 1313ca6 into main Sep 27, 2026
6 of 8 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/c0daf32a/process-exit-reallyexit-not-callable branch September 27, 2026 00:46
Seantheprogrammer93 pushed a commit to Seantheprogrammer93/bun that referenced this pull request Sep 27, 2026
…h#42116)

### Problem
- `new Response(p.body, p)` adopted a JS-backed stream but pulled the
Blob out of a blob-backed one (`Value::from_js`): `p` came out locked
and used, and `r.body !== p.body`.
- After `text()`/`json()`/`blob()`/... the body's stream was unlocked
and `getReader()` worked. undici and Chromium keep it locked.
- Zero-length string/`Uint8Array` bodies were never used up: `blob()`
left `bodyUsed` false, and `.body` handed out a stream the body did not
track.

### Fix
- `Value::from_js` adopts every stream. `to_blob_if_possible` still
lifts a blob/file-backed stream back into a blob when the body is
consumed, served, or uploaded, so the Content-Length framing stays.
- New `m_consumedAsBody` bit on `JSReadableStream`, part of
`nativeHandleDetached()` and so of `isReadableStreamLocked()`.
`set_promise` sets it once the consumer has started, `to_any_blob` after
taking a native source's bytes. `Bun.readableStreamToText()` is
unchanged.
- `.body` on `Empty` stores its stream like the other arms, `use_()`
marks `Empty` used, `to_any_blob` turns a closed never-read stream into
an empty blob, the getters reject a locked stream up front, and
`Response.redirect()`/`error()` get a null body.
- Verified: `test/js/web/fetch/body.test.ts` (145 new cases, 128 fail on
1.4.3, all checked against node v26.3.0), plus the suites in Notes.
Self-reviewed: 4 concerns, 3 addressed, the oven-sh#33461 overlap is noted
below.

### Background
- A body is a `Body::Value`: string, `Blob`, bytes, `Empty`, `Null`, or
`Locked` (a stream). Reading `.body` makes a non-null body `Locked`.
- Blob- and file-backed streams keep a native source. Until something
reads them, `to_any_blob` can take the payload back without running the
stream. The fetch spec reads a body through a reader it never releases,
so a consumed body's stream stays disturbed and locked.

<details><summary>Notes</summary>

- Ledger members: oven-sh#44053 (eager transfer), oven-sh#44054 and oven-sh#44171 (unlocked
after consume, JS-stream close and error paths), oven-sh#44055 (zero-length
bodies). Not in this PR: oven-sh#44057 is covered by oven-sh#33499, and the
"`getReader()` alone marks a native body used" cascade (oven-sh#921) by oven-sh#33461.
oven-sh#44059, oven-sh#44060 and oven-sh#44061 are separate mechanisms.
- Overlap with oven-sh#33461: both touch the body getter prologues,
`ReadableStream::to_any_blob`'s guard and `Value::from_js`. If this
lands first, oven-sh#33461 keeps its `m_nativeSourceMaterialized` gating and
drops its getter and `from_js` hunks on rebase. `to_any_blob` then wants
`is_native_source_consumed || is_locked` as its guard.
- `ReadableStream__detach`/`force_detach` had no other caller and is
removed. `m_consumedAsBody` takes over both halves of what the `-1`
handle sentinel did there: the stream reads as locked, and its native
handle is neither started by `getReader()` nor handed to
`Readable.fromWeb()`'s fast path. Unlike the sentinel it leaves
`m_nativePtr` alone, so the handle stays rooted while an async consumer
runs.
- `Readable.fromWeb()` now throws `ERR_INVALID_STATE` for any locked
stream before it does anything else, as Node does (Node acquires the
reader at that point). Before, a locked native-backed stream had its
handle taken anyway.
- `ReadableStream__isClosedUnread`:
`ReadableStream{Default,Byte}ControllerClose` only moves a stream to
`Closed` once its queue is empty, so `Closed && !disturbed && !locked`
means the stream can never yield a byte. This keeps a touched empty body
(`new Response(""); r.body`) framing and typing exactly like an
untouched one, and `new Response(new Blob([]))` takes the same path.
- A locked (not disturbed) body stream now rejects from the getter with
`TypeError: Invalid state: ReadableStream is locked`, the same error the
C++ helper produced before, and no longer records a pending read first.
For JS-stream bodies `getReader(); releaseLock(); await r.text()` works
and `bodyUsed` stays false while only locked, as in undici.
Native-backed bodies still mark themselves disturbed on `getReader()`;
that is oven-sh#33461's subject.
- `fetch()` upload framing: a blob/bytes-backed or closed-empty stream
body goes out with a Content-Length (as 1.4.3 did for the blob case
through the eager transfer, and as undici does for bodies whose source
it knows). A file-backed stream keeps streaming chunked, as today,
because its length may not be knowable (FIFO, device). A JS stream
streams chunked.
- `Response.redirect()`, `Response.error()` and the S3 `new
Response(s3file)` redirect used `Value::Empty`. The spec body is null.
With `Empty` now tracked like any other body they would have become
visibly one-shot, so they are `Value::Null` here (the same three-line
change sits in oven-sh#33125).
- `Bun.readableStreamToText()` and the other helpers on a stream a Body
already consumed reject with `ERR_INVALID_STATE` "ReadableStream has
already been used" (a stream held by someone else's reader still says
"is locked").
`test/js/web/streams/readable-stream-blob-consumed.test.ts` asserted
`ERR_BODY_ALREADY_USED` from the old blob-loader path and is updated;
its point (no crash, a rejected promise) is unchanged.
- Rebased onto oven-sh#42053: its `take_blob_from_unread_stream` used
`force_detach`; `to_any_blob` now marks the stream consumed itself.
- Suites run on the debug build: `body.test.ts`, `body-stream.test.ts`
(9086), `body-clone`, `body-mixin-errors`, `body-async-iterator`,
`body-stream-excess`, `serve.test.ts`, `bun-server`, `bun-serve-static`,
`bun-serve-file`, `bun-serve-body-json-async`, `serve-if-none-match`,
`proxy.test.ts`, `cookie.test.ts`, `html-rewriter.test.js`,
`bun-write.test.js`, `spawn-stdin-readable-stream`, `streams.test.js`,
`readable-stream-blob-consumed`, `native-source-onclose-leak`,
`sync-pull-fast-path`, `request.test.ts`, `response.test.ts`,
`client-fetch`, `content-length`, `fetch.stream`, `fetch.test.ts`,
`fetch-abort-stream-body`, `fetch-keepalive`, `fetch-backpressure`,
`node-stream.test.js`, `direct-readable-stream`, the node
`test-readable-from-web-*` files, regression 07001 and 09555. The
failures left in
`fetch.test.ts`/`serve.test.ts`/`bun-server`/`fetch-backpressure` are
environment-only here (IPv6, running as root, no internet or S3 egress,
ASAN timeouts, and the ASAN RSS bound in "bounds memory when a handler
forwards req.body") and reproduce with `origin/main`'s `src/`.

</details>
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.

2 participants