Skip to content

error.stack: report frames at new X(...) at the new keyword - #37396

Open
robobun wants to merge 7 commits into
mainfrom
farm/cee5f8f9/stack-column-new-keyword
Open

error.stack: report frames at new X(...) at the new keyword#37396
robobun wants to merge 7 commits into
mainfrom
farm/cee5f8f9/stack-column-new-keyword

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Problem

For a frame that is sitting at a new X(...) expression, the column in the error.stack string points at X (or somewhere else entirely), while Node, Bun.inspect(err) / the uncaught error printer, and Error.prepareStackTrace CallSites all point at the new keyword.

class Thrower { constructor() { throw new Error("x"); } }
class MyError extends Error {}
function customError() {
  throw new MyError("custom");   // `new` is column 9
}
function userConstructor() {
  return new Thrower(1);         // `new` is column 10
}
function localBinding() {
  const Ctor = Thrower;
  return new Ctor(1);            // line 11, `new` is column 10
}
frame node bun 1.4 err.stack bun 1.4 Bun.inspect(err) this PR err.stack
customError :4:9 :4:13 :4:9 :4:9
userConstructor :7:10 :7:14 :7:10 :7:10
localBinding :11:10 :10:16 (previous line) :11:10 :11:10

The same applies to new Map(1), new Map(...args), Error.captureStackTrace, and err.line / err.column. In code that is not transpiled (node:vm, eval) it also applies to throw new Error(...): vm.runInNewContext('throw new Error("foo")') reports :1:16 where node reports :1:7.

Cause

JSC's position for a construct is the divot at the end of the callee. Since #11581, Bun::getAdjustedPositionForBytecode (ErrorStackFrame.cpp) moves op_construct* / op_super_construct* positions back to the start of the expression, which is where V8 reports them, and both the code frame printer (ZigException.cpp) and the CallSite path (JSCStackFrame::getSourcePositions) use it. formatStackTrace in FormatStackTraceForJS.cpp, which produces the .stack string, still used the raw frame.computeLineAndColumn() and fed that into the source map remap. The transpiler's source map has a mapping at the start of the new expression but none at the divot, so the remap snaps the divot to whatever mapping precedes it: normally the callee identifier, and when the callee was inlined (localBinding above) the identifier's original location on another line.

Fix

  • formatStackTrace takes its positions from the same adjustment the other two consumers use (new getAdjustedLineColumnForStackFrame wrapper in ErrorStackFrame.cpp), so the three renderings agree and the remap starts from a position the source map has. err.line / err.column / originalLine / originalColumn are derived from the same values and follow. The line/column variant looks at the opcode first: only construct frames decode the expression info (needed for the divot and start offset), every other frame keeps using CodeBlock::lineColumnForBytecodeIndex, the cached lookup computeLineAndColumn() used before, so the .stack path costs what it did before for the frames it does not change. The CallSite path (JSCStackFrame::calculateSourcePositions) is moved to the same variant, it was decoding every frame; ZigException.cpp keeps the full variant because the source preview needs the offset.
  • formatStackTrace used to decide whether a frame has a position by checking for a non-zero line or column, so a frame at column 1 printed as file.js:8 and a frame at 1:1 printed no position at all. Constructs at the start of a line now legitimately land on column 1, so the :line:column suffix is keyed on whether pass 1 produced a position instead. Calls at column 1 gain their :1 too (at file.js:8:1, which is what node prints; Bun.inspect already printed it).
  • ZigException.cpp is switched to the same helper. No behavior change: its fallback for a frame with a code block but no bytecode index was unreachable (JSC always records both), and would have hit the RELEASE_ASSERT in lineColumnForBytecodeIndex if it ever ran; the helper reports no position instead.
  • adjustPositionBackwards, which now has one more caller, is made to work when new and the callee are on different lines. Transpiled code mostly takes the same-line path (the printer puts new and a simple callee back on one line; a class or function expression callee still spans lines, covered by the classExpressionCallee case, which main reports two lines off), and eval, new Function and node:vm code hits it with whatever text the user wrote, in the encoding of the string it came from. On a 16-bit source the function zeroed the position in release builds and hit ASSERT_NOT_REACHED in debug builds, reachable today through Bun.inspect and Error.prepareStackTrace. It now indexes the StringView directly (works for both encodings), counts the same line terminators JSC's lexer counts (LF, CR, CRLF, U+2028, U+2029) and only the ones between the expression start and the divot (the old loop also looked at the character at the divot, so return new\n Map\n (1) printed at construct (file.js:) with no position), counts the column correctly when the expression starts on the first line of the source (the i > 0 loop bound was off by one, and the source's start column, node:vm's columnOffset, which JSC includes in first-line columns, was dropped), and leaves the divot position alone instead of reporting 1:1 when it cannot recount.

Why this is the right behavior

The .stack string is what external tools read (source map remappers, error reporters, test runners building code frames from stack strings), and V8 reports the new keyword there. Bun already chose the new keyword in #11581 for its other two renderings of the same frame; the string was the odd one out, and because of the source map behavior above the old column was not a consistent JSC flavored answer either, just whatever mapping happened to sit before the divot. Three node:vm tests from Node's suite had been given Bun-specific expected columns for this (#32018): with this change test-vm-run-in-new-context.js and test-vm-context.js produce Node's column and go back to the upstream assertion (the latter still needs its loose caret check), and test-vm-basic.js now only differs in the ways its comment already lists.

What does not change: frames at calls, throw, property accesses and so on (only construct opcodes are adjusted); Bun.inspect and CallSite output for constructs on a single line; new Error(...) in transpiled code, which the runtime transpiler currently lowers to a call. Cost: for frames that are not at a construct, the .stack path runs the same cached lineColumnForBytecodeIndex lookup it ran before; an earlier revision of this PR decoded the expression info for every frame, which review flagged, hence the opcode check. Construct frames pay one expression info decode, which Bun.inspect and CallSites were already paying for every frame; the CallSite path now pays it only for constructs too. Release builds, CPU time per new Error().stack, minimum of 8 interleaved runs on a heavily loaded machine (so only good to a few percent): 10 small frames: pre-PR 8.1us, decode-every-frame revision 8.4us, this revision 8.0us; error created at the end of a 3000-statement function: 7.0us / 7.4us / 7.2us (the pre-PR binary is the published canary of a slightly older base, the other two are local builds).

Tests

test/js/bun/test/stack.test.ts:

  • A transpiled fixture covering a custom error class, a global constructor, a user constructor called from the frame, new with spread (op_construct_varargs), a callee the transpiler inlines (wrong line before), new split across lines, Error.captureStackTrace, and err.line / err.column. Before this change it reports 13:13, 16:14, 19:14, 22:14, 25:16, 30:5, 34:7 instead of 13:9, 16:10, 19:10, 22:10, 26:10, 29:10, 34:3. Two more cases put the call and the construct at column 1; before this change they print fixture.js:39 (no column) and fixture.js:42:5, now 39:1 and 42:1.
  • eval'd and node:vm sources with new on an earlier line than the callee (latin1, UTF-16, one where the expression starts on line 1 with a line break right after the callee, CR / CRLF / U+2028 separators between new and the callee and, with new on line 2, CR / U+2028 / U+2029 before it as well (so the column count has to stop at them), a vm script with columnOffset: 100, and a vm script that starts with the new, whose frame used to print as at byte-zero.js:1:8 and now prints :1:1), checking .stack, Bun.inspect and CallSites. Before this change .stack reports the divot (4:8, 4:8, 2:6, ...); the UTF-16 source reports 1:1 from Bun.inspect and line 1 from CallSites in release builds and aborts on ASSERT_NOT_REACHED in debug builds; the line 1 source prints no position at all from Bun.inspect. Node reports 3:10, 3:10, 1:31, 1:31 x3 and 1:131 for these, which is what they now produce everywhere.

Updated expectations: the vm-sourceUrl.test.ts snapshot (2:16 -> 2:7, node prints 2:7), the three node:vm tests above (which still pass on Node v26), and the first case of test/bake/dev/server-sourcemap.test.ts. That file's server-side frames currently land one line above the statement (the server HMR chunk's source map accounts for the client chunk's one-line prefix, a pre-existing bug that is being handled separately), and the column is whatever mapping on that line precedes the frame's generated column: for throw new Error(...) the old divot column picked the ( of function myFunc( (6:16), the new column picks column 1 like the throwError/6:1, helperFunction/5:1 and churn cases in the same file already do.


[review] gate passed · iteration 0 · 11 files touched

fails on main (without fix)
ASAN without fix: 3 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bake/dev/server-sourcemap.test.ts test/js/bun/test/stack.test.ts
bun test v1.4.0 (b0386de2d)

test/bake/dev/server-sourcemap.test.ts:
Dev server testing directory: /tmp/bun-dev-test-fDJv5e
bun add v1.4.0 (b0386de2d)
Resolving dependencies
Resolved, downloaded and extracted [2]
Saved lockfile

installed react@0.0.0-experimental-603e6108-20241029
installed react-dom@0.0.0-experimental-603e6108-20241029
installed react-server-dom-bun@0.0.0-experimental-603e6108-20241029
installed react-refresh@0.0.0-experimental-603e6108-20241029

6 packages installed [364.00ms]
bun install v1.4.0 (b0386de2d)

Checked 6 installs across 7 packages (no changes) [98.00ms]
�[0;30mdev|�[0m Started development server: http://localhost:32931
�[0;30mdev|�[0m �[32mBundled page in 2382ms�[0m�[2m:�[0m pages/[...slug].tsx �[2m+ 2 more�[0m
�[0;30mdev|�[0m �[0m�[1m1 |�[0m �[0m�[35mexport�[0m �[0m�[35mdefault�[0m �[0m�[35masync�[0m �[0m�[35mfunction�[0m MyPage(params) {
�[0;30mdev|�[0m �[0m�[1m2 |�[0m   myFunc()�[0m�[2m;�[0m
�[0;30mdev|�[0m �[0m�[1m3 |�[0m   �[0m�[
... (truncated)

release without fix: 1 skipped
bun test v1.4.0-canary.1 (2f732e4a9)

test/bake/dev/server-sourcemap.test.ts:
Dev server testing directory: /tmp/bun-dev-test-TA7HTu
bun add v1.4.0-canary.1 (2f732e4a9)
Resolving dependencies
Resolved, downloaded and extracted [0]
Saved lockfile

installed react@0.0.0-experimental-603e6108-20241029
installed react-dom@0.0.0-experimental-603e6108-20241029
installed react-server-dom-bun@0.0.0-experimental-603e6108-20241029
installed react-refresh@0.0.0-experimental-603e6108-20241029

6 packages installed [9.00ms]
bun install v1.4.0-canary.1 (2f732e4a9)

Checked 6 installs across 7 packages (no changes) [0.00ms]
�[0;30mdev|�[0m Started development server: http://localhost:39807
�[0;30mdev|�[0m �[32mBundled page in 68ms�[0m�[2m:�[0m pages/[...slug].tsx �[2m+ 2 more�[0m
�[0;30mdev|�[0m �[0m�[1m1 |�[0m �[0m�[35mexport�[0m �[0m�[35mdefault�[0m �[0m�[35masync�[0m �[0m�[35mfunction�[0m MyPage(params) {
�[0;30mdev|�[0m �[0m�[1m2 |�[0m   myFunc()�[0m�[2m;�[0m
�[0;30mdev|�[0m �[0m�[1m3 |�[0m   �[0m�[35mreturn�[0m �[0m<�[0mh1>{JSON�[0m�[3m�[1m.stringify�[0m(params)}�[0m<�[0m/h1>�[0m�[2m;�[0m
�[0;30mdev|�[0m �[0m�[1m4 |�[0m }
�[0;30mdev|�[0m �[0m�[1m5 |�[0m 
�[0;30mdev|�[0m �[0m�
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bake/dev/server-sourcemap.test.ts test/js/bun/test/stack.test.ts
bun test v1.4.0 (b0386de2d)

test/bake/dev/server-sourcemap.test.ts:
Dev server testing directory: /tmp/bun-dev-test-xi4mYv
bun add v1.4.0 (b0386de2d)
Resolving dependencies
Resolved, downloaded and extracted [0]
Saved lockfile

installed react@0.0.0-experimental-603e6108-20241029
installed react-dom@0.0.0-experimental-603e6108-20241029
installed react-server-dom-bun@0.0.0-experimental-603e6108-20241029
installed react-refresh@0.0.0-experimental-603e6108-20241029

6 packages installed [223.00ms]
bun install v1.4.0 (b0386de2d)

Checked 6 installs across 7 packages (no changes) [126.00ms]
�[0;30mdev|�[0m Started development server: http://localhost:35675
�[0;30mdev|�[0m �[32mBundled page in 2285ms�[0m�[2m:�[0m pages/[...slug].tsx �[2m+ 2 more�[0m
�[0;30mdev|�[0m �[0m�[1m1 |�[0m �[0m�[35mexport�[0m �[0m�[35mdefault�[0m �[0m�[35masync�[0m �[0m�[35mfunction�[0m MyPage(params) {
�[0;30mdev|�[0m �[0m�[1m2 |�[0m   myFunc()�[0m�[2m;�[0m
�[0;30mdev|�[0m �[0m�[1m3 |�[0m   �[0m�
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 956ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/23] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[2/23] gen cpp.rs (cppbind)
[3/23] gen JS modules (bundle-modules)
Preprocess modules (14922ms)
Bundle modules (70ms)
Postprocesss modules (194ms)
Bundle Functions (1029ms)
Generate Code (37ms)

[16.28s] Bundled "src/js" for production
  2610 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[3/12] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�
... (truncated)
diff hotspot
src/jsc/bindings/ErrorStackFrame.cpp               | 163 ++++++++++--------
 src/jsc/bindings/ErrorStackFrame.h                 |  15 ++
 src/jsc/bindings/ErrorStackTrace.cpp               |   2 +-
 src/jsc/bindings/FormatStackTraceForJS.cpp         |  30 ++--
 src/jsc/bindings/ZigException.cpp                  |  17 +-
 test/bake/dev/server-sourcemap.test.ts             |   4 +-
 test/js/bun/test/stack.test.ts                     | 191 ++++++++++++++++++++-
 test/js/node/test/parallel/test-vm-basic.js        |  13 +-
 test/js/node/test/parallel/test-vm-context.js      |   8 +-
 .../test/parallel/test-vm-run-in-new-context.js    |   4 +-
 .../vm/__snapshots__/vm-sourceUrl.test.ts.snap     |   2 +-
 11 files changed, 331 insertions(+), 118 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/jsc/bindings/ErrorStackFrame.cpp                          5     12      0
src/jsc/bindings/ErrorStackFrame.h                            3      7      0
src/jsc/bindings/ErrorStackTrace.cpp                          3      1      0
src/jsc/bindings/FormatStackTraceForJS.cpp                    7     11      0
src/jsc/bindings/ZigException.cpp                             5      1      0
test/bake/dev/server-sourcemap.test.ts                        1      1      0
test/js/bun/test/stack.test.ts                                9     23      0
test/js/node/test/parallel/test-vm-basic.js                   1      3      0
test/js/node/test/parallel/test-vm-context.js                 1      1      0
test/js/node/test/parallel/test-vm-run-in-new-context.js      1      2      0
test/js/node/vm/__snapshots__/vm-sourceUrl.test.ts.snap       0      0      0

formatStackTrace used JSC's raw divot (the end of the callee) for the
.stack string and fed it into the source map remap, while Bun.inspect and
Error.prepareStackTrace CallSites already go through
getAdjustedPositionForBytecode, which moves construct positions back to
the new keyword like V8. The divot has no source map mapping of its own, so
the remap snapped to the callee identifier, or to another line when the
callee had been inlined. Use the adjusted position in formatStackTrace too.

adjustPositionBackwards now handles 16-bit sources (eval, new Function and
node:vm code is not transpiled) instead of zeroing the position or hitting
ASSERT_NOT_REACHED, counts only the line breaks between the expression
start and the divot, fixes the column when the expression starts on the
first line, and keeps the divot when it cannot recount.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

This change centralizes stack-frame position adjustment, supports additional source-text formats, and reuses adjusted positions in stack formatting and exception handling. Tests cover constructors, source maps, virtual-machine offsets, Unicode text, and line endings.

Changes

Stack position adjustment

Layer / File(s) Summary
Position adjustment contract and implementation
src/jsc/bindings/ErrorStackFrame.h, src/jsc/bindings/ErrorStackFrame.cpp
Adds the public frame-position helper. It handles unavailable metadata, UTF-8 and UTF-16 source text, line terminators, bounds, and provider start columns.
Stack consumer integration
src/jsc/bindings/FormatStackTraceForJS.cpp, src/jsc/bindings/ZigException.cpp
Uses ZigStackFramePosition for adjusted positions. Sourcemapping and exception population preserve invalid byte positions and adjusted coordinates.
Stack position validation
test/js/bun/test/stack.test.ts, test/bake/dev/server-sourcemap.test.ts, test/js/node/test/parallel/test-vm-*.js
Adds constructor, eval, Unicode, line-ending, source-map, and offset coverage. Updates expected stack columns to the adjusted positions.

Possibly related PRs

  • oven-sh/bun#37081: Both changes update stack-frame position representation and handling of unavailable coordinates.
  • oven-sh/bun#37388: Both changes address stack-frame columns for new constructor calls.
🚥 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 summarizes the primary change: stack frames for constructor expressions now point to the new keyword.
Description check ✅ Passed The description explains the problem, fix, scope, and verification results, including extensive test coverage and evidence.

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

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. CI is green on the current head b0386de (build 92164, 181/181) and was green on 2f732e4 before the last round (build 91958); every review thread is addressed or answered in place.

Reproduced on bun 1.4.0 and a debug build of main with the fixture in test/js/bun/test/stack.test.ts: err.stack reports 13:13, 16:14, 19:14, 22:14, 25:16, 30:5, 34:7 for frames whose new keyword sits at 13:9, 16:10, 19:10, 22:10, 26:10, 29:10, 34:3 (Node and Bun.inspect report the latter). The eval / node:vm cases report the divot (4:8, 2:6, ...) and the UTF-16 one aborts a debug build on ASSERT_NOT_REACHED in adjustPositionBackwards before this change.

Verified locally with the debug build: the new tests fail without the src/ changes and pass with them; test/js/bun/util, test/js/node/vm, test/js/node/v8, test/js/bun/test/printing, test/js/node/util/node-inspect-tests, test/cli/run, test/bake/dev/server-sourcemap.test.ts and the three node:vm tests pass apart from timeouts that reproduce identically on an unmodified build in this environment. inspect-error-leak.test.js and error-gc-test.test.js report the same RSS and timings with and without the change.

Review so far: the two findings on the line recount (other line terminators, node:vm columnOffset) are fixed in b7357f6 with tests; the 1:1 / column-1 rendering finding is fixed in 2f732e4 with tests; the comment-length and the two CodeRabbit findings are resolved in their threads. #37388 is independent but touches the same expectations, see the comment below for what needs refreshing when the second of the two lands.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:00 AM PT - Aug 11th, 2026

@robobun, your commit b0386de2ddc4f125b310e67cdedc261e4402dff3 passed in Build #92210! 🎉


🧪   To try this PR locally:

bunx bun-pr 37396

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

bun-37396 --bun

Comment thread src/jsc/bindings/ErrorStackFrame.cpp
Comment thread src/jsc/bindings/ErrorStackFrame.cpp
…ng construct positions

adjustPositionBackwards only recognized LF when it had to recount the line
and column of a construct spanning lines, while JSC counts CR, CRLF, U+2028
and U+2029 as well, and it dropped the source's start column (node:vm
columnOffset) for expressions starting on the first line. Also update the
dev server source map test, whose server frames now land on the column 1
mapping like the other cases in that file.
Comment thread src/jsc/bindings/ErrorStackFrame.cpp Outdated
Comment thread src/jsc/bindings/ErrorStackFrame.cpp Outdated
Comment thread src/jsc/bindings/ErrorStackFrame.cpp Outdated
Comment thread src/jsc/bindings/ErrorStackFrame.h Outdated
Comment thread src/jsc/bindings/ErrorStackFrame.h Outdated
Comment thread src/jsc/bindings/FormatStackTraceForJS.cpp Outdated
Comment thread src/jsc/bindings/ErrorStackFrame.cpp Outdated
Comment thread src/jsc/bindings/FormatStackTraceForJS.cpp Outdated

@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: 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/FormatStackTraceForJS.cpp`:
- Around line 264-272: Update the remapping branch in FormatStackTraceForJS
around hasLineAndColumnInfo() to first check whether frame.hasBytecodeIndex() is
available; use Bun::getAdjustedPositionForStackFrame(frame) only when it is, and
fall back to frame.computeLineAndColumn() when the bytecode index is unset so
existing line and column data is preserved.

In `@test/js/node/test/parallel/test-vm-context.js`:
- Around line 115-117: Update the Bun-specific caret regex in the stack
assertion to match the observed exact indentation before ^ rather than allowing
zero or more spaces. Keep the V8 regex unchanged and preserve the shared
expected-filename.js:33:131 assertion, ensuring the Bun check can fail when the
caret column changes.
🪄 Autofix

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: b6c14230-2449-4a8e-87dc-9eb0678bddc1

📥 Commits

Reviewing files that changed from the base of the PR and between 9fcdea8 and b7357f6.

⛔ Files ignored due to path filters (1)
  • test/js/node/vm/__snapshots__/vm-sourceUrl.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (9)
  • src/jsc/bindings/ErrorStackFrame.cpp
  • src/jsc/bindings/ErrorStackFrame.h
  • src/jsc/bindings/FormatStackTraceForJS.cpp
  • src/jsc/bindings/ZigException.cpp
  • test/bake/dev/server-sourcemap.test.ts
  • test/js/bun/test/stack.test.ts
  • test/js/node/test/parallel/test-vm-basic.js
  • test/js/node/test/parallel/test-vm-context.js
  • test/js/node/test/parallel/test-vm-run-in-new-context.js

Comment thread src/jsc/bindings/FormatStackTraceForJS.cpp Outdated
Comment thread test/js/node/test/parallel/test-vm-context.js
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-ups pushed:

  • b7357f6: the two review findings on adjustPositionBackwards. It now counts the same line terminators JSC's lexer counts (CR, CRLF, U+2028, U+2029 in addition to LF) and adds the source's start column (node:vm columnOffset) when the recount ends on the first line. stack.test.ts gained a case for each; they produce node's values (1:31 for the three separators, 1:131 with columnOffset: 100).
  • Same commit: the CI failure in test/bake/dev/server-sourcemap.test.ts was its first case still expecting the old column. The dev server's server-side frames land one line above the statement (the server HMR chunk's map counts the client chunk's one-line prefix; pre-existing and being fixed separately), and on that line the old divot column picked the ( of function myFunc( while the new column picks column 1, which is what the other cases in that file already expect. Updated the expectation to 6:1; it fails on main and passes here.
  • e761e59, 6b32ba3: shorter comments.

Related: #37388 stops the transpiler from rewriting new Error(...) into a call. The two are independent (that PR's description calls out the column difference this one fixes), but whichever lands second needs two expectations refreshed, since frames at new Error(...) in transpiled code only move to the new keyword once both are in: err.line and err.column are set in stack.test.ts becomes column: 13 / originalColumn: 13, and the node:util.inspect case in inspect-error.test.js moves from :19 to :15. Both PRs also append a test to the end of stack.test.ts, so there will be a trivial textual conflict.

Comment thread src/jsc/bindings/FormatStackTraceForJS.cpp Outdated
formatStackTrace decided whether a frame has a position by checking for a
non-zero line or column, so a frame at column 1 printed as file.js:8 and a
frame at 1:1 printed no position at all. Constructs at the start of a line
now legitimately land on column 1, so key the suffix on whether pass 1
produced a position instead. Calls at column 1 gain their :1 as well, which
is what V8 prints.
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

2f732e4 addresses the remaining review finding, and it was a bit wider than the 1:1 example: formatStackTrace treated a zero line or column as "no position", so a frame at column 1 already printed as at file.js:8 on main, and with this PR every construct at the start of a line would have joined it (new Thrower(1) at column 1 went from :42:5 to :42). The suffix is now keyed on whether pass 1 produced a position, so :8:1, :42:1 and :1:1 all print, which is what node prints and what Bun.inspect already did. Added the column-1 call, the column-1 construct and a byte-0 vm script to stack.test.ts; the stack-related suites (test/js/node/vm, v8, inspect-error, printing snapshots, the node:vm tests, bake/dev/server-sourcemap) still pass, and a grep of the test tree found no expectation relying on the line-only form.

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

Reviewed again after 2f732e4 — no new findings. That commit addresses the earlier 1:1-position note (the :line:column suffix is now keyed on originalPositions[i].line_zero_based >= 0, and the byteZero case pins at byte-zero.js:1:1); the same commit also drops the inner displayColumn > 0 check so column-1 frames print :1, covered by callAtColumnOne/constructAtColumnOne. Given this rewrites adjustPositionBackwards and changes the .stack rendering path for every frame, a maintainer sign-off would still be worthwhile.

What was reviewed:

  • getAdjustedPositionForStackFrame guard vs. hasLineAndColumnInfo() — the code-block-without-bytecode-index case returns the -1 sentinel and hasPosition skips it, same net effect as before.
  • adjustPositionBackwards: bounds check against source.length(), CRLF counted as one break, first-line startPosition().m_column add, and the failure paths now leave pos unchanged instead of zeroing it.
  • ZigException.cpp: byte_position < 0 gate replaces the removed hasBytecodeIndex() branch so the source-line scan cannot run with a -1 offset.
Extended reasoning...

Overview

The PR routes formatStackTrace (the .stack string producer) through the same getAdjustedPositionForBytecode helper that Bun.inspect and Error.prepareStackTrace CallSites already use, so frames at new X(...) report the new keyword's column in all three renderings. adjustPositionBackwards is rewritten to index StringView directly (both encodings), count all four ECMAScript line terminators, honour the source's start column on line 1, and leave the position unchanged rather than zeroing it when it can't recount. ZigException.cpp's dead !hasBytecodeIndex() branch is folded into the new getAdjustedPositionForStackFrame helper. The latest commit (2f732e4) keys the :line:column suffix on whether pass 1 produced a position, fixing the 1:1 and column-1 cases raised in the previous review round.

Security risks

None. This is stack-frame position arithmetic over source text already held by JSC; no user-controlled sizes drive allocation, and the one bounds check (pos.byte_position > source.length()) bails out before indexing.

Level of scrutiny

Medium-high. formatStackTrace runs for every .stack materialization, and adjustPositionBackwards is now reachable from three consumers on 8- and 16-bit sources. The rewrite is small but subtle (line-terminator set, CRLF collapsing, first-line columnOffset, off-by-one at index 0) and each edge is covered by an assertion in stack.test.ts. Three vendored node:vm tests move back toward upstream expectations, which is the direction the repo prefers.

Other factors

Two prior automated-review rounds each found one issue and both were fixed with dedicated tests (b7357f6 for line terminators / columnOffset, 2f732e4 for the 1:1 guard). CodeRabbit's two findings were argued and withdrawn. The change also silently makes every column-1 frame print :1 (matching V8) and always prints the column when a line is printed — a user-visible format change beyond constructs, which is why a maintainer look is still warranted.

The .stack string and CallSite paths only need a line and column, and for
every frame that is not at a construct that is JSC's own position, which
CodeBlock::lineColumnForBytecodeIndex caches per bytecode index. Decoding the
expression info entry (a linear walk through its chapter) is now limited to
construct frames, where the divot and start offset are needed, and to the
code frame printer, which needs the source offset.

Also cover the parts of the recount that had no failing test: line
terminators other than LF before the new keyword (where the column count has
to stop), and a class expression callee, which the transpiler keeps on
several lines so transpiled code reaches the recount as well.
Comment thread src/jsc/bindings/ErrorStackFrame.cpp Outdated
Comment thread src/jsc/bindings/ErrorStackFrame.cpp Outdated
Comment thread src/jsc/bindings/ErrorStackFrame.h Outdated
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

CI for 2f732e4 was green (build 91958). A further self-review pass turned up three things, all pushed in 1fd3ef1 (b0386de is comments only):

  • Cost: the .stack string path was decoding the expression info entry for every frame, while before this PR it used JSC's cached line/column lookup. getAdjustedLineColumnForBytecode now checks the opcode first and only decodes for construct frames; everything else goes through lineColumnForBytecodeIndex as before. The CallSite path uses the same variant (it used to decode every frame); the code frame printer keeps the full variant because it needs the source offset. Numbers are in the description: the decode-every-frame revision measured about 5% slower than pre-PR on the shapes I tried, this revision measures the same as pre-PR, within the noise of the machine.
  • The CR / U+2028 cases only exercised the line count, not the column count (they all had new on line 1, so the backwards walk ended at the start of the source either way). Added second-line variants for CR, U+2028 and U+2029, which report 2:36 instead of 2:31 if the column walk only stops at LF.
  • The description claimed transpiled code never reaches the cross-line recount. It does when the callee is a class or function expression, which the printer keeps on several lines; added classExpressionCallee, which main reports two lines off (48:3 for a new on line 46) and which now matches node (46:10). Description fixed accordingly.

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

I re-reviewed after 1fd3ef1 / b0386de and didn't find any further issues — the three earlier findings (columnOffset on line 1, non-LF line terminators, and the 1:1 / column-1 suffix guard) are all fixed with covering tests. Given this rewrites the position-adjustment path that feeds .stack, source-map remapping, and err.line/err.column, and changes the output shape (always :line:column), a human sign-off would still be worthwhile.

What was reviewed:

  • adjustPositionBackwards bounds, CRLF-as-one-break handling, and the i < 0 startColumn add-back — indexing stays within [0, source.length()).
  • getAdjustedLineColumnForBytecode: non-construct frames go through lineColumnForBytecodeIndex (same lookup as pre-PR computeLineAndColumn); the hasBytecodeIndex() guard makes the removed ZigException fallback safe.
  • hasPosition in formatStackTrace correctly distinguishes the -1 sentinel from a real 0:0; remapped frames still print because the flag keys on the original, not the remapped, position.
Extended reasoning...

Overview

The PR unifies the three renderings of a stack frame at new X(...) (.stack string, Bun.inspect, CallSite objects) so they all report the new keyword's position, matching Node/V8. It rewrites adjustPositionBackwards in ErrorStackFrame.cpp (16-bit source support, all four ECMAScript line terminators, node:vm columnOffset on line 1, keep-divot-on-failure instead of zeroing), adds a getAdjustedLineColumnForBytecode variant that only decodes expression info for construct opcodes, wires formatStackTrace and JSCStackFrame::calculateSourcePositions to it, replaces the > 0 suffix guard in formatStackTrace with an explicit hasPosition flag, and folds ZigException.cpp's dead !hasBytecodeIndex fallback into the shared helper. Tests cover the transpiled matrix, eval/vm sources across encodings and terminators, column-1 and byte-0 frames, and three vendored node:vm tests move back toward upstream assertions.

Security risks

None. This is display-side stack formatting; inputs are JSC-produced positions and the source provider's own text. The only untrusted-length read is source[i] in the recount loops, which are bounded by start >= 0, i < pos.byte_position, and the explicit pos.byte_position > source.length() bail. No allocation sizing, no user-controlled indices.

Level of scrutiny

Moderate-high. error.stack is user-visible output that external tooling (error reporters, source-map remappers, test runners) parses, and this changes its shape for every frame that lands at column 1 (now :N:1 instead of :N). It's also on the .stack materialization path, so the opcode-first split in 1fd3ef1 was specifically added to keep non-construct frames on the cached lookup — that's a perf-sensitive choice a maintainer should confirm. The adjustPositionBackwards rewrite touches encoding-sensitive indexing over StringView.

Other factors

All three of my earlier inline findings were fixed with dedicated test cases; CodeRabbit's two findings were withdrawn after discussion. CI was reported green on 2f732e4. The PR description flags a known interaction with #37388 (two expectations to refresh whichever lands second) that whoever merges should be aware of. The change is well-scoped and thoroughly tested, but not mechanical — deferring rather than approving on that basis.

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