Skip to content

[JSC] removeEntry() and clearAll() keep the realm's [[LoadedModules]] in step with the registry - #748

Merged
dylan-conway merged 9 commits into
mainfrom
claude/module-loader-removed-entry
Sep 30, 2026
Merged

dylan-conway merged 9 commits into
mainfrom
claude/module-loader-removed-entry

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Problem

A null dereference in JSModuleLoader::loadModule ← moduleLoadTopSettled, fault address 0x10 (Bun crash reports BUN-4NFS, BUN-41VH). A debug build stops earlier, at ASSERTION FAILED: loadedEntry->record() == loaded in hostLoadImportedModule().

The loader has two tables of what is loaded:

table maps role
the registry, m_moduleMap key → entry (status, promises, record) what every stage of a load works from
the realm's [[LoadedModules]], m_loadedModules key → record a repeated import() finds its record here and skips resolve()

The fast path in hostLoadImportedModule() takes the record it finds in the second table, looks its entry up in the registry, and returns that entry's load promise. It counts on the two tables agreeing, and asserts it.

removeEntry() and clearAll() clear both, and leave them out of step in two ways.

1. A write that comes after the purge. A load that is in flight at that moment writes its record into the second table afterwards, when its dependencies have loaded:

  1. import("./a") starts. a is registered, its dependencies are loading.
  2. a is removed. Both tables are cleared of it, and it is not in the second one yet.
  3. The dependencies finish, and the load in flight adds a to the second table.
  4. The next import("./a") registers a new entry, finds the old record in the second table, pairs it with the new entry, which has no load promise yet, and dereferences null.

If a new load of a has started by step 3 there is no crash: later imports silently get the old module.

2. An entry the purge misses. The second table is keyed by the specifier that was asked for, the registry by the key resolve() made of it. removeEntry(key) purges the second table by specifier only. With a resolve() that turns a key it produced into yet another key, the table holds c → record of d, removeEntry(d) leaves that behind, and the next import() finds a record whose entry is gone. Nothing has to be in flight.

The routes in Bun:

route Bun 1.4.2 canary 11c41c645 with this change
delete require.cache[path] segfault ok (oven-sh/bun#40123 skips a module that is still loading) ok
mock.module() segfault segfault ok
build.module() in a plugin segfault segfault ok
a bun --hot reload segfault segfault ok
a bun --hot reload, the old load finishing while a new one is in flight the old module the old module the new module
a plugin's onResolve redirecting a → b → c → d, delete require.cache[d], import(a) again segfault segfault ok

The first four are what the crash reports show. Nothing in them points at the last one, which was found in review: it takes an unusual plugin.

Why the fix is here and not in Bun

Upstream is not at fault. It writes a record into the second table only once the module and all its dependencies have loaded, and it removes an entry from the registry only when its fetch failed. The two never overlap, so the tables cannot disagree.

removeEntry() and clearAll() are this fork's. They remove any entry at any time. They do purge the second table, but a purge cannot catch what has not been written yet.

Bun cannot avoid the state from outside. oven-sh/bun#40123 does for require.cache, by not removing a module that is still loading. mock.module() could wait for the load instead. A hot reload cannot do either: it clears the whole registry at once, and the loader keeps no list of the loads in flight for it to wait for.

Fix

1. finishLoadingImportedModule() is the only place that writes to the second table. Before it adds a record to the realm's table, it checks that the registry still holds that record under its key. If not, the write is skipped. The load itself goes on, so whoever awaits it still gets its module. +10 −0, in one function, inside #if USE(BUN_JSC_ADDITIONS), with upstream's line kept in the #else.

2. removeEntry() purges the second table by the key of the record as well as by the specifier. One line, in a function of this fork.

A module's own [[LoadedModules]] is not affected by either.

For a program that removes no module nothing changes. The condition is what the fast path already asserts about the two tables (loadedEntry exists, loadedEntry->record() == loaded), checked at the write instead of the read. An entry has its record from fetchComplete() on, which is before any of the calls that reach this write, so without a removal the condition holds. The second change is in removeEntry(), which such a program never calls.

An earlier version of this PR removed the second table instead. That saved a resolve() per load by name for every program, which is a change in behavior a Bun test pins, and it deleted upstream code. This version does neither.

Verification

The numbers below were taken with the first change alone. The second was added afterwards.

The condition is never false without a removal. A build that logs every evaluation of the condition, with the number of removals its loader had seen, ran 615 of Bun's test files (compiled executables, hot reload and watch, bun run, the test runner, plugins, mocks, Bun.ModuleGraph, resolution, node:module, node:vm, workers, and the 394 regression tests):

true 141,793 times
false, after a removal 8 times: the three new tests and five runs of the scripts below
false, with no removal 0

No assertion failed. 20 of the files fail or time out on that debug build. They do on a debug build of unmodified main too, with the same tests failing by name.

Same behavior as main. Two release builds of Bun from one tree, differing in these ten lines: 23 scripts print the same. They cover numbers of onLoad and onResolve calls, identity of error objects, order against a promise chain, retries after a failure to resolve, build, link or evaluate, which of two concurrent imports finishes first, and require() while an import() of the same module is in flight. test/bundler/bundler_compile_prelinked.test.ts, which pins the number of resolve, fetch, evaluate and import hook calls of a compiled executable, passes 79 of 79.

The bug. The six routes above, on a debug build with assertions.

JSTests/modules: 149 of 149 with a debug jsc with assertions and ASAN.

Cost: one hash lookup when a load by name finishes. The same two release builds, runs interleaved and pinned, with a copy of the baseline as an A/A control:

instructions baseline baseline (copy) this change
1.4 million warm import()s 31.4692e9 31.4670e9 31.6052e9 (+0.43%, about 97 per import)
200 first import()s, whole process 54.23e6 54.30e6 54.39e6 (+0.3%)

Times overlap: the best warm import() of an absolute path is 1.795 µs and 1.763 µs for the two baselines and 1.716 µs with the change, on a busy machine.

Tests. removeEntry() is not reachable from the jsc shell, so the tests of the crash are in oven-sh/bun#44281: one each for mock.module(), build.module(), a hot reload and the redirect chain. Without a fix, Bun's CI had the first three segfault at 0x10 on macOS, Linux (glibc and musl) and Windows, x64 and arm64.

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Preview build of 25dd940: autobuild-preview-pr-748-25dd940b

@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: f2115e5b-6aa2-4417-88db-2513b4952c21

📥 Commits

Reviewing files that changed from the base of the PR and between eff6b5f and e0e02de.

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

Included review availability: This review used your included allowance. 1 included review remains after this review. Your included PR review attempts over the past 7 days set your current allowance at 3 reviews per hour.


Walkthrough

Imported-module lookup now uses the registry-key path for symbol specifiers and realm referrers. Successful results are stored in [[LoadedModules]] only for module referrers. The loader’s separate loaded-module map and related cleanup and GC-root handling are removed.

Changes

Module loading cache handling

Layer / File(s) Summary
Registry lookup and loaded-module storage
Source/JavaScriptCore/runtime/JSModuleLoader.cpp, Source/JavaScriptCore/runtime/JSModuleLoader.h
Imported-module lookup uses the registry-key path for symbol specifiers and realm referrers. Successful results enter [[LoadedModules]] only when the referrer is a module. The loader’s separate map, cleanup logic, and GC-root handling are removed.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to e0e02

No concrete merge-blocking issue is established. Confidence is limited because registry-key consistency for realm-referrer imports could not be verified; normal build and module-loading checks remain necessary.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description clearly explains the bug, root cause, fix, affected code paths, and verification. However, it does not follow the repository template because it omits the Bugzilla bug title and link, … Add the Bugzilla issue title and URL, include the required “Reviewed by NOBODY (OOPS!).” line or reviewer information, and list the changed files and relevant functions using the repository template.
✅ Passed checks (3 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 identifies the JSC module-loader change and the consistency issue between removeEntry(), clearAll(), [[LoadedModules]], and the registry. It is specific and related to the changes.
Full details: Description check

Explanation

The description clearly explains the bug, root cause, fix, affected code paths, and verification. However, it does not follow the repository template because it omits the Bugzilla bug title and link, the reviewed-by line, and the required changed-file and function summary.

  • Fix all pre-merge checks with AI

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked the entryOfLoad fallback (context with no entry still writes the error to whatever entry now sits under the key) and the referrer overload's lookup at JSModuleLoader.cpp:930 by the unresolved m_specifier (an entry registered under a different resolved key is never adopted) — both are pre-existing upstream behavior this PR narrows rather than introduces, so not flagged separately.

Extended reasoning...

The change is 37 lines across JSMicrotask.cpp, JSModuleLoader.cpp and ModuleLoadingContext.h, all under USE(BUN_JSC_ADDITIONS), pinning an in-flight top-level module load to its own registry entry and gating the [[LoadedModules]] cache write on the record still being registered. It touches no injection, auth or data-exposure surface. A confirmed inline finding on the moduleLoadTopSettled re-registration path plus further verified findings that are not posted mean a human should review; there are no in-repo tests since removeEntry() is not reachable from the jsc shell.

Comment thread Source/JavaScriptCore/runtime/JSMicrotask.cpp Outdated
@dylan-conway dylan-conway changed the title [JSC] A module load left in flight by removeEntry() or clearAll() writes only to its own registry entry [JSC] A module load holds on to its registry entry, so removeEntry() and clearAll() are safe while it is in flight Sep 30, 2026

@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.

Beyond the inline finding, I also checked the unconditional context->entry() dereferences in the Bun branches of moduleLoadTopSettled and moduleLoadTopRejected: those two continuations are only attached from the by-name loadModule() (JSModuleLoader.cpp:449/453), where entryOfLoad is always non-null under USE(BUN_JSC_ADDITIONS), and the record-based loadModule() path that can seed a null entry via getRegisteredMayBeNull only reaches moduleLoadStoreError, which keeps its if (!entry) return guard. The upstream #else branches pass a null entry to the new defaulted parameter, so non-Bun behavior is unchanged.

Extended reasoning...

The change touches JSC's module loader (JSModuleLoader.cpp/.h, JSMicrotask.cpp, ModuleLoadingContext.h) so an in-flight load under USE(BUN_JSC_ADDITIONS) holds its own ModuleRegistryEntry instead of re-looking it up by key; no security-sensitive surface. A confirmed finding is being posted inline, so this body only records the null-dereference and upstream-behavior concerns that were examined and ruled out.

Comment thread Source/JavaScriptCore/runtime/JSModuleLoader.cpp Outdated
…odule() opens, loadEntry() loads, and the realm's [[LoadedModules]] is gone
@dylan-conway dylan-conway changed the title [JSC] A module load holds on to its registry entry, so removeEntry() and clearAll() are safe while it is in flight [JSC] A module load asks the registry for its entry once, so removeEntry() and clearAll() are safe while it is in flight Sep 30, 2026

@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.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Still open from earlier reviews (1):

  • Unresolved: 1 blocking on lines changed since (possibly already fixed).

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

@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.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Still open from earlier reviews (1):

  • Unresolved: 1 blocking on lines changed since (possibly already fixed).

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

Comment thread Source/JavaScriptCore/runtime/JSMicrotask.cpp Outdated
Comment thread Source/JavaScriptCore/runtime/JSModuleLoader.cpp Outdated
dylan-conway added a commit to oven-sh/bun that referenced this pull request Sep 30, 2026
…ules]]: hostLoadImportedModule() asks the registry
@dylan-conway dylan-conway changed the title [JSC] A module load asks the registry for its entry once, so removeEntry() and clearAll() are safe while it is in flight [JSC] The registry is the realm's [[LoadedModules]], so removeEntry() and clearAll() have one table to clear Sep 30, 2026

@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 the current version of this PR (now only the removal of the realm's [[LoadedModules]] table, after the last commit put the rest of the loader back as on main) and found no bugs; a human look is still worthwhile because this is JSC's module loader, CI runs it without assertions, and the PR description still describes the larger refactor (loadEntry(), entry-carrying ModuleLoadingContext, removal in setFetchError()) that the diff no longer contains, so it is worth confirming the reduced scope is meant to fix only the [[LoadedModules]] crash and not the other four issues the description lists.

What was reviewed:

  • The widened specifier.isSymbol() || referrer.isRealm() lookup in hostLoadImportedModule(): every realm-referrer caller (moduleLoadTopSettled after provideFetch(), loadAndEvaluateModule(SourceCode)) passes the key the entry was just registered under, and resolve() still runs when no entry exists, so behavior only changes where the old code already required resolve(key) == key.
  • finishLoadingImportedModule(): the realm branch now records nothing; the deleted fast path was the only reader of that table, and the CyclicModuleRecord branch is unchanged.
  • visitChildrenImpl, removeEntry(), clearAll(): no remaining reference to m_loadedModules anywhere in Source/JavaScriptCore, so no unvisited WriteBarrier is left behind.
Extended reasoning...

The diff touches two files in Source/JavaScriptCore/runtime (JSModuleLoader.cpp/.h, +8/-46) and removes the realm-level [[LoadedModules]] table, its GC visit, its two purges, and the hostLoadImportedModule fast path that read it, replacing that with a direct registry lookup for realm-referrer loads. It touches no auth, injection, or data-exposure surface. It was not approved because the module loader is a core engine path owned by jsc-reviewers in CODEOWNERS, the fork's CI lanes run without assertions, no test accompanies the change, and the PR description is stale relative to the code after the final commit reverted the larger refactor. The earlier inline findings from prior runs were on code that the final commit removed from the diff.

dylan-conway added a commit to oven-sh/bun that referenced this pull request Sep 30, 2026
… module to it that the registry no longer holds
@dylan-conway dylan-conway changed the title [JSC] The registry is the realm's [[LoadedModules]], so removeEntry() and clearAll() have one table to clear [JSC] A module that removeEntry() or clearAll() took out while it was loading is not added to the realm's [[LoadedModules]] Sep 30, 2026

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread Source/JavaScriptCore/runtime/JSModuleLoader.cpp
Comment thread Source/JavaScriptCore/runtime/JSModuleLoader.cpp
dylan-conway added a commit to oven-sh/bun that referenced this pull request Sep 30, 2026
@dylan-conway dylan-conway changed the title [JSC] A module that removeEntry() or clearAll() took out while it was loading is not added to the realm's [[LoadedModules]] [JSC] removeEntry() and clearAll() keep the realm's [[LoadedModules]] in step with the registry Sep 30, 2026
@dylan-conway
dylan-conway merged commit 0e8e9c2 into main Sep 30, 2026
46 checks passed
dylan-conway added a commit to oven-sh/bun that referenced this pull request Sep 30, 2026

@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 the latest push (the removeEntry() purge-by-record line) and found no bugs; the final version nets out to ~12 lines, but since it changes the invariant between the registry and the realm's [[LoadedModules]] in a path that is only exercised by Bun's removal routes, a human look would still be worthwhile.

What was reviewed:

  • The new gate in finishLoadingImportedModule mirrors the exact lookup the fast path in hostLoadImportedModule asserts on (record->moduleKey() with the request's type), so a skipped write can only cost a resolve() on the next import, never a wrong record.
  • The unconditional m_module dereference in removeEntry: the loader's m_loadedModules is populated only from finishLoadingImportedModule with a non-null record, and removeIf visits live entries only, so it cannot be null.
  • Both edits sit inside USE(BUN_JSC_ADDITIONS) (the header block spans lines 193-263), and the #else arm keeps upstream's line unchanged; module (non-realm) referrers take the original path.
Extended reasoning...

The change touches Source/JavaScriptCore/runtime/JSModuleLoader.cpp and JSModuleLoader.h only, adding a registry-consistency guard before the realm's [[LoadedModules]] insert and widening removeEntry() to also purge by the record's module key. It touches no auth, injection, or data-exposure surface. The last commit (25dd940) addresses the pre-existing specifier-vs-key mismatch flagged inline in the prior run, and no new findings surfaced this run. Approval is withheld because .github/CODEOWNERS covers /Source/JavaScriptCore, and the module-loader lifecycle under concurrent removal is subtle enough (the branch went through several reverted designs) that a maintainer should confirm the invariant choice; CI also runs release-only, so the ASSERTs in this path are not exercised.

dylan-conway added a commit to oven-sh/bun that referenced this pull request Sep 30, 2026
…44281)

### What does this PR do?

Fixes a segfault at address `0x10` in `JSC::JSModuleLoader::loadModule`
← `moduleLoadTopSettled` (Sentry BUN-4NFS, BUN-41VH), on every platform.
The fix is in oven-sh/WebKit#748 (merged), which also explains why it
belongs there. This PR bumps WebKit to it and adds the tests.

Two things ride along:

- **The WebKit bump goes to `fb1167ebf2cb`, the current tip.** Besides
the fix (`0e8e9c238e91`) that brings in one more commit,
oven-sh/WebKit#749: on Linux, OS randomness is read with `getrandom(2)`,
and `/dev/urandom` is opened only when that fails.
- **A type error on main.** `src/js/bun/sql.ts(892,26): error TS2339:
Property 'command' does not exist on type '{}'` came in with #35119 and
fails "Lint JavaScript" for every PR that includes it.
`unsafeQueryFromTransaction()` now says what its query resolves to
(`SQLResultArray`). It is a type argument only, so the code that runs is
the same.

**Cause**

The module loader has two tables of what is loaded: the registry, and a
shortcut that lets a repeated `import()` skip resolving. Removing a
module clears both. But an `import()` of that module that is still in
flight adds it to the shortcut afterwards, when its dependencies have
loaded. The next `import()` finds the old module there, pairs it with a
new registry entry that has no load promise yet, and dereferences null.

```ts
const inFlight = import("./a"); // a.ts is registered, its dependencies are still loading
mock.module("./a", () => ({ a: "mocked-a" }));
await inFlight;
await import("./a"); // segfault
```

Everything that removes a module while its `import()` is in flight gets
there:

| route | 1.4.2 | canary `11c41c645` | with the fix |
|---|---|---|---|
| `delete require.cache[path]` | segfault | ok (#40123) | ok |
| `mock.module()` | segfault | segfault | ok |
| `build.module()` in a plugin | segfault | segfault | ok |
| a `bun --hot` reload | segfault | segfault | ok |
| a plugin's `onResolve` redirecting a → b → c → d, `delete
require.cache[d]`, `import(a)` again | segfault | segfault | ok |

The last one needs nothing in flight: the shortcut is keyed by the name
that was asked for, and removing a module cleared it only by the name it
resolved to. It was found in review, and nothing in the crash reports
points at it.

Of the 481 reports, 273 are from `bun test`, 203 from `bun run` or `bun
<file>`, and 240 had an HTTP server running.

**Fix** (oven-sh/WebKit#748)

The place that adds a module to the shortcut first checks that the
registry still holds it, and skips the write if not. And removing a
module clears it from the shortcut under whatever name it was asked for.
It is eleven lines in Bun's part of the loader. For a program that
removes no module nothing changes.

### How did you verify your code works?

Four new tests, one for each route that still crashes. All four fail on
1.4.2 and on canary `11c41c645`, and pass against oven-sh/WebKit#748 on
debug and release builds (Linux x64). Without the WebKit bump, CI had
all three segfault at `0x10` on every platform: macOS, Linux glibc and
musl, and Windows, x64 and arm64. The hot reload test passed 10 runs of
10.

| test | file |
|---|---|
| mock.module() of a module whose import() is still loading its
dependencies | `mock-module.test.ts` |
| build.module() of a module whose import() is still loading its
dependencies | `plugins.test.ts` |
| should import a module again after a hot reload while its import() was
still loading its dependencies | `hot.test.ts` |
| import() after delete require.cache of a module that onResolve
redirected a resolved path to | `plugins.test.ts` |

The WebKit PR has the evidence that nothing changes for a program that
removes no module, the comparison of behavior with main, WebKit's own
module tests and the cost.
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