Skip to content

Preserve original Error stack when uncaughtException handler rethrows - #30508

Closed
robobun wants to merge 4 commits into
mainfrom
farm/990f76f0/preserve-error-stack-in-uncaught-rethrow
Closed

robobun wants to merge 4 commits into
mainfrom
farm/990f76f0/preserve-error-stack-in-uncaught-rethrow

Conversation

@robobun

@robobun robobun commented May 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #30504.

Repro

process.on('uncaughtException', err => {
  throw err;
});
function throwUncaughtError() {
  throw new Error('Boom');  // line 5
}
throwUncaughtError();

Before

3 | process.on('uncaughtException', err => {
4 |   throw err;
            ^
error: Boom
      at <anonymous> (rethrow.cjs:4:9)

Bun points at the rethrow site (line 4) and drops every frame from the original throw.

After (and Node's behavior)

7 | function throwUncaughtError() {
8 |   throw new Error('Boom');
                ^
error: Boom
      at throwUncaughtError (rethrow.cjs:8:13)
      at <anonymous> (rethrow.cjs:11:1)

Cause

When the listener does throw err, JSC wraps the pre-existing Error in a
fresh JSC::Exception that captures a new stack at the rethrow site.
fromErrorInstance (src/jsc/bindings/ZigException.cpp) preferred that
wrapper stack over the Error's own stackTrace(), so the original
frames captured at new Error(...) construction were thrown away.

Fix

Swap the precedence in fromErrorInstance and in the OnlySourceLines
path of ZigException__collectSourceLines so the Error's own stack
wins. The wrapper stack is still used as a fallback when the Error has
none (rare — happens with synthetic rethrows of non-Error values, etc.).

Verification

  • New test: test/js/node/process/process.test.js > "preserves the
    original Error stack when the uncaughtException handler rethrows".
    Fails on main (Received: ... at <anonymous> (.../index.cjs:4:17)");
    passes on this branch.
  • Existing uncaughtException suite (process.test.js -t uncaughtException): 5/5 pass.
  • test/regression/issue/08794.test.ts, circular-error-stack.test.ts,
    circular-error-stack-edge-cases.test.ts,
    23022-stack-trace-iterator.test.ts,
    fix-bindings-stack-trace.test.ts,
    prepare-stack-trace-crash.test.ts: all pass.
  • test/js/bun/test/stack.test.ts,
    test/js/bun/sourcemap/internal-sourcemap.test.ts: all pass.
  • Node compat sanity: test-events-uncaught-exception-stack.js,
    test-exception-handler.js, test-exception-handler2.js,
    test-emit-after-uncaught-exception.js all exit 0.

When a user rethrows an existing Error — e.g. from inside
`process.on('uncaughtException')` — JSC wraps the value in a fresh
`JSC::Exception` whose stack points at the `throw err` site rather
than the original throw site. In `fromErrorInstance` (and the
companion source-line collector) we were preferring that outer
wrapper stack, which hid the original stack stored on the Error
instance.

Swap the precedence so the Error's own `stackTrace()` wins. This
matches Node's behavior of using `err.stack` for the rethrown
Error and preserves context like the enclosing function name in
the reported trace.

Fixes #30504
@robobun

robobun commented May 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:41 PM PT - May 11th, 2026

❌ @robobun, your commit 5398dff has 1 failures in Build #53421 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30508

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

bun-30508 --bun

@coderabbitai

coderabbitai Bot commented May 11, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6392794a-3887-476d-97bb-9db850cabb65

📥 Commits

Reviewing files that changed from the base of the PR and between 135aae1 and 6e279b9.

📒 Files selected for processing (4)
  • test/js/bun/test/dots.test.ts
  • test/js/bun/test/only-failures.test.ts
  • test/regression/issue/12782.test.ts
  • test/regression/issue/19850/19850.test.ts

Walkthrough

This PR prefers an ErrorInstance's own non-empty stackTrace() over a wrapper stack when building exception frames and source lines, preserving original throw sites when errors are rethrown; a test verifies uncaughtException rethrow preserves the original stack entry.

Changes

Error Stack Trace Preservation on Rethrow

Layer / File(s) Summary
Stack Source Selection in fromErrorInstance
src/jsc/bindings/ZigException.cpp
In fromErrorInstance, stack population now checks the unwrapped ErrorInstance's stackTrace() first (when non-empty); falls back to the wrapper stackTrace parameter only if the error's stack is absent or empty.
Stack Source Selection in ZigException__collectSourceLines
src/jsc/bindings/ZigException.cpp
In ZigException__collectSourceLines, stack-source preference now mirrors fromErrorInstance: prefers the unwrapped error's stackTrace() when non-empty, otherwise falls back to jscException->stack() when collecting OnlySourceLines.
Test Coverage for Rethrow Stack Preservation
test/js/node/process/process.test.js
New test spawns a CommonJS script with an uncaughtException handler that rethrows the caught error; verifies the exit code and that stderr stack includes both the original throw site function name (throwUncaughtError) and error message (Boom).
Updated inline stderr snapshots
test/js/bun/test/dots.test.ts, test/js/bun/test/only-failures.test.ts, test/regression/issue/12782.test.ts, test/regression/issue/19850/19850.test.ts
Adjusted caret/code-frame marker positions and snapshot expectations to match the updated stack/source-line formatting emitted by the runtime.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: preserving original Error stack when an uncaughtException handler rethrows, which directly addresses the core issue in the changeset.
Description check ✅ Passed The description includes a detailed reproducer, before/after comparison, explanation of root cause, fix description, and verification with multiple test results, fully addressing the template sections.
Linked Issues check ✅ Passed The PR fully addresses issue #30504 by fixing the stack trace precedence logic in ZigException.cpp to preserve the original Error's stack over the wrapper stack, matching the expected Node.js behavior documented in the issue.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the stack trace preservation issue: core logic in ZigException.cpp, new test case for the fix, and snapshot updates reflecting the corrected behavior.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

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

Actionable comments posted: 1

🤖 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 `@test/js/node/process/process.test.js`:
- Around line 845-850: The test currently asserts exitCode before validating
stderr; change the assertion order so you first await and assert stderr contents
(checks using proc.stderr.text(), expecting "throwUncaughtError" and "Boom") and
only after those stderr expectations assert the exit code (exitCode === 7).
Update the block that assigns const [stderr, exitCode] = await
Promise.all([proc.stderr.text(), proc.exited]) so the
expect(stderr).toContain(...) checks come before expect(exitCode).toBe(7),
preserving the existing proc, stderr, and exitCode variables and behavior.
🪄 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: e645f49e-efce-4fd0-bb39-c2e62369250e

📥 Commits

Reviewing files that changed from the base of the PR and between 450072b and 1865a23.

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

Comment thread test/js/node/process/process.test.js
@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Incorrect line numbers in error stack for TypeScript code #15859 - Reproducer catches an error, reads .stack, then does throw error (rethrow). The JSC::Exception wrapper created at the rethrow site was preferred over the Error instance's own stack, producing incorrect line numbers in TypeScript code.
  2. Unsupported proper logging of AggregateError, Error.cause, modified/accessed Error.stack #1352 - The sub-bug where accessing/modifying err.stack then throwing/logging the error produces wrong stack output shares the same root cause: the JSC::Exception wrapper overrides the Error instance's own stackTrace(). (The AggregateError formatting and .cause display portions are out of scope for this PR.)

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #15859
Fixes #1352

🤖 Generated with Claude Code

Comment thread test/js/node/process/process.test.js Outdated
Comment on lines +846 to +850
expect(exitCode).toBe(7);
// Stack must reference the original throw site inside throwUncaughtError,
// not only the `throw err` rethrow site inside the handler.
expect(stderr).toContain("throwUncaughtError");
expect(stderr).toContain("Boom");

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.

🟡 nit: per CLAUDE.md ("Assert the exit code last"), move expect(exitCode).toBe(7) after the two expect(stderr).toContain(...) checks so a failure prints the actual stderr instead of just "Expected: 7". The neighboring tests have the same pre-existing ordering, but new code might as well follow the documented convention.

Extended reasoning...

What

The new test at test/js/node/process/process.test.js:846 asserts expect(exitCode).toBe(7) before the two stderr-content assertions:

const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(7);
// Stack must reference the original throw site inside throwUncaughtError,
// not only the `throw err` rethrow site inside the handler.
expect(stderr).toContain("throwUncaughtError");
expect(stderr).toContain("Boom");

The repo's root CLAUDE.md (Writing Tests, lines ~119/128) explicitly states:

When spawning processes, tests should expect(stdout).toBe(...) BEFORE expect(exitCode).toBe(0). This gives you a more useful error message on test failure.
Assert the exit code last.

Why it matters

If this test fails for any reason — say a future change makes the spawned process crash with a different exit code, or the rethrow path regresses in a way that also changes the exit status — the first failing assertion is what gets reported. With expect(exitCode).toBe(7) first, the failure message would be the relatively uninformative "Expected: 7, Received: " and the test would stop before printing what was actually written to stderr. Asserting the stderr content first means the diff shows the printed stack trace, which is exactly the thing this test exists to validate.

Step-by-step

  1. Suppose a regression causes the child to exit with code 1 and print a different stack to stderr.
  2. Promise.all resolves with stderr = "<some error output>", exitCode = 1.
  3. Line 846 runs first: expect(1).toBe(7) → fails with "Expected: 7, Received: 1".
  4. The test stops; the stderr content (the diagnostic you actually want) is never asserted or shown.
  5. With the order swapped, expect(stderr).toContain("throwUncaughtError") runs first and the failure message includes the full received stderr string, immediately showing what the stack actually looked like.

Why existing code doesn't prevent it

Nothing in the test file enforces assertion order. The immediately preceding tests ("aborts when the uncaughtException handler throws", lines ~808–823) use the same exit-code-first pattern, so this PR is following local style — but those are pre-existing and the documented project convention is unambiguous that exit code goes last.

Fix

Reorder the three assertions:

expect(stderr).toContain("throwUncaughtError");
expect(stderr).toContain("Boom");
expect(exitCode).toBe(7);

This is purely a test-ergonomics nit; it does not affect correctness or coverage.

robobun added 2 commits May 11, 2026 17:26
Error stack traces now come from the ErrorInstance's own stack
(captured at `new Error()` construction) rather than the outer
JSC::Exception wrapper (captured at the `throw` opcode). The
reported column shifts from the end of the throw expression to
the Error constructor call site.
@robobun

robobun commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Related: #36437 handles the same precedence swap plus the case where ErrorInstance::stackTrace() is null (structured-clone'd errors from node:worker_threads, which only carry .stack as a string). In that situation the swap here still falls through to the wrapper's throw-site stack and the worker frames are dropped. #36437 adds the .stack-string parse between the two and keeps the wrapper stack as a final fallback.

robobun added a commit that referenced this pull request Aug 13, 2026
Adds the process-level cases from #30508 to process.test.js: a handler that
rethrows, and one that reads err.stack before rethrowing, both must report
the function that created the Error rather than the handler.
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Consolidated into #36437, which prefers the Error's own stack the same way this PR does and additionally falls back to the .stack string, so it also covers a handler that reads err.stack before rethrowing and errors that arrive through structuredClone or a worker. #36437 now also carries the process-level rethrow test from this PR (test/js/node/process/process.test.js). Closing.

@robobun robobun closed this Aug 13, 2026
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.

Rethrowing inside process.on('uncaughtException') loses original Error stack

1 participant