Skip to content

[JSC] Hand a module link error to the host before it is thrown - #960

Draft
robobun wants to merge 4 commits into
mainfrom
robobun/0eec2e41/module-link-error-location
Draft

robobun wants to merge 4 commits into
mainfrom
robobun/0eec2e41/module-link-error-location

Conversation

@robobun

@robobun robobun commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Part of oven-sh/bun#5582.

Problem

  • A module link error does not say which module holds the failing import: SyntaxError: Export named 'a' not found in module '/repro/dep.ts'.
  • CyclicModuleRecord::initializeEnvironment throws it while no code of the importing module runs, so the error has no frame there.
  • In a cycle, getModuleNamespace throws Exported binding name 'x' is not found. for a stale export { x } from.

Fix

  • The 11 throws of the Bun arm of initializeEnvironment go through throwImportLinkError and throwIndirectExportLinkError. They build the same errors and call Bun__locateModuleLinkError with the importing record, the error and the name that identifies the statement.
  • CyclicModuleRecord.h declares that function. The definition here is weak and does nothing, so jsc throws the same errors. Bun defines it in Locate the failing import of a module link error in the source of the importer bun#47531.
  • The 4 throws of getModuleNamespace leave the name out, as the specification's GetModuleNamespace does. The module that has the statement then throws from its own link.

Background

Downsides

Notes

A link that succeeds (gdb, one nexti at a time, release builds of Bun at 9e76acc6f, Linux x64)

  • initializeEnvironment with its calls of linkImportEntry executes 221, 446 and 356 instructions for the three module records of a two-module program before, and 226, 446 and 361 after.
  • The calls, the conditional branches and the compares are the same. The difference is register and stack moves: the compiler allocates the registers of the function differently.
  • initializeEnvironment is 8,094 bytes before and 6,974 after. linkImportEntry is 1,335 and 787. getModuleNamespace is 10,336 and 9,671. The two new functions are 1,261 and 683 bytes.

Other facts

  • The two functions also make the second ResolveExport call that the "Did you mean" messages need. Before, the arms made it.
  • Bun finds the statement in the source that the user wrote and writes the location once, with ErrorInstance::setErrorInfoForEmbedderError. JavaScriptCore writes nothing on the error.
  • Node reports the same statement for the barrel cycle.
  • With [JSC] Module linking: name the binding and the module when an indirect export does not resolve #581, one thrower must own both the message and the call of the hook.
  • The branch starts at the commit that Bun pins (4d45755), so that Bun's CI can build the preview.

@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Preview build of 4dea705: autobuild-preview-pr-960-4dea7054

robobun added a commit to oven-sh/bun that referenced this pull request Oct 31, 2026
robobun added a commit to oven-sh/bun that referenced this pull request Oct 31, 2026
The hook leaves the error as it is when the link runs under a
JavaScript frame or the statement is not found, so require() of an ES
module, expect(import()).rejects and plugin text behave as on main.

The locator parses a statement at a time and stops at the first match.
It does not look in text that is already bundled, in the modules of an
executable, or past the first mebibyte of the file.

The change to the stack-string printer leaves this branch: #38308 and
#38328 own it.

WEBKIT_VERSION names the third preview build of oven-sh/WebKit#960,
where a module namespace leaves out a name that does not resolve, so a
stale `export { x } from` in a barrel cycle reports its own statement.
@robobun

robobun commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

@Jarred-Sumner two decisions in this pair of PRs are yours.

1. Where the position comes from. On oven-sh/bun#7700 you sketched a throwResolutionError that calls createSyntaxError and adds the importer keys to the error. The two throw functions here are that helper. On top of it, oven-sh/bun#47531 reads the importing file again on a failed link and parses it up to the failing specifier, so that the error says at /repro/index.ts:1:10 and shows the source line. The cost is paid only on a failed link: one more open of the file and at most one mebibyte parsed. That is below the noise on the repro, and at most 16 ms and 25 MB of peak memory. If you do not want a file read at the throw, the floor is your sketch: the helper writes the URL of the importer on the error, with no call into the host and no line.

2. The AOT parity program. test/aot/parallel/modules/names-that-export-statements-do-not-find pinned stack, line, column and sourceURL of a link error as undefined. An executable holds no position of an import, so oven-sh/bun#47531 moves those four fields, for all 24 cases, into a // Todo: program. Say if positions for --aot must land first.

One more change here needs your eye: getModuleNamespace now leaves out a name that does not resolve, as the specification does. Before, it threw Exported binding name 'x' is not found., which a barrel cycle reaches.

A link error is about one statement of the importing module: a named
import that the other module does not export, an ambiguous one, an
`export { x } from` that does not resolve. No code of that module runs
while it links, so the error has no frame there and does not say which
module holds the statement.

The eleven throws of the Bun arm of
CyclicModuleRecord::initializeEnvironment now go through
throwModuleLinkError. It calls Bun__locateModuleLinkError with the
importing record, the error, the kind of statement and the name that
identifies it: the local name of an import, the exported name of an
indirect export. The host finds the statement in the source the user
wrote and gives the error its location. JavaScriptCore cannot do that
when the host transpiles, because the text it holds is not that source.

The default definition is weak and does nothing, so `jsc` throws the
same errors as before. ImportEntry and ExportEntry do not change, and a
link that succeeds runs the same code.
The helper has a second form that takes the message and makes the
SyntaxError itself, so an arm that built a message still makes one call.

linkImportEntry takes the importing record from the module environment
it already holds. The lambda captures nothing more than before, so a
link that succeeds stores nothing more either.
In a cycle, the namespace of a module can be asked for before the module
that has `export { name } from` is linked. The four arms of
getModuleNamespace then threw `Exported binding name 'name' is not
found.`, which names no module and no statement.

GetModuleNamespace leaves such a name out (step 3.c of the
specification). The module that has the statement throws when it is
linked, through throwModuleLinkError, so the host can say where the
statement is.

The host's function is now declared in CyclicModuleRecord.h, and the
compiler checks the host's definition against it.
@robobun
robobun force-pushed the robobun/0eec2e41/module-link-error-location branch from 3063046 to 3adbc9f Compare October 31, 2026 21:00
The arms of CyclicModuleRecord::initializeEnvironment that throw a link
error built the message in place. Two of them also asked ResolveExport a
second time, for the "Did you mean" text. All of that was code of the
function that links every module, and the values it needed were kept in
registers and stack slots of that function.

throwIndirectExportLinkError and throwImportLinkError now build the
error from what the arm already holds: the entry, the other module and
the kind of resolution. Each arm is one call. The messages do not
change.
robobun added a commit to oven-sh/bun that referenced this pull request Oct 31, 2026
robobun added a commit to oven-sh/bun that referenced this pull request Nov 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants