Skip to content

Print the --trace-exit and --trace-env-js-stack stacks from native code, without Error.prepareStackTrace - #45933

Open
robobun wants to merge 1 commit into
mainfrom
robobun/d64732d0/trace-flags-native-stack
Open

robobun wants to merge 1 commit into
mainfrom
robobun/d64732d0/trace-flags-native-stack

Conversation

@robobun

@robobun robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • --trace-exit and --trace-env-js-stack read new Error().stack (src/js/internal/process/pre_execution.ts:131, :248). If Error.prepareStackTrace returns a non-string, process.exit() and each process.env operation throw TypeError: new Error().stack.split is not a function.
  • bun --trace-exit --stack-trace-limit=0 -e "process.exit(0)" exits 1 with TypeError: undefined is not an object.
  • Bun prints the exit warning before the exit listeners run, so its code can be wrong. No user reported this.

Fix

  • Bun::formatCurrentStackTrace() formats the current frames as the default error.stack does, with no Error and no JS. --trace-env-js-stack and the node:async_hooks stub warning use it.
  • process.reallyExit() prints the --trace-exit warning, as Node's Environment::Exit() does. The JS wrapper is gone.
  • Verified: test/js/node/process/process-trace-flags.test.ts, async_hooks.node.test.ts (29 cases fail on main), and the four vendored test-trace-*.js files, unedited.
  • Self-reviewed: 9 concerns raised, 8 addressed. Rejected: a test that needs a timer race (see Notes).

Background

  • Error.prepareStackTrace is the V8 hook that formats error.stack. It can return any value.
  • process.exit() runs the exit listeners, then calls the native process.reallyExit(), which ends the process.
  • A guard or formatter swap in JS still runs user code. A native env printer only ports JS: the process.env Proxy stays.

Downsides

  • Flag output changes: the env trace has 10 frames (was 11), and the top exit frame is at exit (unknown).
  • process.exit(-1) prints code 255 (Node: -1): Bun keeps 8 bits of the exit code.
  • Without the flags: +4 instructions in process.reallyExit(), +2,799 B of text, sizeof(Bun::Process) 136 to 144.
Notes

Repro (main, with node v26.3.0 for reference)

bun --trace-env-js-stack -e "Error.prepareStackTrace = (e, cs) => cs; try { const v = process.env.HOME; console.log('RESULT read ok', typeof v); } catch (e) { console.log('RESULT THROWS', e.name + ': ' + e.message); }" 2>/dev/null | grep RESULT
# main: RESULT THROWS TypeError: new Error().stack.split is not a function. ...
# node, this branch: RESULT read ok string

bun --trace-exit -e "Error.prepareStackTrace = (e, cs) => cs; process.on('exit', c => console.log('RESULT exit event', c)); try { process.exit(0); } catch (e) { console.log('RESULT THROWS', e.name + ': ' + e.message); }" 2>/dev/null | grep RESULT
# main: RESULT THROWS TypeError: ... then RESULT exit event 0
# node, this branch: RESULT exit event 0

No user reported this. Both flags are hidden from --help and are not in the docs. The defect was found in the code.

The same mechanism breaks both flags on main for a formatter that throws and for a replaced globalThis.Error. With Error.stackTraceLimit = 0, process.exit() throws and the env trace loses its frames. Object.freeze(Error) and an accessor on Error.stackTraceLimit break --trace-env-js-stack only, because only its printer wrote that property. A formatter that returns a string runs once for each traced operation, and its text replaces the frames.

What Node does

Changes to the --trace-exit output

Case main this branch Node
exit listener sets process.exitCode = 7 code 0, before the listener code 7, after the listener same as branch
process.exit(9) in an exit listener 2 warnings 1 warning 1 warning
process.exit() at the stack limit 6 to 8 warnings, by stack depth 1 warning 1 warning
direct process.reallyExit(3) no warning warning, code 3 same as branch
script replaced process.reallyExit warning no warning no warning
process.exit("abc") (throws ERR_INVALID_ARG_TYPE) warning, code abc no warning no warning
--stack-trace-limit=0 TypeError, exit status 1 warning, no frames same as branch
Error.stackTraceLimit = 50, then recurse(20) every frame (23) 10 frames 10 frames
process.exit(-1) code -1 code 255 code -1
process.exit(300) code 300 code 44 code 300

12 of 14 fixtures match node v26.3.0 on warning order, exit code and frame names (main: 8 of 14). The two others are the last two rows. Bun keeps 8 bits of the exit code (ExitHandler::exit_code is a u8), and process.exit() passes those to process.reallyExit(). process.exitCode and the exit event report the same 255 and 44. The main branch printed the argument of process.exit() before it was stored. A direct process.reallyExit(256) prints code 256.

Measurements (release builds of main fd539ca with and without this change, linux x64)

  • release binary: text +2,799 B (size, 106,677,292 to 106,680,091). The stripped file grows by one 4,096 B page. New and changed native symbols +3,502 B (nm -S). Bundled pre_execution.js 7,692 B (was 8,474).
  • zero-flag process.reallyExit: 33 instructions before Bun__Process__exit (was 29, objdump). setProcessExitCodeInner, Process_functionExit and the process.env getters are byte-identical in size.
  • sizeof(Bun::Process): 136 to 144 bytes. The GC cell stays at 144 B.
  • formatter calls per 100 traced env reads: 0 (was 100, node 0). Error.stackTraceLimit gets and sets: 0 and 0 (main throws a TypeError when the property is an accessor, node 0 and 0). Formatter calls per traced exit: 0 (was 1, node 0).
  • GC cells per traced env read: Error 0 (was 1), string 16 (was 20), Array 3 (was 3), RegExp 1 (was 1) (bun:jsc heapStats, JIT off).
  • write syscalls per traced env read: 1 (was 1, /proc/self/io). Writes to fd 2 per traced exit: 1 (was 1, gdb catch syscall write, because strace is not installed).
  • instructions per traced env read at depth 2 / 200 / 2000: 37.7k / 81.2k / 82.6k (main 45.7k / 96.0k / 98.4k). instructions:u from a perf_event_open counter, two-N difference, because perf is not installed.
  • --trace-exit overhead over no flag for -e 'process.exit(0)': 1.09 M instructions (was 4.03 M). Median of 10 alternated runs, spread 1.068 M to 1.111 M.
  • Function cells after require('node:async_hooks') with no flag: main 494, this branch 494.

Not in this PR

  • util.getCallSites and isInsideNodeModules (internal/shared.ts, reached from url.parse()) still swap Error.prepareStackTrace around a capture. They need call sites, not text. Read Error.prepareStackTrace from the Error constructor when a stack is formatted #45570 and BUN_JSC_immutableGlobals: one switch that makes every built-in immutable #45177 each give them a native capture.
  • BUN_JSC_immutableGlobals: one switch that makes every built-in immutable #45177 (@dylan-conway) rewrites the same capture lines of printEnvTrace with Error.captureStackTrace, which still runs the user formatter, and lists --trace-exit as not covered. After this change that hunk and that entry are not needed.
  • The 8-bit exit code (table above). process: match Node exit code semantics; fix node:events captureRejections #32814 widened it and was closed as stale. There is no open PR.
  • A replaced String.prototype.split, startsWith or RegExp.prototype.exec still makes a traced process.env read throw, as on main (node: no). The env printer keeps its string and regex calls. This change removes only its use of Error.
  • The traces keep the async frames that error.stack has, inside the frame limit. Node prints synchronous frames only. error.stack itself can still pass Error.stackTraceLimit by the frames of an await chain.
  • at exit (unknown): the formatter prints every host function frame as unknown.
  • In a worker, process.exit() called from an exit listener returns to the listener (worker: run the 'exit' listeners before the worker's VM is unpublished #40132). The warning is printed once, for the first call that reaches process.reallyExit().
  • Exits after an uncaught exception do not go through process.reallyExit and print no warning (Node prints one).
  • The env trace keeps Bun's at lines. Node prints numbered frames under a header, and --trace-env-js-stack implies --trace-env there.
  • bun --trace-exit file.wasm now ends with a warning, because the WASI runner calls process.reallyExit(0).
  • No test covers a termination request that arrives while the exit warning is written (a vm timeout with a blocked stderr). The test needs a reader that leaves stderr full for longer than the timeout, so it waits on a timer. Checked by hand: main throws ERR_SCRIPT_EXECUTION_TIMEOUT and keeps running. This branch exits, as Node does.
  • With BUN_FEATURE_FLAG_VERBOSE_WARNINGS=1, the node:async_hooks warning looks for a known caller in 10 frames. Main looked in Error.stackTraceLimit frames.
  • process.reallyExit("3") and process.reallyExit(3.7) exit 0 (Node: 3). The isAnyInt() check in process.reallyExit() is as it was.

Why a new test file

The 27 cases each start a process and share three helpers. test/js/node/process/process.test.js already has about 190 tests in 3,300 lines. In a debug build on a 12-core machine, three of its cases timed out in a run of the whole file, so the new cases are not in that batch.

Suites run with the debug build

test/js/node/process/process-trace-flags.test.ts, test/js/node/async_hooks/async_hooks.node.test.ts, test/js/node/trace_events/trace-events.test.ts, test/js/node/v8/capture-stack-trace.test.js, the exit tests of test/js/node/process/process.test.js, and 50 vendored Node tests for process.exit, process.env, worker exit, util.inspect, vm timeouts and the trace flags. BUN_JSC_validateExceptionChecks=1 reports nothing for both flags. cargo check passes for x86_64-pc-windows-msvc and aarch64-apple-darwin.


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/async_hooks/async_hooks.node.test.ts

…de, without Error.prepareStackTrace

Both flags built their stack with new Error().stack in
internal/process/pre_execution. That is the value of the user's
Error.prepareStackTrace, so a formatter that returned a non-string made
process.exit() and every process.env operation throw. Node prints both
traces from native code.

- Bun::formatCurrentStackTrace() formats the current frames as the
  default error.stack does. It makes no Error and runs no JS.
- process.reallyExit() prints the --trace-exit warning, as Node's
  Environment::Exit() does. The JS wrapper of process.exit() is gone.
- --trace-env-js-stack prints ten frames from the native capture.
- The warning of the async_hooks stubs reads its stack the same way.
@robobun

robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:28 PM PT - Oct 18th, 2026

✅ @robobun, your commit f57967531bab6a56ce92459f937b19f7f4f3d1ac passed in Build #128395! 🎉


🧪   To try this PR locally:

bunx bun-pr 45933

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

bun-45933 --bun

@robobun

robobun commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main (and on bun 1.4.3) with the two commands in the PR body:

  • bun --trace-exit --stack-trace-limit=0 -e "process.exit(0)" exits 1 with TypeError: undefined is not an object. Node v26.3.0 exits 0.
  • With Error.prepareStackTrace = (e, cs) => cs, process.exit(0) under --trace-exit and a process.env read under --trace-env-js-stack throw TypeError: new Error().stack.split is not a function. Node v26.3.0 does not throw.

The fix and the tests are in this PR: #45933

@coderabbitai

coderabbitai Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

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: Advanced
  • Run ID: 9878e4fb-23ff-4dd2-a552-dac6137e82f6
📥 Commits

Reviewing files that changed from the base of the PR and between 5900354 and f579675.

📒 Files selected for processing: 10
  • src/js/internal/process/pre_execution.ts
  • src/js/internal/trace_events.ts
  • src/js/node/async_hooks.ts
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/BunProcess.h
  • src/jsc/bindings/FormatStackTraceForJS.cpp
  • src/jsc/bindings/FormatStackTraceForJS.h
  • src/runtime/node/node_process.rs
  • test/js/node/async_hooks/async_hooks.node.test.ts
  • test/js/node/process/process-trace-flags.test.ts

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

Walkthrough

Environment stack traces and async-hooks warnings now use a native current-stack formatter. The --trace-exit path enables native exit tracing and writes its warning before exit. Tests cover frame limits, modified Error behavior, trace contents, and worker exits.

Changes

Native stack formatting and consumers

Layer / File(s) Summary
Current-stack formatting and consumers
src/jsc/bindings/FormatStackTraceForJS.*, src/js/internal/process/pre_execution.ts, src/js/node/async_hooks.ts, test/js/node/async_hooks/*, test/js/node/process/process-trace-flags.test.ts
A native formatter captures current frames without constructing an Error. Environment tracing and async-hooks warnings use the formatter. Tests cover custom Error behavior and environment trace frames.

Native process exit tracing

Layer / File(s) Summary
Native process exit tracing
src/jsc/bindings/BunProcess.*, src/runtime/node/node_process.rs, src/js/internal/process/pre_execution.ts, src/js/internal/trace_events.ts, test/js/node/process/process-trace-flags.test.ts
Pre-execution enables native exit tracing with a frame limit. The native exit path formats a warning with the exit code and stack, then writes it to stderr. Tests cover exit scenarios, frame limits, and worker thread IDs.

Suggested reviewers: dylan-conway

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to f5796

The native trace changes are ready to merge after normal checks; no actionable issue remains.

Pre-merge checks: Passed 4
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 the main change: native stack formatting for --trace-exit and --trace-env-js-stack without Error.prepareStackTrace.
Description check Passed The description is detailed and covers the problem, fix, verification, behavior changes, limitations, and test results. It does not use the template headings "What does this PR do?" and "How did you v…
Full details: Description check

Explanation

The description is detailed and covers the problem, fix, verification, behavior changes, limitations, and test results. It does not use the template headings "What does this PR do?" and "How did you verify your code works?", but it provides the required information under equivalent sections.

✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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.

Beyond the inline findings, I also checked the new Bun__Process__writeExitTrace boundary for string ownership — Bun::toString on the local WTF::String and to_utf8() on the Rust side both borrow, so no ref is leaked or double-released — and the reallyExit exit-code change: static_cast<uint8_t>(toInt32 % 256) yields the same byte as the old uint8_t path for every int32, so only the printed value changed. The per-VM m_traceExitFrameLimit lives on the Process object, so each worker enables and clears its own.

Extended reasoning...

The change moves the --trace-exit and --trace-env-js-stack stack capture from JS into a new native helper in FormatStackTraceForJS.cpp, adds a frame-limit optional on Bun::Process, prints the exit warning from Process_functionReallyExit via a new Rust stderr export, and adds subprocess tests. It touches process exit and native stack walking under DisallowVMEntry but no auth, crypto, or injection surface. Two test-coverage nits were reported inline; a human look is still worthwhile given the native exit path and the formatStackTrace-with-null-error contract the helper relies on.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 test/js/node/process/process-trace-flags.test.ts — nit: Maintainers get no regression coverage for two Error states the change claims to fix for --trace-exit. The --trace-exit matrix at test/js/node/process/process-trace-flags.test.ts:51-57 omits "Error.stackTraceLimit is an accessor" and "Error is frozen", which the --trace-env-js-stack matrix at line 713 runs over all of errorStates. Fix: run the exit matrix over every key of errorStates (or state why two are excluded), so a future read or write of Error.stackTraceLimit on the exit path shows up as the accessor's stdout line.

    Why this was flagged

    The PR description lists Object.freeze(Error) and an accessor on Error.stackTraceLimit as inputs that broke process.exit() on the base branch. The new exit test at test/js/node/process/process-trace-flags.test.ts:51-57 lists only five of the seven errorStates keys, leaving those two out, while the env test at line 713 uses Object.keys(errorStates). The accessor state logs "Error.stackTraceLimit was read" / "was written" to stdout, and the exit test asserts stdout: "", so including it is the only check that process.exit() under --trace-exit no longer touches Error.stackTraceLimit. If a later change reintroduces an Error read on the exit path, no test fails. No runtime behaviour differs from the base; this is a coverage gap for the sibling inputs the REVIEW.md variant-matrix rule asks for.

    Verification: nit. A future change makes the --trace-exit path read or write Error.stackTraceLimit — nothing in the suite would catch it for the two omitted states. The --trace-exit matrix at test/js/node/process/process-trace-flags.test.ts:51-57 omits "Error.stackTraceLimit is an accessor" and "Error is frozen". On base the exit path never read or wrote Error.stackTraceLimit.

@robobun

robobun commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

About the two review notes on the tests:

  • The --trace-exit matrix has the five Error states that break process.exit() on main. Object.freeze(Error) and an accessor on Error.stackTraceLimit do not break it: only the env printer wrote that property. On 1.4.3 and on main those two cases print the warning and exit with the requested code, so they pass with or without this change. The description said "the same mechanism" for all seven states. I corrected that sentence.
  • The dedicated test file: the reason is in the review thread and in the Notes of the description.

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.

1 participant