Skip to content

error printer: excerpt the frame picked for the code frame, not frame 0, for sources without a source map - #38356

Open
robobun wants to merge 1 commit into
mainfrom
farm/32b18a29/code-frame-excerpt-chosen-frame
Open

error printer: excerpt the frame picked for the code frame, not frame 0, for sources without a source map#38356
robobun wants to merge 1 commit into
mainfrom
farm/32b18a29/code-frame-excerpt-chosen-frame

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • When an error is thrown inside a JS builtin (or one of bun's own node:* modules) that was called from a source bun did not transpile (a node:vm script, eval, new Function), the code frame above the error shows the builtin's own source instead of the user's line. Affects the uncaught-error output, Bun.inspect(err) and console.error(err). Reproduces on 1.4.0 and on main:
    const vm = require("node:vm");
    new vm.Script("[].reduce((a, b) => a)", { filename: "/virtual/r.js" }).runInThisContext({ displayErrors: false });
    prints
    1 | (function (callback )
                  ^
    TypeError: reduce of empty array with no initial value
          at reduce (1:11)
          at /virtual/r.js:1:10
    
    (a debug build prints lines 7-12 of the Array.prototype.reduce builtin; with emitter.emit("error") and no listener it prints bun's node:events source). Expected, and what this PR prints: 1 | [].reduce((a, b) => a).
  • The same stack in a file bun transpiled already shows the user's line, because that path reads the file itself (see below). Only the no-source-map path was wrong.
  • Cause: remap_zig_exception (src/jsc/VirtualMachine.rs, the top loop around line 5599) picks the first frame that is in a user source, skipping builtin and bun:/node: frames. When the source map lookup for that frame finds nothing it calls exception.collect_source_lines(), and populateStackTrace in src/jsc/bindings/ZigException.cpp (the OnlySourceLines branch, line 448 on main) excerpted frames[0] unconditionally (is_top = i == 0); it was never told which frame had been picked (Fix segmentation fault during building stack traces string #22902 split the two passes and kept frame 0 for this one).

Fix

Background

  • Code frame: the N | source text line plus ^ that bun prints above an error's name: message. It is built in two steps. JSC__JSValue__toZigException (OnlyPosition pass) copies JSC's stack frames into ZigStackTrace.frames, recording each one's index into JSC's frame vector in jsc_stack_frame_index. remap_zig_exception then filters those frames, picks top, and either rebuilds the lines itself from the original file (sources bun transpiled: it has them on disk or in the source map) or, when there is no source map for the frame, asks C++ to cut the lines out of the JSC::SourceProvider of that frame (OnlySourceLines pass). The second case is the only one that can see a node:vm / eval / new Function source, which exists nowhere but inside JSC.
  • A JS builtin such as Array.prototype.reduce is itself JavaScript inside JSC, so it appears as a frame with an empty source URL and a position inside the builtin's text; that text is what was being excerpted. Bun's node:* modules appear the same way with a node: URL.
  • frame_index indexes ZigStackTrace.frames after Rust's filtering; the filtering swaps entries but each entry keeps its own jsc_stack_frame_index, so C++ still reaches the right JSC frame. Frames parsed out of an error.stack string carry -1 there and get no excerpt, as before.

remap_zig_exception picks the first frame that is in one of the user's
sources (skipping JS builtins and bun's own modules) and shows that
frame's line as the code frame. When that source has no source map
(node:vm scripts, eval, new Function) the lines are collected by
ZigException__collectSourceLines, which always excerpted frame 0. With a
builtin on top of the stack this printed the builtin's own source text,
e.g. Array.prototype.reduce's, above "reduce of empty array with no
initial value".

Pass the index of the picked frame through to C++ and collect the lines
of that frame only. The other frames' positions were only being
recomputed by that pass, which nothing needed.
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on 1.4.0 (NO_COLOR=1 bun repro.js with the vm.Script snippet from the description prints the builtin's source line above the TypeError) and on a debug build of main (prints lines 7-12 of the reduce builtin). With this branch both print 1 | [].reduce((a, b) => a).

Fail-before / pass-after: USE_SYSTEM_BUN=1 bun test test/js/node/vm/vm.test.ts -t "code frame of an error thrown inside a builtin" fails all 6 new tests on 1.4.0; bun bd test passes them.

@coderabbitai

coderabbitai Bot commented Aug 14, 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: ee143a8d-2533-4384-ad65-0172831ddb0a

📥 Commits

Reviewing files that changed from the base of the PR and between 1cf8af0 and 86c92d2.

📒 Files selected for processing (4)
  • src/jsc/VirtualMachine.rs
  • src/jsc/ZigException.rs
  • src/jsc/bindings/ZigException.cpp
  • test/js/node/vm/vm.test.ts

Walkthrough

Source-line collection now receives the selected stack-frame index. Native collection validates and resolves that frame. VM tests cover code frames from scripts, dynamic execution, built-in methods, events, and uncaught errors.

Changes

Exception source-frame collection

Layer / File(s) Summary
Frame-indexed source collection
src/jsc/ZigException.rs, src/jsc/bindings/ZigException.cpp
The Rust and C++ APIs accept a frame index. Native collection validates the index, resolves the matching JSC frame, and collects source lines only for that frame.
VirtualMachine frame selection wiring
src/jsc/VirtualMachine.rs
Remapped and direct source-line collection pass the selected top-frame index.
VM code-frame validation
test/js/node/vm/vm.test.ts
Tests validate filenames, excerpts, caret placement, headers, disabled display errors, and uncaught-process output for VM-related errors.

Possibly related PRs

  • oven-sh/bun#38324: Directly modifies the same exception source-line collection APIs and frame-selection paths.
  • oven-sh/bun#36602: Modifies exception source-frame handling in VirtualMachine.rs and ZigException.cpp.
  • oven-sh/bun#38240: Modifies source-line extraction in ZigException.cpp.

Suggested reviewers: jarred-sumner

🚥 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 and concisely describes the main fix: excerpting the selected frame instead of frame 0 for sources without source maps.
Description check ✅ Passed The description explains the problem, fix, scope, limitations, and verification results in sufficient detail.

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Cross-reference from going over the open PRs in this area: #38324 rewrites the same collector (ZigException__collectSourceLines becomes a slice out of a SourceProvider pinned on each frame) but still slices frame 0, so the bug fixed here survives it unchanged. The two conflict textually in ZigException.cpp / ZigException.rs only; on top of #38324 this PR reduces to passing top into its collectSourceLines and indexing with it. If #38324 merges first I will rebase this one to that shape; if this one merges first, #38324's rebase is the same two-line change in the other direction. Noted in the PR description as well.

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

LGTM — the excerpt now comes from the same frame the printer already picked for the code frame's location.

What was reviewed:

  • top as u8 cast: top indexes into frames_buf[..frames_len] where frames_len fits in u8 (Holder::FRAME_COUNT = 32), and C++ bounds-checks it against trace.frames_len before use.
  • Dropped OnlySourceLines loop over non-top frames: it only re-ran populateStackFramePosition with nullptr source-line buffers, which memcpy'd the same position bytes the OnlyPosition pass had already stored — nothing consumed the rewrite.
  • referenced_source_provider ref balance: still exactly one provider->ref() per collect_source_lines call (only the picked frame reaches the ref path, same as before when only is_top did), released in ZigException::deinit.
  • FFI signature: Rust extern declaration, both call sites, and the C++ definition all take the new uint8_t frame_index; the default source_lines_frame_index = 0 on populateStackTrace keeps the OnlyPosition callers (fromErrorInstance, JSC__Exception__getStackTrace) unchanged.
Extended reasoning...

Overview

This PR fixes the code-frame excerpt shown above errors thrown inside a JS builtin (or a bun node:* module) when called from a source without a source map (node:vm scripts, eval, new Function). remap_zig_exception already picks the first user frame (top) and uses its source_url/position for the code frame's label; the fix threads top through to ZigException__collectSourceLinespopulateStackTrace(OnlySourceLines) so the excerpted text comes from that same frame's SourceProvider instead of unconditionally from frames[0]. The now-redundant loop over the remaining frames (which only rewrote positions with values the OnlyPosition pass had already stored) and the now-constant is_top parameter are removed. Six new tests in test/js/node/vm/vm.test.ts cover vm.Script, runInNewContext, eval, new Function, a node:events frame on top, and the uncaught-error path via Bun.spawn.

Security risks

None. This is presentation-only (error-printer output). No new inputs are parsed, no allocation sizes derive from untrusted data, and the new frame_index is a scalar produced by Rust from an in-bounds enumeration and re-bounds-checked in C++ (>= trace.frames_len and jsc_stack_frame_index < 0 || >= frames.size() both early-return). The SourceProvider ref taken for the excerpt is the same one that was taken before, just for a different frame, and is released in ZigException::deinit as before.

Level of scrutiny

Medium. It touches the JSC↔Rust FFI boundary and drops a loop, so the review checked (a) the FFI signature is consistent on both sides, (b) the cast top as u8 cannot truncate (top indexes a frames_len: u8-bounded slice, capped at 32), (c) the dropped loop had no side effect anyone consumed — it re-memcpy'd positions from the same JSC frame the first pass had already stored, and (d) the referenced_source_provider ref/deref balance is unchanged (exactly one ref per call, still released in deinit). All check out. The behavioral surface is limited to which line of text appears in an error message.

Other factors

The tests are well-constructed: they call the builtin from line 2 so both the excerpt text and its line-number label must come from the user frame; they assert against Bun.inspect and the uncaught-error stderr; the subprocess test drains stdout/stderr/exited concurrently and asserts exitCode last. The PR description documents fail-before/pass-after with USE_SYSTEM_BUN=1 and an ASAN RSS check over 30k iterations. The caret-column question is explicitly out of scope (handled by #38335/#38349) and the tests only assert a caret line exists, not its column, so they won't conflict.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:05 PM PT - Aug 13th, 2026

@robobun, your commit 86c92d2 is building: #95433

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:05 AM PT - Aug 14th, 2026

@robobun, your commit 86c92d2 has some failures in Build #95433 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38356

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

bun-38356 --bun

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