error printer: place the code frame caret by the excerpt, not by the top frame's column - #38349
error printer: place the code frame caret by the excerpt, not by the top frame's column#38349robobun wants to merge 3 commits into
Conversation
The caret under the source excerpt was indented by the top frame's column. On the first line of a source compiled with a start column (node:vm's columnOffset) that column includes the offset, while the excerpt is the physical line, so the caret landed columnOffset columns to the right of the token. ZigStackTrace gains a caret column that is filled together with the excerpt: from the byte offset into the excerpted line when the lines are cut out of the source provider, and from the mapped column when they are rebuilt from a source map. The printer indents the caret by that column; the frame's reported position is unchanged.
|
Warning Review limit reached
Next review available in: 2 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (6)
Comment |
|
Status
|
There was a problem hiding this comment.
Beyond the inline nit, I checked the #[repr(C)] layout of the new ZigStackTrace field on both sides — bun_core::Ordinal is #[repr(transparent)] over c_int, and the field order/position matches headers-handwritten.h (the int32_t lands in the existing padding between the two u8s and frames_ptr). I also verified the else if let Some(top) = top_frame → else change is behavior-preserving (the preceding if already handles top_frame.is_none()), and that the new source_lines_caret_column out-param in populateStackFramePosition is only dereferenced on the is_top && OnlySourceLines path where it is non-null.
Extended reasoning...
The change is well-reasoned and the FFI layout checks out, but it modifies a #[repr(C)] struct shared across the Rust/C++ boundary and interacts with three other open PRs (#38335, #38244, #38240) per the description — a human should confirm merge ordering. The one finding is a harness-convention nit (undrained stdout on a child that writes nothing to it), not a functional issue.
|
Updated 2:05 AM PT - Aug 14th, 2026
❌ @robobun, your commit 067c8c7 has some failures in 🧪 To try this PR locally: bunx bun-pr 38349That installs a local version of the PR into your bun-38349 --bun |
The excerpted error line now starts after the previous line's newline instead of on it, so the caret column is the position's offset into the text that is stored; the printer trimmed that newline anyway.
There was a problem hiding this comment.
I re-reviewed after 297bbcf and 067c8c7 and found no bugs; the earlier stdout-drain nit and the comment-cop notes are addressed. Because this changes the layout of a #[repr(C)] struct shared across the Rust/C++ boundary and reworks how the error printer derives its caret for every code frame, a human look is still worthwhile.
Checked: Ordinal is #[repr(transparent)] over c_int, so the new field matches int32_t and both sides place it in the same 4-byte slot between source_lines_to_collect and frames_ptr. The else if let Some(top) → else change is safe — the preceding if already guards top_frame.is_none(). col.max(0) on the indent handles the INVALID initializer. The textStart shift only drops a leading \n that every printer already trimmed via SourceLine::trimmed_text.
Extended reasoning...
Overview
Adds source_lines_caret_column (Rust Ordinal / C++ int32_t) to ZigStackTrace, filled by every code path that writes source_lines[0] — populateStackFramePosition (byte offset minus the excerpt's own start), the lineText fallback in fromErrorInstance, and the source-map path in remap_zig_exception. The printer now indents the ^ by that stored column instead of by the top frame's position.column. populateStackFramePosition also now cuts source_lines[0] starting after the previous line's \n so the excerpt and the caret share an origin. Six new tests cover vm.Script first-line/second-line, compileFunction, and the uncaught-error subprocess path.
Security risks
None. This is presentation-only (where the caret is drawn); frame positions, error.stack, and prepareStackTrace are unchanged. No untrusted input crosses a new boundary.
Level of scrutiny
Medium-high. The change itself is small and the mechanism is clearly correct (caret = index into the printed text, so derive it from the printed text), but it edits a hand-mirrored FFI struct and the error printer that runs for every uncaught error, Bun.inspect(err), and console.error(err). A layout mismatch or a missed producer of source_lines[0] would be visible everywhere. I verified the layout on both sides (Ordinal is #[repr(transparent)] c_int; the field lands at the same padded offset before frames_ptr), traced all three writers of source_lines[0] to confirm each also writes the new field, and confirmed the removed if let Some(top) binding was dead (the if arm above it already handles top_frame.is_none()).
Other factors
- Previous feedback is addressed: the spawn test now drains stdout and asserts it empty; the multi-paragraph comments were reduced after the excerpt origin was made to match the caret origin.
- The description notes overlap with three open PRs (#38335, #38244, #38240); the interactions look benign but merit a maintainer's ordering call.
- The tests assert behaviour relative to a
columnOffset: 0baseline rather than hard-coding columns, so they stay stable across debug/release column differences.
Problem
Bun.inspect(err)/console.error(err)) is drawncolumnOffsetcolumns too far right when the error is on the first line of anode:vmsource compiled with acolumnOffset.new vm.Script('throw new Error("x")', { filename: "s.js", columnOffset: 20 }).runInThisContext({ displayErrors: false })prints1 | throw new Error("x")with the^20 columns to the right ofnew, past the end of the line. The frame line under it,at s.js:1:27, is correct: Node addscolumnOffsetto the first line's reported columns.[].reduce((a, b) => a)inx.js, the excerpt is the user's line but the caret is drawn at the builtin's own column (11 on a release build, 9 on a debug build) instead of underreduce.populateStackFramePosition(src/jsc/bindings/ZigException.cpp) cutssource_lines[0]out of the source text by byte offset, andremap_zig_exception(src/jsc/VirtualMachine.rs) rebuilds the lines from the file of the frame it picks;print_error_instance_bodythen indents the caret by the column of a frame it picks itself. A frame's column is what Node-style stack frames report, not an index into the printed line: on the first line of a source with a start column JSC adds that start column (CodeBlock::expressionInfoForBytecodeIndexaddsfirstLineColumnOffset()there), and the frame the printer picks is not always the frame the lines were taken from.Fix
ZigStackTracegets a caret column that belongs tosource_lines[0]and is written by whoever writes that line:populateStackFramePositiontakes the position's byte offset minus the offset it cuts the line at; thelineTextpath infromErrorInstanceand the source-map path inremap_zig_exceptionstore the column they built the line from. The printer indents the caret by it. Frame positions (at file:line:col,error.stack,prepareStackTrace) are unchanged.populateStackFramePositionused to cut the error line starting on the previous line's\n(its backward scan stops on that byte, and the lines-above loop depends on it staying there) and left the printer to trim the\n. The line is now cut from the byte after it, so the caret offset and the stored text have the same origin. Every printer trimmed that newline (SourceLine::trimmed_text), so output is unchanged; the only thing that sees the difference is the error line in the inspector'sLifecycleReporter.errorpayload, whose source-map-built lines never had the newline anyway.byte_position - textStartis exact whatever the source's start line or column is, and it stays exact through thenew X()walk-back ingetAdjustedPositionForBytecode, which moves the byte offset along with the column. It is also what Node does for its own arrow header (GetErrorSourcesubtracts the script origin's column offset on the first line), and what node:vm's header in Bun already does (handleExceptioninNodeVM.cpp); the printer was the remaining consumer doing otherwise. On the source-map path the stored column is the one the printer used before, so transpiled files print exactly as they did.int32_tin the C struct and abun_core::Ordinalon the Rust side, the same splitZigStackFramePositionuses (aWTF::OrdinalNumbermember would makeZigExceptionnon-C-compatible for theextern "C"declaration that returns it by value). It occupies existing padding, so the layout of the surrounding fields is unchanged on both sides.test/js/node/vm/vm.test.ts, "code frame of an error thrown from a source compiled with columnOffset":vm.Scriptfirst-line errors from a construct, a property access and an unresolvable identifier (caret equal to the un-offset run, frame column shifted by the offset), an error on the second line (identical to the un-offset run),compileFunction(the caret follows the text; this one passes today because compileFunction currently ignorescolumnOffset, and pins the printer for node:vm: report compileFunction body lines relative to lineOffset when it is 0 #38240, which makes it apply), and the uncaught printer in a subprocess throughvm.runInThisContext. 4 of the 6 fail on the released 1.4.0 (caret at 26 instead of 6, frame column 27 in both), which prints the same output as a debug build of main for the repro; all 6 pass with the change.inspect-error.test.js(its two minified-file snapshots fail without this change too, from a debug-onlyat requireframe),inspect.test.js,reportError.test.ts,vm-sourceUrl.test.ts, the rest ofvm.test.ts,bun/test/stack.test.ts,test-test.test.tsand the otherbun testoutput tests,console-log.test.ts, the caret-asserting regression tests, and the source-mapped code frame inbundler_bun.test.ts(bun/TargetBunSourcemapInline, wherenewis walked back across a line).remap_zig_exceptionpicked) along with the GitHub Actions annotation; with this PR the caret no longer depends on the printer's frame choice and its caret test passes either way, the annotation half is independent, and the two diffs do not touch the same lines. error printer: fix the code frame lines above errors thrown from vm/eval sources #38244 rewrites the excerpt block inpopulateStackFramePosition; on top of it the new line isbyte_positionminus its line start. node:vm: report compileFunction body lines relative to lineOffset when it is 0 #38240 strips compileFunction's wrapper from the first excerpted line; the caret then has to be measured from that start too, which the compileFunction test here catches.Background
name: messageline Bun prints above a stack trace, for uncaught errors and forBun.inspect/console.errorof an Error.remap_zig_exceptionfillsZigStackTrace.source_lines(index 0 is the error's line, higher indices the lines above it), either from the original file through a source map or, when nothing maps (vm, eval,new Function, builtins), by callingpopulateStackFramePosition, which slices the lines out of JSC's copy of the source;print_error_instance_bodyrenders them.ZigStackTrace/ZigException: C structs declared inheaders-handwritten.hand mirrored by#[repr(C)]structs insrc/jsc/; Rust allocates them (zig_exception::Holder) and C++ fills them in. The source lines and their line numbers live on the trace; each frame only has its own position.getAdjustedPositionForBytecodeturns this into Bun'sZigStackFramePosition(line, column, byte offset), movingnew X()positions back onto thenewkeyword. The column is the one Node-style frames print; for aSourceCodethat does not start at column 0 (node:vm'scolumnOffset, which Node defines as added to the first line's columns only) JSC adds the start column to positions on the first line, so on that line the column is not an index into the physical text.Before / after
With
columnOffset: 0, or with the error on any line but the first, the output is the same before and after.displayErrors: falseonly keeps node:vm from replacingerr.stackwith its own header; the same excerpt is printed byBun.inspect(err).