error printer: don't invent a 1:1 position for frames parsed out of error.stack without one - #38328
error printer: don't invent a 1:1 position for frames parsed out of error.stack without one#38328robobun wants to merge 1 commit into
Conversation
…on unpositioned V8StackTraceIterator defaulted the line and column of a parsed frame to zero-based 0, so "at foo (native)", "at unknown" and "at bar (x.js:7)" came back out of the printer as "foo (native:1:1)", "unknown:1:1" and "bar (x.js:7:1)". Default them to beforeFirst() and set byte_position to -1 so an unparsed location is exactly ZigStackFramePosition::INVALID. On the Rust side, stop the three places that turned that back into a position: remap_zig_exception clamped the top frame's position to 0:0 when it was already remapped and previewed line 1 of the file for it, print_stack_trace dropped the file of a named frame whose position is unknown, and print_github_annotation emitted the column whenever any part of the position was set.
|
Warning Review limit reached
Next review available in: 47 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 (4)
Comment |
|
Updated 1:05 AM PT - Aug 14th, 2026
❌ @robobun, your commit df7e291 has some failures in 🧪 To try this PR locally: bunx bun-pr 38328That installs a local version of the PR into your bun-38328 --bun |
|
Status: fix pushed, waiting on CI. Reproduced on the release binary (1.4.0) and on main's |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The sentinel change is small and the consumers I traced already branch on per-ordinal validity, but because it shifts ZigStackFramePosition's "unknown" convention at the C++/Rust boundary and the description names blast radius beyond parsed frames (wasm frames with a named source, the HMR-runtime case), a human look at those output changes would still be worthwhile.
What was reviewed:
bun_sourcemap::LineColumnOffset::{lines,columns}andZigStackFramePosition::{line,column}are bothbun_core::Ordinal, so the direct copy inremap_zig_exceptiontype-checks and preserves-1.- After the new
!mapping.original.lines.is_valid()early break, the write-back atframes[top].positionre-stores the same INVALID ordinals andcodeis empty, soget_lines_in_textandcollect_source_linessee the same no-preview path as any other empty-source case. print_stack_trace's new!file.is_empty()arm still routes throughsource_url_formatter, which already handles file-only / file:line / file:line:col; thehas_name && position invalid && file emptybranch below is unchanged.- The GitHub annotation change only alters output for frames whose line is invalid (drops the location) or whose column alone is invalid (drops
col=); the fully-positioned path is byte-identical.
Extended reasoning...
Overview
The PR stops Bun's error printer from inventing :1:1 for stack frames that were parsed back out of error.stack without a position. It changes V8StackTraceIterator::StackFrame's default line/column from zero-based 0 to beforeFirst() (-1) and sets byte_position = -1 when copying into ZigStackFrame, so an unpositioned parsed frame equals ZigStackFramePosition::INVALID. On the Rust side it removes the .zero_based().max(0) clamp in remap_zig_exception (direct Ordinal copy instead), skips fetching a file for the source preview when there is no line, makes print_stack_trace print at name (file) when a frame has a file but no position, and makes print_github_annotation gate the location on line.is_valid() and emit col= only when the column is valid. Tests cover all five frame shapes through Bun.inspect, the source-preview/caret selection, and two spawned processes checking the uncaught printout and ::error annotation under GITHUB_ACTIONS=true.
Security risks
None. This is presentation-only (error message formatting and CI annotation strings). No parsing of untrusted input was added; the existing V8StackTraceIterator::parseFrame body is unchanged. No allocation, FFI ownership, or lifetime changes.
Level of scrutiny
Medium. The runtime effect is cosmetic, but the mechanism is a sentinel-value convention at the C++/Rust FFI boundary (ZigStackFramePosition). I verified the types line up (bun_sourcemap::Ordinal re-exports bun_core::Ordinal, matching ZigStackFramePosition::line/column), that is_invalid() compares all three fields so byte_position = -1 is required, and that the downstream write-back and get_lines_in_text path after the new early break degrade to the existing empty-source behaviour rather than doing anything new with a -1 line.
Other factors
The PR description explicitly enumerates blast radius beyond parsed-string frames — wasm frames with a named source and the dev server's all-HMR-frames case now print at name (file) instead of at name — and lists ten related test files that were re-run. That is the right diligence, but it is also a user-visible output change outside the direct bug that a maintainer should sign off on. The change is not a mechanical/config tweak, so per the approval guidelines I'm deferring rather than auto-approving.
|
Thanks. On the two output changes outside parsed frames, I re-checked and narrowed them in the description:
The description's blast radius bullet now says exactly that. |
Problem
Once
error.stackhas been read or assigned,Bun.inspect(err),console.error(err)and the uncaught exception printout rebuild the frames by parsing that string. Frames the string prints without a position come back out with one invented for them:at foo (native)prints asat foo (native:1:1),at unknownasat unknown:1:1, and a frame with only a line,at bar (x.js:7), asat bar (x.js:7:1). Reproduces on 1.4.0 and on main:Real errors hit it too: after
.stackis read, aJSON.parseSyntaxError printsat parse (unknown:1:1), an indirect evalat eval (unknown:1:1), and theERR_SOCKET_CLOSEDerror intest/regression/issue/23022-stack-trace-iterator.test.tsprintsat unknown:1:1.The invented position is also acted on: the source preview is looked up for it, so a stack whose first frame is
at first (native)followed by a real frame draws the caret at column 1 instead of under the real frame's column, a frame naming a file without a line gets line 1 of that file previewed, and underGITHUB_ACTIONSthe annotation for such an error is::error file=../workspace/bun/native,line=1,col=1,....Cause, C++ side:
V8StackTraceIterator::StackFrame(src/jsc/bindings/ZigException.cpp:248) initializeslineNumber/columnNumberto zero-based 0, a valid line 1 / column 1, and the copy infromErrorInstance(ZigException.cpp:612) stores them unconditionally.current = {}also leavesbyte_positionat 0, so even with the ordinals unset the position would not equalZigStackFramePosition::INVALID, which is what the Rust side tests withis_invalid().Cause, Rust side (
src/jsc/VirtualMachine.rs):remap_zig_exceptionbuilds the lookup for an already-remapped top frame with.zero_based().max(0)and writes it back, turning INVALID into 0:0 again (this dates back to the Zig version and was masked by the C++ default);print_stack_traceused!position.is_invalid()to decide whether a named frame has a file, so a frame with a file and no position would print asat foowith the file dropped;print_github_annotationprintedcol=whenever the position as a whole was set, which for a line-only frame iscol=0.Fix
StackFramedefaults the ordinals toOrdinalNumber::beforeFirst()(-1, the conventionCallSite.handBunProcess.cppalready use) and the copy setsbyte_position = -1, the same explicit-1, -1, -1thatFormatStackTraceForJS.cppwrites for frames it does not position. A frame whose location parsed to nothing is now exactlyZigStackFramePosition::INVALID; one that parsed a line only has a valid line and an invalid column.remap_zig_exceptioncopies the top frame's ordinals into the lookup unchanged (both sides arebun_core::Ordinal, the same direct copy the write-back below it already does) and skips fetching the file for the preview when there is no line to preview.print_stack_traceprintsat name (file...)when the frame has a file or a position.print_github_annotationkeys the location on the line being valid and addscol=only when the column is.Holderinitializes frames to it, the structured path leaves it for wasm frames,FormatStackTraceForJS.cppwrites it explicitly). The consumers already handle it:SourceURLFormatterprintsfile,file:lineorfile:line:coldepending on which ordinals are valid, the JUnit reporter (test_command.rsrecord_failure) does the same, and the preview code skips frames whose position is invalid. The three places changed here were the ones that either used the sentinel as a proxy for "has a file" or clamped it away. The output now matches whaterror.stackitself says, and what Node prints for such frames (it never invents a position either).error_report_request.rs), and only when every frame is in the HMR runtime (otherwise those frames are dropped before printing); that case now printsat fn (Bun HMR Runtime)instead ofat fn, which is what it already printed for such frames without a name. Structured frames are unaffected:populateStackTraceonly keeps frames with a position or wasm frames, and wasm frames never get a file (Zig::sourceURLreturns[wasm code], whichpopulateStackFrameMetadatadiscards), so they still printat namethrough the unchanged branch; builtin frames with a position but no file (at require (51:24)in debug builds) print as before. The GitHub annotation only changes for frames without a line (no location instead of a bogus one) or without a column (nocol=instead ofcol=0/col=1).error.stackalways print:line:columnfor frames that have one (today a frame at column 1 prints asx.js:7, which round-trips through this change asx.js:7, and through main asx.js:7:1by accident); once both land such frames round-trip exactly. error printer: keep parsing error.stack past frames without a function name #38308 and error printer: print the AggregateError header, label [cause]/[errors] blocks, and guard the .errors walk #36602 change the body ofparseFrameand error printer: remap frames when the original source is unavailable, and not twice after error.stack #38296 changesremap_zig_exceptiona few lines below the hunks here; none of them touch these lines, and the shared preamble ofinspect-error.test.js(harness import,normalizeError, shifted snapshot line numbers) is byte-identical to theirs so either order rebases cleanly.test/js/bun/util/inspect-error.test.js,describe("printing the frames parsed back out of error.stack without a position"): the five frame shapes above throughBun.inspect; a native frame as the only frame (the clamp); a file without a line (no preview, no:1:1); a native frame above a positioned one (the caret belongs to the positioned one, which is whatbyte_position = -1buys); and two spawned uncaught errors underGITHUB_ACTIONS=truechecking the printout and the annotation for a top frame without a position and with a line only.test/regression/issue/23022-stack-trace-iterator.test.tsnow requires theunknownline to be exactlyat unknown. All six fail on the release binary and on main'ssrc/underbun bd(thenormalizeErrorupdate in the same file also makes the two minified-file tests pass underbun bdagain, as in error printer: keep parsing error.stack past frames without a function name #38308), and pass with this change.inspect.test.js,reportError.test.ts,test/js/bun/test/stack.test.ts,error-name-preservation.test.ts,circular-error-stack*.test.ts,fix-bindings-stack-trace.test.ts,prepare-stack-trace-crash.test.ts,test/js/node/v8/capture-stack-trace.test.js,test/cli/test/bun-test.test.ts(the existing annotation tests),test/js/bun/test/bun_test.test.ts.Background
error.stackis lazy in JSC: anErrorInstancekeeps the capturedJSC::StackFrames until.stackis first read or written, formats them into the string, and drops them. Bun's native error printer (remap_zig_exception+print_error_instance_bodyinVirtualMachine.rs, behindBun.inspect,console.*, uncaught exceptions and the test reporters) therefore has two sources of frames: the structured ones while they exist, otherwise the string, whichfromErrorInstanceparses back intoZigStackFrames withV8StackTraceIterator.ZigStackFrame/ZigStackFramePositionare the#[repr(C)]structs shared between the C++ that fills them and the Rust that prints them. A position is threei32s: zero-based line, zero-based column and a byte offset, each-1when unknown;ZigStackFramePosition::INVALIDis all three at-1andis_invalid()compares the whole struct.bun_core::OrdinalandWTF::OrdinalNumberare the same representation of "zero-based index, -1 means none" on the two sides.file,file:lineorfile:line:column. Native functions print asname (native), and frames with neither a name nor a file print as the bare wordunknown; both are common in Bun's own stacks.Outputs before and after
Release 1.4.0 / main, the repro from the test file:
With this change:
Real errors after
.stackwas read, with this change (at <parse>forJSON.parsepreviously printed asat <parse> (1:1); its presence at all is a separate, already tracked issue):Annotations on release for the two spawned test cases:
With this change: