Skip to content

error: gate the <parse> stack frame on the parser having recorded a location, not on sourceURL - #37432

Open
robobun wants to merge 7 commits into
mainfrom
farm/0b79c11b/cloned-syntaxerror-parse-frame
Open

error: gate the <parse> stack frame on the parser having recorded a location, not on sourceURL#37432
robobun wants to merge 7 commits into
mainfrom
farm/0b79c11b/cloned-syntaxerror-parse-frame

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #37386 (its four commits come first; the change here is the last commit). Pairs with oven-sh/WebKit#407, pinned here as a preview build; once that lands I will replace WEBKIT_VERSION with the main sha.

What does this PR do?

#37386 stops .stack from giving every SyntaxError a fabricated at <parse> (:0) line by only emitting the <parse> frame when the error records a sourceURL, which is what JSC's addErrorInfo() leaves on a parser error. A sourceURL is not unique to parser errors though. A SyntaxError that has one for another reason still gets the frame as soon as it is given frames from a different file:

// a.js
export const clone = structuredClone(new SyntaxError("made in a.js"));

// b.js
import { clone } from "./a.js";
Error.captureStackTrace(clone);
console.log(clone.stack);
# bun 1.4.0, and #37386
SyntaxError: made in a.js
    at <parse> (/tmp/clone/a.js:1)
    at /tmp/clone/b.js:2:7

# node
SyntaxError: made in a.js
    at file:///tmp/clone/b.js:2:7

The realistic shape is an error postMessaged by a worker that the parent then captureStackTraces. Two ways an error that never had a parser location ends up with a sourceURL:

  • Structured clone (structuredClone, postMessage) recreates the error through the ErrorInstance::create overload that takes the original's line/column/sourceURL (SerializedScriptValue.cpp), so every clone records the file its original was created in.
  • GC: when a function on an error's stack is collected, ErrorInstance::finalizeUnconditionally flushes the frames to a string and records the first frame's URL on the error. A new SyntaxError() created inside a function that has since been collected shows the same <parse> line when captured afterwards, and so does a syntax error thrown by eval() (which JSC does flag as a parse error, but records no location for), no cloning involved: at <parse> (gc-me.js:1) in the new tests.

A plain .stack read on a clone was already fine (it returns the serialized stack string) and still is. Only errors that get frames installed after the fact are affected, and those all go through this one formatter.

Fix

The thing the frame renders is the location addErrorInfo() recorded, so that is what the formatter should test for. oven-sh/WebKit#407 adds a Bun-only bit to ErrorInstance, set by addErrorInfo() right where it records the line and sourceURL (before it materializes the stack through our hook), exposed as hasParseLocation(). Clones, GC-flushed errors and eval parse errors never get it; every parser error for a named source does. formatStackTrace() now checks that instead of the error type. The sourceURL check stays so parser errors for URL-less sources (new Function, vm.compileFunction without a filename) keep formatting without a frame, as #37386 made them.

JSC's existing isParseError() does not work for this, which is why the engine change is needed: ParserError::toErrorObject() sets it only after addErrorInfo() has already formatted the stack (#37386 verified that gating on it dropped the frame for every parser error), and it is also set for eval-code syntax errors, which have no location to show and would still pick up the GC-recorded one. The first revision of this PR gated on it with the ordering fixed and review turned up exactly that eval case, so the discriminator is now a dedicated bit.

Parser errors are unaffected: the existing guards in stack.test.ts (vm.Script and vm.compileFunction with a filename, import() of modules JSC rejects with and without captured frames) pass against the new build, and an import() of an ESM module JSC rejects still prints at <parse> (bad.mjs:2).

The clone deserializer is deliberately left alone: a clone carrying its original's line/column/sourceURL is correct and observable (structuredClone(err).sourceURL). The formatter was reading the wrong signal.

Tests

Added to the SyntaxError .stack block in test/js/bun/test/stack.test.ts:

  • structuredClone() of a SyntaxError, then Error.captureStackTrace() run from a vm.Script with a different filename
  • a SyntaxError posted by a Worker, then captureStackTraced by the parent
  • a SyntaxError created by new SyntaxError(), eval("{") and (0, eval)("{") inside a vm.Script function that Bun.gc(true) collects, then captureStackTraced (the eval rows need the VM's last-thrown exception displaced first, since it keeps a caught error's frames alive)

Each fails with the <parse> line in place of the real first frame on the released build (USE_SYSTEM_BUN=1 bun test test/js/bun/test/stack.test.ts) and on a debug build with the preview WebKit but src/ at #37386's state; the two eval rows also fail against the first revision of this PR. With this change the file passes (bun bd test test/js/bun/test/stack.test.ts), including under BUN_JSC_validateExceptionChecks=1.

Also run against the debug build: test/js/node/v8/capture-stack-trace.test.js, test/js/node/vm/vm.test.ts, test/js/node/vm/vm-sourceUrl.test.ts, test/js/web/workers/structured-clone.test.ts, test/js/node/worker_threads/worker_threads.test.ts, all passing. test/js/bun/util/inspect-error.test.js has two minified-file snapshot failures that reproduce on a pristine main debug build (an extra builtin require frame, tracked separately) and are unrelated.

Notes for the reviewer

… location

formatStackTrace appended a synthetic "at <parse> (url:line)" frame to
every ErrorInstance of type SyntaxError whose first frame did not come
from err->sourceURL(). Only errors created by JSC's parser have a
sourceURL recorded (addErrorInfo), so for a SyntaxError constructed by
user code, JSON.parse or RegExp the comparison was always true and the
frame was rendered from empty data as "at <parse> (:0)". Once .stack had
been read, the error printer re-parsed that line and lost the real
location of the error entirely.

Require a recorded sourceURL before emitting the frame. Parser errors
for named sources (vm.Script, imported modules) keep it; everything else,
including parser errors for URL-less sources such as new Function(),
formats as header plus real frames like node.
…ompileFunction in the URL-less test

new Function("{") materializes .stack from inside JSC's own parse-error
path, which never checks for an exception from the stack hook, so the
test aborted under BUN_JSC_validateExceptionChecks on the debug CI lane.
node:vm's compile path checks. Also drain stdout in the uncaught-error
test.
A SyntaxError can record a sourceURL without coming out of JSC's parser:
structured clone recreates an error with its original's line, column and
sourceURL, and the GC finalizer that flushes an error's frames to a
string records the first frame's URL on it. If such an error is later
given frames by Error.captureStackTrace() from a different file,
formatStackTrace() rendered that URL as a fabricated
"at <parse> (creating-file.js:1)" line ahead of the real frames.

Require the parser's own flag instead. It was not usable before because
ParserError::toErrorObject() set it only after addErrorInfo() had already
materialized the stack through our hook; the WebKit build pinned here
(preview of oven-sh/WebKit#407) sets it first. The SyntaxError type check
stays because parse-time stack overflow and out-of-memory errors carry
the flag too, and the sourceURL check stays so parser errors for
URL-less sources still format without the frame.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

WebKit preview pin

Layer / File(s) Summary
Update WebKit preview version
scripts/build/deps/webkit.ts
The default WebKit version now uses the pull request 407 preview autobuild tag.

SyntaxError stack formatting

Layer / File(s) Summary
Refine parse-frame formatting
src/jsc/bindings/FormatStackTraceForJS.cpp
Parse frames now require explicit parse locations and non-empty source URLs. Source-map remapping runs when a global object exists.
Validate SyntaxError stack behavior
test/js/bun/test/stack.test.ts
Tests cover ordinary, parser-generated, structured-cloned, worker, garbage-collected, and module-import SyntaxError stacks.

Possibly related PRs

  • oven-sh/bun#37396: Both changes refine JavaScript stack-frame formatting and source-location behavior.
  • oven-sh/bun#36437: Its worker and structured-clone stack tests exercise related stack-selection behavior.
  • oven-sh/bun#36625: Both changes involve stack-trace source-map remapping.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: gating the stack frame on recorded parser location rather than sourceURL.
Description check ✅ Passed The description includes both required sections and provides detailed implementation context, test coverage, verification results, and dependency information.

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

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on bun 1.4.0 and on #37386 with the two-file structuredClone + Error.captureStackTrace repro in the description (also as worker postMessage -> parent, as a GC-flushed new SyntaxError(), and as a GC-flushed eval() syntax error); these are the new tests in test/js/bun/test/stack.test.ts, which fail without the FormatStackTraceForJS.cpp change and pass with it against the preview WebKit (also under BUN_JSC_validateExceptionChecks=1).

Current revision (4315f53) gates on a dedicated hasParseLocation() bit from oven-sh/WebKit#407 rather than isParseError(), which review showed still let eval-code parse errors through; see the comment below for details. Review threads are addressed and resolved. CI for this revision: build 92194, 190/190 jobs green on the new pin.

Blocked only on oven-sh/WebKit#407 (pinned here as preview build f6d8e2a0); stacked on #37386. Once the WebKit PR merges I will swap WEBKIT_VERSION to the main sha.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it pins a preview WebKit build (autobuild-preview-pr-407-deb6bcb2) that the description says must be replaced once oven-sh/WebKit#407 lands, and because that bump also pulls in oven-sh/WebKit#405 relative to the current pin, a human should confirm the WebKit coordination before this merges.

What was reviewed

  • The isParseError() gate in formatStackTrace() — strictly narrows the existing condition; the redundant nested !sourceURLForFrame.isEmpty() guards it removes are provably dead now that !err->sourceURL().isEmpty() is checked up front.
  • New tests: structured-clone / worker-postMessage / GC-flush cases each assert the real first frame (not just absence of <parse>), and the parser-error guards confirm the frame is still emitted where it should be.
  • Worker test wires onerror to reject and terminates in finally; subprocess tests drain stdout/stderr concurrently.
Extended reasoning...

Overview

This PR tightens the condition under which .stack formatting emits a synthetic at <parse> (url:line) frame for SyntaxErrors. It is stacked on #37386 (whose commits are included in the diff) and paired with oven-sh/WebKit#407, which reorders ParserError::toErrorObject() so isParseError() is set before Bun's stack hook runs. The Bun-side change is one added conjunct (err->isParseError()) plus removal of a now-redundant nested isEmpty() check, a three-line comment, and three new tests covering the structured-clone, worker-postMessage, and GC-flush paths that previously produced a spurious <parse> frame. WEBKIT_VERSION is pinned to a preview build tag.

Security risks

None identified. This is display-only formatting of error stack strings; no untrusted input reaches an allocation size, path, or command. The change strictly narrows an existing branch.

Level of scrutiny

Medium-high. The C++ change itself is small and well-argued, but it is coupled to an unlanded WebKit PR and ships a preview dependency pin that the author explicitly flags as temporary ("Replace with the main sha once it lands"). The bump also carries oven-sh/WebKit#405 as a rider. Merging with a preview tag in WEBKIT_VERSION would leave main depending on a non-mainline WebKit build, which is a coordination decision a maintainer should make.

Other factors

  • The PR is stacked on #37386; a human should confirm merge order.
  • Test coverage is thorough: positive guards (parser errors keep the frame via vm.Script, vm.compileFunction with filename, and import() of JSC-rejected modules) and negative guards (clone, worker, GC) both assert exact frame content, not just not.toContain.
  • The prebuiltDestDir / prebuiltUrl helpers in webkit.ts already handle autobuild- prefixed tags, so the preview pin will resolve correctly — but it is still a preview.
  • No prior reviews from me on this PR.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

On the WebKit coordination: this is not meant to merge with the preview tag. The order is oven-sh/WebKit#407 first, then I swap WEBKIT_VERSION here to the resulting main sha (which makes the oven-sh/WebKit#405 rider the same bump #37352 already proposes), then #37386 and this one. The preview pin is only there so CI can run the Bun-side change against the fixed engine in the meantime.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:36 AM PT - Aug 11th, 2026

@robobun, your commit 4315f536775a07bd3372e2b79c0b5d057e4950b6 passed in Build #92194! 🎉


🧪   To try this PR locally:

bunx bun-pr 37432

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

bun-37432 --bun

isParseError() is also set for syntax errors in eval code, which go
through toErrorObject() without addErrorInfo() and so keep live frames;
once GC flushed those frames and recorded the first frame's URL on the
error, Error.captureStackTrace() still produced the fabricated frame for
them. The reworked oven-sh/WebKit#407 instead has addErrorInfo() set a
dedicated bit right where it records the parser's line and sourceURL,
which is exactly what the frame renders, so the error type check is no
longer needed either.

Adds the eval() and indirect eval rows to the GC test; a caught error's
frames stay alive while it is the VM's last exception, so the test
throws something else before collecting.
Comment thread src/jsc/bindings/FormatStackTraceForJS.cpp Outdated
@robobun robobun changed the title error: gate the <parse> stack frame on isParseError() so cloned and GC-flushed SyntaxErrors don't get one error: gate the <parse> stack frame on the parser having recorded a location, not on sourceURL Aug 11, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/build/deps/webkit.ts`:
- Around line 6-8: Update the WEBKIT_VERSION constant from the temporary preview
tag to 44855720a693f3afd2e217bc986ab75a9ae0f43e, preserving the existing version
format and related reproducibility behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9ffbd0cf-be6d-4f62-a441-ef4c4ac2b8fb

📥 Commits

Reviewing files that changed from the base of the PR and between cd15fba and 32117b8.

📒 Files selected for processing (3)
  • scripts/build/deps/webkit.ts
  • src/jsc/bindings/FormatStackTraceForJS.cpp
  • test/js/bun/test/stack.test.ts

Comment thread scripts/build/deps/webkit.ts
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Revised since the first round of review (32117b8, 4315f53): gating on isParseError() left one case open. JSC also sets that flag for syntax errors in eval code, which are built without addErrorInfo() and so keep live frames; once GC flushed those frames and recorded the first frame's URL on the error, Error.captureStackTrace() still produced the fabricated line for them (at <parse> (gc-me.js:1), reproduced on the first revision). oven-sh/WebKit#407 now adds a dedicated bit that addErrorInfo() sets where it records the parser's location (hasParseLocation()), ParserError.h is untouched, the formatter checks that bit instead of the error type, and the GC test gained eval() / indirect eval rows that fail on the first revision. Pin moved to the new preview build; the description is updated. The process.versions test does not list webkit, so there is no assertion to move when the pin changes to the merged sha.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it depends on an unmerged engine change (oven-sh/WebKit#407, pinned here as a preview build that must be swapped for a main sha before merge) and is stacked on #37386, a human should coordinate the merge order.

What was reviewed:

  • The hasParseLocation() gate in FormatStackTraceForJS.cpp — the removed inner !sourceURLForFrame.isEmpty() check is now subsumed by the outer condition, so the remap block is still guarded.
  • The new tests cover clone, worker postMessage, GC-flushed frames (including the eval case that motivated moving off isParseError()), and confirm real parser errors keep the frame.
  • Checked that the globalObject null-guard on the remap call is preserved.
Extended reasoning...

Overview

This PR changes one condition in src/jsc/bindings/FormatStackTraceForJS.cpp: the synthetic at <parse> (...) frame is now emitted only when ErrorInstance::hasParseLocation() is set and sourceURL() is non-empty, replacing the prior errorType() == ErrorType::SyntaxError check (from stacked #37386). It also collapses a redundant nested !sourceURLForFrame.isEmpty() check now that emptiness is tested in the outer condition. scripts/build/deps/webkit.ts pins WebKit to a preview build of oven-sh/WebKit#407 (which introduces hasParseLocation()), and test/js/bun/test/stack.test.ts gains a substantial describe("SyntaxError .stack") block covering user-constructed errors, structured clone, worker postMessage, GC-flushed frames with eval/indirect-eval variants, and positive cases where parser errors keep the frame.

Security risks

None identified. This is stack-trace formatting; no auth, crypto, permissions, or untrusted-input parsing is touched. The changed condition narrows when a synthetic frame is rendered.

Level of scrutiny

High, for process reasons rather than code complexity. The C++ diff is small and the mechanism is well-argued in the description, but the change (a) calls a new engine API that only exists in an unmerged WebKit PR, (b) pins WEBKIT_VERSION to a preview autobuild tag that the author explicitly says must be replaced with the merged main sha before this lands, and (c) is stacked on #37386. Merging this out of order would either fail to compile (wrong WebKit) or ship a preview pin. That coordination is a human decision.

Other factors

  • The bug hunting system found no issues.
  • The removed inner if (!sourceURLForFrame.isEmpty()) was dead once the outer condition gained the same check; the globalObject guard around Bun__remapStackFramePositions is preserved.
  • Tests look solid: they use tempDir, drain subprocess pipes concurrently, wire worker.onerror to reject, and include both negative (no <parse>) and positive (parser errors keep it) assertions. The GC test's 4-round loop with a comment about conservative-scan stale pointers and the last-exception displacement is deliberate.
  • All prior review threads on the timeline (comment-cop on the long comment, CodeRabbit on the preview pin) are resolved; the current revision reflects the isParseError()hasParseLocation() change made after review turned up the eval case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant