Skip to content

JSModuleLoader: decide needsErrorReaction by membership in innerModuleLoading - #396

Open
robobun wants to merge 1 commit into
mainfrom
fix/inner-module-loading-sibling-reentrancy
Open

JSModuleLoader: decide needsErrorReaction by membership in innerModuleLoading#396
robobun wants to merge 1 commit into
mainfrom
fix/inner-module-loading-sibling-reentrancy

Conversation

@robobun

@robobun robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Symptom

On a mixed ESM/CJS graph where a CommonJS module require()s an ESM module that an ancestor also imports statically, debug builds of bun abort inside the graph loading step:

ASSERTION FAILED: module->loadedModules().size() <= loadedModulesCountBefore + 1
Source/JavaScriptCore/runtime/JSModuleLoader.cpp(883) : void JSC::JSModuleLoader::innerModuleLoading(...)

Repro (6 files), with bun built against this tree:

# entry.mjs
import "./a.mjs";
import "./c.mjs";
console.log((globalThis.o ??= []).concat("entry").join(","));
# a.mjs
import "./b.cjs"; (globalThis.o ??= []).push("a"); export const a = 1;
# b.cjs
require("./c.mjs"); (globalThis.o ??= []).push("b"); module.exports = {};
# c.mjs
import "./d.cjs"; import "./e.mjs"; (globalThis.o ??= []).push("c"); export const c = 1;
# d.cjs
require("./e.mjs"); (globalThis.o ??= []).push("d"); module.exports = {};
# e.mjs
(globalThis.o ??= []).push("e"); export const e = 1;
# req.cjs
require(require("path").resolve(process.argv[2]));

bun req.cjs ./entry.mjs and BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER=1 bun entry.mjs both hit the assertion 3/3; NODE_COMPILE_CACHE=/tmp/cc bun entry.mjs hits it as well.

Cause

Bun evaluates CommonJS module bodies during makeModule, so user code runs inside the host load hook while innerModuleLoading is iterating a module's requests. When that body require()s an ESM sibling of the graph being loaded, the synchronous module queue drains and runs pending ModuleLoadStep reactions for other requests of the same referrer module. module.loadedModules() therefore grows across the hostLoadImportedModule call without the current request having completed, and can grow by more than one.

The size-delta check conflates "this request completed synchronously" with "any request completed": needsErrorReaction comes out false for a request that is still pending, so its ModuleGraphLoadingError reaction is never attached and a later rejection of that request is lost (the graph load then stays pending forever). On debug builds the two growth assertions abort.

Fix

Decide needsErrorReaction by membership of the request itself in module.loadedModules(), which is the property the error-reaction decision actually depends on, and drop the size-based assertions (growth by more than one is legal under this reentrancy).

With this change plus a bun-side companion (delivering sync-transpiled source to a registry entry whose async fetch is still in flight, oven-sh/bun PR to follow), all three repro doors complete with the evaluation order Node prints: e,d,c,b,a,entry.

…eLoading

A require(esm) nested inside the host load hook drains the synchronous
module queue: Bun evaluates CommonJS module bodies during makeModule, and
such a body can require an ESM sibling of the graph that is currently
loading. The drain then runs pending ModuleLoadStep reactions for other
requests of the same referrer module, so module.loadedModules() can grow
across the hostLoadImportedModule call without the current request having
completed, and can grow by more than one. The size-delta check then
misidentifies whether this request still needs a ModuleGraphLoadingError
reaction (a missed reaction loses the rejection, leaving the graph load
pending forever), and the growth assertion fires on debug builds:

ASSERTION FAILED: module->loadedModules().size() <= loadedModulesCountBefore + 1

Decide by membership of the request itself instead.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a01acbcb-85ca-4614-b3a7-a68e41bbec5b

📥 Commits

Reviewing files that changed from the base of the PR and between 78d45d3 and bea4078.

📒 Files selected for processing (1)
  • Source/JavaScriptCore/runtime/JSModuleLoader.cpp

Walkthrough

innerModuleLoading now checks for the requested module in loadedModules after host loading. It no longer relies on collection-size changes, so synchronous nested loading can add or process multiple unrelated entries.

Changes

Module loading

Layer / File(s) Summary
Synchronous module detection
Source/JavaScriptCore/runtime/JSModuleLoader.cpp
innerModuleLoading checks whether the requested (specifier, type) exists in loadedModules. It removes the size-based assertion and attaches the module-graph error reaction only when the request was not loaded synchronously.

Suggested reviewers: constellation, sosukesuzuki, jarred-sumner

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the symptom, cause, repro, and fix, but it omits the required Bugzilla link, reviewer line, and changed-file/function list. Add the bug title and Bugzilla URL, the required Reviewed by line, and the changed file and function entries from the repository template.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: membership-based error-reaction decisions in innerModuleLoading.
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.

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

@claude claude Bot left a comment

Copy link
Copy Markdown

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 changes reentrancy handling in JSC's module graph loading — a critical path — and is one half of a two-PR fix with a companion in oven-sh/bun, a human look would still be worthwhile.

What was reviewed:

  • The new membership check uses the same ModuleMapKey { request.m_specifier.impl(), request.type() } as the pre-call find() at 2.d.ii and as finishLoadingImportedModule, which populates the map — keys line up.
  • The removed second ASSERT was already asserting that the size-delta test equals the membership test, so in every case the old code accepted, behavior is unchanged; only the reentrant case (which the old code asserted against) differs.
  • The extra contains() per request is a hash lookup on a map already probed just above and is dwarfed by hostLoadImportedModule — not a perf concern.
Extended reasoning...

Overview

This PR changes one decision in JSModuleLoader::innerModuleLoading (JSModuleLoader.cpp): after calling hostLoadImportedModule for a request, it now decides whether to attach a ModuleGraphLoadingError reaction by checking whether this specific request is present in module.[[LoadedModules]], instead of comparing the map's size before/after the call. It also drops two debug assertions that assumed the map could grow by at most one across that call. Net code delta is small (one variable and two ASSERTs removed, one contains() added, plus an explanatory comment).

Security risks

None identified. This is internal module-loading control flow; no parsing, auth, or externally-attacker-controlled data handling changes. The failure mode being fixed is a hung load / debug assertion, not a memory-safety or privilege issue.

Level of scrutiny

High. This is JavaScriptCore's ECMA-262 module loading algorithm — correctness here affects every ESM graph Bun loads, and the scenario involves subtle reentrancy (Bun evaluating CJS bodies inside makeModule, which drains the synchronous module queue and can advance other pending requests of the same referrer during a single hostLoadImportedModule call). The change is small and the argument is tight — the removed second ASSERT literally stated needsErrorReaction != contains(key), i.e. the old size-delta expression was already asserted equivalent to the new membership expression in every case the old code considered valid — so the new code is provably no worse in the non-reentrant case and correct in the reentrant one. Still, module-loader reentrancy is exactly the kind of thing where a second pair of eyes on the invariants is worth having.

Other factors

  • The PR description gives a concrete 6-file repro and explains the mechanism precisely; the comment added in-code preserves that reasoning for future readers.
  • No test is added here; the repro requires the Bun embedder (CJS-evaluated-in-makeModule + require(esm)), and the description says a companion oven-sh/bun PR is coming. A reviewer may want to confirm that companion lands together or that a JSC-shell-level regression is infeasible.
  • The ModuleMapKey construction matches both the 2.d.ii pre-check and finishLoadingImportedModule's insert, so there is no key-shape mismatch that could cause a false miss.
  • No prior reviews or comments on the PR to account for.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
bea4078d autobuild-preview-pr-396-bea4078d 2026-08-08 06:08:32 UTC

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.

1 participant