From f1433080e1d5f2ee00abf9b15387dbcdb95aaaac Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:03:05 +0000 Subject: [PATCH 1/5] error: report default class constructor frames at the class definition A class without an explicit constructor gets one that JSC compiles from BuiltinExecutables::defaultConstructorSourceCode(), a one-line source with no URL, so its stack frames rendered as "new X (unknown:1:17)" in error.stack, CallSites and the uncaught error printer, and err.sourceURL and err.line were empty and 1 when such a frame was on top. Attribute these frames to the class's own SourceCode instead, positioned at the `class` keyword like V8 does, in all three renderers. The column is counted from the line start in the source text because the SourceCode's own start column is relative to the function start when the class is on the first line of a lazily parsed function body. --- src/jsc/bindings/ErrorStackFrame.cpp | 65 ++++++++++- src/jsc/bindings/ErrorStackFrame.h | 24 ++++ src/jsc/bindings/ErrorStackTrace.cpp | 25 ++-- src/jsc/bindings/FormatStackTraceForJS.cpp | 3 +- src/jsc/bindings/ZigException.cpp | 7 +- test/js/bun/test/stack.test.ts | 123 +++++++++++++++++++- test/js/node/v8/capture-stack-trace.test.js | 58 +++++++++ 7 files changed, 291 insertions(+), 14 deletions(-) diff --git a/src/jsc/bindings/ErrorStackFrame.cpp b/src/jsc/bindings/ErrorStackFrame.cpp index 806a340be246..acf1465304d9 100644 --- a/src/jsc/bindings/ErrorStackFrame.cpp +++ b/src/jsc/bindings/ErrorStackFrame.cpp @@ -1,13 +1,70 @@ #include "root.h" +#include "ErrorStackFrame.h" #include "JavaScriptCore/CodeBlock.h" -#include "headers-handwritten.h" -#include "JavaScriptCore/BytecodeIndex.h" +#include "JavaScriptCore/FunctionExecutable.h" +#include "JavaScriptCore/SourceProvider.h" +#include "JavaScriptCore/StackFrame.h" +#include "JavaScriptCore/UnlinkedFunctionExecutable.h" #include "wtf/Assertions.h" #include "wtf/text/OrdinalNumber.h" namespace Bun { using namespace JSC; +SourceCode defaultClassConstructorClassSource(ScriptExecutable* executable) +{ + auto* function = dynamicDowncast(executable); + if (!function || !function->unlinkedExecutable()->isBuiltinDefaultClassConstructor()) + return {}; + return function->classSource(); +} + +static bool isLineTerminator(char16_t c) +{ + return c == '\n' || c == '\r' || c == 0x2028 || c == 0x2029; +} + +ZigStackFramePosition classSourceStartPosition(const SourceCode& classSource) +{ + auto* provider = classSource.provider(); + int start = classSource.startOffset(); + WTF::StringView source = provider->source(); + + // classSource.startColumn() is counted from wherever the lexer started, which for a class on + // the first line of a lazily parsed function body is that function, not the line. Count from + // the line start instead; the column is only taken as-is when the source text is unavailable. + int column = classSource.startColumn().zeroBasedInt(); + if (static_cast(start) <= source.length()) { + int lineStart = start; + while (lineStart > 0 && !isLineTerminator(source[lineStart - 1])) + lineStart--; + column = start - lineStart; + // JSC's columns on the first line of a source include its start column (node:vm columnOffset). + if (lineStart == 0) + column += provider->startPosition().m_column.zeroBasedInt(); + } + + return ZigStackFramePosition { + .line_zero_based = classSource.firstLine().zeroBasedInt(), + .column_zero_based = column, + .byte_position = start, + }; +} + +LineColumn computeLineAndColumn(const StackFrame& frame) +{ + auto* codeBlock = frame.codeBlock(); + auto classSource = codeBlock ? defaultClassConstructorClassSource(codeBlock->ownerExecutable()) : SourceCode(); + if (!classSource.isNull()) { + auto position = classSourceStartPosition(classSource); + return LineColumn { + .line = static_cast(position.line().oneBasedInt()), + .column = static_cast(position.column().oneBasedInt()), + }; + } + return frame.computeLineAndColumn(); +} + /// Adjust a `ZigStackFramePosition` by a number of bytes. This accounts for when the adjustment /// crosses line boundaries, and thus requires the source code in order to properly compute /// the result. @@ -64,6 +121,10 @@ void adjustPositionBackwards(ZigStackFramePosition& pos, int amount, CodeBlock* ZigStackFramePosition getAdjustedPositionForBytecode(JSC::CodeBlock* code, JSC::BytecodeIndex bc) { + auto classSource = defaultClassConstructorClassSource(code->ownerExecutable()); + if (!classSource.isNull()) + return classSourceStartPosition(classSource); + auto expr = code->expressionInfoForBytecodeIndex(bc); ZigStackFramePosition pos { diff --git a/src/jsc/bindings/ErrorStackFrame.h b/src/jsc/bindings/ErrorStackFrame.h index d7a04e83d6cd..ea4b0b672ad8 100644 --- a/src/jsc/bindings/ErrorStackFrame.h +++ b/src/jsc/bindings/ErrorStackFrame.h @@ -1,9 +1,33 @@ +#pragma once + #include "root.h" #include "headers-handwritten.h" #include "JavaScriptCore/BytecodeIndex.h" +#include "JavaScriptCore/LineColumn.h" +#include "JavaScriptCore/SourceCode.h" + +namespace JSC { +class CodeBlock; +class ScriptExecutable; +class StackFrame; +} namespace Bun { +/// JSC compiles the constructor of a class that declares none from +/// BuiltinExecutables::defaultConstructorSourceCode(), a fixed one-line string with no URL, so that +/// executable's source() and bytecode positions only describe that string. For such an executable +/// this returns the SourceCode of the class itself (its file, starting at the `class` keyword), which +/// is where V8 reports these frames. Null for every other executable. +JSC::SourceCode defaultClassConstructorClassSource(JSC::ScriptExecutable* executable); + +/// Position of the `class` keyword a class SourceCode starts at, in the same coordinates JSC reports +/// bytecode positions of that file in. +ZigStackFramePosition classSourceStartPosition(const JSC::SourceCode& classSource); + ZigStackFramePosition getAdjustedPositionForBytecode(JSC::CodeBlock* code, JSC::BytecodeIndex bc); +/// frame.computeLineAndColumn(), except that a default class constructor frame is placed at its class. +JSC::LineColumn computeLineAndColumn(const JSC::StackFrame& frame); + } // namespace Bun diff --git a/src/jsc/bindings/ErrorStackTrace.cpp b/src/jsc/bindings/ErrorStackTrace.cpp index 7afd14d4b236..b20c51ce7b12 100644 --- a/src/jsc/bindings/ErrorStackTrace.cpp +++ b/src/jsc/bindings/ErrorStackTrace.cpp @@ -304,7 +304,16 @@ JSCStackFrame::JSCStackFrame(JSC::VM& vm, const JSC::StackFrame& frame) intptr_t JSCStackFrame::sourceID() const { - return m_codeBlock ? m_codeBlock->ownerExecutable()->sourceID() : JSC::noSourceID; + if (!m_codeBlock) { + return JSC::noSourceID; + } + + auto classSource = Bun::defaultClassConstructorClassSource(m_codeBlock->ownerExecutable()); + if (!classSource.isNull()) { + return classSource.providerID(); + } + + return m_codeBlock->ownerExecutable()->sourceID(); } JSC::JSString* JSCStackFrame::sourceURL() @@ -371,12 +380,9 @@ ALWAYS_INLINE String JSCStackFrame::retrieveSourceURL() // Instead, try to get some identifying information for this frame // Try to use sourceID if available - if (m_codeBlock) { - auto sourceID = m_codeBlock->ownerExecutable()->sourceID(); - if (sourceID != JSC::noSourceID) { - // Use a placeholder that includes the sourceID to make frames distinguishable - return makeString("[source:"_s, sourceID, "]"_s); - } + if (auto sourceID = this->sourceID(); sourceID != JSC::noSourceID) { + // Use a placeholder that includes the sourceID to make frames distinguishable + return makeString("[source:"_s, sourceID, "]"_s); } // Last resort: return a distinguishable placeholder instead of empty string @@ -470,6 +476,11 @@ String sourceURL(JSC::CodeBlock& codeBlock) return String(); } + auto classSource = Bun::defaultClassConstructorClassSource(codeBlock.ownerExecutable()); + if (!classSource.isNull()) { + return sourceURL(classSource); + } + const auto& source = codeBlock.source(); return sourceURL(source); } diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index d622679f31aa..6803d26e7788 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -21,6 +21,7 @@ #include "BunClientData.h" #include "CallSite.h" +#include "ErrorStackFrame.h" #include "ErrorStackTrace.h" #include "headers-handwritten.h" @@ -264,7 +265,7 @@ WTF::String formatStackTrace( if (!frame.hasLineAndColumnInfo()) continue; - originalLineColumns[i] = frame.computeLineAndColumn(); + originalLineColumns[i] = Bun::computeLineAndColumn(frame); JSC::JSGlobalObject* globalObjectForFrame = lexicalGlobalObject; if (auto* callee = frame.callee()) { diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index ea84d276eda9..22137f4f31d3 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -135,7 +135,10 @@ static void populateStackFramePosition(const JSC::StackFrame& stackFrame, BunStr if (!code) return; - auto* provider = code->source().provider(); + // The position below is in the class's source for a default class constructor, so the + // source lines have to come from it as well. + auto classSource = Bun::defaultClassConstructorClassSource(code->ownerExecutable()); + auto* provider = classSource.isNull() ? code->source().provider() : classSource.provider(); if (!provider) [[unlikely]] return; // Make sure the range is valid: @@ -146,7 +149,7 @@ static void populateStackFramePosition(const JSC::StackFrame& stackFrame, BunStr if (!stackFrame.hasBytecodeIndex()) { if (stackFrame.hasLineAndColumnInfo()) { - auto lineColumn = stackFrame.computeLineAndColumn(); + auto lineColumn = Bun::computeLineAndColumn(stackFrame); position.line_zero_based = OrdinalNumber::fromOneBasedInt(lineColumn.line).zeroBasedInt(); position.column_zero_based = OrdinalNumber::fromOneBasedInt(lineColumn.column).zeroBasedInt(); } diff --git a/test/js/bun/test/stack.test.ts b/test/js/bun/test/stack.test.ts index 63b28630a3f6..92cb38970671 100644 --- a/test/js/bun/test/stack.test.ts +++ b/test/js/bun/test/stack.test.ts @@ -1,6 +1,6 @@ import { $ } from "bun"; -import { expect, test } from "bun:test"; -import { bunEnv, bunExe, bunRun, normalizeBunSnapshot } from "harness"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, bunRun, normalizeBunSnapshot, tempDir } from "harness"; import { join } from "node:path"; test("name property is used for function calls in Error.stack", () => { @@ -149,3 +149,122 @@ test("Async functions frame should be included in stack trace", async () => { at async (file:NN:NN)" `); }); + +// JSC compiles the constructor of a class that declares none from a fixed source string, so these +// frames used to render as "new X (unknown:1:17)". Like V8, they should point at the `class` keyword. +describe("frames of a class without an explicit constructor", () => { + test.concurrent("error.stack, error.sourceURL and error.line point at the class", async () => { + using dir = tempDir("default-ctor-stack", { + // Every position below is asserted on, so the lines are laid out deliberately. `// @bun` makes + // bun load the file as-is: these are JSC's own positions, with no source map involved. + "fixture.js": `// @bun +class Thrower { constructor() { this.err = new Error("T"); } } +export class Derived extends Thrower {} +export class Fields { err = new Error("F"); } +function later() { + class Later extends Thrower {} + return new Later().err; +} +function sameLine() { class SameLine extends Thrower {} return new SameLine().err; } +const Anon = class extends Thrower {}; +class Nullary extends null {} +let nullary; +try { new Nullary(); } catch (e) { nullary = e; } +const norm = s => String(s).replaceAll(import.meta.path, ""); +const frame = (err, name) => norm(err.stack.split("\\n").map(l => l.trim()).find(l => l.startsWith("at new " + name + " "))); +console.log(JSON.stringify({ + derived: frame(new Derived().err, "Derived"), + fields: frame(new Fields().err, "Fields"), + later: frame(later(), "Later"), + sameLine: frame(sameLine(), "SameLine"), + anon: frame(new Anon().err, "Anon"), + nullary: { sourceURL: norm(nullary.sourceURL), line: nullary.line, column: nullary.column }, +})); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + // The `class` keyword, not `export`, like V8. + derived: "at new Derived (:3:8)", + // A base class: its default constructor is what runs the field initializer. + fields: "at new Fields (:4:8)", + later: "at new Later (:6:5)", + // On the first line of a function body, JSC's own column for the class counts from the + // function rather than from the line (it would give 9:6). + sameLine: "at new SameLine (:9:23)", + anon: "at new Anon (:10:14)", + // The default constructor of `Nullary` is the top frame: `super()` is what throws. + nullary: { sourceURL: "", line: 11, column: 1 }, + }); + expect(exitCode).toBe(0); + }); + + test.concurrent("the class position is source mapped back to the original file", async () => { + using dir = tempDir("default-ctor-stack-sourcemap", { + // The interface and the type alias are removed by the transpiler, so the positions JSC reports + // differ from the ones in this file. + "fixture.ts": `interface Shape { + width: number; +} +class Thrower { + err: Error; + constructor() { + this.err = new Error("T"); + } +} +export class Derived extends Thrower {} +function later(): Error { + type Unused = Shape; + class Later extends Thrower {} + return new Later().err; +} +const norm = (s: string | undefined) => String(s).replaceAll(import.meta.path, ""); +const frame = (err: Error, name: string) => + norm(err.stack!.split("\\n").map(l => l.trim()).find(l => l.startsWith("at new " + name + " "))); +console.log(JSON.stringify({ derived: frame(new Derived().err, "Derived"), later: frame(later(), "Later") })); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + derived: "at new Derived (:10:8)", + later: "at new Later (:13:3)", + }); + expect(exitCode).toBe(0); + }); + + test.concurrent("an uncaught error thrown by the default constructor is printed at the class", async () => { + using dir = tempDir("default-ctor-stack-uncaught", { + "fixture.js": "class Nullary extends null {}\nnew Nullary();\n", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("1 | class Nullary extends null {}\n ^\n"); + expect(stderr).toMatch(/^\s+at new Nullary \(.*fixture\.js:1:1\)/m); + expect(exitCode).toBe(1); + }); +}); diff --git a/test/js/node/v8/capture-stack-trace.test.js b/test/js/node/v8/capture-stack-trace.test.js index 94e4bef75b63..adbbf64eb056 100644 --- a/test/js/node/v8/capture-stack-trace.test.js +++ b/test/js/node/v8/capture-stack-trace.test.js @@ -535,6 +535,64 @@ test("CallFrame.p.isConstructor", () => { Error.prepareStackTrace = prevPrepareStackTrace; }); +test("CallSites of a class without a constructor point at the class definition", () => { + Error.prepareStackTrace = (err, callSites) => callSites; + + let fromBase; + class Base { + constructor() { + fromBase = new Error(); + } + } + // JSC compiles the constructor of a class that declares none from an internal one-line source, and + // its frames used to report that source: no file, line 1. V8 reports the line of the `class` + // keyword, which is also where each `marked(Base)` below is called from. + const markers = []; + function marked(Super) { + markers.push(new Error()); + return Super; + } + class Derived extends marked(Base) {} + function makeInner() { + class Inner extends marked(Base) {} + new Inner(); + } + + new Derived(); + const derivedError = fromBase; + makeInner(); + const innerError = fromBase; + // In the markers, [0] is `marked` and [1] the `extends` clause; in the errors from Base, [0] is + // `new Base` and [1] the synthesized constructor of the subclass. + const [derivedClassSite, innerClassSite] = markers.map(marker => marker.stack[1]); + const derivedSite = derivedError.stack[1]; + const innerSite = innerError.stack[1]; + Error.prepareStackTrace = origPrepareStackTrace; + + const describeSite = site => ({ + functionName: site.getFunctionName(), + isConstructor: site.isConstructor(), + fileName: site.getFileName(), + lineNumber: site.getLineNumber(), + scriptId: site.getScriptId(), + }); + const expectedAt = (classSite, functionName) => ({ + functionName, + isConstructor: true, + fileName: classSite.getFileName(), + lineNumber: classSite.getLineNumber(), + scriptId: classSite.getScriptId(), + }); + + expect(derivedClassSite.getFileName()).toBe(import.meta.path); + expect(innerClassSite.getLineNumber()).not.toBe(derivedClassSite.getLineNumber()); + expect(describeSite(derivedSite)).toEqual(expectedAt(derivedClassSite, "Derived")); + expect(describeSite(innerSite)).toEqual(expectedAt(innerClassSite, "Inner")); + // The `class` keyword comes before the `extends` clause on each line. + expect(derivedSite.getColumnNumber()).toBeLessThan(derivedClassSite.getColumnNumber()); + expect(innerSite.getColumnNumber()).toBeLessThan(innerClassSite.getColumnNumber()); +}); + test("CallFrame.p.isNative", () => { let prevPrepareStackTrace = Error.prepareStackTrace; Error.prepareStackTrace = (e, s) => { From e9f4c3a19425800cbfecadb28cd8a90e1370c9f5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:14:12 +0000 Subject: [PATCH 2/5] test: assert the exact CallSite column of the class keyword and drain stdout in the uncaught fixture --- test/js/bun/test/stack.test.ts | 3 ++- test/js/node/v8/capture-stack-trace.test.js | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/test/js/bun/test/stack.test.ts b/test/js/bun/test/stack.test.ts index 92cb38970671..bf7459dfc03d 100644 --- a/test/js/bun/test/stack.test.ts +++ b/test/js/bun/test/stack.test.ts @@ -261,8 +261,9 @@ console.log(JSON.stringify({ derived: frame(new Derived().err, "Derived"), later cwd: String(dir), stderr: "pipe", }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(""); expect(stderr).toContain("1 | class Nullary extends null {}\n ^\n"); expect(stderr).toMatch(/^\s+at new Nullary \(.*fixture\.js:1:1\)/m); expect(exitCode).toBe(1); diff --git a/test/js/node/v8/capture-stack-trace.test.js b/test/js/node/v8/capture-stack-trace.test.js index adbbf64eb056..f2bdf17c2806 100644 --- a/test/js/node/v8/capture-stack-trace.test.js +++ b/test/js/node/v8/capture-stack-trace.test.js @@ -2,6 +2,7 @@ import { nativeFrameForTesting } from "bun:internal-for-testing"; import { noInline } from "bun:jsc"; import { afterEach, expect, mock, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; +import { readFileSync } from "node:fs"; const origPrepareStackTrace = Error.prepareStackTrace; afterEach(() => { Error.prepareStackTrace = origPrepareStackTrace; @@ -574,13 +575,17 @@ test("CallSites of a class without a constructor point at the class definition", isConstructor: site.isConstructor(), fileName: site.getFileName(), lineNumber: site.getLineNumber(), + columnNumber: site.getColumnNumber(), scriptId: site.getScriptId(), }); + const sourceLines = readFileSync(import.meta.path, "utf8").split("\n"); const expectedAt = (classSite, functionName) => ({ functionName, isConstructor: true, fileName: classSite.getFileName(), lineNumber: classSite.getLineNumber(), + // getColumnNumber() is zero-based; this is the column of the `class` keyword on that line. + columnNumber: sourceLines[classSite.getLineNumber() - 1].indexOf(`class ${functionName} `), scriptId: classSite.getScriptId(), }); @@ -588,9 +593,6 @@ test("CallSites of a class without a constructor point at the class definition", expect(innerClassSite.getLineNumber()).not.toBe(derivedClassSite.getLineNumber()); expect(describeSite(derivedSite)).toEqual(expectedAt(derivedClassSite, "Derived")); expect(describeSite(innerSite)).toEqual(expectedAt(innerClassSite, "Inner")); - // The `class` keyword comes before the `extends` clause on each line. - expect(derivedSite.getColumnNumber()).toBeLessThan(derivedClassSite.getColumnNumber()); - expect(innerSite.getColumnNumber()).toBeLessThan(innerClassSite.getColumnNumber()); }); test("CallFrame.p.isNative", () => { From 68894c416381f91ac8dea2cf0d53c24ac564771b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:21:16 +0000 Subject: [PATCH 3/5] Shorten the comments on the default constructor helpers --- src/jsc/bindings/ErrorStackFrame.cpp | 4 +--- src/jsc/bindings/ErrorStackFrame.h | 12 ++++-------- src/jsc/bindings/ZigException.cpp | 3 +-- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/jsc/bindings/ErrorStackFrame.cpp b/src/jsc/bindings/ErrorStackFrame.cpp index acf1465304d9..e89b797a95e4 100644 --- a/src/jsc/bindings/ErrorStackFrame.cpp +++ b/src/jsc/bindings/ErrorStackFrame.cpp @@ -30,9 +30,7 @@ ZigStackFramePosition classSourceStartPosition(const SourceCode& classSource) int start = classSource.startOffset(); WTF::StringView source = provider->source(); - // classSource.startColumn() is counted from wherever the lexer started, which for a class on - // the first line of a lazily parsed function body is that function, not the line. Count from - // the line start instead; the column is only taken as-is when the source text is unavailable. + // startColumn() is only a fallback: on the first line of a lazily parsed function it counts from the function. int column = classSource.startColumn().zeroBasedInt(); if (static_cast(start) <= source.length()) { int lineStart = start; diff --git a/src/jsc/bindings/ErrorStackFrame.h b/src/jsc/bindings/ErrorStackFrame.h index ea4b0b672ad8..0d0a4b039f16 100644 --- a/src/jsc/bindings/ErrorStackFrame.h +++ b/src/jsc/bindings/ErrorStackFrame.h @@ -14,20 +14,16 @@ class StackFrame; namespace Bun { -/// JSC compiles the constructor of a class that declares none from -/// BuiltinExecutables::defaultConstructorSourceCode(), a fixed one-line string with no URL, so that -/// executable's source() and bytecode positions only describe that string. For such an executable -/// this returns the SourceCode of the class itself (its file, starting at the `class` keyword), which -/// is where V8 reports these frames. Null for every other executable. +/// The class whose constructor JSC synthesized because it declared none (that constructor's own +/// source() is the URL-less "(function () { })" template), or null for any other executable. JSC::SourceCode defaultClassConstructorClassSource(JSC::ScriptExecutable* executable); -/// Position of the `class` keyword a class SourceCode starts at, in the same coordinates JSC reports -/// bytecode positions of that file in. +/// Position of the `class` keyword `classSource` starts at; frames of its default constructor go there, as in V8. ZigStackFramePosition classSourceStartPosition(const JSC::SourceCode& classSource); ZigStackFramePosition getAdjustedPositionForBytecode(JSC::CodeBlock* code, JSC::BytecodeIndex bc); -/// frame.computeLineAndColumn(), except that a default class constructor frame is placed at its class. +/// frame.computeLineAndColumn(), with default class constructor frames placed at their class. JSC::LineColumn computeLineAndColumn(const JSC::StackFrame& frame); } // namespace Bun diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index 22137f4f31d3..14ed1b3b436d 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -135,8 +135,7 @@ static void populateStackFramePosition(const JSC::StackFrame& stackFrame, BunStr if (!code) return; - // The position below is in the class's source for a default class constructor, so the - // source lines have to come from it as well. + // Same source getAdjustedPositionForBytecode() positions the frame in. auto classSource = Bun::defaultClassConstructorClassSource(code->ownerExecutable()); auto* provider = classSource.isNull() ? code->source().provider() : classSource.provider(); if (!provider) [[unlikely]] From e67c4d7eb5ac77608b3cf96091866adc2982c64c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:23:14 +0000 Subject: [PATCH 4/5] Shorten the defaultClassConstructorClassSource doc comment --- src/jsc/bindings/ErrorStackFrame.h | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/bindings/ErrorStackFrame.h b/src/jsc/bindings/ErrorStackFrame.h index 0d0a4b039f16..a13bc67f618c 100644 --- a/src/jsc/bindings/ErrorStackFrame.h +++ b/src/jsc/bindings/ErrorStackFrame.h @@ -14,8 +14,7 @@ class StackFrame; namespace Bun { -/// The class whose constructor JSC synthesized because it declared none (that constructor's own -/// source() is the URL-less "(function () { })" template), or null for any other executable. +/// The class of a constructor JSC synthesized (its own source() is the URL-less "(function () { })" template), else null. JSC::SourceCode defaultClassConstructorClassSource(JSC::ScriptExecutable* executable); /// Position of the `class` keyword `classSource` starts at; frames of its default constructor go there, as in V8. From fc01cd4c623c2d599884cd206060ffbc724d16e5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:41:41 +0000 Subject: [PATCH 5/5] Apply a negative node:vm lineOffset to the class position too The column already took the provider's start column as-is; JSC clamps a negative lineOffset away in SourceCode, so add it back to the line the same way. Covers both offsets, both signs, in stack.test.ts with the positions node prints for the same scripts. --- src/jsc/bindings/ErrorStackFrame.cpp | 9 ++++-- test/js/bun/test/stack.test.ts | 43 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/ErrorStackFrame.cpp b/src/jsc/bindings/ErrorStackFrame.cpp index e89b797a95e4..4c382b515014 100644 --- a/src/jsc/bindings/ErrorStackFrame.cpp +++ b/src/jsc/bindings/ErrorStackFrame.cpp @@ -29,6 +29,10 @@ ZigStackFramePosition classSourceStartPosition(const SourceCode& classSource) auto* provider = classSource.provider(); int start = classSource.startOffset(); WTF::StringView source = provider->source(); + // node:vm lineOffset/columnOffset: JSC applies them (the column to the first line only) but clamps negative ones away; V8 does not. + TextPosition providerStart = provider->startPosition(); + + int line = classSource.firstLine().zeroBasedInt() + std::min(providerStart.m_line.zeroBasedInt(), 0); // startColumn() is only a fallback: on the first line of a lazily parsed function it counts from the function. int column = classSource.startColumn().zeroBasedInt(); @@ -37,13 +41,12 @@ ZigStackFramePosition classSourceStartPosition(const SourceCode& classSource) while (lineStart > 0 && !isLineTerminator(source[lineStart - 1])) lineStart--; column = start - lineStart; - // JSC's columns on the first line of a source include its start column (node:vm columnOffset). if (lineStart == 0) - column += provider->startPosition().m_column.zeroBasedInt(); + column += providerStart.m_column.zeroBasedInt(); } return ZigStackFramePosition { - .line_zero_based = classSource.firstLine().zeroBasedInt(), + .line_zero_based = line, .column_zero_based = column, .byte_position = start, }; diff --git a/test/js/bun/test/stack.test.ts b/test/js/bun/test/stack.test.ts index bf7459dfc03d..8d978b049f0e 100644 --- a/test/js/bun/test/stack.test.ts +++ b/test/js/bun/test/stack.test.ts @@ -2,6 +2,7 @@ import { $ } from "bun"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, bunRun, normalizeBunSnapshot, tempDir } from "harness"; import { join } from "node:path"; +import vm from "node:vm"; test("name property is used for function calls in Error.stack", () => { function WRONG() { @@ -268,4 +269,46 @@ console.log(JSON.stringify({ derived: frame(new Derived().err, "Derived"), later expect(stderr).toMatch(/^\s+at new Nullary \(.*fixture\.js:1:1\)/m); expect(exitCode).toBe(1); }); + + test("node:vm lineOffset and columnOffset are applied to the class position", () => { + class Thrower { + err: Error; + constructor() { + this.err = new Error("T"); + } + } + const context = vm.createContext({ Thrower }); + const run = (code: string, options: vm.ScriptOptions) => + new vm.Script(code, options).runInContext(context) as Error; + const frame = (err: Error, name: string) => + err + .stack!.split("\n") + .map(line => line.trim()) + .find(line => line.startsWith(`at new ${name} `)); + const offsets = { filename: "offsets.vm.js", lineOffset: 10, columnOffset: 5 }; + + // The expected positions are what node prints for the same scripts. + expect({ + firstLine: frame(run("class First extends Thrower {} new First().err;", offsets), "First"), + laterLine: frame(run("\n\n class Later extends Thrower {}\nnew Later().err;", offsets), "Later"), + negativeColumn: frame( + run(" class NegCol extends Thrower {} new NegCol().err;", { filename: "neg.vm.js", columnOffset: -3 }), + "NegCol", + ), + negativeLine: frame( + run("\n\n\n class NegLine extends Thrower {}\n new NegLine().err;", { + filename: "neg.vm.js", + lineOffset: -2, + }), + "NegLine", + ), + }).toEqual({ + // The column offset only applies to the first line. + firstLine: "at new First (offsets.vm.js:11:6)", + laterLine: "at new Later (offsets.vm.js:13:5)", + // JSC clamps negative offsets away for the positions it reports itself. + negativeColumn: "at new NegCol (neg.vm.js:1:7)", + negativeLine: "at new NegLine (neg.vm.js:2:3)", + }); + }); });