Skip to content

node:vm: apply negative lineOffset/columnOffset to stack frames and the error header - #38248

Open
robobun wants to merge 6 commits into
mainfrom
farm/361b4b03/vm-negative-line-offset
Open

node:vm: apply negative lineOffset/columnOffset to stack frames and the error header#38248
robobun wants to merge 6 commits into
mainfrom
farm/361b4b03/vm-negative-line-offset

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A negative lineOffset is ignored at runtime by vm.Script, vm.runInThisContext and vm.runInContext. new vm.Script("1;\n2;\n3;\nthrow new Error('x')", { filename: "n.js", lineOffset: -2 }).runInThisContext() produces a stack starting n.js:4 / Error: x / at n.js:4:16; Node prints n.js:2, the source line, a caret, then at n.js:2:7. Positive offsets work. A negative offset is the usual way to compensate for wrapper lines a caller prepended to the source.
  • The header node:vm prepends to the stack loses its source line and caret for negative offsets, and with lineOffset: -1 it shows the line after the throw, caret included.
  • columnOffset < 0 is dropped from the first line's frame columns the same way, and vm.compileFunction reports body line N as N + 1 for every lineOffset <= 0.
  • Cause: JSC::SourceCode's constructor clamps the line and column a source starts at to 1 (vendor/WebKit/Source/JavaScriptCore/parser/SourceCode.h), and every position JSC reports is clamped start + physical position, so the negative part never reaches the frames. handleException (src/jsc/bindings/NodeVM.cpp) then subtracted the provider's unclamped start (-2) from JSC's line to locate the source line, landing two lines below the throw.

Fix

  • Bun::applyNegativeSourceStart (src/jsc/bindings/ErrorStackFrame.cpp): the SourceProvider still carries the requested start, so after JSC's line/column is read, min(startLine, 0) is added to the line and, on the first physical line, min(startColumn, 0) to the column. It runs as the last step of getAdjustedPositionForBytecode (call sites for Error.prepareStackTrace, Bun.inspect, the uncaught error printer), after the walk that moves new X() frames back to the new keyword, since that walk works in the coordinates JSC reported and can end on the first line; and in formatStackTrace (error.stack) and populateStackFramePosition's fallback through the new getLineColumnForStackFrame.
  • Correct because JSC's value is exactly physical + max(start, 0): CodeBlock::lineColumnForBytecodeIndex adds the executable's first line, and UnlinkedFunctionExecutable::linkedSourceCode derives nested functions' first line and column from the clamped program values while sharing the provider. Adding min(start, 0) therefore gives physical + start, which is what V8 reports and Node prints. Every provider outside node:vm starts at 0:0 (JSC's own makeSource callers, eval, new Function, Bun's providers), so this is a no-op for them; positive offsets are unchanged.
  • handleException removes only what JSC added (max(start, 0)) to find the physical line and prints physical + start as the header line, so the header matches Node (n.js:2, nn.js:-6, z.js:0) and the frames under it. The caret uses the clamped start column the same way instead of relying on an unsigned wraparound.
  • constructAnonymousFunction shifts the wrapped program's start line by one unconditionally; its comment gave the clamp as the reason not to, which no longer applies. Body line N now reports as N + lineOffset for every offset, which also removes the off-by-one at the default lineOffset: 0 (INT_MIN is left alone rather than overflowed). compileFunction's columnOffset on body line 1 was never applied and still is not (that line is physical line 2 of the wrapped program).
  • Positions an offset pushes to line 0 or below: error.stack and CallSite#toJSON carry them as V8 would (at f (nn.js:-6:18)); Bun's own renderers treat a non-positive line as "no position", and three of them needed fixing for that to come out clean: the error printer's stack-string parser (ZigException.cpp, V8StackTraceIterator) read line numbers as unsigned and turned nn.js:-6:18 into the url nn.js:-6 with line 18; the printer (src/jsc/ZigStackFrame.rs) wrote the : separator when only the column was valid (at f (nn.js:)); and error.line / error.column, which ErrorInstance stores unsigned, wrapped to 4294967290 and now read 0. They render as at f (nn.js) now.
  • Trade-off to be aware of: the bias lives in the start position node:vm already stores on the provider. JSC's debugger reads that value unclamped and InspectorDebuggerAgent::resolveBreakpoint compares it as unsigned, so breakpoints cannot be set in a source whose start is negative. That was already the case for vm.Script with a negative lineOffset; with this PR it also covers compileFunction code at the default offset, which only matters when a debugger attaches after the functions were compiled (compileFunction programs are not reported to an already attached debugger at all). Recording the bias on NodeVMScriptFetcher instead (keyed by source id so eval code inside the body stays unbiased, as node:vm: report compileFunction body lines relative to lineOffset when it is 0 #38240 does for its wrapper columns) would avoid that at the cost of threading it through the three creation sites; I kept the provider since it is the data JSC already keeps for exactly this purpose and eval/new Function get fresh providers for free.
  • Verified with test/js/node/vm/vm.test.ts: the can specify lineOffset / columnOffset todos are implemented for all six run entry points, and a new describe covers the header (line, source line, caret) through Script#runInThisContext, Script#runInNewContext and vm.runInThisContext, the wrong-line case, frames in nested functions, columnOffset in both directions, lines at or below zero (header, error.line, and the uncaught printer with and without the decorated stack), Error.prepareStackTrace call sites (including a new whose callee is on the next line and gets walked back onto the first), Bun.inspect, compileFunction with offsets -1/0/3, and the uncaught printer in a subprocess. 23 tests fail on the released binary and on a debug build without the src/ change; all pass with it. Columns are asserted relative to an un-offset run, so the tests do not depend on which column JSC attributes to a throw.
  • Also ran test/js/node/vm/ (script-leak.test.ts times out on this debug+ASAN container with and without the change), all 95 test/js/node/test/parallel/test-vm-*.js (the Bun-specific expectations in test-vm-basic.js that documented the compileFunction off-by-one now expect Node's lines, following the file's existing typeof Bun gating), test/js/bun/test/stack.test.ts, test/js/node/v8/capture-stack-trace.test.js, test/regression/issue/23022-stack-trace-iterator.test.ts, test/js/bun/sourcemap/, test/js/node/module/sourcemap.test.js, inspect.test.js and inspect-error.test.js (its two minified-file snapshots fail on debug builds before this change as well; tracked separately). Output for non-vm errors is byte-identical to the released binary in the scripts I compared.
  • Overlaps: node:vm: report compileFunction body lines relative to lineOffset when it is 0 #38240 fixes the compileFunction off-by-one by putting the body on the wrapper's own line plus a fetcher-recorded column correction. With the line bias from this PR the one-line shift already gives correct lines, so what remains distinct there is columnOffset on body line 1. If its layout is kept, its constructAnonymousFunction block has to replace this PR's shift (keeping both reports one line too high), and its column step has to run in JSC's coordinates, i.e. before applyNegativeSourceStart. node:vm: keep lineOffset/columnOffset from overflowing JSC parser positions #38228, node:vm: validate lineOffset/columnOffset by value like Node's validateInt32 #38236, node:vm: report compileFunction body errors as SyntaxErrors and reject bodies that close the function #38239 and node:vm: accept a hashbang line at the start of a compileFunction body #38245 touch neighbouring lines of constructAnonymousFunction. error.stack: report frames at new X(...) at the new keyword #37396 restructures the same spots of formatStackTrace / ErrorStackFrame.cpp / populateStackFramePosition; on top of it, applyNegativeSourceStart stays the last step of both of its branches, and its rewritten walk should add the clamped (max(start, 0)) start column when it lands on the first line so that this step still converts JSC coordinates; the line-crossing test case here catches a double application.

Background

  • lineOffset / columnOffset tell node:vm where a snippet sits inside a larger file. They are zero-based and signed; Node adds lineOffset to every line and columnOffset to the first line's columns only, and V8 reports the sums as-is, including 0 or negative results.
  • A JSC SourceProvider holds the source text and the TextPosition it was created with. A SourceCode is a range of a provider plus the one-based line and column JSC counts from, clamped to 1. Frames report positions relative to their code block's SourceCode; functions defined in a program get SourceCodes derived from the program's, all pointing at the same provider.
  • The "arrow header" is Bun's implementation of Node's DecorateErrorStack: when an error escapes a vm run, handleException prepends <url>:<line>, the offending source line and a caret to error.stack. Compile-time SyntaxErrors get the same from decorateParseErrorStack, which already handled negative offsets.
  • getAdjustedPositionForBytecode is where Bun already post-processes JSC positions (it moves new X() frames to the new keyword); formatStackTrace builds the error.stack string and used to read JSC's raw line/column directly. Positions cross to the Rust printer as ZigStackFramePosition (zero-based ints, where a negative value means "none"); once an error's stack string has been materialized, the printer gets its frames by re-parsing that string with V8StackTraceIterator.

[review] gate passed · iteration 1 · 8 files touched

fails on main (without fix)
ASAN without fix: 23 failed, 50 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/vm/vm.test.ts
bun test v1.4.0 (e69768312)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [25.74ms]
(pass) vm > runInContext() > can return a value [19.50ms]
(pass) vm > runInContext() > can return a complex value [16.23ms]
(pass) vm > runInContext() > can return the last value [20.94ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [17.93ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [14.30ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [16.73ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [16.38ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [16.47ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [22.43ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [23.41ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [25.79ms]
(pass) vm > runInContext() > new Float32Arr
... (truncated)

release without fix: 23 failed, 50 skipped
bun test v1.4.0-canary.1 (b7a043103)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [0.39ms]
(pass) vm > runInContext() > can return a value [0.22ms]
(pass) vm > runInContext() > can return a complex value [0.21ms]
(pass) vm > runInContext() > can return the last value [0.17ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [0.22ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [0.16ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [0.15ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [0.17ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [0.16ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [0.15ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [0.17ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [0.16ms]
(pass) vm > runInContext() > new Float32Array() in VM context doesn't crash [0.16ms]
(pass) vm > runInContext() > new Float64Array() in VM context doesn't crash [0.14ms]
(pass) vm > runInContext() > new Big
... (truncated)
passes on PR (with fix)
ASAN with fix: 50 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/vm/vm.test.ts
bun test v1.4.0 (e69768312)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [22.03ms]
(pass) vm > runInContext() > can return a value [15.89ms]
(pass) vm > runInContext() > can return a complex value [16.41ms]
(pass) vm > runInContext() > can return the last value [15.28ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [17.66ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [14.41ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [16.08ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [16.13ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [16.21ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [24.33ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [21.28ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [18.61ms]
(pass) vm > runInContext() > new Float32Arr
... (truncated)

release with fix: 50 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     e69768312e
  features     baseline

22 deps, 123 codegen, 1176 objects in 904ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] gen ErrorCode+*.h
[2/1238] install /workspace/bun
bun install v1.4.0-canary.1 (b7a043103)

Checked 107 installs across 153 packages (no changes) [21.00ms]
[3/1238] gen bindgenv2
[4/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (b7a043103)

Checked 1 install across 2 packages (no changes) [2.00ms]
[5/1238] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[6/1238] fetch zlib
[zlib] up to date
[7/1238] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (b7a043103)

Checked 129 installs across 147 packages (no changes) [18.00ms]
[8/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[9/1238] gen .bind.ts → GeneratedBindings.cpp
[10/1238] fetch tinycc
[tinycc] up to date
... (truncated)
diff hotspot
src/jsc/ZigStackFrame.rs                    |   5 +-
 src/jsc/bindings/ErrorStackFrame.cpp        |  36 ++++-
 src/jsc/bindings/ErrorStackFrame.h          |  13 ++
 src/jsc/bindings/FormatStackTraceForJS.cpp  |  30 ++--
 src/jsc/bindings/NodeVM.cpp                 |  26 ++--
 src/jsc/bindings/ZigException.cpp           |  17 +--
 test/js/node/test/parallel/test-vm-basic.js |  11 +-
 test/js/node/vm/vm.test.ts                  | 229 +++++++++++++++++++++++++++-
 8 files changed, 315 insertions(+), 52 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                         reads  edits  tests
src/jsc/ZigStackFrame.rs                         2      1      0
src/jsc/bindings/ErrorStackFrame.cpp             5      8      0
src/jsc/bindings/ErrorStackFrame.h               2      4      0
src/jsc/bindings/FormatStackTraceForJS.cpp       5      8      0
src/jsc/bindings/NodeVM.cpp                      5      8      0
src/jsc/bindings/ZigException.cpp                5      2      0
test/js/node/test/parallel/test-vm-basic.js      2      3      0
test/js/node/vm/vm.test.ts                       8     13      0

…he arrow header

JSC::SourceCode clamps the line and column a source starts at to 1, so
code compiled with a negative node:vm lineOffset or columnOffset reported
physical positions: the offset only worked when it was positive. The
SourceProvider still carries the requested start, so the stack formatters
(error.stack, Error.prepareStackTrace call sites, Bun.inspect and the
uncaught error printer) now add the negative part back, which is what V8
reports for the same options.

handleException computed the physical line for the arrow header by
subtracting the unclamped (negative) start, so it looked up a line that
does not exist (no source line or caret) or, with lineOffset -1, showed
the line after the throw. It now subtracts only the part JSC actually
added and reports the line with the whole offset, like Node.

compileFunction's wrapper shift is now unconditional, so body line N
reports as N + lineOffset for zero and negative offsets as well.
@coderabbitai

coderabbitai Bot commented Aug 13, 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: 9 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: c86e9956-4f1a-4315-96bf-e14c12e6387c

📥 Commits

Reviewing files that changed from the base of the PR and between 1805964 and e697683.

📒 Files selected for processing (8)
  • src/jsc/ZigStackFrame.rs
  • src/jsc/bindings/ErrorStackFrame.cpp
  • src/jsc/bindings/ErrorStackFrame.h
  • src/jsc/bindings/FormatStackTraceForJS.cpp
  • src/jsc/bindings/NodeVM.cpp
  • src/jsc/bindings/ZigException.cpp
  • test/js/node/test/parallel/test-vm-basic.js
  • test/js/node/vm/vm.test.ts

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

@robobun

robobun commented Aug 13, 2026

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

@robobun, your commit e697683 has some failures in Build #95880 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38248

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

bun-38248 --bun

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced on 1.4.0 and a debug build of main (b5afcac) with:

const vm = require("node:vm");
try {
  new vm.Script("1;\n2;\n3;\nthrow new Error('x')", { filename: "n.js", lineOffset: -2 }).runInThisContext();
} catch (e) { console.log(e.stack.split("\n").slice(0, 6)); }

Before: ["n.js:4", "Error: x", " at n.js:4:16", ...] (offset ignored, no source line). After: ["n.js:2", "throw new Error('x')", "<caret>", "", "Error: x", " at n.js:2:16"], matching Node's lines (n.js:2, at n.js:2:7; the column differs because JSC attributes the throw to a different column than V8, as for positive offsets today).

Tests: test/js/node/vm/vm.test.ts (23 new cases, failing before / passing after), plus the Bun-specific expectations in test/js/node/test/parallel/test-vm-basic.js now expecting Node's compileFunction lines.

CI state (head e697683, no code change since fbcfcb3): every job that ran passed (177/179 in build 95880, same in 95340). The two remaining jobs are the darwin 14 aarch64 test lanes, which expired without running in 95340 and have been waiting for an agent in 95880; the test files Buildkite lists as flaky in those builds passed on retry or alone and none involve this change (the inspect-error-leak timeout in the ASAN batch takes the same ~81s with and without this diff on a slow box). Ready for review as is.

Comment thread src/jsc/bindings/ErrorStackFrame.cpp Outdated
adjustPositionBackwards walks the position back to the `new` keyword in
the coordinates JSC reported and recounts the column from the source when
it crosses a line, so the offset has to be added once it is done: added
first, a negative column offset could trigger that recount (and be
dropped by it), and a position walked back onto the first line missed the
column offset that applies there.
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/NodeVM.cpp Outdated
Comment thread src/jsc/bindings/NodeVM.cpp Outdated
Comment thread src/jsc/bindings/ErrorStackFrame.h Outdated
Comment thread src/jsc/bindings/NodeVM.cpp Outdated
Comment thread src/jsc/bindings/NodeVM.cpp Outdated
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-ups pushed since the review started:

  • 5a9d4a2: applyNegativeSourceStart now runs after the new X() walk-back in getAdjustedPositionForBytecode (the walk works in JSC's coordinates and can end on the first line), with a test case for a construct whose callee is on the next line.
  • 1daa1e2, e2c6cb4: the comments flagged above were cut down to one line each; the reasoning is in the PR description.

test/js/node/vm/vm.test.ts still fails 23 cases without the src/ change and passes with it.

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

Re-reviewed after 5a9d4a2/1daa1e2/e2c6cb4 — the ordering issue I flagged earlier is fixed (the negative-offset step now runs after the op_construct walk, with the line-crossing new\nError case added to the prepareStackTrace test), and no new issues were found this run. A human look would still be worthwhile given the overlap with #37396/#38240 and the neighbouring constructAnonymousFunction PRs the description calls out.

What was checked: getLineColumnForStackFrame is only reached behind frame.hasLineAndColumnInfo(), so frame.codeBlock() is non-null there; applyNegativeSourceStart early-returns on a null provider and is a no-op when the provider start is non-negative, so non-vm frames in formatStackTrace are unchanged; the INT_MIN guard on startLine-- avoids the signed-overflow the old clamp incidentally prevented.

Extended reasoning...

Overview

Adds Bun::applyNegativeSourceStart in ErrorStackFrame.cpp to re-apply the negative part of a node:vm lineOffset/columnOffset that JSC::SourceCode clamps away, and threads it through the three position-reading paths: getAdjustedPositionForBytecode (CallSites / Bun.inspect / uncaught printer), a new getLineColumnForStackFrame used by formatStackTrace (error.stack), and handleException's arrow-header in NodeVM.cpp. constructAnonymousFunction now decrements the wrapped start line unconditionally (with an INT_MIN guard) so compileFunction reports body line N as N + lineOffset for every sign. Tests: 23 new cases in test/js/node/vm/vm.test.ts plus updated Bun-branch expectations in test-vm-basic.js.

Security risks

None identified. The change is arithmetic on line/column ordinals for error-message formatting; inputs are already range-validated int32 by BaseVMOptions::fromJS. No allocation sizing, no untrusted-length indexing.

Level of scrutiny

Moderate-to-high. The helper itself is small, but it now runs inside formatStackTrace — the shared error.stack path for every error in the runtime. Its correctness depends on a JSC-internal invariant (only node:vm providers ever carry a negative startPosition) that I could not verify against the vendored WebKit sources in this checkout, though the reasoning in the PR description is sound and the null-provider / non-negative early-outs make it a no-op elsewhere by construction.

Other factors

  • The ordering bug from my previous review was fixed in 5a9d4a2 and covered by the new " new\nError('x')" test case, so the adjustPositionBackwards recompute now runs on JSC's clamped coordinates before the offset is folded in.
  • The comment-cop feedback was addressed in 1daa1e2/e2c6cb4.
  • The finder-raised null-CodeBlock concern was checked: getLineColumnForStackFrame's only caller guards on hasLineAndColumnInfo().
  • The description flags textual overlap with #37396, #38240, #38228, #38236, #38239, #38245 in the same functions; sequencing those merges is the main reason to keep a human in the loop rather than auto-approve.

…bage

Offsets larger than the line now produce frames whose line is 0 or
negative. error.stack prints them as V8 does; the renderers that cannot:

- the error printer's stack-string parser read line numbers as unsigned,
  so "at f (x.js:-6:9)" came back as url "x.js:-6" with the column as its
  line; it now parses them signed, and such lines count as no position
- the printer wrote the ":" separator when only the column was valid,
  leaving "at f (x.js:)"
- error.line / error.column are unsigned in ErrorInstance and wrapped
  around; they now read as 0

populateStackFramePosition's no-bytecode-index fallback goes through
getLineColumnForStackFrame like formatStackTrace does.
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed fbcfcb3 after going through the change once more for inputs whose offset is larger than the line (lines 0 or below). error.stack already printed those the way V8 does, but three Bun-side renderers produced garbage for them: the error printer's stack-string parser read line numbers as unsigned (at f (nn.js:-6:18) became url nn.js:-6, line 18), the printer wrote a trailing : when only the column was valid, and error.line wrapped around to 4294967290. Those now all treat such a line as "no position" (at f (nn.js), error.line === 0), with assertions added to the existing tests. The same pass also routes populateStackFramePosition's no-bytecode-index fallback through the new helper.

The description now also spells out the one trade-off of keeping the bias in the provider's start position (JSC's debugger refuses breakpoints in sources with a negative start; previously already the case for vm.Script with a negative lineOffset, now also for compileFunction code when a debugger attaches late) and corrects the note on how this composes with #38240.

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

The reordering in 5a9d4a2 addresses the earlier finding — applyNegativeSourceStart now runs after the op_construct walk-back, and the new\nError test case covers the line-crossing path. I didn't find further issues this pass, but given this touches every stack-frame rendering path (formatStackTrace, ZigException / V8StackTraceIterator, the uncaught printer, CallSite positions), overlaps with several open PRs on the same lines, and Build #95043 is showing failures, a human look is still worthwhile.

What was reviewed:

  • getLineColumnForStackFrame call sites all reach it with a non-null code block (guarded by hasLineAndColumnInfo() / the earlier codeBlock() check in populateStackFramePosition); applyNegativeSourceStart handles a null provider.
  • SourceURLFormatter dropping the || column.is_valid() term: the print block below has no line-invalid branch, so this only removes a stray trailing : for below-zero positions — matches the at f (nn.js) test.
  • toUnsignedOneBased clamps negative one-based ordinals to 0 before the unsigned write-back, so error.line reads as unset rather than wrapping.
  • V8StackTraceIterator now parses signed segments so re-parsed nn.js:-6:9 frames round-trip as invalid (below-zero) rather than as part of the URL.
Extended reasoning...

Overview

This PR makes negative lineOffset/columnOffset in node:vm behave like Node/V8 by re-applying the negative part of the provider start position that JSC::SourceCode clamps away. It introduces Bun::applyNegativeSourceStart and a shared getLineColumnForStackFrame in ErrorStackFrame.cpp, threads them through formatStackTrace (error.stack), ZigException.cpp (Bun.inspect / uncaught printer / V8 stack re-parsing), and ErrorStackTrace CallSites, fixes handleException's arrow-header line/caret arithmetic in NodeVM.cpp, removes the compileFunction lineOffset > 0 guard so body line N reports as N + lineOffset for every sign, and adjusts SourceURLFormatter so frames whose line ends up ≤ 0 render without a :pos suffix. ~200 lines of tests cover the header, frames in nested functions, prepareStackTrace CallSites (including the walk-back-onto-first-line case my earlier comment flagged), Bun.inspect, compileFunction across offset signs, and the uncaught printer in a subprocess.

Since the previous review

My earlier inline finding (applying the negative offset before adjustPositionBackwards let the physical-column recompute discard it) was fixed in 5a9d4a2 by moving the call after the switch, with a test where the construct's divot is on line 2 and gets walked back onto line 1. Comment-cop feedback was addressed in 1daa1e2/e2c6cb4. fbcfcb3 tightened SourceURLFormatter so a below-zero line no longer prints a dangling :.

Security risks

None identified. The change is arithmetic on already-computed source positions; inputs are int32-validated lineOffset/columnOffset. INT_MIN is explicitly guarded in constructAnonymousFunction before the decrement. Negative results are handled by treating them as "no position" (Rust side) or clamping to 0 for the unsigned error.line/error.column fields.

Level of scrutiny

Medium-high. Stack-frame position computation is shared by every error path in the runtime, so a mistake here would affect all error output, not just node:vm. The change is designed to be a no-op for non-vm sources (start position 0:0), and the first-physical-line check for columnOffset matches JSC's own semantics, but the number of call sites and the noted overlaps with #37396 / #38240 / #38228 / #38236 / #38239 / #38245 mean a maintainer should confirm the merge order and that Build #95043's failures are unrelated.

Other factors

Test coverage is thorough and asserts columns relative to an un-offset baseline rather than hard-coding JSC's divot columns, so it should be robust. The Bun-specific expectations in test-vm-basic.js were updated to Node's line numbers now that the compileFunction off-by-one is fixed. The V8StackTraceIterator change to signed parsing is a small semantic widening (a URL segment like -1 after a colon would now parse as a line number), which is the intended behaviour for the nn.js:-6:9 case and unlikely to collide with real URLs.

@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 the follow-up commits (5a9d4a2, 1daa1e2, e2c6cb4, fbcfcb3) and found no further bugs — the ordering issue I raised earlier is fixed and now covered by the line-crossing new\nError test. Given the reach of this change (every stack-trace rendering path), the documented debugger-breakpoint trade-off, and the coordination needed with #37396 / #38240 and neighbours, a maintainer look is still worthwhile.

What was reviewed:

  • applyNegativeSourceStart first-line check against std::max(startLine, 0) for the mixed positive-lineOffset / negative-columnOffset case — matches the { lineOffset: 5, columnOffset: -2 } test.
  • getLineColumnForStackFrame call sites for null codeBlock() — both are guarded (!code return in populateStackFramePosition; hasLineAndColumnInfo() in formatStackTrace).
  • V8StackTraceIterator signed-int parse and the SourceURLFormatter : gate for below-zero positions — covered by the nn.js subprocess assertions.
  • toUnsignedOneBased clamping so error.line reads 0 rather than wrapping.
Extended reasoning...

Overview

The PR re-applies the negative part of lineOffset/columnOffset that JSC's SourceCode clamps away, so node:vm stack frames, the arrow header, Error.prepareStackTrace CallSites, Bun.inspect, and the uncaught-error printer all report Node-matching positions. It touches ErrorStackFrame.{cpp,h} (new applyNegativeSourceStart + getLineColumnForStackFrame), FormatStackTraceForJS.cpp (route error.stack through the helper; clamp negative one-based positions to 0 for JSC's unsigned error.line/error.column), NodeVM.cpp (handleException header arithmetic; constructAnonymousFunction unconditional -1 shift), ZigException.cpp (signed line/column parsing in V8StackTraceIterator; fallback path routed through the helper), and ZigStackFrame.rs (drop the trailing : when only the column is valid). ~200 lines of new tests in vm.test.ts and updated Bun-gated expectations in test-vm-basic.js.

Security risks

None identified. The change is arithmetic on already-computed stack positions and string formatting; no new user-controlled parsing, allocation, or trust boundaries. The INT_MIN guard in constructAnonymousFunction prevents the one signed-overflow case.

Level of scrutiny

High. applyNegativeSourceStart runs on every frame with a code block via formatStackTrace and populateStackFramePosition — it is a no-op for every provider whose start is ≥ 0 (all non-vm sources), but a mistake here would misreport positions for ordinary errors. The PR description also documents a real trade-off (JSC's debugger compares provider start unsigned, so breakpoints cannot be set in a source with a negative start — now also affects compileFunction at the default offset when a debugger attaches late) and an alternative design (record the bias on NodeVMScriptFetcher keyed by source id). That is a maintainer-level design call.

Other factors

My earlier finding (applying the negative start before the op_construct walk-back) was fixed in 5a9d4a2 with a regression test; the comment-cop feedback was addressed in 1daa1e2/e2c6cb4; fbcfcb3 added below-zero handling with tests. Test coverage is thorough (23 cases across six entry points, header, nested functions, both offset signs, prepareStackTrace, Bun.inspect, compileFunction, and a subprocess for the uncaught printer). The description calls out overlap with five open PRs (#37396, #38228, #38236, #38239, #38240, #38245) touching the same functions, with specific merge-order guidance — a human should coordinate that.

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