From af49e73446a37660000df0fd2ebc49bdcb5db59a Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Thu, 13 Aug 2026 21:06:37 +0000
Subject: [PATCH 1/9] node:vm: report compileFunction body lines relative to
lineOffset when it is 0
compileFunction compiled the body as line 2 of a "(function () {\n
\n})"
program and started the program one line early to compensate. JSC clamps a
SourceCode's first line to 1, so with the default lineOffset of 0 the
compensation was lost and every runtime position in the function (stack
frames, call sites, the vm error header) was reported one line too high.
Compile the body on the same line as the wrapper instead, so body line N is
physical line N of the program for every lineOffset. Columns on body line 1
now include the wrapper text; the part of columnOffset beyond it is still
applied (previously columnOffset was dropped entirely). The wrapper's length
is recorded on the NodeVMScriptFetcher so handleException can keep showing
the user's own first line, with the caret in the right place, when such a
function throws inside a vm run.
---
src/jsc/bindings/NodeVM.cpp | 70 ++++++---
src/jsc/bindings/NodeVMScriptFetcher.h | 9 ++
test/js/node/test/parallel/test-vm-basic.js | 24 ++--
test/js/node/vm/vm.test.ts | 149 ++++++++++++++++++++
4 files changed, 221 insertions(+), 31 deletions(-)
diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp
index 54a3d25d2947..3f8ec6505f02 100644
--- a/src/jsc/bindings/NodeVM.cpp
+++ b/src/jsc/bindings/NodeVM.cpp
@@ -183,19 +183,25 @@ JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, c
}
// wrap the arguments in an anonymous function expression
- int startOffset = 0;
- String code = stringifyAnonymousFunction(globalObject, args, throwScope, &startOffset);
+ int wrapperPrefixLength = 0;
+ String code = stringifyAnonymousFunction(globalObject, args, throwScope, &wrapperPrefixLength);
EXCEPTION_ASSERT(!!throwScope.exception() == code.isNull());
-
- // The user's body starts on line 2 of the wrapped program (after the
- // "(function () {\n" prefix). Shift the provider's start position up one
- // line so reported positions line up with the body the way V8's
- // CompileFunction does: body line 1 reports as lineOffset+1. JSC clamps
- // non-positive provider start positions to zero, so once the input is
- // already <= 0 there is nothing to gain by going further negative; clamp
- // there to keep the value bounded for downstream arithmetic.
- int lineZeroBased = position.m_line.zeroBasedInt();
- TextPosition wrappedPosition(OrdinalNumber::fromZeroBasedInt(lineZeroBased > 0 ? lineZeroBased - 1 : lineZeroBased), position.m_column);
+ RETURN_IF_EXCEPTION(throwScope, nullptr);
+
+ // The body starts on the wrapped program's first line (see
+ // stringifyAnonymousFunction), so the program starts at lineOffset itself
+ // and body line N is reported as lineOffset + N. Columns on body line 1 are
+ // physical columns of the wrapped line, so they already include the
+ // wrapper prefix; Node reports columnOffset + column there, so apply only
+ // the part of columnOffset that exceeds the prefix (a SourceCode cannot
+ // start at a negative column).
+ int columnOffset = position.m_column.zeroBasedInt();
+ TextPosition wrappedPosition(position.m_line, OrdinalNumber::fromZeroBasedInt(columnOffset > wrapperPrefixLength ? columnOffset - wrapperPrefixLength : 0));
+
+ // Lets handleException map a frame on the first line back to the user's
+ // source when decorating a runtime error with the offending line.
+ if (auto* fetcher = sourceOrigin.fetcher(); fetcher && fetcher->fetcherType() == ScriptFetcher::Type::NodeVM)
+ static_cast(fetcher)->setWrapperPrefixLength(static_cast(wrapperPrefixLength));
SourceCode sourceCode(
JSC::StringSourceProvider::create(code, sourceOrigin, WTF::move(options.filename), sourceTaintOrigin, wrappedPosition, SourceProviderSourceType::Program),
@@ -380,21 +386,30 @@ static JSPromise* importModuleInner(JSGlobalObject* globalObject, JSString* modu
RELEASE_AND_RETURN(scope, JSPromise::resolvedPromise(globalObject, thenResult));
}
-// Helper function to create an anonymous function expression with parameters
+// Helper function to create an anonymous function expression with parameters.
+//
+// The body follows `{` on the same line rather than on a line of its own: JSC
+// clamps a SourceCode's first line to 1, so a wrapper line cannot be
+// compensated for when lineOffset is 0 (the default) and every body line would
+// be reported one too high. This way body line N is physical line N of the
+// program. The price is that columns on body line 1 include the wrapper text;
+// *outOffset receives its length. The "\n" before `})` keeps a trailing `//`
+// comment in the body from swallowing the closing of the wrapper.
String stringifyAnonymousFunction(JSGlobalObject* globalObject, const ArgList& args, ThrowScope& scope, int* outOffset)
{
// How we stringify functions is important for creating anonymous function expressions
String program;
if (args.isEmpty()) {
// No arguments, just an empty function body
- program = "(function () {\n\n})"_s;
+ program = "(function () {\n})"_s;
+ *outOffset = "(function () {"_s.length();
} else if (args.size() == 1) {
// Just the function body
auto body = args.at(0).toWTFString(globalObject);
RETURN_IF_EXCEPTION(scope, {});
- program = tryMakeString("(function () {\n"_s, body, "\n})"_s);
- *outOffset = "(function () {\n"_s.length();
+ program = tryMakeString("(function () {"_s, body, "\n})"_s);
+ *outOffset = "(function () {"_s.length();
if (!program) [[unlikely]] {
throwOutOfMemoryError(globalObject, scope);
@@ -419,8 +434,8 @@ String stringifyAnonymousFunction(JSGlobalObject* globalObject, const ArgList& a
auto body = args.at(parameterCount).toWTFString(globalObject);
RETURN_IF_EXCEPTION(scope, {});
- program = tryMakeString("(function ("_s, paramString.toString(), ") {\n"_s, body, "\n})"_s);
- *outOffset = "(function ("_s.length() + paramString.length() + ") {\n"_s.length();
+ program = tryMakeString("(function ("_s, paramString.toString(), ") {"_s, body, "\n})"_s);
+ *outOffset = "(function ("_s.length() + paramString.length() + ") {"_s.length();
if (!program) [[unlikely]] {
throwOutOfMemoryError(globalObject, scope);
@@ -574,14 +589,25 @@ bool handleException(JSGlobalObject* globalObject, VM& vm, NakedPtrsource().provider()) {
+ // The user's source starts after compileFunction's wrapper prefix
+ // (if any), which shares the first line with it; JSC's columns on
+ // that line count the prefix too.
+ unsigned wrapperPrefixLength = 0;
+ if (auto* fetcher = provider->sourceOrigin().fetcher(); fetcher && fetcher->fetcherType() == ScriptFetcher::Type::NodeVM)
+ wrapperPrefixLength = static_cast(fetcher)->wrapperPrefixLength();
+ StringView userSource = provider->source().substring(wrapperPrefixLength);
+
int64_t startLineZeroBased = provider->startPosition().m_line.zeroBasedInt();
int64_t physicalLine = static_cast(line_and_column.line) - startLineZeroBased;
- sourceLineText = nthSourceLineForArrowHeader(provider->source(), physicalLine);
+ sourceLineText = nthSourceLineForArrowHeader(userSource, physicalLine);
if (!sourceLineText.isNull()) {
caretColumn = line_and_column.column;
- unsigned startColumnZeroBased = static_cast(provider->startPosition().m_column.zeroBasedInt());
- if (physicalLine == 1 && caretColumn > startColumnZeroBased)
- caretColumn -= startColumnZeroBased;
+ if (physicalLine == 1) {
+ // SourceCode clamps a negative start column to 0, so that is what the frame's column includes.
+ unsigned startColumnZeroBased = static_cast(std::max(0, provider->startPosition().m_column.zeroBasedInt()));
+ unsigned firstLineShift = startColumnZeroBased + wrapperPrefixLength;
+ caretColumn = caretColumn > firstLineShift ? caretColumn - firstLineShift : 0;
+ }
}
}
}
diff --git a/src/jsc/bindings/NodeVMScriptFetcher.h b/src/jsc/bindings/NodeVMScriptFetcher.h
index 7275d906f5a4..ac7306979252 100644
--- a/src/jsc/bindings/NodeVMScriptFetcher.h
+++ b/src/jsc/bindings/NodeVMScriptFetcher.h
@@ -41,6 +41,14 @@ class NodeVMScriptFetcher : public JSC::ScriptFetcher {
});
}
+ // vm.compileFunction compiles `(function () {` with the body
+ // starting on the same line as the wrapper so that body line N is reported
+ // as line lineOffset + N. This is the length of that wrapper text, which
+ // precedes the user's source on the provider's first line; 0 for sources
+ // that are compiled as written (vm.Script, modules).
+ unsigned wrapperPrefixLength() const { return m_wrapperPrefixLength; }
+ void setWrapperPrefixLength(unsigned length) { m_wrapperPrefixLength = length; }
+
private:
JSC::Strong m_dynamicImportCallback;
// m_owner is the NodeVMScript / JSFunction / module wrapper that holds this
@@ -50,6 +58,7 @@ class NodeVMScriptFetcher : public JSC::ScriptFetcher {
// as a GC root). Use Weak instead: when the owner is collected its
// SourceCode chain drops the last RefPtr to this fetcher.
JSC::Weak m_owner;
+ unsigned m_wrapperPrefixLength = 0;
bool m_isUsingDefaultLoader = false;
NodeVMScriptFetcher(JSC::VM& vm, JSC::JSValue dynamicImportCallback, JSC::JSValue owner)
diff --git a/test/js/node/test/parallel/test-vm-basic.js b/test/js/node/test/parallel/test-vm-basic.js
index be409b937246..c9d53a58d04f 100644
--- a/test/js/node/test/parallel/test-vm-basic.js
+++ b/test/js/node/test/parallel/test-vm-basic.js
@@ -131,9 +131,14 @@ const vm = require('vm');
// vm.compileFunction
{
+ // Bun compiles the body on the same line as the wrapper so that stack
+ // frames report body line N as lineOffset + N (the engine cannot start a
+ // source at line 0), which is what toString() reflects.
assert.strictEqual(
vm.compileFunction('console.log("Hello, World!")').toString(),
- 'function () {\nconsole.log("Hello, World!")\n}'
+ typeof Bun === 'undefined'
+ ? 'function () {\nconsole.log("Hello, World!")\n}'
+ : 'function () {console.log("Hello, World!")\n}'
);
assert.strictEqual(
@@ -277,17 +282,18 @@ const vm = require('vm');
// Setting value to run the last three tests
Error.stackTraceLimit = 1;
- // Bun's compileFunction stack frames differ from Node's: JSC attributes
- // the throw to a different column (and columnOffset is not applied), the
- // wrapper costs one line when lineOffset is 0, and the anonymous source is
- // labeled differently.
+ // Bun's compileFunction stack frames differ from Node's in their columns:
+ // JSC attributes the throw to a different column than V8, and columns on
+ // body line 1 also count the `(function () {` wrapper the body shares its
+ // line with (so a columnOffset shorter than the wrapper has no effect).
+ // The anonymous source is labeled differently too. Lines match Node.
assert.throws(() => {
vm.compileFunction('throw new Error("Sample Error")')();
}, {
message: 'Sample Error',
stack: typeof Bun === 'undefined'
? 'Error: Sample Error\n at :1:7'
- : 'Error: Sample Error\n at (file:///:2:16)'
+ : 'Error: Sample Error\n at (file:///:1:30)'
});
assert.throws(() => {
@@ -300,7 +306,7 @@ const vm = require('vm');
message: 'Sample Error',
stack: typeof Bun === 'undefined'
? 'Error: Sample Error\n at :4:7'
- : 'Error: Sample Error\n at (file:///:4:16)'
+ : 'Error: Sample Error\n at (file:///:4:30)'
});
assert.throws(() => {
@@ -313,7 +319,7 @@ const vm = require('vm');
message: 'Sample Error',
stack: typeof Bun === 'undefined'
? 'Error: Sample Error\n at :1:10'
- : 'Error: Sample Error\n at (file:///:2:16)'
+ : 'Error: Sample Error\n at (file:///:1:30)'
});
assert.strictEqual(
@@ -337,7 +343,7 @@ const vm = require('vm');
// Bun's compileFunction stack frames differ from Node's (see above).
stack: typeof Bun === 'undefined'
? 'ReferenceError: varInContext is not defined\n at :1:1'
- : 'ReferenceError: varInContext is not defined\n at (file:///:2:20)'
+ : 'ReferenceError: varInContext is not defined\n at (file:///:1:34)'
});
assert.notDeepStrictEqual(
diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts
index d3d04239d179..3a2d551b7532 100644
--- a/test/js/node/vm/vm.test.ts
+++ b/test/js/node/vm/vm.test.ts
@@ -269,6 +269,155 @@ describe("vm", () => {
expect(e).toBeTruthy();
}
});
+
+ // Positions of runtime errors thrown by the compiled function. Node
+ // reports body line N as lineOffset + N; the body is line 1, not the
+ // line after a wrapper.
+ function thrownPosition(filename: string, fn: () => unknown) {
+ let stack: string = "";
+ try {
+ fn();
+ } catch (e: any) {
+ stack = e.stack;
+ }
+ const match = stack.match(new RegExp(`${filename.replaceAll(".", "\\.")}:(\\d+):(\\d+)`));
+ if (!match) throw new Error(`no ${filename} frame in:\n${stack}`);
+ return { line: Number(match[1]), column: Number(match[2]) };
+ }
+
+ test("runtime errors report body line N as lineOffset + N", () => {
+ const filename = "cf-lines.js";
+ const results: unknown[] = [];
+ const expected: unknown[] = [];
+ for (const lineOffset of [undefined, 0, 1, 7]) {
+ const options = lineOffset === undefined ? { filename } : { filename, lineOffset };
+ const base = lineOffset ?? 0;
+ results.push({
+ lineOffset,
+ line1: thrownPosition(filename, compileFunction('throw new Error("line 1")', [], options)).line,
+ line3: thrownPosition(filename, compileFunction('1;\n2;\nthrow new Error("line 3")', [], options)).line,
+ line1WithParams: thrownPosition(filename, () =>
+ compileFunction("throw new Error(String(a + b))", ["a", "b"], options)(1, 2),
+ ).line,
+ // Functions nested in the body are positioned relative to the same origin.
+ nestedArrowOnLine1: thrownPosition(
+ filename,
+ compileFunction('return () => { throw new Error("arrow") };', [], options)(),
+ ).line,
+ nestedFunctionOnLine2: thrownPosition(
+ filename,
+ compileFunction('return function inner() {\n throw new Error("inner");\n};', [], options)(),
+ ).line,
+ });
+ expected.push({
+ lineOffset,
+ line1: base + 1,
+ line3: base + 3,
+ line1WithParams: base + 1,
+ nestedArrowOnLine1: base + 1,
+ nestedFunctionOnLine2: base + 2,
+ });
+ }
+ expect(results).toEqual(expected);
+ });
+
+ test("Error.prepareStackTrace call sites see the same body lines", () => {
+ const previous = Error.prepareStackTrace;
+ Error.prepareStackTrace = (_, callSites) =>
+ callSites.map(site => `${site.getFileName()}:${site.getLineNumber()}`);
+ try {
+ const fn = compileFunction("return [new Error('a').stack[0], (() => new Error('b').stack[0])()];", [], {
+ filename: "cf-callsites.js",
+ });
+ expect(fn()).toEqual(["cf-callsites.js:1", "cf-callsites.js:1"]);
+ const offsetFn = compileFunction("\nreturn new Error('c').stack[0];", [], {
+ filename: "cf-callsites.js",
+ lineOffset: 10,
+ });
+ expect(offsetFn()).toBe("cf-callsites.js:12");
+ } finally {
+ Error.prepareStackTrace = previous;
+ }
+ });
+
+ test("columnOffset shifts body line 1 only", () => {
+ const filename = "cf-columns.js";
+ const statement = 'throw new Error("column")';
+ // The column JSC assigns to this statement when it starts a line that no
+ // offset applies to.
+ const { column } = thrownPosition(filename, compileFunction("\n" + statement, [], { filename }));
+ // Body line 1 shares its line with the `(function () {` wrapper, so its
+ // columns include that text; a columnOffset at least that long is
+ // applied exactly, as in Node.
+ expect(thrownPosition(filename, compileFunction(statement, [], { filename, columnOffset: 100 }))).toEqual({
+ line: 1,
+ column: column + 100,
+ });
+ expect(
+ thrownPosition(filename, () => compileFunction(statement, ["a", "b"], { filename, columnOffset: 100 })()),
+ ).toEqual({ line: 1, column: column + 100 });
+ // Later lines are never shifted.
+ expect(thrownPosition(filename, compileFunction("\n" + statement, [], { filename, columnOffset: 100 }))).toEqual({
+ line: 2,
+ column,
+ });
+ // Without a columnOffset, line 1 still reports line 1.
+ expect(thrownPosition(filename, compileFunction(statement, [], { filename })).line).toBe(1);
+ });
+
+ test("runtime arrow header shows the body line when called from a vm script", () => {
+ // Node decorates an error escaping a vm run with `:`, the
+ // offending source line and a caret. For a function from compileFunction
+ // the line and source text are those of the body, not of the wrapper.
+ const header = (fn: () => unknown) => {
+ let stack: string = "";
+ try {
+ fn();
+ } catch (e: any) {
+ stack = e.stack;
+ }
+ return stack.split("\n").slice(0, 4);
+ };
+ const line1 = 'throw new Error("line 1")';
+ const line2 = '0;\n throw new Error("line 2")';
+
+ // Reference: the same statements compiled as a script, where no wrapper exists.
+ const [, , scriptCaret] = header(() => new Script(line1, { filename: "ref.js" }).runInThisContext());
+ expect(scriptCaret).toMatch(/^ *\^$/);
+ const [, , scriptCaret2] = header(() => new Script(line2, { filename: "ref.js" }).runInThisContext());
+ expect(scriptCaret2).toMatch(/^ +\^$/);
+
+ const callFromScript = (fn: Function) => () =>
+ new Script("fn()", { filename: "outer.js" }).runInNewContext({ fn });
+
+ expect(header(callFromScript(compileFunction(line1, [], { filename: "cf-header.js" })))).toEqual([
+ "cf-header.js:1",
+ line1,
+ scriptCaret,
+ "",
+ ]);
+ expect(header(callFromScript(compileFunction(line1, ["a", "b"], { filename: "cf-header.js" })))).toEqual([
+ "cf-header.js:1",
+ line1,
+ scriptCaret,
+ "",
+ ]);
+ expect(
+ header(callFromScript(compileFunction(line1, [], { filename: "cf-header.js", columnOffset: 100 }))),
+ ).toEqual(["cf-header.js:1", line1, scriptCaret, ""]);
+ expect(header(callFromScript(compileFunction(line2, [], { filename: "cf-header.js" })))).toEqual([
+ "cf-header.js:2",
+ ' throw new Error("line 2")',
+ scriptCaret2,
+ "",
+ ]);
+ expect(header(callFromScript(compileFunction(line1, [], { filename: "cf-header.js", lineOffset: 4 })))).toEqual([
+ "cf-header.js:5",
+ line1,
+ scriptCaret,
+ "",
+ ]);
+ });
});
});
From 490ed80ba4b2b0b9e83aa464563d73f6476b1c07 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Thu, 13 Aug 2026 21:49:14 +0000
Subject: [PATCH 2/9] node:vm: key the compileFunction wrapper length to its
own source provider
eval() and new Function() inside the body create providers that inherit the
function's SourceOrigin, and with it the NodeVMScriptFetcher, but contain no
wrapper. Record the wrapper program's SourceID next to the prefix length so
handleException only strips the prefix from frames in that provider, and
apply the strip to the extracted first line rather than to the whole source
so the physical line numbering never shifts.
---
src/jsc/bindings/NodeVM.cpp | 26 +++++++++++++++-----------
src/jsc/bindings/NodeVMScriptFetcher.h | 21 ++++++++++++++++-----
test/js/node/vm/vm.test.ts | 14 ++++++++++++++
3 files changed, 45 insertions(+), 16 deletions(-)
diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp
index 3f8ec6505f02..952af2064c70 100644
--- a/src/jsc/bindings/NodeVM.cpp
+++ b/src/jsc/bindings/NodeVM.cpp
@@ -198,14 +198,14 @@ JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, c
int columnOffset = position.m_column.zeroBasedInt();
TextPosition wrappedPosition(position.m_line, OrdinalNumber::fromZeroBasedInt(columnOffset > wrapperPrefixLength ? columnOffset - wrapperPrefixLength : 0));
+ Ref provider = JSC::StringSourceProvider::create(code, sourceOrigin, WTF::move(options.filename), sourceTaintOrigin, wrappedPosition, SourceProviderSourceType::Program);
+
// Lets handleException map a frame on the first line back to the user's
// source when decorating a runtime error with the offending line.
if (auto* fetcher = sourceOrigin.fetcher(); fetcher && fetcher->fetcherType() == ScriptFetcher::Type::NodeVM)
- static_cast(fetcher)->setWrapperPrefixLength(static_cast(wrapperPrefixLength));
+ static_cast(fetcher)->setWrapper(provider.get(), static_cast(wrapperPrefixLength));
- SourceCode sourceCode(
- JSC::StringSourceProvider::create(code, sourceOrigin, WTF::move(options.filename), sourceTaintOrigin, wrappedPosition, SourceProviderSourceType::Program),
- wrappedPosition.m_line.oneBasedInt(), wrappedPosition.m_column.oneBasedInt());
+ SourceCode sourceCode(WTF::move(provider), wrappedPosition.m_line.oneBasedInt(), wrappedPosition.m_column.oneBasedInt());
CodeCache* cache = vm.codeCache();
ProgramExecutable* programExecutable = ProgramExecutable::create(globalObject, sourceCode);
@@ -505,7 +505,9 @@ JSC::EncodedJSValue createCachedData(JSGlobalObject* globalObject, const JSC::So
// AppendExceptionLine helpers shared by the runtime (handleException) and
// compile-time (decorateParseErrorStack) paths — a single implementation of
// Node's arrow-header format so the two call sites cannot drift.
-static String nthSourceLineForArrowHeader(StringView source, int64_t physicalLine1Based)
+// firstLineSkip is the length of compileFunction's wrapper, which shares the
+// first line with the user's source and is not part of it.
+static String nthSourceLineForArrowHeader(StringView source, int64_t physicalLine1Based, unsigned firstLineSkip = 0)
{
if (physicalLine1Based < 1 || physicalLine1Based > static_cast(source.length()) + 1)
return {};
@@ -520,6 +522,8 @@ static String nthSourceLineForArrowHeader(StringView source, int64_t physicalLin
if (lineEnd == WTF::notFound)
lineEnd = source.length();
StringView lineView = source.substring(lineStart, lineEnd - lineStart);
+ if (physicalLine1Based == 1)
+ lineView = lineView.substring(firstLineSkip);
if (lineView.endsWith('\r'))
lineView = lineView.left(lineView.length() - 1);
// Like Node, skip the decoration for excessively long lines.
@@ -589,17 +593,17 @@ bool handleException(JSGlobalObject* globalObject, VM& vm, NakedPtrsource().provider()) {
- // The user's source starts after compileFunction's wrapper prefix
- // (if any), which shares the first line with it; JSC's columns on
- // that line count the prefix too.
+ // In a compileFunction program the user's first line starts after
+ // the wrapper prefix, and JSC's columns on that line count the
+ // prefix too. Zero for every other provider, including eval() code
+ // from inside such a function, which shares the fetcher.
unsigned wrapperPrefixLength = 0;
if (auto* fetcher = provider->sourceOrigin().fetcher(); fetcher && fetcher->fetcherType() == ScriptFetcher::Type::NodeVM)
- wrapperPrefixLength = static_cast(fetcher)->wrapperPrefixLength();
- StringView userSource = provider->source().substring(wrapperPrefixLength);
+ wrapperPrefixLength = static_cast(fetcher)->wrapperPrefixLength(*provider);
int64_t startLineZeroBased = provider->startPosition().m_line.zeroBasedInt();
int64_t physicalLine = static_cast(line_and_column.line) - startLineZeroBased;
- sourceLineText = nthSourceLineForArrowHeader(userSource, physicalLine);
+ sourceLineText = nthSourceLineForArrowHeader(provider->source(), physicalLine, wrapperPrefixLength);
if (!sourceLineText.isNull()) {
caretColumn = line_and_column.column;
if (physicalLine == 1) {
diff --git a/src/jsc/bindings/NodeVMScriptFetcher.h b/src/jsc/bindings/NodeVMScriptFetcher.h
index ac7306979252..155d50941ed4 100644
--- a/src/jsc/bindings/NodeVMScriptFetcher.h
+++ b/src/jsc/bindings/NodeVMScriptFetcher.h
@@ -3,6 +3,7 @@
#include "root.h"
#include
+#include
#include
#include
#include
@@ -43,11 +44,20 @@ class NodeVMScriptFetcher : public JSC::ScriptFetcher {
// vm.compileFunction compiles `(function () {` with the body
// starting on the same line as the wrapper so that body line N is reported
- // as line lineOffset + N. This is the length of that wrapper text, which
- // precedes the user's source on the provider's first line; 0 for sources
- // that are compiled as written (vm.Script, modules).
- unsigned wrapperPrefixLength() const { return m_wrapperPrefixLength; }
- void setWrapperPrefixLength(unsigned length) { m_wrapperPrefixLength = length; }
+ // as line lineOffset + N. Records the provider holding that program and the
+ // length of the wrapper text preceding the user's source on its first line.
+ // Keyed by provider because eval() and new Function() inside the body create
+ // providers that inherit this fetcher through the SourceOrigin but contain
+ // no wrapper; for those, and for vm.Script sources, this returns 0.
+ void setWrapper(JSC::SourceProvider& provider, unsigned prefixLength)
+ {
+ m_wrapperSourceID = provider.asID();
+ m_wrapperPrefixLength = prefixLength;
+ }
+ unsigned wrapperPrefixLength(JSC::SourceProvider& provider) const
+ {
+ return m_wrapperPrefixLength && provider.asID() == m_wrapperSourceID ? m_wrapperPrefixLength : 0;
+ }
private:
JSC::Strong m_dynamicImportCallback;
@@ -58,6 +68,7 @@ class NodeVMScriptFetcher : public JSC::ScriptFetcher {
// as a GC root). Use Weak instead: when the owner is collected its
// SourceCode chain drops the last RefPtr to this fetcher.
JSC::Weak m_owner;
+ JSC::SourceID m_wrapperSourceID = 0;
unsigned m_wrapperPrefixLength = 0;
bool m_isUsingDefaultLoader = false;
diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts
index 3a2d551b7532..77bf002bc04d 100644
--- a/test/js/node/vm/vm.test.ts
+++ b/test/js/node/vm/vm.test.ts
@@ -417,6 +417,20 @@ describe("vm", () => {
scriptCaret,
"",
]);
+
+ // Code eval'd from inside the body gets its own source, which has no
+ // wrapper, even though it inherits the function's origin.
+ for (const evalCall of ["eval", "(0, eval)"]) {
+ const [headerLine, sourceLine, caret] = header(
+ callFromScript(compileFunction(`${evalCall}(${JSON.stringify(line1)})`, [], { filename: "cf-header.js" })),
+ );
+ expect({ evalCall, headerLine: headerLine.replace(/^.*:/, ":"), sourceLine, caret }).toEqual({
+ evalCall,
+ headerLine: ":1",
+ sourceLine: line1,
+ caret: scriptCaret,
+ });
+ }
});
});
});
From 9b5adb7c2291be860ca30a44929e991074b9c43f Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Thu, 13 Aug 2026 21:54:28 +0000
Subject: [PATCH 3/9] node:vm: shorten the compileFunction wrapper comments
---
src/jsc/bindings/NodeVM.cpp | 34 ++++++++------------------
src/jsc/bindings/NodeVMScriptFetcher.h | 10 +++-----
2 files changed, 13 insertions(+), 31 deletions(-)
diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp
index 952af2064c70..2d5384504729 100644
--- a/src/jsc/bindings/NodeVM.cpp
+++ b/src/jsc/bindings/NodeVM.cpp
@@ -188,20 +188,14 @@ JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, c
EXCEPTION_ASSERT(!!throwScope.exception() == code.isNull());
RETURN_IF_EXCEPTION(throwScope, nullptr);
- // The body starts on the wrapped program's first line (see
- // stringifyAnonymousFunction), so the program starts at lineOffset itself
- // and body line N is reported as lineOffset + N. Columns on body line 1 are
- // physical columns of the wrapped line, so they already include the
- // wrapper prefix; Node reports columnOffset + column there, so apply only
- // the part of columnOffset that exceeds the prefix (a SourceCode cannot
- // start at a negative column).
+ // The body starts on the program's first line (see stringifyAnonymousFunction),
+ // so that line's columns already include the wrapper: only the part of
+ // columnOffset beyond it is applied, as a source cannot start at a negative column.
int columnOffset = position.m_column.zeroBasedInt();
TextPosition wrappedPosition(position.m_line, OrdinalNumber::fromZeroBasedInt(columnOffset > wrapperPrefixLength ? columnOffset - wrapperPrefixLength : 0));
Ref provider = JSC::StringSourceProvider::create(code, sourceOrigin, WTF::move(options.filename), sourceTaintOrigin, wrappedPosition, SourceProviderSourceType::Program);
- // Lets handleException map a frame on the first line back to the user's
- // source when decorating a runtime error with the offending line.
if (auto* fetcher = sourceOrigin.fetcher(); fetcher && fetcher->fetcherType() == ScriptFetcher::Type::NodeVM)
static_cast(fetcher)->setWrapper(provider.get(), static_cast(wrapperPrefixLength));
@@ -386,15 +380,10 @@ static JSPromise* importModuleInner(JSGlobalObject* globalObject, JSString* modu
RELEASE_AND_RETURN(scope, JSPromise::resolvedPromise(globalObject, thenResult));
}
-// Helper function to create an anonymous function expression with parameters.
-//
-// The body follows `{` on the same line rather than on a line of its own: JSC
-// clamps a SourceCode's first line to 1, so a wrapper line cannot be
-// compensated for when lineOffset is 0 (the default) and every body line would
-// be reported one too high. This way body line N is physical line N of the
-// program. The price is that columns on body line 1 include the wrapper text;
-// *outOffset receives its length. The "\n" before `})` keeps a trailing `//`
-// comment in the body from swallowing the closing of the wrapper.
+// Builds `(function () {\n})`; *outOffset is the body's offset.
+// The body must start on the wrapper's own line: JSC clamps a SourceCode's first
+// line to 1, so a wrapper line could not be compensated for at lineOffset 0 and
+// every body line would be reported one too high.
String stringifyAnonymousFunction(JSGlobalObject* globalObject, const ArgList& args, ThrowScope& scope, int* outOffset)
{
// How we stringify functions is important for creating anonymous function expressions
@@ -505,8 +494,7 @@ JSC::EncodedJSValue createCachedData(JSGlobalObject* globalObject, const JSC::So
// AppendExceptionLine helpers shared by the runtime (handleException) and
// compile-time (decorateParseErrorStack) paths — a single implementation of
// Node's arrow-header format so the two call sites cannot drift.
-// firstLineSkip is the length of compileFunction's wrapper, which shares the
-// first line with the user's source and is not part of it.
+// firstLineSkip: compileFunction's wrapper text in front of the user's first line.
static String nthSourceLineForArrowHeader(StringView source, int64_t physicalLine1Based, unsigned firstLineSkip = 0)
{
if (physicalLine1Based < 1 || physicalLine1Based > static_cast(source.length()) + 1)
@@ -593,10 +581,8 @@ bool handleException(JSGlobalObject* globalObject, VM& vm, NakedPtrsource().provider()) {
- // In a compileFunction program the user's first line starts after
- // the wrapper prefix, and JSC's columns on that line count the
- // prefix too. Zero for every other provider, including eval() code
- // from inside such a function, which shares the fetcher.
+ // Non-zero only for a compileFunction program, whose first line
+ // (text and columns) starts with the wrapper.
unsigned wrapperPrefixLength = 0;
if (auto* fetcher = provider->sourceOrigin().fetcher(); fetcher && fetcher->fetcherType() == ScriptFetcher::Type::NodeVM)
wrapperPrefixLength = static_cast(fetcher)->wrapperPrefixLength(*provider);
diff --git a/src/jsc/bindings/NodeVMScriptFetcher.h b/src/jsc/bindings/NodeVMScriptFetcher.h
index 155d50941ed4..51411d7b13d1 100644
--- a/src/jsc/bindings/NodeVMScriptFetcher.h
+++ b/src/jsc/bindings/NodeVMScriptFetcher.h
@@ -42,13 +42,9 @@ class NodeVMScriptFetcher : public JSC::ScriptFetcher {
});
}
- // vm.compileFunction compiles `(function () {` with the body
- // starting on the same line as the wrapper so that body line N is reported
- // as line lineOffset + N. Records the provider holding that program and the
- // length of the wrapper text preceding the user's source on its first line.
- // Keyed by provider because eval() and new Function() inside the body create
- // providers that inherit this fetcher through the SourceOrigin but contain
- // no wrapper; for those, and for vm.Script sources, this returns 0.
+ // The compileFunction program whose first line starts with the `(function (...) {`
+ // wrapper, and the wrapper's length. Keyed by provider: eval() and new Function()
+ // inside the body inherit this fetcher through the SourceOrigin but have no wrapper.
void setWrapper(JSC::SourceProvider& provider, unsigned prefixLength)
{
m_wrapperSourceID = provider.asID();
From 6279bf03e020b2b208d94334f0477e4a31a33c0d Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Thu, 13 Aug 2026 21:57:13 +0000
Subject: [PATCH 4/9] node:vm: trim wrapper comments to the rationale
---
src/jsc/bindings/NodeVM.cpp | 13 ++++---------
src/jsc/bindings/NodeVMScriptFetcher.h | 5 ++---
2 files changed, 6 insertions(+), 12 deletions(-)
diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp
index 2d5384504729..776672de6b4d 100644
--- a/src/jsc/bindings/NodeVM.cpp
+++ b/src/jsc/bindings/NodeVM.cpp
@@ -188,9 +188,7 @@ JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, c
EXCEPTION_ASSERT(!!throwScope.exception() == code.isNull());
RETURN_IF_EXCEPTION(throwScope, nullptr);
- // The body starts on the program's first line (see stringifyAnonymousFunction),
- // so that line's columns already include the wrapper: only the part of
- // columnOffset beyond it is applied, as a source cannot start at a negative column.
+ // Line 1's columns already include the wrapper, so only the excess of columnOffset over it is applied.
int columnOffset = position.m_column.zeroBasedInt();
TextPosition wrappedPosition(position.m_line, OrdinalNumber::fromZeroBasedInt(columnOffset > wrapperPrefixLength ? columnOffset - wrapperPrefixLength : 0));
@@ -380,10 +378,9 @@ static JSPromise* importModuleInner(JSGlobalObject* globalObject, JSString* modu
RELEASE_AND_RETURN(scope, JSPromise::resolvedPromise(globalObject, thenResult));
}
-// Builds `(function () {\n})`; *outOffset is the body's offset.
-// The body must start on the wrapper's own line: JSC clamps a SourceCode's first
-// line to 1, so a wrapper line could not be compensated for at lineOffset 0 and
-// every body line would be reported one too high.
+// Builds `(function () {\n})`; *outOffset is the body's offset. The body shares the
+// wrapper's line on purpose: JSC clamps a source's first line to 1, so a wrapper line cannot be
+// compensated for when lineOffset is 0.
String stringifyAnonymousFunction(JSGlobalObject* globalObject, const ArgList& args, ThrowScope& scope, int* outOffset)
{
// How we stringify functions is important for creating anonymous function expressions
@@ -581,8 +578,6 @@ bool handleException(JSGlobalObject* globalObject, VM& vm, NakedPtrsource().provider()) {
- // Non-zero only for a compileFunction program, whose first line
- // (text and columns) starts with the wrapper.
unsigned wrapperPrefixLength = 0;
if (auto* fetcher = provider->sourceOrigin().fetcher(); fetcher && fetcher->fetcherType() == ScriptFetcher::Type::NodeVM)
wrapperPrefixLength = static_cast(fetcher)->wrapperPrefixLength(*provider);
diff --git a/src/jsc/bindings/NodeVMScriptFetcher.h b/src/jsc/bindings/NodeVMScriptFetcher.h
index 51411d7b13d1..3eeb3fddfe95 100644
--- a/src/jsc/bindings/NodeVMScriptFetcher.h
+++ b/src/jsc/bindings/NodeVMScriptFetcher.h
@@ -42,9 +42,8 @@ class NodeVMScriptFetcher : public JSC::ScriptFetcher {
});
}
- // The compileFunction program whose first line starts with the `(function (...) {`
- // wrapper, and the wrapper's length. Keyed by provider: eval() and new Function()
- // inside the body inherit this fetcher through the SourceOrigin but have no wrapper.
+ // compileFunction's program and the length of the `(function (...) {` wrapper on its first line.
+ // Keyed by provider: eval() and new Function() code inherits this fetcher but has no wrapper.
void setWrapper(JSC::SourceProvider& provider, unsigned prefixLength)
{
m_wrapperSourceID = provider.asID();
From c38ae8e2bf351a53a765634a6554202e68895441 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Thu, 13 Aug 2026 22:01:53 +0000
Subject: [PATCH 5/9] node:vm: one-line wrapper comments
---
src/jsc/bindings/NodeVM.cpp | 4 +---
src/jsc/bindings/NodeVMScriptFetcher.h | 3 +--
2 files changed, 2 insertions(+), 5 deletions(-)
diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp
index 776672de6b4d..4098ac229913 100644
--- a/src/jsc/bindings/NodeVM.cpp
+++ b/src/jsc/bindings/NodeVM.cpp
@@ -378,9 +378,7 @@ static JSPromise* importModuleInner(JSGlobalObject* globalObject, JSString* modu
RELEASE_AND_RETURN(scope, JSPromise::resolvedPromise(globalObject, thenResult));
}
-// Builds `(function () {\n})`; *outOffset is the body's offset. The body shares the
-// wrapper's line on purpose: JSC clamps a source's first line to 1, so a wrapper line cannot be
-// compensated for when lineOffset is 0.
+// The body deliberately shares the wrapper's line: JSC clamps a source's first line to 1, so a wrapper line could not be compensated for at lineOffset 0.
String stringifyAnonymousFunction(JSGlobalObject* globalObject, const ArgList& args, ThrowScope& scope, int* outOffset)
{
// How we stringify functions is important for creating anonymous function expressions
diff --git a/src/jsc/bindings/NodeVMScriptFetcher.h b/src/jsc/bindings/NodeVMScriptFetcher.h
index 3eeb3fddfe95..2dfd5089a57a 100644
--- a/src/jsc/bindings/NodeVMScriptFetcher.h
+++ b/src/jsc/bindings/NodeVMScriptFetcher.h
@@ -42,8 +42,7 @@ class NodeVMScriptFetcher : public JSC::ScriptFetcher {
});
}
- // compileFunction's program and the length of the `(function (...) {` wrapper on its first line.
- // Keyed by provider: eval() and new Function() code inherits this fetcher but has no wrapper.
+ // Keyed by provider because eval() and new Function() code inherits this fetcher without the wrapper.
void setWrapper(JSC::SourceProvider& provider, unsigned prefixLength)
{
m_wrapperSourceID = provider.asID();
From 0c34c9ab6b7f6705aa3432eaba7a742aa5dc4503 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Fri, 14 Aug 2026 01:44:44 +0000
Subject: [PATCH 6/9] node:vm: report the body's own columns on a
compileFunction's first line
With the body on the wrapper's line, JSC's columns for that line count the
wrapper text, and Bun's error printer showed the wrapper glued to the user's
first line. Record, next to the wrapper length, how far those columns exceed
the columns Node reports (the wrapper net of columnOffset), take it off in the
two places Bun derives frame positions from JSC (the error.stack formatter and
getAdjustedPositionForBytecode, which feeds call sites and the error printer),
and skip the wrapper text when the printer excerpts that line.
Line 1 positions now match the same code on any other line plus columnOffset,
for every consumer, so the vendored test-vm-basic.js expectations become the
plain columns.
---
src/jsc/bindings/ErrorStackFrame.cpp | 6 ++
src/jsc/bindings/FormatStackTraceForJS.cpp | 8 ++
src/jsc/bindings/NodeVM.cpp | 13 +--
src/jsc/bindings/NodeVMScriptFetcher.h | 44 +++++++--
src/jsc/bindings/ZigException.cpp | 10 ++-
test/js/node/test/parallel/test-vm-basic.js | 23 +++--
test/js/node/vm/vm.test.ts | 99 +++++++++++++++++----
7 files changed, 158 insertions(+), 45 deletions(-)
diff --git a/src/jsc/bindings/ErrorStackFrame.cpp b/src/jsc/bindings/ErrorStackFrame.cpp
index 806a340be246..abd131c1f92d 100644
--- a/src/jsc/bindings/ErrorStackFrame.cpp
+++ b/src/jsc/bindings/ErrorStackFrame.cpp
@@ -1,6 +1,7 @@
#include "root.h"
#include "JavaScriptCore/CodeBlock.h"
#include "headers-handwritten.h"
+#include "NodeVMScriptFetcher.h"
#include "JavaScriptCore/BytecodeIndex.h"
#include "wtf/Assertions.h"
#include "wtf/text/OrdinalNumber.h"
@@ -95,6 +96,11 @@ ZigStackFramePosition getAdjustedPositionForBytecode(JSC::CodeBlock* code, JSC::
break;
}
+ if (auto* provider = code->source().provider()) {
+ if (unsigned wrapperColumns = NodeVMScriptFetcher::wrapperColumnsOnLine(*provider, pos.line_zero_based))
+ pos.column_zero_based = static_cast(std::max(static_cast(pos.column_zero_based) - wrapperColumns, 0));
+ }
+
return pos;
}
diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp
index d622679f31aa..ccc0ae9bceda 100644
--- a/src/jsc/bindings/FormatStackTraceForJS.cpp
+++ b/src/jsc/bindings/FormatStackTraceForJS.cpp
@@ -22,6 +22,7 @@
#include "BunClientData.h"
#include "CallSite.h"
#include "ErrorStackTrace.h"
+#include "NodeVMScriptFetcher.h"
#include "headers-handwritten.h"
#include
@@ -265,6 +266,13 @@ WTF::String formatStackTrace(
if (!frame.hasLineAndColumnInfo()) continue;
originalLineColumns[i] = frame.computeLineAndColumn();
+ if (auto* codeBlock = frame.codeBlock()) {
+ if (auto* provider = codeBlock->source().provider()) {
+ LineColumn& lineColumn = originalLineColumns[i];
+ if (unsigned wrapperColumns = NodeVMScriptFetcher::wrapperColumnsOnLine(*provider, static_cast(lineColumn.line) - 1))
+ lineColumn.column = lineColumn.column > wrapperColumns ? lineColumn.column - wrapperColumns : 1;
+ }
+ }
JSC::JSGlobalObject* globalObjectForFrame = lexicalGlobalObject;
if (auto* callee = frame.callee()) {
diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp
index 4098ac229913..2597b3d6b834 100644
--- a/src/jsc/bindings/NodeVM.cpp
+++ b/src/jsc/bindings/NodeVM.cpp
@@ -188,14 +188,17 @@ JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, c
EXCEPTION_ASSERT(!!throwScope.exception() == code.isNull());
RETURN_IF_EXCEPTION(throwScope, nullptr);
- // Line 1's columns already include the wrapper, so only the excess of columnOffset over it is applied.
+ // JSC counts the wrapper in line 1's columns and cannot start a source at a negative column: the part of
+ // columnOffset beyond the wrapper is applied here, the rest is taken off again when positions are reported.
int columnOffset = position.m_column.zeroBasedInt();
- TextPosition wrappedPosition(position.m_line, OrdinalNumber::fromZeroBasedInt(columnOffset > wrapperPrefixLength ? columnOffset - wrapperPrefixLength : 0));
+ int appliedColumnOffset = columnOffset > wrapperPrefixLength ? columnOffset - wrapperPrefixLength : 0;
+ unsigned wrapperColumns = static_cast(static_cast(wrapperPrefixLength) + appliedColumnOffset - columnOffset);
+ TextPosition wrappedPosition(position.m_line, OrdinalNumber::fromZeroBasedInt(appliedColumnOffset));
Ref provider = JSC::StringSourceProvider::create(code, sourceOrigin, WTF::move(options.filename), sourceTaintOrigin, wrappedPosition, SourceProviderSourceType::Program);
if (auto* fetcher = sourceOrigin.fetcher(); fetcher && fetcher->fetcherType() == ScriptFetcher::Type::NodeVM)
- static_cast(fetcher)->setWrapper(provider.get(), static_cast(wrapperPrefixLength));
+ static_cast(fetcher)->setWrapper(provider.get(), static_cast(wrapperPrefixLength), wrapperColumns);
SourceCode sourceCode(WTF::move(provider), wrappedPosition.m_line.oneBasedInt(), wrappedPosition.m_column.oneBasedInt());
@@ -576,9 +579,7 @@ bool handleException(JSGlobalObject* globalObject, VM& vm, NakedPtrsource().provider()) {
- unsigned wrapperPrefixLength = 0;
- if (auto* fetcher = provider->sourceOrigin().fetcher(); fetcher && fetcher->fetcherType() == ScriptFetcher::Type::NodeVM)
- wrapperPrefixLength = static_cast(fetcher)->wrapperPrefixLength(*provider);
+ unsigned wrapperPrefixLength = NodeVMScriptFetcher::wrapperTextLength(*provider);
int64_t startLineZeroBased = provider->startPosition().m_line.zeroBasedInt();
int64_t physicalLine = static_cast(line_and_column.line) - startLineZeroBased;
diff --git a/src/jsc/bindings/NodeVMScriptFetcher.h b/src/jsc/bindings/NodeVMScriptFetcher.h
index 2dfd5089a57a..c2f77d555f18 100644
--- a/src/jsc/bindings/NodeVMScriptFetcher.h
+++ b/src/jsc/bindings/NodeVMScriptFetcher.h
@@ -42,18 +42,47 @@ class NodeVMScriptFetcher : public JSC::ScriptFetcher {
});
}
- // Keyed by provider because eval() and new Function() code inherits this fetcher without the wrapper.
- void setWrapper(JSC::SourceProvider& provider, unsigned prefixLength)
+ // compileFunction's program has its `(function () {` wrapper on the body's first line (see
+ // stringifyAnonymousFunction): `textLength` is that text, `columns` is what JSC's columns on that line
+ // exceed Node's by (the wrapper net of columnOffset). Keyed by provider because eval() and
+ // new Function() code inherits this fetcher without the wrapper.
+ void setWrapper(JSC::SourceProvider& program, unsigned textLength, unsigned columns)
{
- m_wrapperSourceID = provider.asID();
- m_wrapperPrefixLength = prefixLength;
+ m_wrapperSourceID = program.asID();
+ m_wrapperTextLength = textLength;
+ m_wrapperColumns = columns;
}
- unsigned wrapperPrefixLength(JSC::SourceProvider& provider) const
+
+ // 0 unless `provider` is a compileFunction program.
+ static unsigned wrapperTextLength(JSC::SourceProvider& provider)
+ {
+ auto* fetcher = wrapperFetcherFor(provider);
+ return fetcher ? fetcher->m_wrapperTextLength : 0;
+ }
+
+ // 0 unless `lineZeroBased` is the first line of a compileFunction program.
+ static unsigned wrapperColumnsOnLine(JSC::SourceProvider& provider, int lineZeroBased)
{
- return m_wrapperPrefixLength && provider.asID() == m_wrapperSourceID ? m_wrapperPrefixLength : 0;
+ auto* fetcher = wrapperFetcherFor(provider);
+ if (!fetcher)
+ return 0;
+ // SourceCode clamps the start line to the first line, so JSC reports the first physical line as max(lineOffset, 0).
+ int firstLine = std::max(0, provider.startPosition().m_line.zeroBasedInt());
+ return lineZeroBased == firstLine ? fetcher->m_wrapperColumns : 0;
}
private:
+ static NodeVMScriptFetcher* wrapperFetcherFor(JSC::SourceProvider& provider)
+ {
+ auto* fetcher = provider.sourceOrigin().fetcher();
+ if (!fetcher || fetcher->fetcherType() != Type::NodeVM)
+ return nullptr;
+ auto* vmFetcher = static_cast(fetcher);
+ if (!vmFetcher->m_wrapperTextLength || provider.asID() != vmFetcher->m_wrapperSourceID)
+ return nullptr;
+ return vmFetcher;
+ }
+
JSC::Strong m_dynamicImportCallback;
// m_owner is the NodeVMScript / JSFunction / module wrapper that holds this
// fetcher via m_source -> SourceProvider -> SourceOrigin -> RefPtr.
@@ -63,7 +92,8 @@ class NodeVMScriptFetcher : public JSC::ScriptFetcher {
// SourceCode chain drops the last RefPtr to this fetcher.
JSC::Weak m_owner;
JSC::SourceID m_wrapperSourceID = 0;
- unsigned m_wrapperPrefixLength = 0;
+ unsigned m_wrapperTextLength = 0;
+ unsigned m_wrapperColumns = 0;
bool m_isUsingDefaultLoader = false;
NodeVMScriptFetcher(JSC::VM& vm, JSC::JSValue dynamicImportCallback, JSC::JSValue owner)
diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp
index ea84d276eda9..2a147b394aa6 100644
--- a/src/jsc/bindings/ZigException.cpp
+++ b/src/jsc/bindings/ZigException.cpp
@@ -43,6 +43,7 @@
#include "ErrorStackFrame.h"
#include "ErrorStackTrace.h"
+#include "NodeVMScriptFetcher.h"
#include "ObjectBindings.h"
#include
@@ -161,6 +162,9 @@ static void populateStackFramePosition(const JSC::StackFrame& stackFrame, BunStr
return;
if (source_lines_count > 1 && source_lines != nullptr && sourceString.is8Bit()) {
+ // vm.compileFunction's wrapper starts the first line; it is not part of the user's source.
+ unsigned wrapperTextLength = Bun::NodeVMScriptFetcher::wrapperTextLength(*provider);
+
// Search for the beginning of the line
unsigned int lineStart = location.byte_position;
while (lineStart > 0 && sourceString[lineStart] != '\n') {
@@ -185,7 +189,8 @@ static void populateStackFramePosition(const JSC::StackFrame& stackFrame, BunStr
(*referenced_source_provider)->deref();
}
*referenced_source_provider = provider;
- source_lines[0] = Bun::toStringView(sourceString.substring(lineStart, lineEnd - lineStart));
+ unsigned int textStart = lineStart == 0 ? std::min(wrapperTextLength, lineEnd) : lineStart;
+ source_lines[0] = Bun::toStringView(sourceString.substring(textStart, lineEnd - textStart));
source_line_numbers[0] = location.line();
if (lineStart > 0) {
@@ -211,7 +216,8 @@ static void populateStackFramePosition(const JSC::StackFrame& stackFrame, BunStr
}
// We are at the beginning of the line
- source_lines[source_line_i] = Bun::toStringView(sourceString.substring(byte_offset_in_source_string, end_of_line_offset - byte_offset_in_source_string + 1));
+ unsigned int contextStart = byte_offset_in_source_string == 0 ? std::min(wrapperTextLength, end_of_line_offset + 1) : byte_offset_in_source_string;
+ source_lines[source_line_i] = Bun::toStringView(sourceString.substring(contextStart, end_of_line_offset + 1 - contextStart));
source_line_numbers[source_line_i] = location.line().fromZeroBasedInt(location.line().zeroBasedInt() - source_line_i);
source_line_i++;
diff --git a/test/js/node/test/parallel/test-vm-basic.js b/test/js/node/test/parallel/test-vm-basic.js
index c9d53a58d04f..bee21b70daec 100644
--- a/test/js/node/test/parallel/test-vm-basic.js
+++ b/test/js/node/test/parallel/test-vm-basic.js
@@ -131,9 +131,9 @@ const vm = require('vm');
// vm.compileFunction
{
- // Bun compiles the body on the same line as the wrapper so that stack
- // frames report body line N as lineOffset + N (the engine cannot start a
- // source at line 0), which is what toString() reflects.
+ // Bun compiles the body on the same line as the wrapper (the engine cannot
+ // start a source at line 0, so a wrapper line would shift every body line),
+ // and toString() returns the text as compiled.
assert.strictEqual(
vm.compileFunction('console.log("Hello, World!")').toString(),
typeof Bun === 'undefined'
@@ -282,18 +282,17 @@ const vm = require('vm');
// Setting value to run the last three tests
Error.stackTraceLimit = 1;
- // Bun's compileFunction stack frames differ from Node's in their columns:
- // JSC attributes the throw to a different column than V8, and columns on
- // body line 1 also count the `(function () {` wrapper the body shares its
- // line with (so a columnOffset shorter than the wrapper has no effect).
- // The anonymous source is labeled differently too. Lines match Node.
+ // Bun's compileFunction stack frames differ from Node's in two ways: JSC
+ // attributes the throw to the call's `(` where V8 uses the `new` keyword
+ // (hence column 16 where Node has 7), and the anonymous source is labeled
+ // differently. Lines and offsets match Node.
assert.throws(() => {
vm.compileFunction('throw new Error("Sample Error")')();
}, {
message: 'Sample Error',
stack: typeof Bun === 'undefined'
? 'Error: Sample Error\n at :1:7'
- : 'Error: Sample Error\n at (file:///:1:30)'
+ : 'Error: Sample Error\n at (file:///:1:16)'
});
assert.throws(() => {
@@ -306,7 +305,7 @@ const vm = require('vm');
message: 'Sample Error',
stack: typeof Bun === 'undefined'
? 'Error: Sample Error\n at :4:7'
- : 'Error: Sample Error\n at (file:///:4:30)'
+ : 'Error: Sample Error\n at (file:///:4:16)'
});
assert.throws(() => {
@@ -319,7 +318,7 @@ const vm = require('vm');
message: 'Sample Error',
stack: typeof Bun === 'undefined'
? 'Error: Sample Error\n at :1:10'
- : 'Error: Sample Error\n at (file:///:1:30)'
+ : 'Error: Sample Error\n at (file:///:1:19)'
});
assert.strictEqual(
@@ -343,7 +342,7 @@ const vm = require('vm');
// Bun's compileFunction stack frames differ from Node's (see above).
stack: typeof Bun === 'undefined'
? 'ReferenceError: varInContext is not defined\n at :1:1'
- : 'ReferenceError: varInContext is not defined\n at (file:///:1:34)'
+ : 'ReferenceError: varInContext is not defined\n at (file:///:1:20)'
});
assert.notDeepStrictEqual(
diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts
index 77bf002bc04d..1f463375b33d 100644
--- a/test/js/node/vm/vm.test.ts
+++ b/test/js/node/vm/vm.test.ts
@@ -340,29 +340,92 @@ describe("vm", () => {
}
});
- test("columnOffset shifts body line 1 only", () => {
+ test("body line 1 reports the body's own columns, plus columnOffset", () => {
const filename = "cf-columns.js";
const statement = 'throw new Error("column")';
// The column JSC assigns to this statement when it starts a line that no
- // offset applies to.
+ // wrapper or offset touches.
const { column } = thrownPosition(filename, compileFunction("\n" + statement, [], { filename }));
- // Body line 1 shares its line with the `(function () {` wrapper, so its
- // columns include that text; a columnOffset at least that long is
- // applied exactly, as in Node.
- expect(thrownPosition(filename, compileFunction(statement, [], { filename, columnOffset: 100 }))).toEqual({
- line: 1,
- column: column + 100,
- });
- expect(
- thrownPosition(filename, () => compileFunction(statement, ["a", "b"], { filename, columnOffset: 100 })()),
- ).toEqual({ line: 1, column: column + 100 });
- // Later lines are never shifted.
- expect(thrownPosition(filename, compileFunction("\n" + statement, [], { filename, columnOffset: 100 }))).toEqual({
- line: 2,
- column,
+ const results = {
+ line1: thrownPosition(filename, compileFunction(statement, [], { filename })),
+ line1WithParams: thrownPosition(filename, () => compileFunction(statement, ["a", "b"], { filename })()),
+ // Node adds columnOffset on the first line only, whatever its size.
+ line1Offset3: thrownPosition(filename, compileFunction(statement, [], { filename, columnOffset: 3 })),
+ line1Offset100: thrownPosition(filename, compileFunction(statement, [], { filename, columnOffset: 100 })),
+ line1WithParamsOffset100: thrownPosition(filename, () =>
+ compileFunction(statement, ["a", "b"], { filename, columnOffset: 100 })(),
+ ),
+ line2Offset100: thrownPosition(
+ filename,
+ compileFunction("\n" + statement, [], { filename, columnOffset: 100 }),
+ ),
+ nestedArrowOnLine1: thrownPosition(
+ filename,
+ compileFunction(`return () => { ${statement} };`, [], { filename })(),
+ ),
+ nestedArrowOnLine2: thrownPosition(
+ filename,
+ compileFunction(`\nreturn () => { ${statement} };`, [], { filename })(),
+ ),
+ };
+ expect(results).toEqual({
+ line1: { line: 1, column },
+ line1WithParams: { line: 1, column },
+ line1Offset3: { line: 1, column: column + 3 },
+ line1Offset100: { line: 1, column: column + 100 },
+ line1WithParamsOffset100: { line: 1, column: column + 100 },
+ line2Offset100: { line: 2, column },
+ nestedArrowOnLine1: { line: 1, column: results.nestedArrowOnLine2.column },
+ nestedArrowOnLine2: { line: 2, column: results.nestedArrowOnLine2.column },
});
- // Without a columnOffset, line 1 still reports line 1.
- expect(thrownPosition(filename, compileFunction(statement, [], { filename })).line).toBe(1);
+ });
+
+ test("Error.prepareStackTrace call sites report the same columns on body line 1", () => {
+ const previous = Error.prepareStackTrace;
+ Error.prepareStackTrace = (_, callSites) => callSites.map(site => [site.getLineNumber(), site.getColumnNumber()]);
+ try {
+ const body = "return new Error('x').stack[0];";
+ const [, column] = compileFunction("\n" + body, [], { filename: "cf-callsite-columns.js" })();
+ expect({
+ line1: compileFunction(body, [], { filename: "cf-callsite-columns.js" })(),
+ line1WithParams: compileFunction(body, ["a"], { filename: "cf-callsite-columns.js" })(),
+ line1Offset5: compileFunction(body, [], { filename: "cf-callsite-columns.js", columnOffset: 5 })(),
+ }).toEqual({
+ line1: [1, column],
+ line1WithParams: [1, column],
+ line1Offset5: [1, column + 5],
+ });
+ } finally {
+ Error.prepareStackTrace = previous;
+ }
+ });
+
+ test.concurrent("uncaught error output shows the body line without the wrapper", async () => {
+ const run = async (code: string) => {
+ await using proc = Bun.spawn({
+ cmd: [bunExe(), "-e", code],
+ env: bunEnv,
+ stderr: "pipe",
+ stdout: "pipe",
+ });
+ const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
+ expect(exitCode).toBe(1);
+ // The source excerpt: line-numbered source lines followed by the caret line.
+ return stderr.split("\n").filter(line => /^\s*\d+ \| /.test(line) || /^\s*\^$/.test(line));
+ };
+ const body = JSON.stringify('let ok = 1; throw new Error("boom")');
+ const [fromScript, fromFunction, fromOffsetFunction] = await Promise.all([
+ // Reference: the same text run as a script, which has no wrapper.
+ run(
+ `new (require("node:vm").Script)(${body}, { filename: "/virtual/ref.js" }).runInThisContext({ displayErrors: false })`,
+ ),
+ run(`require("node:vm").compileFunction(${body}, ["exports", "require"], { filename: "/virtual/cf.js" })()`),
+ run(`require("node:vm").compileFunction(${body}, [], { filename: "/virtual/cf.js", lineOffset: 10 })()`),
+ ]);
+ expect(fromScript).toEqual([`1 | ${JSON.parse(body)}`, expect.stringMatching(/^ +\^$/)]);
+ expect(fromFunction).toEqual(fromScript);
+ // A wider line number indents the caret one more column.
+ expect(fromOffsetFunction).toEqual([`11 | ${JSON.parse(body)}`, " " + fromScript[1]]);
});
test("runtime arrow header shows the body line when called from a vm script", () => {
From 7443cf520109b91aa67488c322f3f51aaac5eb97 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Fri, 14 Aug 2026 01:47:26 +0000
Subject: [PATCH 7/9] node:vm: one-line comments for the wrapper bookkeeping
---
src/jsc/bindings/NodeVM.cpp | 3 +--
src/jsc/bindings/NodeVMScriptFetcher.h | 14 ++++++--------
2 files changed, 7 insertions(+), 10 deletions(-)
diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp
index 2597b3d6b834..0176962708cb 100644
--- a/src/jsc/bindings/NodeVM.cpp
+++ b/src/jsc/bindings/NodeVM.cpp
@@ -188,8 +188,7 @@ JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, c
EXCEPTION_ASSERT(!!throwScope.exception() == code.isNull());
RETURN_IF_EXCEPTION(throwScope, nullptr);
- // JSC counts the wrapper in line 1's columns and cannot start a source at a negative column: the part of
- // columnOffset beyond the wrapper is applied here, the rest is taken off again when positions are reported.
+ // JSC counts the wrapper in line 1's columns and cannot start a source at a negative column: apply what exceeds the wrapper here, take the rest off when positions are reported.
int columnOffset = position.m_column.zeroBasedInt();
int appliedColumnOffset = columnOffset > wrapperPrefixLength ? columnOffset - wrapperPrefixLength : 0;
unsigned wrapperColumns = static_cast(static_cast(wrapperPrefixLength) + appliedColumnOffset - columnOffset);
diff --git a/src/jsc/bindings/NodeVMScriptFetcher.h b/src/jsc/bindings/NodeVMScriptFetcher.h
index c2f77d555f18..40c91256bba6 100644
--- a/src/jsc/bindings/NodeVMScriptFetcher.h
+++ b/src/jsc/bindings/NodeVMScriptFetcher.h
@@ -42,25 +42,22 @@ class NodeVMScriptFetcher : public JSC::ScriptFetcher {
});
}
- // compileFunction's program has its `(function () {` wrapper on the body's first line (see
- // stringifyAnonymousFunction): `textLength` is that text, `columns` is what JSC's columns on that line
- // exceed Node's by (the wrapper net of columnOffset). Keyed by provider because eval() and
- // new Function() code inherits this fetcher without the wrapper.
- void setWrapper(JSC::SourceProvider& program, unsigned textLength, unsigned columns)
+ // The compileFunction wrapper (see stringifyAnonymousFunction) shares the program's first line with the body.
+ void setWrapper(JSC::SourceProvider& program, unsigned textLength, unsigned columnsBeyondNode)
{
m_wrapperSourceID = program.asID();
m_wrapperTextLength = textLength;
- m_wrapperColumns = columns;
+ m_wrapperColumns = columnsBeyondNode;
}
- // 0 unless `provider` is a compileFunction program.
+ // Wrapper text starting the first line; 0 unless `provider` is a compileFunction program.
static unsigned wrapperTextLength(JSC::SourceProvider& provider)
{
auto* fetcher = wrapperFetcherFor(provider);
return fetcher ? fetcher->m_wrapperTextLength : 0;
}
- // 0 unless `lineZeroBased` is the first line of a compileFunction program.
+ // By how much JSC's columns exceed Node's on this line; 0 unless it is a compileFunction program's first line.
static unsigned wrapperColumnsOnLine(JSC::SourceProvider& provider, int lineZeroBased)
{
auto* fetcher = wrapperFetcherFor(provider);
@@ -72,6 +69,7 @@ class NodeVMScriptFetcher : public JSC::ScriptFetcher {
}
private:
+ // Matched by provider: eval() and new Function() code inside the body inherits this fetcher without the wrapper.
static NodeVMScriptFetcher* wrapperFetcherFor(JSC::SourceProvider& provider)
{
auto* fetcher = provider.sourceOrigin().fetcher();
From 6d96f605fc44a2db5a95c7a1db5c1fe48b387ef3 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Fri, 14 Aug 2026 02:20:39 +0000
Subject: [PATCH 8/9] test(vm): drain stdout and assert the excerpt together
with the exit code
---
test/js/node/vm/vm.test.ts | 23 +++++++++++++++++------
1 file changed, 17 insertions(+), 6 deletions(-)
diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts
index 1f463375b33d..b9ff3f4b8a3b 100644
--- a/test/js/node/vm/vm.test.ts
+++ b/test/js/node/vm/vm.test.ts
@@ -408,10 +408,13 @@ describe("vm", () => {
stderr: "pipe",
stdout: "pipe",
});
- const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
- expect(exitCode).toBe(1);
- // The source excerpt: line-numbered source lines followed by the caret line.
- return stderr.split("\n").filter(line => /^\s*\d+ \| /.test(line) || /^\s*\^$/.test(line));
+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+ return {
+ // The source excerpt: line-numbered source lines followed by the caret line.
+ excerpt: stderr.split("\n").filter(line => /^\s*\d+ \| /.test(line) || /^\s*\^$/.test(line)),
+ stdout,
+ exitCode,
+ };
};
const body = JSON.stringify('let ok = 1; throw new Error("boom")');
const [fromScript, fromFunction, fromOffsetFunction] = await Promise.all([
@@ -422,10 +425,18 @@ describe("vm", () => {
run(`require("node:vm").compileFunction(${body}, ["exports", "require"], { filename: "/virtual/cf.js" })()`),
run(`require("node:vm").compileFunction(${body}, [], { filename: "/virtual/cf.js", lineOffset: 10 })()`),
]);
- expect(fromScript).toEqual([`1 | ${JSON.parse(body)}`, expect.stringMatching(/^ +\^$/)]);
+ expect(fromScript).toEqual({
+ excerpt: [`1 | ${JSON.parse(body)}`, expect.stringMatching(/^ +\^$/)],
+ stdout: "",
+ exitCode: 1,
+ });
expect(fromFunction).toEqual(fromScript);
// A wider line number indents the caret one more column.
- expect(fromOffsetFunction).toEqual([`11 | ${JSON.parse(body)}`, " " + fromScript[1]]);
+ expect(fromOffsetFunction).toEqual({
+ excerpt: [`11 | ${JSON.parse(body)}`, " " + fromScript.excerpt[1]],
+ stdout: "",
+ exitCode: 1,
+ });
});
test("runtime arrow header shows the body line when called from a vm script", () => {
From 5d4c0f54f5edd963a7e26c436810b0448d641fed Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Fri, 14 Aug 2026 10:44:42 +0000
Subject: [PATCH 9/9] ci: retrigger