Skip to content

JSModuleLoader: a removed registry entry's in-flight load must not re-cache its record - #472

Open
robobun wants to merge 2 commits into
mainfrom
farm/ae0c7496/module-loader-stale-loaded-modules
Open

JSModuleLoader: a removed registry entry's in-flight load must not re-cache its record#472
robobun wants to merge 2 commits into
mainfrom
farm/ae0c7496/module-loader-stale-loaded-modules

Conversation

@robobun

@robobun robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun crashes in JSModuleLoader::loadModule (JSModuleLoader.cpp:813): performPromiseThenWithInternalMicrotask reads status() of a null promise (segfault at 0x10, under moduleLoadTopSettled). Sentry BUN-443N (canary builds, macOS and Linux) and BUN-4NFS (the same stack on the Bun 1.4.0 release build).
  • The null promise comes from the [[LoadedModules]] fast path in hostLoadImportedModule (JSModuleLoader.cpp:624-650). It returns loadPromise() of the registry entry for a cached record, and only asserts that this entry exists, holds that record, and has a load promise. Bun's removeEntry() (mock.module(), delete require.cache[key], plugin virtual modules) drops an entry, and its cache line, while a load of it is still in flight. That load then puts its record back into the cache in finishLoadingImportedModule. The next import() of the key gets a fresh entry from provideFetch, hits the fast path, and returns its null loadPromise().
  • The fast path has the same problem without an in-flight load when the cache key and the registry key differ (a host resolve() hook that is not idempotent on its own output): removeEntry() of the registry key leaves the cache line behind, and the next import crashes on the null loadedEntry (address 0x38) or, if the key was re-registered, resolves with the stale record.

Fix

  • finishLoadingImportedModule: with the realm as referrer, cache the record only if isCacheableLoadedModule() holds, which is the condition the fast path asserts: the registry holds this record through an entry that has a load promise or was markLoaded(). The load still continues its payload, so the in-flight import() resolves with the module it started to load, and a module's own [[LoadedModules]] is still filled in, since that module links against the record it requested.
  • hostLoadImportedModule: check the same condition on a realm-level cache hit. Otherwise drop the cache line (under cellLock(), like the insertion) and fall through to the normal path, which loads what the registry holds now. The upstream body of the hit is unchanged. Per-module caches are not affected by removeEntry(), and innerModuleLoading consults them before it calls here.
  • Both changes are under USE(BUN_JSC_ADDITIONS): only removeEntry() and clearAll() can make the cache disagree with the registry. In the normal flow the condition holds at both places, so repeat imports are still served from the cache (checked, see notes).
  • Verified: the five tests of Module loader: removing a registry entry out from under the import cache no longer crashes the next import() (WebKit bump for oven-sh/WebKit#472) bun#39674 (which pins this PR's preview build) pass with this change; four fail without it. Details in the notes.

Background

  • The registry (m_moduleMap) maps a module key to a ModuleRegistryEntry: its promises, plus the record once fetched. removeEntry() is a fork addition. Upstream only removes entries whose fetch failed (removeFailedFetchEntry()), which never reach the cache, so upstream can assert here.
  • Realm-level m_loadedModules is RealmRecord.[[LoadedModules]]: a cache from a top-level import specifier to its record. The fast path uses it to skip the host resolve() hook on repeat imports. It answers with the registry entry's loadPromise, so it is only valid while it agrees with the registry.
  • A top-level load runs as moduleLoadStep microtasks whose ModuleLoadingContext holds the entry they started from. After removeEntry() they complete against that detached entry.
  • The branch is directly on top of f5deafe090, the commit Bun pins (rebased each time Bun moved its pin: 0f966e81b7, b7f217b4a6, aea1f010b6, c148a12dd8, cb61607f1a, 1cb96a7b0e, 76882271d7, 2da33d53e3, 7259739917, 0bb01ed526), so the preview build is that commit plus this change. That base lets an embedder provideModule() + markLoaded() an entry without a loadPromise, and the fast path answers such a hit with loadedPromise(); isCacheableLoadedModule() accepts those entries too (loadPromise() || isLoaded()), so their records stay cacheable.
Notes

Repro shape (Bun 1.4.0-canary crashes every time): import a module whose dependency is held back by an async onLoad plugin, delete require.cache[entry] while it waits, release it, await the import, then import() the entry again. A debug build fails ASSERT(loadedEntry->record() == loaded) at the fast path instead. If nothing re-imports the key before the crash path, loadedEntry itself is null.

Why isCacheableLoadedModule() holds in the normal flow: fetchComplete() stores the record in the entry before the module promise settles, and setLoadPromise() runs before the first moduleLoadStep is queued, so by the time finishLoadingImportedModule runs for a live entry, the entry holds the record and has a load promise. Repeat imports of one key produce the same dumpModuleLoadingState trace as before this change: the second resolve() is still skipped.

The skip in finishLoadingImportedModule covers step 1.a as well as 1.b: when a replacement load finishes first and caches its own record, the removed load's completion used to trip the step 1.a assertion (JSModuleLoader.cpp:955) in debug builds.

Local verification used the pinned 0f966e81b7 linux debug-asan tarball with the UnifiedSource-runtime-26 bundle (the one that holds JSModuleLoader.cpp) recompiled from this branch and swapped into libJavaScriptCore.a. A debug Bun linked against it passes the three new tests: the delete require.cache[] shape, the shape where the replacement load finishes before the removed one, and the mock.module() shape. The same Bun linked against the unmodified tarball fails all three: the first and third on the fast path assertion, the second on the step 1.a assertion. The release Bun segfaults at 0x10 on the first and third and passes the second, since release ignores the assertion.

3000 iterations of delete require.cache[] + import() take the same time with and without the change (about 12.5 s each on the debug-asan build). Both files also compile with USE_BUN_JSC_ADDITIONS off. Bun's test/js/bun/test/mock/, test/js/node/module/, test/js/bun/plugin/plugins.test.ts and the dynamic import and require(esm) files of test/js/bun/resolve/ pass against the patched engine.

Found in review, covered by oven-sh/bun#39674's onResolve chain tests: with a resolve hook that maps a -> b -> c -> d, Bun's import path resolves the specifier more than once, so the realm-level cache line is keyed c while the registry entry and record are keyed d. delete require.cache[d] then leaves the c line behind. Unpatched, the next import("./a") segfaults at 0x38 (loadedEntry null), or resolves with the stale record if d was imported again in between; with this change the hit-side check evicts the line and the import loads (or returns) what the registry holds. This is the case that exercises the hostLoadImportedModule half; the in-flight tests are carried by the finishLoadingImportedModule half.

Pre-existing and out of scope here: when the removed in-flight load fails, moduleLoadTopSettled (fetch rejected), moduleLoadTopRejected and moduleLoadStoreError in JSMicrotask.cpp store the error through ensureRegistered(key), which creates a fresh entry for the key, so the next import rejects with the removed load's error instead of loading again. No crash; fixing it means threading the entry through the top-level ModuleLoadingContext, which overlaps with #258, so it is left for a follow-up.

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 23 days. After that, they cost $0.25 per reviewed file.

Or wait 21 minutes for your next included review.

View limit details

Limit details: You’ve used all 5 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d5379931-793b-426f-ae64-c8f879350f3c

📥 Commits

Reviewing files that changed from the base of the PR and between ceb9f90 and eaacac2.

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

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: 49889372-f41a-4cd3-aaea-e658f161b924

📥 Commits

Reviewing files that changed from the base of the PR and between cb61607 and daf9f5f9bc466567e32ad6d0648a3a33b4583b97.

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

Included review availability: Your plan provides up to 5 included reviews per hour; 0 remain after this review.


Walkthrough

Changes

Module Cache Validation

Layer / File(s) Summary
Cacheability validation contract
Source/JavaScriptCore/runtime/JSModuleLoader.h, Source/JavaScriptCore/runtime/JSModuleLoader.cpp
Adds isCacheableLoadedModule to validate registry identity and the presence of a load promise.
Cache lookup and completion filtering
Source/JavaScriptCore/runtime/JSModuleLoader.cpp, Source/JavaScriptCore/runtime/JSModuleLoader.h
Invalidates stale realm-level cache hits and excludes detached or replaced records from m_loadedModules after loading.
🚥 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 JSModuleLoader change and the in-flight load re-caching bug it fixes.
Description check ✅ Passed The description is comprehensive and directly related to the change. It explains the problem, root cause, fix, scope, tests, performance impact, and known limitations. It does not include the Bugzilla…
Full details: Description check

Explanation

The description is comprehensive and directly related to the change. It explains the problem, root cause, fix, scope, tests, performance impact, and known limitations. It does not include the Bugzilla URL or reviewer line required by the repository template.

Warning

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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

Comment thread Source/JavaScriptCore/runtime/JSModuleLoader.h
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
eaacac28 autobuild-preview-pr-472-eaacac28 2026-08-28 14:38:31 UTC
cfccea9d autobuild-preview-pr-472-cfccea9d 2026-08-28 10:27:23 UTC
6a17c6a2 autobuild-preview-pr-472-6a17c6a2 2026-08-28 09:04:38 UTC
f7cb3cb4 autobuild-preview-pr-472-f7cb3cb4 2026-08-28 03:13:27 UTC
d3deb973 autobuild-preview-pr-472-d3deb973 2026-08-28 00:15:21 UTC
030d206f autobuild-preview-pr-472-030d206f 2026-08-27 16:32:04 UTC
18461ab5 autobuild-preview-pr-472-18461ab5 2026-08-26 23:04:43 UTC
084e8533 autobuild-preview-pr-472-084e8533 2026-08-26 05:32:33 UTC
319c644c autobuild-preview-pr-472-319c644c 2026-08-25 23:54:37 UTC
daf9f5f9 autobuild-preview-pr-472-daf9f5f9 2026-08-25 10:48:23 UTC
22dfd709 autobuild-preview-pr-472-22dfd709 2026-08-24 12:46:11 UTC
50b13c06 autobuild-preview-pr-472-50b13c06 2026-08-23 09:40:42 UTC
86b6c4df autobuild-preview-pr-472-86b6c4df 2026-08-21 03:57:03 UTC
4ee15452 autobuild-preview-pr-472-4ee15452 2026-08-19 11:57:56 UTC

@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. The author's reply on the cellLock() question checks out (callers hold it; now documented in removeEntry()), and that thread is resolved. Because this changes control flow in hostLoadImportedModule / finishLoadingImportedModule — spec-implementing module-loader hot paths — a human look would still be worthwhile.

What was reviewed:

  • isCacheableLoadedModule() mirrors exactly the three assertions the fast path relies on; both new call sites are gated to the realm-level cache only (per-module [[LoadedModules]] untouched).
  • The hostLoadImportedModule eviction takes cellLock() like the neighboring insertion, and resets iter to end() so the fall-through path is the pre-existing resolve-and-load flow.
  • The finishLoadingImportedModule skip still runs continueModuleLoading/continueDynamicImport on the payload, so the in-flight import still settles; only the realm-cache write is suppressed.
  • Non-Bun builds: both hunks are under USE(BUN_JSC_ADDITIONS) and the original if line is preserved verbatim in the #else.
Extended reasoning...

Overview

Two-file change to JSModuleLoader.{cpp,h} fixing Sentry BUN-443N: a segfault when Bun's fork-only removeEntry() drops a registry entry while a load of it is in flight (or when the realm-cache key differs from the registry key). The fix adds a private helper isCacheableLoadedModule() that re-checks the fast-path invariant (registry entry exists, holds this record, has a loadPromise), and consults it at both the cache-read side (hostLoadImportedModule) and the cache-write side (finishLoadingImportedModule), realm-level only, all under USE(BUN_JSC_ADDITIONS).

Security risks

None identified. No new user-controlled input surface; the change narrows when an internal cache is trusted. No auth, crypto, or memory-lifetime changes beyond taking cellLock() for the new remove(iter), matching the existing pattern for concurrent-GC safety.

Level of scrutiny

High. hostLoadImportedModule and finishLoadingImportedModule implement ECMA-262 §16.2 module-loading abstract operations and sit on every dynamic-import path in the engine. Mistakes here manifest as subtle spec deviations, cache incoherence, or crashes across the whole runtime. The change is small and well-argued, but the surrounding machinery (microtask-driven ModuleLoadingContext steps, detached entries, per-module vs realm-level [[LoadedModules]], the require(esm) synchronous-queue overlay) is intricate enough that a maintainer with module-loader ownership should confirm the invariant reasoning — particularly that isCacheableLoadedModule() always holds on the normal (non-removeEntry) path so repeat imports still skip resolve().

Other factors

  • The PR description is unusually thorough: repro shape, why the invariant holds in the normal flow, debug-assert vs release-segfault behavior, 3000-iteration perf parity, and five tests in oven-sh/bun#39674 pinned to this preview build.
  • My earlier pre-existing note about removeEntry()/clearAll() locking was answered (Bun callers hold cellLock(); JSCellLock is non-reentrant so taking it inside would deadlock) and the contract is now documented in the header comment via commit a092ae2. That thread is resolved and not a blocker.
  • No outstanding reviewer comments remain. Given the critical path, I'm deferring rather than approving.

@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from a092ae2 to 86b6c4d Compare August 21, 2026 02:56
robobun added a commit to oven-sh/bun that referenced this pull request Aug 21, 2026
…ader's import cache

delete require.cache[path] and mock.module() remove the ES module registry
entry of a module. When an import() of it is still loading, or when the path
import() was resolved to differs from the path the registry ended up with (an
onResolve chain), the loader's import cache kept pointing at the removed entry
and the next import() crashed in JSModuleLoader::loadModule (null loadPromise)
or in hostLoadImportedModule (null entry), or returned the stale module. The
fix is in JavaScriptCore (oven-sh/WebKit#472). The tests cover the
delete require.cache[] shape with the load held in flight by an async onLoad
plugin, the shape where a replacement load finishes before the removed one,
the onResolve chain shape with and without a replacement, and the
mock.module() shape.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 21, 2026
A removed registry entry's in-flight load no longer puts its record back into
the realm-level [[LoadedModules]] cache, and the cache fast path verifies the
registry entry it answers with. Fixes the null loadPromise crash in
JSModuleLoader::loadModule after delete require.cache[] or mock.module() of a
module whose import() was still loading (Sentry BUN-443N), and the null entry
crash and stale module when the cached key and the registry key differ.

Preview tag; it has to move to the merged main sha before this lands.

@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 found no bugs. The earlier cellLock() question is settled — callers hold the lock, and 86b6c4d now documents that invariant on removeEntry(). Because this touches the module loader's cache/registry consistency on a hot path, a human look would still be worthwhile.

What was reviewed:

  • isCacheableLoadedModule() mirrors exactly what the fast-path hit asserts and dereferences (entry, entry->record() == record, entry->loadPromise()), so both guarded sites can no longer reach the null-deref.
  • The finishLoadingImportedModule skip is realm-referrer only; a module's own [[LoadedModules]] is still populated, so getImportedModule() and linking are unaffected.
  • The new loadedModules.remove(iter) is under cellLock() matching visitChildrenImpl(); the iterator obtained before the lock stays valid because only the GC marker (read-only) contends.
  • Both changes are #if USE(BUN_JSC_ADDITIONS); the upstream path compiles unchanged.
Extended reasoning...

Overview

The PR adds a guard predicate isCacheableLoadedModule() and applies it at two points in JSModuleLoader: the realm-level [[LoadedModules]] fast-path hit in hostLoadImportedModule(), and the realm-level cache insertion in finishLoadingImportedModule(). It also adds a header declaration and documents on removeEntry() that callers hold cellLock() and that in-flight detached loads are handled by the new predicate. All logic changes are behind USE(BUN_JSC_ADDITIONS).

Security risks

None identified. This is internal cache-consistency bookkeeping in the ES module loader; there is no new user-controlled input surface, no auth/crypto/permissions code, and no new allocation or pointer arithmetic. The change strictly narrows when a cached record is trusted, which is a hardening direction relative to the previous behavior (which dereferenced a possibly-null loadPromise() / loadedEntry).

Level of scrutiny

High. JSModuleLoader is on the critical path for every import() and require(esm) in Bun, and correctness here depends on subtle ordering invariants between fetchComplete(), setLoadPromise(), moduleLoadStep microtasks, and the detached-entry lifetime after removeEntry(). The PR description is unusually thorough (Sentry ref, repro, debug-assert vs release-segfault behavior, perf check, and paired tests in oven-sh/bun#39674), and the change is small and well-scoped, but the invariants it relies on are not something I can fully verify from the diff alone — e.g., that isCacheableLoadedModule() always holds in the non-removeEntry() flow so repeat imports still hit the cache. That reasoning is stated and spot-checked by the author, but a maintainer familiar with the loader's state machine should confirm it.

Other factors

  • My prior review flagged removeEntry()/clearAll() mutating maps without cellLock(). The author (via robobun) explained that all Bun call sites hold the lock externally and JSCellLock is non-reentrant; commit 86b6c4d now records "Callers hold cellLock()" in the function comment. That thread is resolved and was pre-existing anyway.
  • The iterator used for loadedModules.remove(iter) is obtained via find() before cellLock() is taken. This is safe: the only concurrent user of the map is visitChildrenImpl() on GC marker threads, which iterates read-only, so the iterator cannot be invalidated between find() and remove(). The lock is there to keep the marker from reading a mid-mutation table, not to protect the mutator's own iterator.
  • The cacheRecord = false path in finishLoadingImportedModule() skips step 1 entirely but still runs step 2/3 (continueModuleLoading / continueDynamicImport), so the in-flight import() still resolves — matches the PR's stated intent.
  • Tests exist in the paired Bun PR and the author reports passing the relevant Bun test suites against a patched engine, but there are no in-tree JSC tests added here (typical for this fork; verification is via Bun's test suite).

Given the subtlety of the loader state machine and the fact that this fixes a production crash, deferring to a human reviewer rather than auto-approving.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 21, 2026
…f217b4

The previous preview was built on 0f966e81 and predates oven-sh/WebKit#477,
which the ffi.test.js case from #39829 needs on Windows. This preview is
b7f217b4 (what main pins) plus the #472 change.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 21, 2026
…ader's import cache

delete require.cache[path] and mock.module() remove the ES module registry
entry of a module. When an import() of it is still loading, or when the path
import() was resolved to differs from the path the registry ended up with (an
onResolve chain), the loader's import cache kept pointing at the removed entry
and the next import() crashed in JSModuleLoader::loadModule (null loadPromise)
or in hostLoadImportedModule (null entry), or returned the stale module. The
fix is in JavaScriptCore (oven-sh/WebKit#472). The tests cover the
delete require.cache[] shape with the load held in flight by an async onLoad
plugin, the shape where a replacement load finishes before the removed one,
the onResolve chain shape with and without a replacement, and the
mock.module() shape.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 21, 2026
A removed registry entry's in-flight load no longer puts its record back into
the realm-level [[LoadedModules]] cache, and the cache fast path verifies the
registry entry it answers with. Fixes the null loadPromise crash in
JSModuleLoader::loadModule after delete require.cache[] or mock.module() of a
module whose import() was still loading (Sentry BUN-443N), and the null entry
crash and stale module when the cached key and the registry key differ.

Preview tag; it has to move to the merged main sha before this lands.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 21, 2026
…f217b4

The previous preview was built on 0f966e81 and predates oven-sh/WebKit#477,
which the ffi.test.js case from #39829 needs on Windows. This preview is
b7f217b4 (what main pins) plus the #472 change.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 22, 2026
…ader's import cache

delete require.cache[path] and mock.module() remove the ES module registry
entry of a module. When an import() of it is still loading, or when the path
import() was resolved to differs from the path the registry ended up with (an
onResolve chain), the loader's import cache kept pointing at the removed entry
and the next import() crashed in JSModuleLoader::loadModule (null loadPromise)
or in hostLoadImportedModule (null entry), or returned the stale module. The
fix is in JavaScriptCore (oven-sh/WebKit#472). The tests cover the
delete require.cache[] shape with the load held in flight by an async onLoad
plugin, the shape where a replacement load finishes before the removed one,
the onResolve chain shape with and without a replacement, and the
mock.module() shape.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 22, 2026
A removed registry entry's in-flight load no longer puts its record back into
the realm-level [[LoadedModules]] cache, and the cache fast path verifies the
registry entry it answers with. Fixes the null loadPromise crash in
JSModuleLoader::loadModule after delete require.cache[] or mock.module() of a
module whose import() was still loading (Sentry BUN-443N), and the null entry
crash and stale module when the cached key and the registry key differ.

Preview tag; it has to move to the merged main sha before this lands.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 22, 2026
…f217b4

The previous preview was built on 0f966e81 and predates oven-sh/WebKit#477,
which the ffi.test.js case from #39829 needs on Windows. This preview is
b7f217b4 (what main pins) plus the #472 change.
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from 86b6c4d to 50b13c0 Compare August 23, 2026 07:47

@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 re-reviewed after the latest push and didn't find any bugs. My earlier note about removeEntry()/clearAll() locking is resolved — callers hold cellLock(), and that contract is now documented in the header. Because this touches the core module-loading fast path and the registry/cache coherency invariants it relies on are subtle, a human look would still be worthwhile.

What was reviewed:

  • isCacheableLoadedModule() mirrors exactly what the fast-path hit asserts (entry && entry->record() == record && entry->loadPromise()), so the guard and the assertions stay in lockstep.
  • The new loadedModules.remove(iter) takes cellLock() like the existing insertion; the find() before it is a read on the mutator thread, consistent with other lookups in this file.
  • The finishLoadingImportedModule skip is realm-referrer only, so per-module [[LoadedModules]] still fills in and step 2/3 (continueModuleLoading/continueDynamicImport) still run — the in-flight import still resolves.
  • Both guarded blocks are under USE(BUN_JSC_ADDITIONS); the non-Bun build path is byte-identical.
Extended reasoning...

Overview

This PR fixes Sentry crashes BUN-443N / BUN-4NFS: a segfault in JSModuleLoader::loadModule where the realm-level [[LoadedModules]] fast path in hostLoadImportedModule returns a null loadPromise() (or dereferences a null loadedEntry) after Bun's removeEntry() drops a registry entry while a load of it is still in flight, or when the cache key and registry key differ. Two files change: JSModuleLoader.cpp adds a cacheability guard at both the cache-hit and cache-insert sites plus a small isCacheableLoadedModule() helper; JSModuleLoader.h declares the helper and adds documenting comments to removeEntry().

Since my previous comment, commit 50b13c0652 added the "Callers hold cellLock()" note to removeEntry() and expanded the explanatory comment in hostLoadImportedModule about how a cache line can outlive its entry — addressing the locking concern I raised (which the author correctly pointed out is handled at Bun's call sites, and taking the non-reentrant lock inside would deadlock them).

Security risks

None identified. This is crash-avoidance in the module loader; no auth, crypto, permissions, or untrusted-input parsing is touched. The change narrows what gets cached rather than broadening any capability.

Level of scrutiny

High. hostLoadImportedModule and finishLoadingImportedModule sit on the hot path of every dynamic import() and module graph load in Bun. The fix depends on an invariant (registry entry holds the record and a load promise by the time finishLoadingImportedModule runs for a live entry) that the PR description argues for carefully but that is not mechanically obvious from the diff alone. Getting the realm-vs-module referrer distinction wrong, or the interaction with removeEntry()'s timing, would either regress the crash or break repeat-import caching. This is exactly the kind of change where a maintainer familiar with the C++ module loader rewrite and with #258 should sign off.

Other factors

  • The change is well-tested end-to-end via oven-sh/bun#39674 (five tests, four failing without the patch), plus regression suites listed in the notes, and a 3000-iteration perf check showing no slowdown.
  • All new logic is gated behind USE(BUN_JSC_ADDITIONS), so upstream/non-Bun builds are unaffected.
  • The iterator obtained before taking cellLock() in the new eviction branch is safe: only the mutator thread mutates m_loadedModules, and visitChildrenImpl on marker threads only reads it under the same lock, so the iterator cannot be invalidated between find() and remove(iter).
  • The PR description explicitly scopes out a related pre-existing issue (error paths re-registering via ensureRegistered) as overlapping with #258 — reasonable, but another reason a human familiar with that PR should look.

Given the criticality of the code path and the subtlety of the invariants, I'm deferring to human review rather than approving.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 23, 2026
…ader's import cache

delete require.cache[path] and mock.module() remove the ES module registry
entry of a module. When an import() of it is still loading, or when the path
import() was resolved to differs from the path the registry ended up with (an
onResolve chain), the loader's import cache kept pointing at the removed entry
and the next import() crashed in JSModuleLoader::loadModule (null loadPromise)
or in hostLoadImportedModule (null entry), or returned the stale module. The
fix is in JavaScriptCore (oven-sh/WebKit#472). The tests cover the
delete require.cache[] shape with the load held in flight by an async onLoad
plugin, the shape where a replacement load finishes before the removed one,
the onResolve chain shape with and without a replacement, and the
mock.module() shape.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 23, 2026
A removed registry entry's in-flight load no longer puts its record back into
the realm-level [[LoadedModules]] cache, and the cache fast path verifies the
registry entry it answers with. Fixes the null loadPromise crash in
JSModuleLoader::loadModule after delete require.cache[] or mock.module() of a
module whose import() was still loading (Sentry BUN-443N), and the null entry
crash and stale module when the cached key and the registry key differ.

Preview tag; it has to move to the merged main sha before this lands.
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from 50b13c0 to 22dfd70 Compare August 24, 2026 11:17

@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 re-ran the automated review and found no bugs. The earlier note about removeEntry()/clearAll() locking was answered (callers hold cellLock(), now documented in the header) and that thread is resolved. Because this touches JSC's module-loader fast path and the registry/[[LoadedModules]] consistency invariants, a human look is still worthwhile.

What was reviewed:

  • isCacheableLoadedModule() mirrors the three fast-path assertions; both guard sites check it only for the realm-level cache, per-module [[LoadedModules]] still fills in.
  • The new loadedModules.remove(iter) takes cellLock(); the iterator from the unlocked find() stays valid because concurrent GC only reads the map.
  • cacheRecord = false skips step 1's cache insert but still runs ContinueModuleLoading/ContinueDynamicImport, so the in-flight payload resolves.
  • Both files compile with USE(BUN_JSC_ADDITIONS) off — the non-Bun path is byte-identical.
Extended reasoning...

Overview

This PR fixes a production segfault (Sentry BUN-443N / BUN-4NFS) in Bun's ES module loader. It adds a Bun-only helper isCacheableLoadedModule() that mirrors the assertions in hostLoadImportedModule's [[LoadedModules]] fast path, and guards both the cache-hit read and the cache insert in finishLoadingImportedModule with it. When removeEntry() drops a registry entry while a load is in flight (or when a resolve hook makes the cache key differ from the registry key), the stale cache line is evicted / not written, so the fast path never dereferences a null loadPromise() or a null loadedEntry. All new logic is under #if USE(BUN_JSC_ADDITIONS); the upstream path is unchanged.

Security risks

None identified. This is internal cache-coherency logic in the module loader; no parsing, auth, or externally-controlled data handling changes. The change strictly adds checks before dereferencing.

Level of scrutiny

High. JSModuleLoader::hostLoadImportedModule and finishLoadingImportedModule are on the hot path of every dynamic import() in Bun, and the correctness argument relies on subtle invariants: when fetchComplete()/setLoadPromise() run relative to moduleLoadStep, that ModuleLoadingContext keeps a detached entry alive, that per-module [[LoadedModules]] is consulted by innerModuleLoading before reaching this fast path, and that the mutator-side unlocked find() → locked remove(iter) is safe because the GC marker only iterates. The PR description walks through each of these and the paired Bun PR adds five regression tests, but this is exactly the kind of engine-core change a maintainer should sign off on.

Other factors

  • My earlier pre-existing-issue note (unlocked removeEntry()) was answered: Bun's callers hold cellLock(), JSCellLock isn't reentrant, and a follow-up commit documented the contract in the header. That thread is resolved and was never a blocker for this PR.
  • The change is well-scoped (~40 effective lines, all guarded by the Bun feature flag), thoroughly explained, and locally verified against a debug-asan build with the paired test suite in oven-sh/bun#39674.
  • I'm deferring rather than approving because the guidelines call for human review on complex changes to critical engine paths, not because of any specific concern with the diff.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 24, 2026
…ader's import cache

delete require.cache[path] and mock.module() remove the ES module registry
entry of a module. When an import() of it is still loading, or when the path
import() was resolved to differs from the path the registry ended up with (an
onResolve chain), the loader's import cache kept pointing at the removed entry
and the next import() crashed in JSModuleLoader::loadModule (null loadPromise)
or in hostLoadImportedModule (null entry), or returned the stale module. The
fix is in JavaScriptCore (oven-sh/WebKit#472). The tests cover the
delete require.cache[] shape with the load held in flight by an async onLoad
plugin, the shape where a replacement load finishes before the removed one,
the onResolve chain shape with and without a replacement, and the
mock.module() shape.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 24, 2026
A removed registry entry's in-flight load no longer puts its record back into
the realm-level [[LoadedModules]] cache, and the cache fast path verifies the
registry entry it answers with. Fixes the null loadPromise crash in
JSModuleLoader::loadModule after delete require.cache[] or mock.module() of a
module whose import() was still loading (Sentry BUN-443N), and the null entry
crash and stale module when the cached key and the registry key differ.

Preview tag; it has to move to the merged main sha before this lands.
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from 22dfd70 to daf9f5f Compare August 25, 2026 09:28

@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. The fix looks correct and is thoroughly documented and tested, but since it changes core JSC module-loader state handling (registry/cache coherence, promise chains, GC cellLock() interaction), a human review is still warranted.

Checked that isCacheableLoadedModule() mirrors exactly the three conditions the fast path asserts and returns, so the guard and the assert cannot disagree.
Checked the !record gate on the eviction — per-module [[LoadedModules]] is untouched, and the realm-level branch takes cellLock() for the removal like the insertion does.
Checked that skipping step 1 in finishLoadingImportedModule still falls through to step 2/3 so the payload continues and the in-flight import resolves.
The earlier cellLock() concern on removeEntry() was resolved — callers hold the lock, now documented in the comment.

Extended reasoning...

Overview

The PR guards two points in JSModuleLoader against a stale realm-level [[LoadedModules]] cache after Bun's fork-only removeEntry() drops a registry entry mid-load: (1) hostLoadImportedModule now validates a realm-level cache hit with isCacheableLoadedModule() and evicts it under cellLock() if stale, and (2) finishLoadingImportedModule skips inserting a record into the realm-level cache when the same predicate fails. A new private helper isCacheableLoadedModule() encapsulates the exact condition the fast path asserts (entry && entry->record() == record && entry->loadPromise()). All new logic is under USE(BUN_JSC_ADDITIONS); the header adds the declaration and a clarifying comment on removeEntry().

Security risks

None identified. This is internal engine cache/registry coherence; no untrusted input parsing, auth, or data-exposure surface is touched. The change strictly narrows when a cache is populated/consulted and otherwise falls through to the existing load path.

Level of scrutiny

High. JSModuleLoader is a critical runtime path in JavaScriptCore: it drives dynamic import, module graph loading, and interacts with concurrent GC via cellLock() and with microtask ordering via ModuleLoadingContext. The fix depends on subtle invariants (when loadPromise() / record() are set relative to moduleLoadStep, that innerModuleLoading consults the per-module cache before reaching hostLoadImportedModule, that the payload continuation is independent of the step-1 cache write). The PR description argues each of these convincingly and cites passing tests in oven-sh/bun#39674 plus regression suites, but engine module-loader changes of this kind should be signed off by a human familiar with the loader's state machine.

Other factors

The one prior inline finding from this bot (missing cellLock() in removeEntry()/clearAll()) was answered — Bun call sites hold the lock and JSCellLock is non-reentrant — and the PR now documents that in the removeEntry() comment; the thread is resolved. The iterator used for loadedModules.remove(iter) is obtained immediately before, with no intervening mutation, and isCacheableLoadedModule() only reads m_moduleMap (not m_loadedModules), so the iterator stays valid. The finishLoadingImportedModule change only gates the cache write; continueModuleLoading/continueDynamicImport still run, so the detached load's promise settles. Given the criticality of the code path and the multi-rebase history, deferring to human review rather than auto-approving.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 25, 2026
…ader's import cache

delete require.cache[path] and mock.module() remove the ES module registry
entry of a module. When an import() of it is still loading, or when the path
import() was resolved to differs from the path the registry ended up with (an
onResolve chain), the loader's import cache kept pointing at the removed entry
and the next import() crashed in JSModuleLoader::loadModule (null loadPromise)
or in hostLoadImportedModule (null entry), or returned the stale module. The
fix is in JavaScriptCore (oven-sh/WebKit#472). The tests cover the
delete require.cache[] shape with the load held in flight by an async onLoad
plugin, the shape where a replacement load finishes before the removed one,
the onResolve chain shape with and without a replacement, and the
mock.module() shape.

@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 found no issues

No high-confidence issues detected in this change.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 26, 2026
…ader's import cache

delete require.cache[path] and mock.module() remove the ES module registry
entry of a module. When an import() of it is still loading, or when the path
import() was resolved to differs from the path the registry ended up with (an
onResolve chain), the loader's import cache kept pointing at the removed entry
and the next import() crashed in JSModuleLoader::loadModule (null loadPromise)
or in hostLoadImportedModule (null entry), or returned the stale module. The
fix is in JavaScriptCore (oven-sh/WebKit#472). The tests cover the
delete require.cache[] shape with the load held in flight by an async onLoad
plugin, the shape where a replacement load finishes before the removed one,
the onResolve chain shape with and without a replacement, and the
mock.module() shape.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 26, 2026
A removed registry entry's in-flight load no longer puts its record back into
the realm-level [[LoadedModules]] cache, and the cache fast path verifies the
registry entry it answers with. Fixes the null loadPromise crash in
JSModuleLoader::loadModule after delete require.cache[] or mock.module() of a
module whose import() was still loading (Sentry BUN-443N), and the null entry
crash and stale module when the cached key and the registry key differ.

Preview tag; it has to move to the merged main sha before this lands.
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from 18461ab to 3580958 Compare August 27, 2026 09:48

@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 found no issues

No high-confidence issues detected in this change.

@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 found no issues

No high-confidence issues detected in this change.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 27, 2026
…ader's import cache

delete require.cache[path] and mock.module() remove the ES module registry
entry of a module. When an import() of it is still loading, or when the path
import() was resolved to differs from the path the registry ended up with (an
onResolve chain), the loader's import cache kept pointing at the removed entry
and the next import() crashed in JSModuleLoader::loadModule (null loadPromise)
or in hostLoadImportedModule (null entry), or returned the stale module. The
fix is in JavaScriptCore (oven-sh/WebKit#472). The tests cover the
delete require.cache[] shape with the load held in flight by an async onLoad
plugin, the shape where a replacement load finishes before the removed one,
the onResolve chain shape with and without a replacement, and the
mock.module() shape.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 27, 2026
A removed registry entry's in-flight load no longer puts its record back into
the realm-level [[LoadedModules]] cache, and the cache fast path verifies the
registry entry it answers with. Fixes the null loadPromise crash in
JSModuleLoader::loadModule after delete require.cache[] or mock.module() of a
module whose import() was still loading (Sentry BUN-443N), and the null entry
crash and stale module when the cached key and the registry key differ.

Preview tag; it has to move to the merged main sha before this lands.
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from 030d206 to d3deb97 Compare August 27, 2026 23:23

@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 found no issues

No high-confidence issues detected in this change.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 28, 2026
…ader's import cache

delete require.cache[path] and mock.module() remove the ES module registry
entry of a module. When an import() of it is still loading, or when the path
import() was resolved to differs from the path the registry ended up with (an
onResolve chain), the loader's import cache kept pointing at the removed entry
and the next import() crashed in JSModuleLoader::loadModule (null loadPromise)
or in hostLoadImportedModule (null entry), or returned the stale module. The
fix is in JavaScriptCore (oven-sh/WebKit#472). The tests cover the
delete require.cache[] shape with the load held in flight by an async onLoad
plugin, the shape where a replacement load finishes before the removed one,
the onResolve chain shape with and without a replacement, and the
mock.module() shape.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 28, 2026
A removed registry entry's in-flight load no longer puts its record back into
the realm-level [[LoadedModules]] cache, and the cache fast path verifies the
registry entry it answers with. Fixes the null loadPromise crash in
JSModuleLoader::loadModule after delete require.cache[] or mock.module() of a
module whose import() was still loading (Sentry BUN-443N), and the null entry
crash and stale module when the cached key and the registry key differ.

Preview tag; it has to move to the merged main sha before this lands.
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from d3deb97 to f7cb3cb Compare August 28, 2026 02:09

@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 found no issues

No high-confidence issues detected in this change.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 28, 2026
…ader's import cache

delete require.cache[path] and mock.module() remove the ES module registry
entry of a module. When an import() of it is still loading, or when the path
import() was resolved to differs from the path the registry ended up with (an
onResolve chain), the loader's import cache kept pointing at the removed entry
and the next import() crashed in JSModuleLoader::loadModule (null loadPromise)
or in hostLoadImportedModule (null entry), or returned the stale module. The
fix is in JavaScriptCore (oven-sh/WebKit#472). The tests cover the
delete require.cache[] shape with the load held in flight by an async onLoad
plugin, the shape where a replacement load finishes before the removed one,
the onResolve chain shape with and without a replacement, and the
mock.module() shape.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 28, 2026
A removed registry entry's in-flight load no longer puts its record back into
the realm-level [[LoadedModules]] cache, and the cache fast path verifies the
registry entry it answers with. Fixes the null loadPromise crash in
JSModuleLoader::loadModule after delete require.cache[] or mock.module() of a
module whose import() was still loading (Sentry BUN-443N), and the null entry
crash and stale module when the cached key and the registry key differ.

Preview tag; it has to move to the merged main sha before this lands.
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from f7cb3cb to 6a17c6a Compare August 28, 2026 08:13

@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 found no issues

No high-confidence issues detected in this change.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 28, 2026
…ader's import cache

delete require.cache[path] and mock.module() remove the ES module registry
entry of a module. When an import() of it is still loading, or when the path
import() was resolved to differs from the path the registry ended up with (an
onResolve chain), the loader's import cache kept pointing at the removed entry
and the next import() crashed in JSModuleLoader::loadModule (null loadPromise)
or in hostLoadImportedModule (null entry), or returned the stale module. The
fix is in JavaScriptCore (oven-sh/WebKit#472). The tests cover the
delete require.cache[] shape with the load held in flight by an async onLoad
plugin, the shape where a replacement load finishes before the removed one,
the onResolve chain shape with and without a replacement, and the
mock.module() shape.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 28, 2026
A removed registry entry's in-flight load no longer puts its record back into
the realm-level [[LoadedModules]] cache, and the cache fast path verifies the
registry entry it answers with. Fixes the null loadPromise crash in
JSModuleLoader::loadModule after delete require.cache[] or mock.module() of a
module whose import() was still loading (Sentry BUN-443N), and the null entry
crash and stale module when the cached key and the registry key differ.

Preview tag; it has to move to the merged main sha before this lands.
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from 6a17c6a to cfccea9 Compare August 28, 2026 09:40

@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 found no issues

No high-confidence issues detected in this change.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 28, 2026
…ader's import cache

delete require.cache[path] and mock.module() remove the ES module registry
entry of a module. When an import() of it is still loading, or when the path
import() was resolved to differs from the path the registry ended up with (an
onResolve chain), the loader's import cache kept pointing at the removed entry
and the next import() crashed in JSModuleLoader::loadModule (null loadPromise)
or in hostLoadImportedModule (null entry), or returned the stale module. The
fix is in JavaScriptCore (oven-sh/WebKit#472). The tests cover the
delete require.cache[] shape with the load held in flight by an async onLoad
plugin, the shape where a replacement load finishes before the removed one,
the onResolve chain shape with and without a replacement, and the
mock.module() shape.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 28, 2026
A removed registry entry's in-flight load no longer puts its record back into
the realm-level [[LoadedModules]] cache, and the cache fast path verifies the
registry entry it answers with. Fixes the null loadPromise crash in
JSModuleLoader::loadModule after delete require.cache[] or mock.module() of a
module whose import() was still loading (Sentry BUN-443N), and the null entry
crash and stale module when the cached key and the registry key differ.

Preview tag; it has to move to the merged main sha before this lands.
…-cache its record

Bun's removeEntry() (mock.module(), delete require.cache[key], plugin virtual
modules) drops a registry entry, and its m_loadedModules entry, while a load
of that entry can still be in flight. When that load completed,
finishLoadingImportedModule() put its record back into m_loadedModules. The
next top-level import of the key then took the [[LoadedModules]] fast path in
hostLoadImportedModule(), looked up the registry entry that now exists for the
key (a fresh one created by provideFetch(), or none), and returned its
loadPromise(), which is null. JSModuleLoader::loadModule() dereferenced it in
performPromiseThenWithInternalMicrotask().

finishLoadingImportedModule() now only caches a record in the realm-level map
while the registry still holds that record through a loaded entry. The
in-flight load still continues its payload, and a module's own
[[LoadedModules]] is still filled in. hostLoadImportedModule() checks the same
condition on a realm-level cache hit, and otherwise forgets the record and
loads what the registry holds.
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from cfccea9 to eaacac2 Compare August 28, 2026 14:06

@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 found no issues

No high-confidence issues detected in this change.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 28, 2026
…ader's import cache

delete require.cache[path] and mock.module() remove the ES module registry
entry of a module. When an import() of it is still loading, or when the path
import() was resolved to differs from the path the registry ended up with (an
onResolve chain), the loader's import cache kept pointing at the removed entry
and the next import() crashed in JSModuleLoader::loadModule (null loadPromise)
or in hostLoadImportedModule (null entry), or returned the stale module. The
fix is in JavaScriptCore (oven-sh/WebKit#472). The tests cover the
delete require.cache[] shape with the load held in flight by an async onLoad
plugin, the shape where a replacement load finishes before the removed one,
the onResolve chain shape with and without a replacement, and the
mock.module() shape.
robobun added a commit to oven-sh/bun that referenced this pull request Aug 28, 2026
A removed registry entry's in-flight load no longer puts its record back into
the realm-level [[LoadedModules]] cache, and the cache fast path verifies the
registry entry it answers with. Fixes the null loadPromise crash in
JSModuleLoader::loadModule after delete require.cache[] or mock.module() of a
module whose import() was still loading (Sentry BUN-443N), and the null entry
crash and stale module when the cached key and the registry key differ.

Preview tag; it has to move to the merged main sha before this lands.
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