Skip to content

Module loader: reject with one SyntaxError when Bun's parser refuses a file - #48069

Draft
robobun wants to merge 4 commits into
mainfrom
robobun/c6123d56/loader-syntax-error
Draft

robobun wants to merge 4 commits into
mainfrom
robobun/c6123d56/loader-syntax-error

Conversation

@robobun

@robobun robobun commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #7531

Draft: needs a maintainer's yes on the break in Downsides, and oven-sh/WebKit#451.

Problem

  • import() and require() of a file that Bun's parser refuses fail with a BuildMessage. For several diagnostics they fail with an AggregateError. Node gives one SyntaxError.
  • A BuildMessage is not an Error: no stack, Error.isError is false, structuredClone throws. process_fetch_log (src/jsc/VirtualMachine.rs:3959) makes it.

Fix

  • process_fetch_log makes one SyntaxError when the first error has a line. It has 1-based line and column, sourceURL, and a stack that names the file.
  • It keeps up to 256 diagnostics. position, notes and level read the first. The console, bun test and the uncaught printer print the code frames as before.
  • Verified: test/js/bun/resolve/build-error.test.ts and the suites in Notes.
  • Self-reviewed: 46 concerns raised, 45 addressed (4 by the order in Notes). Not done: a named type for the three properties (an API decision).

Background

Downsides

Notes

Order

  1. Print a module's build or resolve error every time it is reported, not only the first time #39292 removes the print-once latch of BuildMessage and ResolveMessage. It needs no WebKit change. The two promise.set_handled lines in Macro.rs here are its lines. It is rebased onto main.
  2. [JSC] Module loader: replay the error of a failed fetch, and register nothing for a rejected fetch WebKit#451 has two commits: the registry replays the error of a failed fetch, and a rejected fetch of a load by name registers nothing. The second commit is from [JSC] A failed loadModule() stores its error in the registry entry it loaded WebKit#754.
  3. Report a module's build errors to every later load of it (WebKit bump for oven-sh/WebKit#451) #39204 pins the merged sha and owns the registry tests. It is also the place to check whether evictModuleThatNothingFetches in BunPlugin.cpp is still needed after the second WebKit commit.
  4. This PR, without its first commit.

Before and after

bad.js is const x;. two.js is const x;\nconst y;. Columns are main and this branch.

main, one diagnostic main, several branch, both
constructor.name BuildMessage AggregateError SyntaxError
Error.isError(e) false true true
e instanceof BuildMessage true false false
e.message The constant "x" must be initialized 2 errors building "<dir>/two.js" The constant "x" must be initialized
e.stack undefined undefined for import(), the frames of the caller for require() SyntaxError: <message>\n at <parse> (<dir>/bad.js:1:7)
e.line / e.column 0 / 6 undefined, or the call site for require() 1 / 7
e.errors none 2 BuildMessage none
e.position, e.notes, e.level yes on each member yes, of the first error
JSON.stringify(e) {"name":"BuildMessage","position":{...},"message":...,"level":"error"} {} {}
Object.prototype.toString.call(e) [object BuildMessage] [object Error] [object Error]
structuredClone(e) throws DataCloneError an Error without errors a SyntaxError with stack, line, column, sourceURL
util.inspect(e) [BuildMessage: <message>] [AggregateError: ...] { [errors]: [...] } SyntaxError: <message> and the frame
uncaught, console.log(e), Bun.inspect(e), a failed expect(e) matcher the code frames the code frames the same code frames

Static imports and the entry point give the same value as import().

Other changes in behaviour

  • A later static importer of the file gets the same error object as the first one. import() and require() of the file itself read the file again and get a new error, as on main.
  • The error is printed each time it is reported. A BuildMessage is printed once per process.
  • The stack has one frame, the position in the file. For require() of a file with several diagnostics, main had the frames of the caller.
  • At most 256 diagnostics reach JS, the first error always among them. Main has the same bound for the AggregateError.
  • Web Worker: ErrorEvent.message is what the console prints for the error, as for every other error. For one diagnostic it was SyntaxError: <message>. It is now the code frame with the file and the position. For several it does not change.
  • node:worker_threads: the parent gets a SyntaxError with stack, line, column and sourceURL. It was a SyntaxError with the message alone, or an Error that said 2 errors building.
  • node:repl prints the file, the source line and a caret above Uncaught SyntaxError, as Node does. It printed Uncaught BuildMessage: <message> with no file.
  • A macro that returns the error of a file with several diagnostics fails the build, as it does for one diagnostic.
  • As a cause given to the Error constructor, the error is printed with its code frame. Main printed nothing for that cause.
  • Under --unhandled-rejections=throw the diagnostics are printed twice. That mode prints every rejected Error twice today (Stop the rejection pass after a report that no listener took, and print an error once under --unhandled-rejections=throw #46199).

What does not change

  • Bun.build logs, Bun.Transpiler, notes, and the BuildMessage and ResolveMessage classes.
  • A failure without a line stays a BuildMessage: a file that cannot be read, a file over 2 GiB, an error in a transcoded XML document, (a, ...b); (the parser gives it no position, A Loc is always a byte offset: Option<Loc> for absence, no -1 sentinel #38825).
  • A file that is nested too deeply for the parser stays a BuildMessage (Maximum call stack size exceeded). JavaScriptCore and Node report a RangeError for it.
  • A resolve failure stays a ResolveMessage. A log whose first error is a resolve error stays an AggregateError.
  • The own properties, code and cause of the error itself are not printed at the top level, as for a BuildMessage. No GitHub annotation and no inspector event, as for a BuildMessage.

What still differs from Node

Open PRs that touch the same code

Measurements

Release builds. A is main b1305dd with the first preview of oven-sh/WebKit#451. B is this branch.

  • Text: 140,289,934 on main, 140,300,458 here (+10,524 bytes, data +64). The first WebKit commit adds 0 bytes.
  • 4,000 successful loads: 590,655,354 instructions (A, range 590.1M to 596.1M) and 591,220,990 (B, range 587.3M to 593.7M). The ranges overlap. The resolve hook is not touched.
  • A failed require(): 61,043 to 65,141 instructions. A failed import(): 70,622 to 74,982.
  • GC cells per failed load: 1 to 4 for one diagnostic, 5 to 7 for two.
  • console.error(new TypeError("x")): +112 instructions of about 32,300. For a SyntaxError: +160. An uncaught TypeError: 4,088,599 and 4,092,759 for the whole process, ranges overlap.
  • bun -e 0: 4,029,403 and 4,030,467, ranges overlap.
  • --trim size is not measured: no build in my environment can trim.

Suites run on the debug build

build-error.test.ts, inspect-error.test.js, plugins.test.ts, macro-test.test.ts, junit.test.js, xml.test.js, transpiler-cache.test.ts, node-module-module.test.js, test-util-types.test.js, mock-module.test.ts, mock-hoist.test.ts, resolve-error.test.ts, the new tests in worker.test.ts, worker_threads.test.ts and repl.test.ts, and test/js/node/test/parallel/test-esm-error-cache.js. On a release build: the five test/aot/*.test.ts files that pin this value, and 173 programs of test/aot/parallel (modules, workers, module-loaders, and each program that names a SyntaxError).

The test "a module whose onLoad callback rejects with the load error of another module" hangs on main for an AggregateError and for an evaluation error. It passes with the second WebKit commit and no change in Bun.


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/module/node-module-module.test.js, test/js/bun/repl/repl.test.ts, test/js/bun/plugin/plugins.test.ts, test/cli/run/transpiler-cache.test.ts, test/aot/a-file-with-a-mistake-that-only-a-computed-name-finds-stops-no-build.test.ts

robobun and others added 2 commits November 7, 2026 05:27
The preview is Bun's pin 4d45755fed plus the two commits of that pull
request. A later importer of a module whose fetch failed gets the error
of that fetch, not a copy with the message alone. A rejected fetch of a
load by name registers nothing.

This commit lets CI run the rest of the branch. Replace the tag with the
merged sha before this lands.
…a file

import(), require() and a static import of a file that Bun's parser
refuses failed with a BuildMessage, or with an AggregateError of them
for several diagnostics. A BuildMessage is not an Error: it has no
stack, Error.isError() is false, and structuredClone() throws.

process_fetch_log now makes one SyntaxError when the first error of the
log is a Build diagnostic with a line. The error has own 1-based line
and column, sourceURL, and a stack of one frame that names the file.
It keeps the wrappers of up to 256 diagnostics under a private name.
position, notes and level read the BuildMessage of the first error.
The console, the uncaught printer, the test runner and the junit
reporter print every diagnostic from the wrappers, so the code frames
that they print stay as they were.

A diagnostic without a line, a file that is nested too deeply for the
parser, a file that cannot be read and a resolve failure still give a
BuildMessage, a ResolveMessage or an AggregateError.

The Worker error hook has no special case for this value. node:repl
prints the location above the message, as Node does. A macro promise
that rejects is marked as handled, because the macro runner reports it.

Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
@github-actions github-actions Bot added the claude label Nov 7, 2026
@robobun

robobun commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:08 AM PT - Nov 7th, 2026

❌ @robobun, your commit a04a8ab has 39 failures in Build #138177 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 48069

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

bun-48069 --bun

@robobun

robobun commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft. It waits for a maintainer's decision on the break, and for oven-sh/WebKit#451.

Reproduction on main (b1305dd):

echo 'const x;' > bad.js
bun -e 'import("./bad.js").catch(e => console.log(e.constructor.name, Error.isError(e), typeof e.stack))'
  • main prints BuildMessage false undefined.
  • This branch prints SyntaxError true string.
  • Node 26.3.0 prints SyntaxError true string.

For const x;\nconst y;, main prints AggregateError true undefined, and this branch prints the same line as for one diagnostic.

CI (build 138177, a04a8ab): the 18 test files that this PR adds or changes pass on every lane. The red tests are not from this diff:

  • 27 files that also fail on main: files under test/aot/, and test/bundler/compile-aot-size.test.ts.
  • test/js/web/request/request-clone-leak.test.ts on the ASAN lane. It measured an RSS delta of 64 MB against its limit of < 64. On a local ASAN build, the same case gives 69 to 72 MB with main and 71 to 72 MB with this branch.

PR: #48069

@robobun

robobun commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator Author

@dylan-conway @Jarred-Sumner this draft needs a yes or a no before more work goes into it.

The question: when Bun's parser refuses a file, can import() and require() fail with a real SyntaxError in place of a BuildMessage or an AggregateError?

  • It changes name, constructor, instanceof BuildMessage, errors and toJSON() of that value. The table is in the Notes of the description.
  • position, notes and level stay readable, and the printed code frames stay the same.
  • Bun.TOML.parse and Bun.JSONC.parse made the same change in 1.4 (List of breaking changes for 1.4 #28792).
  • If the name must stay, the fallback is the same Error object with name and constructor kept as BuildMessage.

The order of the four pull requests that this needs is in the Notes. oven-sh/WebKit#451 needs a human review first.

Comment thread src/js/internal/repl/node-shims.ts Resolved
Comment thread src/jsc/BuildMessage.rs Resolved
Comment thread src/jsc/BuildMessage.rs Resolved
Comment thread src/jsc/BuildMessage.rs Resolved
Comment thread src/jsc/BuildMessage.rs Resolved
Comment thread src/jsc/BuildMessage.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/bindings/LoaderSyntaxError.cpp Resolved
Comment thread src/jsc/bindings/LoaderSyntaxError.cpp Resolved
Comment thread src/jsc/bindings/LoaderSyntaxError.cpp Resolved
Comment thread src/jsc/web_worker.rs Resolved
Comment thread src/runtime/cli/test_command.rs Resolved
@robobun

robobun commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator Author

Each multi-line comment that this PR added under src/ is one line now (a04a8ab). The 16 review threads are resolved.

This branch has not been deployed

No deployments
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