From 8e400fea47400e16e8067b239138b32e335cc85f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:06:47 +0000 Subject: [PATCH 1/4] error printer: show file/line for JSC parser SyntaxErrors with no stack A SyntaxError raised by JavaScriptCore's own parser (strict-mode 'with', 'delete x', invalid regex literals, etc.) during module load has no JS stack frames: the error is created before any JS runs. addErrorInfo() records line and sourceURL on the ErrorInstance's C++ fields, but materializeErrorInfoIfNeeded() only publishes them as .line/.sourceURL JS properties when a stack trace exists, so the error printer found nothing and emitted a bare 'SyntaxError: ...' with no file, line, or code frame. fromErrorInstance() now reads err->sourceURL()/err->line() from the C++ fields directly and synthesizes a frame when no stack is available. JSC never records the parser-error column (column is always 0), and a source-map lookup at column 0 resolves to bun's own start-of-line mapping for the previous source line when the offending statement is indented. Both the new path and the existing frame in formatStackTrace() now query past end-of-line so the lookup lands on the last mapping of the generated line, which reliably resolves to the correct source line. Fixes #5192 --- src/jsc/bindings/FormatStackTraceForJS.cpp | 11 +- src/jsc/bindings/ZigException.cpp | 36 +++++ test/regression/issue/05192.test.ts | 150 +++++++++++++++++++++ 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 test/regression/issue/05192.test.ts diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index d622679f31aa..eae15a3de28e 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -188,7 +188,14 @@ WTF::String formatStackTrace( memset(&remappedFrame, 0, sizeof(ZigStackFrame)); remappedFrame.position.line_zero_based = originalLine.zeroBasedInt(); - remappedFrame.position.column_zero_based = 0; + // JSC's addErrorInfo() discards the parser-error column. Querying + // the source map at column 0 resolves to bun's start-of-line + // mapping for the previous source line when the statement is + // indented; query past end-of-line so the lookup lands on the + // last mapping of the generated line instead. + remappedFrame.position.column_zero_based = err->column() > 0 + ? WTF::OrdinalNumber::fromOneBasedInt(err->column()).zeroBasedInt() + : std::numeric_limits::max(); String sourceURLForFrame = err->sourceURL(); @@ -203,6 +210,8 @@ WTF::String formatStackTrace( sourceURLForFrame = remappedFrame.source_url.toWTFString(); } } + if (err->column() == 0) + remappedFrame.position.column_zero_based = 0; // there is always a newline before each stack frame line, ensuring that the name + message // exist on the first line, even if both are empty diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index ea84d276eda9..b413d0663cbc 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -636,6 +636,42 @@ static void fromErrorInstance(ZigException& except, JSC::JSGlobalObject* global, } if (except.stack.frames_len == 0 && getFromSourceURL) { + // A JSC parser SyntaxError created before any JS runs has no stack, so + // materializeErrorInfoIfNeeded() never publishes line/column/sourceURL as + // JS properties. The C++ fields on ErrorInstance are still set by + // addErrorInfo(), so read those directly and synthesize a frame. + const String& nativeSourceURL = err->sourceURL(); + if (!nativeSourceURL.isEmpty()) { + auto& frame = except.stack.frames_ptr[0]; + frame.source_url.deref(); + frame.source_url = Bun::toStringRef(nativeSourceURL); + bool columnUnknown = err->column() == 0; + if (err->line() > 0) { + frame.position.line_zero_based = OrdinalNumber::fromOneBasedInt(err->line()).zeroBasedInt(); + frame.position.column_zero_based = columnUnknown ? 0 : OrdinalNumber::fromOneBasedInt(err->column()).zeroBasedInt(); + if (auto* zigGlobal = dynamicDowncast(global)) { + if (columnUnknown) { + // addErrorInfo() never records the parser-error column. A + // source-map lookup at column 0 resolves to bun's own + // start-of-line mapping, which points at the end of the + // previous source line; querying the last mapping on the + // generated line (any column past the line end) reliably + // lands on the correct source line. Report column 1 since + // the true column is unknown. + frame.position.column_zero_based = std::numeric_limits::max(); + Bun__remapStackFramePositions(zigGlobal->bunVM(), &frame, 1); + frame.position.column_zero_based = 0; + } else { + Bun__remapStackFramePositions(zigGlobal->bunVM(), &frame, 1); + } + } + } + except.stack.frames_len = 1; + frame.remapped = true; + except.remapped = true; + return; + } + JSC::JSValue sourceURL = getNonObservable(vm, global, obj, vm.propertyNames->sourceURL); if (!scope.clearExceptionExceptTermination()) [[unlikely]] return; diff --git a/test/regression/issue/05192.test.ts b/test/regression/issue/05192.test.ts new file mode 100644 index 000000000000..02e12ea95012 --- /dev/null +++ b/test/regression/issue/05192.test.ts @@ -0,0 +1,150 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// https://github.com/oven-sh/bun/issues/5192 +// +// When JSC (not Bun's own parser) rejects a module with a SyntaxError, the +// ErrorInstance carries line/sourceURL on its C++ fields but those were never +// surfaced because the error has no JS stack at module-parse time. The error +// printed as a bare "SyntaxError: ..." with no file, line, or code frame. + +async function run(files: Record, entry: string) { + using dir = tempDir("issue-5192", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), entry], + env: { ...bunEnv, NO_COLOR: "1" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +// `with` in strict mode is caught by JSC's parser, not Bun's, so it exercises +// the JSC ParserError path directly. +const withInStrict = `"use strict"; +export const x = 1; +function foo() { + with ({ a: 1 }) { + console.log(a); + } +} +foo(); +`; + +test.concurrent("JSC parse SyntaxError in the entry module prints file and line", async () => { + const { stderr, exitCode } = await run({ "entry.mjs": withInStrict }, "entry.mjs"); + expect(stderr).toContain("SyntaxError: 'with' statements are not valid in strict mode."); + // File path with a line number must appear somewhere in the output. + expect(stderr).toMatch(/entry\.mjs:\d+/); + // A code frame with the `with` line must appear. + expect(stderr).toMatch(/\bwith\b.*\{ a: 1 \}/); + expect(exitCode).toBe(1); +}); + +test.concurrent("JSC parse SyntaxError in an imported ESM module prints file and line", async () => { + const { stderr, exitCode } = await run( + { + "module.mjs": withInStrict, + "entry.ts": `import "./module.mjs";`, + }, + "entry.ts", + ); + expect(stderr).toContain("SyntaxError: 'with' statements are not valid in strict mode."); + expect(stderr).toMatch(/module\.mjs:\d+/); + expect(stderr).toMatch(/\bwith\b.*\{ a: 1 \}/); + expect(exitCode).toBe(1); +}); + +test.concurrent("JSC parse SyntaxError in an imported .ts module prints file and line", async () => { + const { stderr, exitCode } = await run( + { + "module.ts": withInStrict, + "entry.ts": `import "./module.ts";`, + }, + "entry.ts", + ); + expect(stderr).toContain("SyntaxError: 'with' statements are not valid in strict mode."); + expect(stderr).toMatch(/module\.ts:\d+/); + expect(stderr).toMatch(/\bwith\b.*\{ a: 1 \}/); + expect(exitCode).toBe(1); +}); + +test.concurrent("JSC parse SyntaxError: 'delete x' in strict mode prints file and line", async () => { + const { stderr, exitCode } = await run( + { + "module.mjs": `export const x = 1; +function fn() { + delete someVar; +} +fn(); +`, + "entry.mjs": `import "./module.mjs";`, + }, + "entry.mjs", + ); + expect(stderr).toContain("SyntaxError: Cannot delete unqualified property 'someVar' in strict mode."); + expect(stderr).toMatch(/module\.mjs:\d+/); + expect(exitCode).toBe(1); +}); + +test.concurrent("JSC parse SyntaxError from dynamic import() prints file and line when re-thrown", async () => { + const { stderr, exitCode } = await run( + { + "module.mjs": withInStrict, + "entry.mjs": `try { + await import("./module.mjs"); +} catch (e) { + console.error(e); +} +`, + }, + "entry.mjs", + ); + expect(stderr).toContain("SyntaxError: 'with' statements are not valid in strict mode."); + expect(stderr).toMatch(/module\.mjs:\d+/); + expect(exitCode).toBe(0); +}); + +test.concurrent("JSC parse SyntaxError in a require()'d CJS module prints the offending line", async () => { + // require() has a JS stack so the `` frame comes from the .stack + // formatter rather than the error printer's synthetic frame; both paths had + // the same column-0 source-map lookup bug that resolved to the previous line. + const { stderr, exitCode } = await run( + { + "mod.cjs": `"use strict"; +with ({ a: 1 }) { console.log(a); } +`, + "main.cjs": `require("./mod.cjs");`, + }, + "main.cjs", + ); + expect(stderr).toContain("SyntaxError: 'with' statements are not valid in strict mode."); + expect(stderr).toMatch(/mod\.cjs:2\b/); + expect(stderr).toMatch(/2 \|.*\bwith\b/); + expect(exitCode).toBe(1); +}); + +test.concurrent("JSC parse SyntaxError reported line points at the offending statement, not the line before", async () => { + // addErrorInfo() discards the parser-error column; a naive source-map lookup + // at column 0 resolves to bun's own start-of-line mapping for the *previous* + // source line when the offending statement is indented. + const { stderr, exitCode } = await run( + { + "module.mjs": `export const x = 1; +function foo() { + with ({ a: 1 }) { + console.log(a); + } +} +foo(); +`, + }, + "module.mjs", + ); + expect(stderr).toContain("SyntaxError: 'with' statements are not valid in strict mode."); + expect(stderr).toMatch(/module\.mjs:3\b/); + expect(stderr).toMatch(/3 \|.*\bwith\b/); + expect(exitCode).toBe(1); +}); From 20012607fe5ce8b065d9ad2b8e783fbd41d8d672 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:08:50 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- test/regression/issue/05192.test.ts | 33 ++++++++++++++++------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/test/regression/issue/05192.test.ts b/test/regression/issue/05192.test.ts index 02e12ea95012..1f16dc389daa 100644 --- a/test/regression/issue/05192.test.ts +++ b/test/regression/issue/05192.test.ts @@ -126,13 +126,15 @@ with ({ a: 1 }) { console.log(a); } expect(exitCode).toBe(1); }); -test.concurrent("JSC parse SyntaxError reported line points at the offending statement, not the line before", async () => { - // addErrorInfo() discards the parser-error column; a naive source-map lookup - // at column 0 resolves to bun's own start-of-line mapping for the *previous* - // source line when the offending statement is indented. - const { stderr, exitCode } = await run( - { - "module.mjs": `export const x = 1; +test.concurrent( + "JSC parse SyntaxError reported line points at the offending statement, not the line before", + async () => { + // addErrorInfo() discards the parser-error column; a naive source-map lookup + // at column 0 resolves to bun's own start-of-line mapping for the *previous* + // source line when the offending statement is indented. + const { stderr, exitCode } = await run( + { + "module.mjs": `export const x = 1; function foo() { with ({ a: 1 }) { console.log(a); @@ -140,11 +142,12 @@ function foo() { } foo(); `, - }, - "module.mjs", - ); - expect(stderr).toContain("SyntaxError: 'with' statements are not valid in strict mode."); - expect(stderr).toMatch(/module\.mjs:3\b/); - expect(stderr).toMatch(/3 \|.*\bwith\b/); - expect(exitCode).toBe(1); -}); + }, + "module.mjs", + ); + expect(stderr).toContain("SyntaxError: 'with' statements are not valid in strict mode."); + expect(stderr).toMatch(/module\.mjs:3\b/); + expect(stderr).toMatch(/3 \|.*\bwith\b/); + expect(exitCode).toBe(1); + }, +); From 53e3fe6ced17d93ff511275ed5ab4432ccd6b076 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:21:01 +0000 Subject: [PATCH 3/4] extract Bun__remapParseErrorFrame helper; trim comments --- src/jsc/bindings/FormatStackTraceForJS.cpp | 20 +++------------ src/jsc/bindings/ZigException.cpp | 29 +++++----------------- src/jsc/bindings/headers-handwritten.h | 13 ++++++++++ 3 files changed, 22 insertions(+), 40 deletions(-) diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index eae15a3de28e..46438b0b3b77 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -188,30 +188,16 @@ WTF::String formatStackTrace( memset(&remappedFrame, 0, sizeof(ZigStackFrame)); remappedFrame.position.line_zero_based = originalLine.zeroBasedInt(); - // JSC's addErrorInfo() discards the parser-error column. Querying - // the source map at column 0 resolves to bun's start-of-line - // mapping for the previous source line when the statement is - // indented; query past end-of-line so the lookup lands on the - // last mapping of the generated line instead. - remappedFrame.position.column_zero_based = err->column() > 0 - ? WTF::OrdinalNumber::fromOneBasedInt(err->column()).zeroBasedInt() - : std::numeric_limits::max(); String sourceURLForFrame = err->sourceURL(); // If it's not a Zig::GlobalObject, don't bother source-mapping it. if (globalObject && !sourceURLForFrame.isEmpty()) { // https://github.com/oven-sh/bun/issues/3595 - if (!sourceURLForFrame.isEmpty()) { - remappedFrame.source_url = Bun::toStringRef(sourceURLForFrame); - // This ensures the lifetime of the sourceURL is accounted for correctly - Bun__remapStackFramePositions(getBunVM(), &remappedFrame, 1); - - sourceURLForFrame = remappedFrame.source_url.toWTFString(); - } + remappedFrame.source_url = Bun::toStringRef(sourceURLForFrame); + Bun__remapParseErrorFrame(getBunVM(), &remappedFrame, err->line(), err->column()); + sourceURLForFrame = remappedFrame.source_url.toWTFString(); } - if (err->column() == 0) - remappedFrame.position.column_zero_based = 0; // there is always a newline before each stack frame line, ensuring that the name + message // exist on the first line, even if both are empty diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index b413d0663cbc..28147b25e274 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -636,35 +636,18 @@ static void fromErrorInstance(ZigException& except, JSC::JSGlobalObject* global, } if (except.stack.frames_len == 0 && getFromSourceURL) { - // A JSC parser SyntaxError created before any JS runs has no stack, so - // materializeErrorInfoIfNeeded() never publishes line/column/sourceURL as - // JS properties. The C++ fields on ErrorInstance are still set by - // addErrorInfo(), so read those directly and synthesize a frame. + // Parse-time SyntaxErrors have no JS stack, so .line/.sourceURL are + // never materialized; addErrorInfo() still sets the C++ fields. const String& nativeSourceURL = err->sourceURL(); if (!nativeSourceURL.isEmpty()) { auto& frame = except.stack.frames_ptr[0]; frame.source_url.deref(); frame.source_url = Bun::toStringRef(nativeSourceURL); - bool columnUnknown = err->column() == 0; if (err->line() > 0) { - frame.position.line_zero_based = OrdinalNumber::fromOneBasedInt(err->line()).zeroBasedInt(); - frame.position.column_zero_based = columnUnknown ? 0 : OrdinalNumber::fromOneBasedInt(err->column()).zeroBasedInt(); - if (auto* zigGlobal = dynamicDowncast(global)) { - if (columnUnknown) { - // addErrorInfo() never records the parser-error column. A - // source-map lookup at column 0 resolves to bun's own - // start-of-line mapping, which points at the end of the - // previous source line; querying the last mapping on the - // generated line (any column past the line end) reliably - // lands on the correct source line. Report column 1 since - // the true column is unknown. - frame.position.column_zero_based = std::numeric_limits::max(); - Bun__remapStackFramePositions(zigGlobal->bunVM(), &frame, 1); - frame.position.column_zero_based = 0; - } else { - Bun__remapStackFramePositions(zigGlobal->bunVM(), &frame, 1); - } - } + if (auto* zigGlobal = dynamicDowncast(global)) + Bun__remapParseErrorFrame(zigGlobal->bunVM(), &frame, err->line(), err->column()); + else + frame.position.line_zero_based = OrdinalNumber::fromOneBasedInt(err->line()).zeroBasedInt(); } except.stack.frames_len = 1; frame.remapped = true; diff --git a/src/jsc/bindings/headers-handwritten.h b/src/jsc/bindings/headers-handwritten.h index 801e4b08022d..97afc57ef927 100644 --- a/src/jsc/bindings/headers-handwritten.h +++ b/src/jsc/bindings/headers-handwritten.h @@ -456,6 +456,19 @@ bool Bun__deepMatch( extern "C" void Bun__remapStackFramePositions(void*, ZigStackFrame*, size_t); +// JSC's addErrorInfo() records the parse-error line but no column; a lookup at +// column 0 lands on the previous source line's trailing mapping, so remap via +// the last mapping on the generated line and report column 1. +ALWAYS_INLINE void Bun__remapParseErrorFrame(void* bunVM, ZigStackFrame* frame, unsigned line, unsigned column) +{ + frame->position.line_zero_based = static_cast(line) - 1; + bool columnKnown = column > 0; + frame->position.column_zero_based = columnKnown ? static_cast(column) - 1 : INT32_MAX; + Bun__remapStackFramePositions(bunVM, frame, 1); + if (!columnKnown) + frame->position.column_zero_based = 0; +} + namespace Inspector { class ScriptArguments; } From 02618d2db73bf211a9b44a2a50eeeee8197c9b9c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:26:15 +0000 Subject: [PATCH 4/4] trim comment blocks to single lines (comment-cop) --- src/jsc/bindings/ZigException.cpp | 3 +-- src/jsc/bindings/headers-handwritten.h | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index 28147b25e274..e375c503a8c3 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -636,8 +636,7 @@ static void fromErrorInstance(ZigException& except, JSC::JSGlobalObject* global, } if (except.stack.frames_len == 0 && getFromSourceURL) { - // Parse-time SyntaxErrors have no JS stack, so .line/.sourceURL are - // never materialized; addErrorInfo() still sets the C++ fields. + // Parse-time SyntaxErrors have no stack; addErrorInfo() still set the C++ fields (#5192). const String& nativeSourceURL = err->sourceURL(); if (!nativeSourceURL.isEmpty()) { auto& frame = except.stack.frames_ptr[0]; diff --git a/src/jsc/bindings/headers-handwritten.h b/src/jsc/bindings/headers-handwritten.h index 97afc57ef927..42faaf23236f 100644 --- a/src/jsc/bindings/headers-handwritten.h +++ b/src/jsc/bindings/headers-handwritten.h @@ -456,13 +456,11 @@ bool Bun__deepMatch( extern "C" void Bun__remapStackFramePositions(void*, ZigStackFrame*, size_t); -// JSC's addErrorInfo() records the parse-error line but no column; a lookup at -// column 0 lands on the previous source line's trailing mapping, so remap via -// the last mapping on the generated line and report column 1. ALWAYS_INLINE void Bun__remapParseErrorFrame(void* bunVM, ZigStackFrame* frame, unsigned line, unsigned column) { frame->position.line_zero_based = static_cast(line) - 1; bool columnKnown = column > 0; + // addErrorInfo() column is 0; a lookup there lands on the previous line's tail, so query past end-of-line. frame->position.column_zero_based = columnKnown ? static_cast(column) - 1 : INT32_MAX; Bun__remapStackFramePositions(bunVM, frame, 1); if (!columnKnown)