Skip to content

node:vm: don't remap vm code through the source map of the file its filename names - #38344

Open
robobun wants to merge 1 commit into
mainfrom
farm/4caf2361/vm-no-source-map-remap
Open

node:vm: don't remap vm code through the source map of the file its filename names#38344
robobun wants to merge 1 commit into
mainfrom
farm/4caf2361/vm-no-source-map-remap

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • vm code compiled under the filename of a file Bun transpiled gets its positions remapped through that file's source map. In the repro below the throw is on physical line 10 of the vm source; under the host's own filename Bun prints at f (/tmp/prrepro/remap_script.js:5:48) with err.line === 5, err.originalLine === 10. Under any other name it prints :10:32; Node prints line 10 under both.
  • Which wrong line comes out depends on what the host's transpiled output has on that line, so it varies with the host file (the new tests pad the host so it always happens).
  • Affected: error.stack, Error.prepareStackTrace call sites, Bun.inspect(err) / console.error(err) / the uncaught exception output (wrong line, plus a code frame taken from the host file), and --cpu-prof (callFrame.lineNumber and positionTicks of the vm function).
  • Cause: the source map table is keyed by file path, and the remap sites decide eligibility by name.
    • formatStackTrace and computeErrorInfoWithPrepareStackTrace (src/jsc/bindings/FormatStackTraceForJS.cpp) excluded vm code by comparing the frame callee's global object with the error's. That only catches vm contexts; runInThisContext, Script#runInThisContext and compileFunction run in the main realm. Once node:vm: apply SourceTextModule lineOffset/columnOffset like Node and name frames after the identifier #38235 names SourceTextModule frames after their identifier, identifier: import.meta.path gets there too.
    • remap_zig_exception (src/jsc/VirtualMachine.rs, the error printer) and computeLineColumnWithSourcemap (the profiler callback) had no exclusion at all, so the printer misreports vm code even in a separate context.
  • Pre-existing (1.4.0 and main).

Fix

  • Zig::isNodeVMSource() (ErrorStackTrace.cpp): a frame is vm code if its SourceProvider's SourceOrigin carries a NodeVMScriptFetcher. Every node:vm compile path attaches one (Script, runInThisContext, runInContext/runInNewContext, compileFunction, SourceTextModule), eval inside vm code inherits the origin, and nothing else in Bun creates a fetcher.
  • Both stack formatters skip the remap request for such frames. This replaces the realm comparison (and its finalizer special case) instead of stacking on it: the comparison was an approximation of this predicate. Side effect: frames from other Zig::GlobalObjects (ShadowRealm, bun test --isolate leftovers) are Bun-loaded code and now remap like main-realm frames instead of being skipped.
  • populateStackFrameMetadata stores the result in a new ZigStackFrame.is_node_vm (occupies the existing padding byte; the Rust side now asserts the layout) and remap_zig_exception skips those frames. A vm top frame falls through to the existing collect_source_lines path, so the code frame comes from the vm source, exactly as it already does for non-colliding names.
  • computeLineColumnWithSourcemap returns early for such providers; the profiler uses it for both definition and sample positions.
  • Why this is right: node:vm compiles the string it is given verbatim, so its positions are already final and the filename is only a label, which is what Node reports. The opposite rule ("remap only Bun's own providers") would break the require.extensions flow, where Bun's transpiled output is handed to an overridden module._compile and compiled in a plain StringSourceProvider under the file's name; remapping that by name is intended.
  • Intentionally unchanged: Bun__CallFrame__getCallerSrcLoc, Bun__CallFrame__getLineNumber and InspectorTestReporterAgent also remap by name, but they locate test()/expect()/inline snapshot calls, which only mean anything for code Bun loaded from the test file itself.
  • Known remaining gap: the synthetic <parse> frame and err.line of a vm SyntaxError are still remapped by name. JSC's addErrorInfo materializes the error before Bun sees anything but the URL string, so that needs a different fix.
  • Tests:
    • test/js/node/vm/vm.test.ts, "vm code compiled under the filename of a file Bun transpiled": a fixture compiles the same code under its own path and under an unrelated name and requires identical results for error.stack (Script, runInThisContext, runInNewContext, compileFunction, SourceTextModule), prepareStackTrace call sites, Bun.inspect (vm frame on top, in a separate context, and below a host frame) and the uncaught output, plus the physical line 5 and the fixture's own frame still being remapped. Before the fix the own-name variants reported line 23 of the fixture.
    • test/cli/run/cpu-prof.test.ts, "vm code compiled under the filename of the profiled file keeps its own positions": callFrame line/column and positionTicks match the unrelated-name run (before: line 23 instead of 4).
    • Also run: test/js/node/vm/, test/js/bun/sourcemap/, test/js/node/module/sourcemap*, test/js/bun/test/stack.test.ts, test/js/node/v8/capture-stack-trace.test.js, test/regression/issue/29240.test.ts, Node's test-vm-source-map-url, test-vm-syntax-error-*, test-vm-module-errors. The two "Error inside minified file" snapshots in inspect-error.test.js fail identically on main with this debug build.
  • error printer: remap frames when the original source is unavailable, and not twice after error.stack #38296 touches the same per-frame loop in remap_zig_exception; whichever lands second needs a one-line rebase.

Background

  • Bun transpiles every module it loads and keeps the resulting source map in a per-VM table keyed by the module's path (SavedSourceMap). Formatting a stack looks each frame's (sourceURL, line, column) up in that table, so any frame whose URL equals a transpiled module's path is remapped, whatever code it came from.
  • A SourceProvider is JSC's handle to one compiled source text. Bun's modules use Zig::SourceProvider; node:vm uses JSC's StringSourceProvider with the caller's filename as its URL. Each provider has a SourceOrigin, which may carry a ScriptFetcher; Bun's JSC fork gives fetchers a fetcherType(), and node:vm already attaches a NodeVMScriptFetcher to everything it compiles so that import() inside vm code can find its importModuleDynamically callback. This change reuses that marker.
  • ZigStackFrame is the #[repr(C)] frame record the C++ bindings fill from JSC frames and the Rust error printer remaps and prints. Stack strings take a different route: C++ builds the request frames itself and remaps them in a batch via Bun__remapStackFramePositions.
Repro (release 1.4.0 vs this branch)
// /tmp/prrepro/remap_script.js
const vm = require("node:vm");
const src = "\n".repeat(9) + "(function f() { throw new Error('boom'); })"; // f is on physical line 10
for (const filename of [__filename, "not-a-loaded-file.js"]) {
  const f = new vm.Script(src, { filename }).runInThisContext();
  try { f(); } catch (e) { console.log(e.stack.split("\n").find(l => / at f/.test(l)), e.line, e.originalLine); }
}
# bun 1.4.0
    at f (/tmp/prrepro/remap_script.js:5:48) 5 10
    at f (not-a-loaded-file.js:10:32) 10 10
# this branch
    at f (/tmp/prrepro/remap_script.js:10:32) 10 undefined
    at f (not-a-loaded-file.js:10:32) 10 undefined
# node
    at f (/tmp/prrepro/remap_script.js:10:23) undefined undefined
    at f (not-a-loaded-file.js:10:23) undefined undefined

The column difference from Node exists under both names and is unrelated (#37396). originalLine is only set on frames that went through a source map, so it is now absent for vm frames under either name; before it was set under both.

In the padded test fixtures, the own-name uncaught output showed fixture lines 18 to 23 as the code frame above at f (fixture.js:23:16), and --cpu-prof reported callFrame.lineNumber 23 with ticks on line 24. The unrelated-name runs, and this branch under both names, show the vm source with :5: and report line 4 (0-based) with ticks on line 5.

…e file its filename names

Bun's source map table is keyed by file path. The stack formatter excluded
vm code by comparing the frame's realm with the error's, which misses vm
code run in the main realm (runInThisContext, Script#runInThisContext,
compileFunction), and the error printer and the CPU profiler had no
exclusion at all. A vm script compiled under the name of a file Bun had
transpiled therefore reported positions remapped through that file's map.

Decide per frame from the code itself instead: every node:vm compile path
attaches a NodeVMScriptFetcher to its SourceOrigin, so Zig::isNodeVMSource()
identifies such code regardless of the realm it runs in. error.stack and
Error.prepareStackTrace call sites skip the remap request for these frames,
populateStackFrameMetadata records the flag on the ZigStackFrame so
remap_zig_exception skips them too, and the profiler's
computeLineColumnWithSourcemap callback returns early for such providers.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1e933a72-76c1-479b-818f-fae8c160a404

📥 Commits

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

📒 Files selected for processing (10)
  • src/jsc/VirtualMachine.rs
  • src/jsc/ZigStackFrame.rs
  • src/jsc/bindings/ErrorStackTrace.cpp
  • src/jsc/bindings/ErrorStackTrace.h
  • src/jsc/bindings/FormatStackTraceForJS.cpp
  • src/jsc/bindings/ZigException.cpp
  • src/jsc/bindings/headers-handwritten.h
  • src/runtime/bake/dev_server/error_report_request.rs
  • test/cli/run/cpu-prof.test.ts
  • test/js/node/vm/vm.test.ts

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed, waiting on CI.

Reproduced on 1.4.0 and main with new vm.Script(src, { filename: __filename }).runInThisContext() (vm code named after the running file): the frame came back remapped through the host file's source map (:5:48 for a throw on physical line 10; err.line === 5, err.originalLine === 10), while the same code under an unrelated name reported :10:32. The same happened in Error.prepareStackTrace call sites, Bun.inspect / the uncaught exception output (there even for runInNewContext) and --cpu-prof.

Fix in this PR: decide per frame from the code's SourceOrigin fetcher (Zig::isNodeVMSource) instead of the frame's realm, and apply it on the printer and profiler paths too. Tests: test/js/node/vm/vm.test.ts ("vm code compiled under the filename of a file Bun transpiled") and test/cli/run/cpu-prof.test.ts; both fail on 1.4.0 and pass with this branch.

@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 reviewed this PR and didn't find any bugs. Because it replaces the realm-comparison heuristic in the stack-trace remap paths (with a documented side effect on ShadowRealm / bun test --isolate frames) and threads a new field through the ZigStackFrame C++/Rust FFI struct, a human look would still be worthwhile.

What was reviewed:

  • isNodeVMSource predicate matches the existing ScriptFetcher::Type::NodeVM check in NodeVM.cpp; null codeBlock/ownerExecutable/provider paths return false.
  • ZigStackFrame.is_node_vm occupies the existing padding byte (offset 63); the new assert_ffi_layout! pins size 72 / align 8, and both C++ and Rust default-initializers set it to false.
  • computeErrorInfoWithPrepareStackTrace: the removed hasLineAndColumnInfo() guard only fed the deleted realm comparison; getSourcePositions() was already reached without it.
  • remap_zig_exception: the top-frame is_node_vm branch falls through to collect_source_lines (matching the non-colliding-name path); non-top frames skip the per-frame remap loop.
Extended reasoning...

Overview

The PR fixes a bug where node:vm code compiled under a filename that Bun already transpiled gets its stack-frame positions remapped through that file's source map. It adds Zig::isNodeVMSource() (checks the SourceProvider's SourceOrigin fetcher for ScriptFetcher::Type::NodeVM), applies it in four remap sites (formatStackTrace, computeErrorInfoWithPrepareStackTrace, computeLineColumnWithSourcemap, remap_zig_exception), and threads the result through a new ZigStackFrame.is_node_vm field so the Rust error printer can skip those frames. Ten files touched across src/jsc/ (Rust and C++ bindings), plus two test files with comprehensive coverage of error.stack, prepareStackTrace CallSites, Bun.inspect, the uncaught-error printer, and --cpu-prof.

Security risks

None identified. The change only affects which frames get looked up in the source-map table when formatting stack traces; no new user input is parsed and no trust boundaries are crossed.

Level of scrutiny

Moderate-to-high. Stack-trace formatting is user-visible and runs on every error, including from finalizers. The change replaces the previous realm-comparison heuristic entirely rather than adding to it, and the PR description explicitly calls out a behavioral side effect: frames from other Zig::GlobalObjects (ShadowRealm, bun test --isolate leftovers) will now be remapped where they previously were not. That's argued to be correct (they are Bun-loaded code), but it's a deliberate behavior change beyond the headline fix that a maintainer should sign off on.

Other factors

  • The FFI struct change is well-guarded: the new bool sits in an existing padding byte, both the C++ constructor and the Rust ZERO constant initialize it, all Rust literal constructors are updated, and a new assert_ffi_layout! macro invocation pins every field offset.
  • The predicate reuses an existing marker (NodeVMScriptFetcher) already checked the same way in NodeVM.cpp:277, so the "every node:vm compile path attaches one" claim is grounded in existing code.
  • Test coverage is thorough (own-name vs other-name equality across five vm entry points, top-frame vs non-top-frame, separate context, plus a --cpu-prof regression) and follows harness conventions (tempDir, bunEnv, concurrent pipe drain, test.concurrent).
  • The removed isDefaultGlobalObjectInAFinalizer special case in formatStackTrace was only there to keep the realm comparison working when no lexical global was available; with the comparison gone it's dead, and the finalizer-safe path still takes FinalizerSafety::MustNotTriggerGC for name lookup.
  • No CODEOWNERS cover the touched files.

Given the cross-language FFI change and the intentional side effect on non-vm cross-realm frames, this exceeds the "simple/mechanical" bar for auto-approval.

@robobun

robobun commented Aug 14, 2026

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

@robobun, your commit 1e7a68f has some failures in Build #95382 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38344

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

bun-38344 --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