Skip to content

JSModuleLoader: a top-level load stores its failure into the entry it loaded - #474

Open
robobun wants to merge 1 commit into
farm/ae0c7496/module-loader-stale-loaded-modulesfrom
farm/4ad8a864/module-loader-removed-entry-error
Open

JSModuleLoader: a top-level load stores its failure into the entry it loaded#474
robobun wants to merge 1 commit into
farm/ae0c7496/module-loader-stale-loaded-modulesfrom
farm/4ad8a864/module-loader-removed-entry-error

Conversation

@robobun

@robobun robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #472 (its branch is the base, so the diff here is only this commit). This PR's preview build contains both.

Upstream 319474@main ("Don't cache HTTP/network errors in the module map") arrived on main with the 8c4fd56 upgrade (#503). This PR used to carry it as a cherry-pick; it is now the fork residual on top of it.

Problem

  • removeEntry() (delete require.cache[], mock.module(), plugin modules) can remove an entry while a top-level load of it is in flight. When that load then fails, moduleLoadTopRejected and moduleLoadStoreError (JSMicrotask.cpp) store its error into whatever entry the key holds at that moment, getRegisteredMayBeNull(key). After a removal that is nothing, or the entry of a replacement load that registered the key in the meantime. In the second case the replacement, a module that loaded fine, rejects every later import() with the removed load's error. Before 319474@main it was worse (ensureRegistered(key), so the key was registered again with the error); JSModuleLoader: a removed registry entry's in-flight load must not re-cache its record #472 fixes the crash flavor of this window.
  • JSModuleLoader::loadModule() answers every later load of a key whose entry holds an error from entry->error(), so one wrongly stored error is permanent.

Fix

  • The two top-level ModuleLoadingContexts record the entry their load attaches to (setEntry()): moduleLoadTopSettled records the entry provideFetch() registered, the second loadModule overload records the entry it links and evaluates. The two error sites store into that entry.
  • Correct because the error is the outcome of loading that entry. The per-entry steps (moduleLoadStep, moduleRegistryFetchSettled) already store into the entry they hold. While the entry stays registered it is the one the key lookup finds, so nothing changes. Once removed, the error stays with the detached entry and the key belongs to whatever replaced it. A load that failed at its fetch has no entry and stores nothing, which is 319474@main's policy.
  • Gated on USE(BUN_JSC_ADDITIONS): only removeEntry() and clearAll() can make the key's entry differ from the load's.
  • Verified: the eight tests of Module loader: a removed module whose in-flight load fails no longer poisons later imports of it (WebKit bump for oven-sh/WebKit#474) bun#39711 fail on JSModuleLoader: a removed registry entry's in-flight load must not re-cache its record #472's engine and pass on this PR's preview build. With the upstream change alone, everything passes except the removal shape a replacement load finished first, which needs this commit. Notes below.

Background

  • The registry maps a key to a ModuleRegistryEntry: promises, record, one error. removeEntry() is a fork addition, so upstream's key lookup and this entry are the same thing there.
  • A top-level load is loadModule(specifier): it fetches, then moduleLoadTopSettled registers the key (provideFetch()) and calls the second overload, which loads, links and evaluates the entry. Each overload carries a ModuleLoadingContext through its microtasks. Those two contexts left m_entry empty until now; the per-dependency ones (hostLoadImportedModule) always held theirs.
Notes

Reproductions (Bun 1.4.0 and a Bun debug build against #472's preview; tests in oven-sh/bun#39711):

  • import() of a module whose dependency an async onLoad holds back, delete require.cache[module], release it, the module throws while it evaluates. Next import(): rejected with the old error, the plugin is not asked again (moduleLoadTopRejected). Fixed by 319474@main.
  • Same, but the dependency's onLoad throws (moduleLoadStoreError and moduleLoadTopRejected). Fixed by 319474@main.
  • Same as the first, but a replacement import() is awaited before the gate opens. A third import() is rejected with the removed load's error although the replacement loaded; with 319474@main alone the replacement's entry is the one getRegisteredMayBeNull() finds. Fixed by this commit only.
  • A static import of a module whose async onLoad is pending registers it as Fetching; an import() issued now shares that fetch. delete require.cache[], then the onLoad throws. Next import(): rejected with the old error (moduleLoadTopSettled's rejected branch). Fixed by 319474@main, which stores nothing for a rejected fetch; this commit keeps it that way (the context has no entry).
  • require() of a top-level-await module (Bun pre-registers the entry and the module keeps evaluating), delete require.cache[], import() it again, then the first run rejects. Before 319474@main the replacement import() never settled: the first run's moduleLoadStoreError registered the key before the replacement's moduleLoadTopSettled, so provideFetch() found an EvaluationFailed entry and did nothing. 319474@main fixes the observed order; this commit also covers the order where the replacement registers first.
  • Without a removal: a file whose build failed loads once it is fixed on disk (by import() and require(), also after a module that imports it failed), and a plugin onLoad that rejected is called again by the next import(). These pin 319474@main's policy as Bun sees it.

Local verification was done by recompiling the three affected unified-source bundles into the autobuild-preview-pr-472-4ee15452 linux debug-asan tarball, once with the upstream change alone (as a cherry-pick, identical to what the upgrade brought) and once with this commit on top, and linking a Bun debug build against each. The pass/fail split above comes from that; the pass-after was then repeated on the published preview builds of this PR. The files also compile with USE_BUN_JSC_ADDITIONS and USE_BUN_EVENT_LOOP set to 0. Against the full change, Bun's test/js/bun/resolve/, test/js/bun/plugin/, test/js/bun/test/mock/, test/js/node/module/ (including the registry concurrent-GC tests), test/cli/run/, test/cli/test/ and test/cli/test/isolation.test.ts pass except for tests that fail the same way on the unmodified engine on a debug-asan build.

Cost: one registry lookup per top-level load in moduleLoadTopSettled and one in the second loadModule overload, plus a write barrier each. m_entry already existed on the context and is already visited; a context keeps a removed entry alive until its load settles, as the per-dependency contexts already did.

Not changed: moduleLoadTopSettled still registers a fulfilled fetch by key (provideFetch()), so a removal during the fetch itself re-registers the pre-removal source; that is upstream's registration point and independent of the error sites. The FIXME in Bun's Bun__onFulfillAsyncModule (register before fetching) is where that would become consistent. #258 touches the same functions (it re-keys the lookups on the attribute string); the two lookups added here would take the request's parameters under it.

@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 error-storage semantics across several async boundaries in the module loader — a state machine whose invariants are subtle and where #472 just fixed the crash flavor of the same window — a human look from someone familiar with the loader would still be worthwhile.

What was reviewed:

  • topLevelLoadEntry() falls back to ensureRegistered() when no entry is recorded, so with USE(BUN_JSC_ADDITIONS) off the three call sites behave exactly as before.
  • setEntry() goes through WriteBarrier::set(vm, this, entry), and m_entry is already visited in ModuleLoadingContext::visitChildrenImpl, so the detached entry stays live until the context is collected.
  • The extra ensureRegistered() in moduleLoadTopSettled after provideFetch() returns the same entry provideFetch() just registered/found (both key on (specifier, type)), and the second-overload lookup is taken before hostLoadImportedModule() runs host code.
  • specifier/type locals in moduleLoadTopSettled (rejected branch) and moduleLoadStoreError are still read from context->moduleRequest() for the isEvaluationError() check, so replacing the entry lookup doesn't change error classification.
Extended reasoning...

Overview

This PR fixes a race in JSModuleLoader where a top-level load whose registry entry was removed via removeEntry() (Bun's delete require.cache[], mock.module()) while in flight would, on failure, store its error under the key again — either into a fresh entry or into a replacement load's entry — permanently poisoning subsequent imports. The fix records the specific ModuleRegistryEntry each top-level ModuleLoadingContext attaches to (setEntry()) and has the three error-storage sites (moduleLoadTopSettled rejected branch, moduleLoadTopRejected, moduleLoadStoreError) store into that entry via a new topLevelLoadEntry() helper instead of looking the key up again. Five files touched: JSMicrotask.cpp, JSModuleLoader.cpp/.h, ModuleLoadingContext.cpp/.h.

Security risks

None identified. This is internal module-loader bookkeeping; no user-controlled input is newly interpreted, no auth/crypto/permissions surface. The GC interaction (holding a detached entry alive via WriteBarrier) is handled correctly — m_entry was already a visited WriteBarrier field, and setEntry() uses the proper barrier.

Level of scrutiny

High. Module loading is a critical runtime code path exercised by every import() and require() in Bun. The change is small in line count but its correctness depends on invariants about which entry a load is "attached to" across multiple microtask hops, and on the ordering between provideFetch(), hostLoadImportedModule()'s host hooks, and the error-storage microtasks. The PR description argues these invariants very carefully and the author verified against five targeted tests plus controls, but this is exactly the kind of state-machine change where a second pair of eyes from someone who knows the loader is valuable.

Other factors

  • Stacked on #472 (which fixed the crash flavor of the same window); the diff here is only the delta.
  • All behavior changes are gated on USE(BUN_JSC_ADDITIONS); the topLevelLoadEntry() helper compiles for both configurations but with the flag off is a pure refactor of the previous ensureRegistered() call.
  • The PR description is unusually thorough — five reproduction cases, local verification methodology, explicit cost accounting, and a note about a related-but-unfixed window. This raises confidence but doesn't substitute for a reviewer who can independently confirm the loader invariants.
  • No prior review comments on this PR.

@robobun
robobun force-pushed the farm/4ad8a864/module-loader-removed-entry-error branch from b5b5142 to a668d5d Compare August 20, 2026 03:13
@robobun robobun changed the title JSModuleLoader: a removed registry entry's failing in-flight load must not register its error under the key again JSModuleLoader: a top-level load stores its failure into the entry it loaded (with a cherry-pick of upstream 319474@main) Aug 20, 2026
robobun added a commit to oven-sh/bun that referenced this pull request Aug 20, 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 this PR and didn't find any bugs. Because it combines an upstream cherry-pick with fork-specific changes to the module loader's error-storage semantics — a race-sensitive path where removeEntry() interacts with in-flight loads — a human look would still be worthwhile.

Checked: removeFailedFetchEntry takes cellLock() before mutating m_moduleMap, matching the concurrent-GC pattern in ensureRegistered/removeEntry. setEntry() uses WriteBarrier::set and m_entry was already visited by visitChildrenImpl, so the newly-held detached entry stays live. The dropped ThrowScope& params on moduleLoadTopRejected/moduleLoadStoreError are no longer read in either build configuration. The ensureRegistered call added in moduleLoadTopSettled's fulfilled branch runs right after provideFetch(), so it returns the same entry rather than allocating a second one.

Extended reasoning...

Overview

This PR modifies five files in JavaScriptCore's module loader: JSMicrotask.cpp (three microtask handlers: moduleLoadTopSettled, moduleLoadTopRejected, moduleLoadStoreError), JSModuleLoader.cpp/h (adds removeFailedFetchEntry, promotes getRegisteredMayBeNull to public, records the registry entry into the second loadModule overload's context), and ModuleLoadingContext.cpp/h (adds setEntry() under USE(BUN_JSC_ADDITIONS)). Commit 1 is a cherry-pick of upstream 319474@main that stops caching fetch/network errors in the module map. Commit 2 is the fork residual: top-level loads now record which ModuleRegistryEntry they attached to and store their failure into that entry rather than into whatever the key currently maps to, which matters when Bun's removeEntry() (delete require.cache[], mock.module()) drops an entry mid-load.

Security risks

None identified. There is no user-controlled input parsing, auth, or crypto here. The change is internal state management for module registry entries. The GC-visible mutations (m_moduleMap.remove, m_entry.set) follow the existing locking/write-barrier patterns.

Level of scrutiny

High. The module loader is a core JSC subsystem, and this change specifically targets a race window between removeEntry() and in-flight top-level loads. The correctness argument depends on the ordering of provideFetch(), ensureRegistered(), hostLoadImportedModule(), and the two error-storing microtasks across both the USE(BUN_JSC_ADDITIONS) and non-Bun builds. Commit 1 also changes observable behavior for all Bun users (failed fetches are re-fetched on the next import() rather than replaying the cached error), and the removal of the fork's #262 branch relies on the upstream rewrite subsuming it. These are design-bearing changes to a critical path that a maintainer familiar with #262/#472 and the Bun-side test PR should confirm.

Other factors

The PR description is very thorough — five reproduction cases, per-commit fix attribution, local debug-asan verification against #472's engine, and a compile check with USE_BUN_JSC_ADDITIONS=0. The bug hunting system found nothing. I verified the concurrent-GC lock discipline in removeFailedFetchEntry, that m_entry is already covered by visitChildrenImpl, and that dropping the ThrowScope& parameters is safe in both build configurations. Nonetheless, the combination of an upstream cherry-pick that changes caching policy, a fork-specific overlay on top of it, and the inherent subtlety of the removeEntry-while-loading race puts this outside what I'd approve without a human maintainer's look.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
56b8426b autobuild-preview-pr-474-56b8426b 2026-08-28 15:13:40 UTC
0e817363 autobuild-preview-pr-474-0e817363 2026-08-28 09:36:28 UTC
2cd2d81b autobuild-preview-pr-474-2cd2d81b 2026-08-28 01:56:49 UTC
6a94e344 autobuild-preview-pr-474-6a94e344 2026-08-27 17:26:43 UTC
42ea2195 autobuild-preview-pr-474-42ea2195 2026-08-26 23:56:52 UTC
3a8db01f autobuild-preview-pr-474-3a8db01f 2026-08-26 06:53:33 UTC
5e159d03 autobuild-preview-pr-474-5e159d03 2026-08-26 00:48:02 UTC
a0e12a58 autobuild-preview-pr-474-a0e12a58 2026-08-25 11:44:16 UTC
ff62d80c autobuild-preview-pr-474-ff62d80c 2026-08-24 13:38:21 UTC
0bddaf4d autobuild-preview-pr-474-0bddaf4d 2026-08-23 10:39:38 UTC
4875ff69 autobuild-preview-pr-474-4875ff69 2026-08-21 05:17:02 UTC
a668d5de autobuild-preview-pr-474-a668d5de 2026-08-20 04:08:40 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. Because it reworks error storage in the module loader's top-level load path (a cherry-pick plus fork-specific race handling around removeEntry()), a human look would still be worthwhile.

What was reviewed:

  • removeFailedFetchEntry takes cellLock() before mutating m_moduleMap, matching the pattern visitChildrenImpl and removeEntry require.
  • setEntry uses a proper write barrier and m_entry is already visited in visitChildrenImpl, so the detached entry stays live until the context is collected.
  • The dropped ThrowScope& parameters on moduleLoadTopRejected/moduleLoadStoreError are safe: the new bodies no longer call anything that can throw (ensureRegistered/error() are gone).
  • The ensureRegistered call after provideFetch in moduleLoadTopSettled looks up the same entry provideFetch just registered, so setEntry records the right one.
Extended reasoning...

Overview

This PR touches five files in JavaScriptCore's module loader: JSMicrotask.cpp, JSModuleLoader.{h,cpp}, and ModuleLoadingContext.{h,cpp}. It has two parts: (1) a cherry-pick of upstream WebKit 319474@main that stops caching fetch/network errors in the module map (adds removeFailedFetchEntry, promotes getRegisteredMayBeNull to public, and rewrites the error branches of moduleLoadTopSettled/moduleLoadTopRejected/moduleLoadStoreError to look up rather than register entries), and (2) a Bun-specific residual under USE(BUN_JSC_ADDITIONS) that has each top-level ModuleLoadingContext record the specific ModuleRegistryEntry its load is attached to via a new setEntry(), so that when the load fails the error is stored into that entry rather than into whatever entry the key resolves to at failure time.

Security risks

None identified. This is internal module-loading bookkeeping — no untrusted-input parsing, auth, or boundary crossing. The GC-facing pieces (write barrier in setEntry, cellLock() around the m_moduleMap.remove in removeFailedFetchEntry) follow the existing patterns in the file, and m_entry was already visited by ModuleLoadingContext::visitChildrenImpl.

Level of scrutiny

High. The module loader is a core, hot runtime path and this change alters error-caching semantics under a race that only exists because Bun's fork adds removeEntry(). The reasoning is subtle (which entry a key resolves to at each microtask boundary, when a context has vs. lacks an entry, how a rejected fetch differs from a rejected evaluation) and the correctness argument spans several functions across two files. The cherry-pick portion is upstream-verified, but the fork residual layers a different lookup strategy on top of it and removes the fork's earlier #262 branch on the grounds that upstream's rewrite subsumes it. This is exactly the kind of change where a maintainer familiar with the loader's history (#258, #262, #472) should confirm the interactions the description enumerates.

Other factors

The PR description is unusually thorough and documents eight specific test scenarios in oven-sh/bun#39711 that were verified against a locally patched engine, including which commit fixes each and two control cases. The preview build passed. No prior human reviews are on the PR. The change is stacked on #472 and interacts with #258's pending re-keying, which the author notes. Given the depth of the semantic change and the number of cross-PR interactions, deferring to a human reviewer is the right call even with no bugs found.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 21, 2026
robobun added a commit to oven-sh/bun that referenced this pull request Aug 21, 2026
robobun added a commit to oven-sh/bun that referenced this pull request Aug 22, 2026
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from 86b6c4d to 50b13c0 Compare August 23, 2026 07:47
@robobun
robobun force-pushed the farm/4ad8a864/module-loader-removed-entry-error branch from 4875ff6 to 0bddaf4 Compare August 23, 2026 09:57

@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 core module-loader error-caching semantics across microtask boundaries and interacts with the fork-only removeEntry() race window, a human look would still be worthwhile.

What was reviewed:

  • Cherry-pick fidelity: moduleLoadTopSettled's rejected branch, moduleLoadTopRejected, moduleLoadStoreError, removeFailedFetchEntry, and the loadModule FetchFailed drop match upstream 319474@main's shape.
  • GC safety of setEntry(): m_entry is a WriteBarrier already visited by visitChildrenImpl, and removeFailedFetchEntry takes cellLock() before mutating m_moduleMap.
  • The ensureRegistered call added in moduleLoadTopSettled runs after provideFetch(), so it returns the same entry rather than creating a stray one.
  • Dropped ThrowScope& params on moduleLoadTopRejected/moduleLoadStoreError — the remaining bodies no longer call anything that needs exception checking.
Extended reasoning...

Overview

This PR touches five files in JSC's runtime module loader: JSMicrotask.cpp, JSModuleLoader.{h,cpp}, and ModuleLoadingContext.{h,cpp}. It is two commits: (1) a cherry-pick of upstream WebKit 319474@main ("Don't cache HTTP/network errors in the module map"), which rewrites how moduleLoadTopSettled/moduleLoadTopRejected/moduleLoadStoreError register errors and adds removeFailedFetchEntry(); (2) a Bun-specific residual under USE(BUN_JSC_ADDITIONS) that records the ModuleRegistryEntry a top-level load attaches to on its ModuleLoadingContext (setEntry()), so that when the load fails its error is stored into that entry rather than whatever entry the key currently maps to. This closes a race where removeEntry() (via delete require.cache[], mock.module(), plugins) drops an entry mid-load and a replacement load's entry then wrongly inherits the removed load's error.

Security risks

None identified. The change is confined to error-caching bookkeeping in the module registry. There is no new parsing, no user-controlled input handling beyond what already existed, and no auth/crypto/permissions surface. The GC-facing additions use the existing WriteBarrier<ModuleRegistryEntry> m_entry field (already visited) and take cellLock() where the map is mutated, matching the surrounding pattern.

Level of scrutiny

High. This is core engine code on the hot path of every ES module import in Bun. The correctness argument depends on subtle ordering across microtasks (fetch → moduleLoadTopSettled → second loadModule overload → moduleLoadStoreError/moduleLoadTopRejected), on the fork-only removeEntry() semantics, and on the interaction with #472 (this PR's base). The upstream cherry-pick also changes user-visible behavior for Bun (failed fetches are now retried on the next import() instead of replaying a cached error), which is a semantic shift worth a maintainer's sign-off even though it aligns with the HTML spec.

Other factors

The PR description is unusually thorough — it enumerates five reproduction shapes in oven-sh/bun#39711, states which commit fixes each, and reports local verification against Bun's resolve/plugin/mock/module test suites plus the concurrent-GC registry tests. The bug-hunting pass found nothing. The added ensureRegistered in moduleLoadTopSettled immediately follows provideFetch() (which itself calls ensureRegistered), so it cannot create a divergent entry; the second loadModule overload reads the entry before hostLoadImportedModule() runs host code, per its comment. Despite all this, the change is neither simple nor mechanical, it is stacked on another open PR, and it alters error-caching policy that every module load depends on — so I'm deferring rather than approving.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 23, 2026
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from 50b13c0 to 22dfd70 Compare August 24, 2026 11:17
@robobun
robobun force-pushed the farm/4ad8a864/module-loader-removed-entry-error branch from 0bddaf4 to ff62d80 Compare August 24, 2026 13:05

@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 the automated bug hunt found no issues. Because it changes error-storage semantics in the module loader's microtask chain and interacts with the fork's removeEntry() race window, a human reviewer familiar with the loader should also take a look.

What was reviewed:

  • The cherry-picked getRegisteredMayBeNull/removeFailedFetchEntry paths against upstream 319474@main's shape.
  • setEntry() GC safety — m_entry is already visited in ModuleLoadingContext::visitChildrenImpl, and the setter uses a write barrier.
  • removeFailedFetchEntry() locking — takes cellLock() before mutating m_moduleMap, and guards against the entry no longer being the map's value.
  • Unused-variable fallout in the !USE(BUN_JSC_ADDITIONS) branch of moduleLoadStoreErrorspecifier/type are still consumed by getRegisteredMayBeNull there.
Extended reasoning...

Overview

The PR touches five files in JSC's module loader: JSMicrotask.cpp (three microtask handlers rewritten), JSModuleLoader.{h,cpp} (new removeFailedFetchEntry(), getRegisteredMayBeNull() made public, loadModule overloads adjusted), and ModuleLoadingContext.{h,cpp} (new setEntry() mutator). It combines a cherry-pick of upstream 319474@main (stop caching fetch/network errors in the module map) with a fork-specific residual under USE(BUN_JSC_ADDITIONS) that makes the two top-level load contexts remember the specific ModuleRegistryEntry they attached to, so that a later failure is stored into that entry rather than whatever the key resolves to after a concurrent removeEntry().

Security risks

No direct security surface — this is internal error-propagation bookkeeping. The GC-relevant additions (WriteBarrier<ModuleRegistryEntry> m_entry mutation via setEntry()) reuse an existing visited field with a proper write barrier, and removeFailedFetchEntry() takes the cell lock before mutating m_moduleMap, matching the pattern used by ensureRegistered() and removeEntry(). I did not spot UAF or missing-barrier concerns.

Level of scrutiny

High. The module loader's microtask chain is race-sensitive and load-bearing for every ES module import in Bun. The change alters when and where errors are cached (an observable behavioral change: failed fetches now retry on the next import()), removes the fork's #262 branch, and threads entry identity through two contexts that previously left it null. The reasoning in the PR description is careful and the reproduction matrix is detailed, but the correctness argument hinges on ordering between provideFetch(), ensureRegistered(), removeEntry(), and the microtask that later reads context->entry() — exactly the kind of invariant a maintainer who owns this code should sign off on.

Other factors

  • Stacked on #472; the base PR should land first or together.
  • The upstream half will also arrive via a future upgrade merge, so a reviewer should confirm the cherry-pick matches upstream to avoid a future conflict.
  • No prior human or bot reviews on the thread; this is the first pass.
  • Tests exist in the companion Bun PR (oven-sh/bun#39711) rather than in this repo, so verification depends on that external suite.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 24, 2026
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from 22dfd70 to daf9f5f Compare August 25, 2026 09:28
@robobun
robobun force-pushed the farm/4ad8a864/module-loader-removed-entry-error branch from ff62d80 to a0e12a5 Compare August 25, 2026 11:13
@robobun robobun changed the title JSModuleLoader: a top-level load stores its failure into the entry it loaded (with a cherry-pick of upstream 319474@main) JSModuleLoader: a top-level load stores its failure into the entry it loaded Aug 25, 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 this PR and didn't find any bugs. Because it changes error-storage semantics in the module loader's top-level load path — subtle ordering between in-flight loads, removeEntry(), and detached-entry lifetime — a human look would still be worthwhile.

What was reviewed:

  • setEntry() uses WriteBarrier::set correctly and m_entry is already visited in visitChildrenImpl, so the detached entry stays live until the context dies.
  • moduleLoadTopSettled's ensureRegistered runs immediately after provideFetch() with no host code in between, so it records the same entry provideFetch registered.
  • specifier/type remain used in moduleLoadStoreError's Bun path (for isEvaluationError); in moduleLoadTopRejected they are correctly scoped to the #else branch.
  • The second loadModule overload reads the entry before hostLoadImportedModule() (which can run host resolve()), matching the comment's intent.
Extended reasoning...

Overview

This PR changes how top-level module loads store their failure. Previously, moduleLoadTopRejected and moduleLoadStoreError looked up the registry entry by key at the moment of failure via getRegisteredMayBeNull(key). Under Bun's removeEntry() (delete require.cache[], mock.module()), that key may by then point to nothing or to a replacement load's entry, so a removed load's error could poison an unrelated replacement. The fix records the entry the load actually attaches to on its ModuleLoadingContext (via a new setEntry()) at the point of registration, and the error sites store into that recorded entry instead. All changes are gated behind USE(BUN_JSC_ADDITIONS).

Files touched: JSMicrotask.cpp (the two error-storing microtask handlers and the moduleLoadTopSettled registration point), JSModuleLoader.cpp (second loadModule overload records the entry before hostLoadImportedModule), ModuleLoadingContext.{h,cpp} (new setEntry method), and a comment update in JSModuleLoader.h's removeEntry().

Security risks

None identified. This is internal engine bookkeeping for module-loader error caching; no auth, crypto, or external input handling. The GC-lifetime change (a context keeps a detached entry alive until the load settles) matches what per-dependency contexts already do, and m_entry was already a visited WriteBarrier field.

Level of scrutiny

High. This is core JSC module-loader infrastructure with subtle invariants about microtask ordering, registry mutation during in-flight loads, and GC visitation. It is stacked on #472 and interacts with upstream 319474@main. The author has done thorough verification (eight targeted tests in oven-sh/bun#39711, debug-asan builds, concurrent-GC registry tests, and a compile check with the Bun flags off), and the reasoning in the description is precise, but the change is neither mechanical nor obvious — it depends on understanding exactly when each microtask fires relative to removeEntry() and replacement loads.

Other factors

  • The change is small and well-commented, and m_entry already existed on the context (it was only unused for the top-level create() overload), so no new GC plumbing was required.
  • I checked that ensureRegistered in moduleLoadTopSettled cannot see a different entry than provideFetch() registered (no yield or host callout between them), that setEntry fires the write barrier, and that the specifier/type locals remain correctly scoped/used under both preprocessor branches.
  • No prior human review or comments on the PR; only the preview-build bot has posted.
  • Given the criticality of the module loader and the subtlety of the concurrent-removal window this fixes, I'm deferring rather than approving.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 25, 2026
robobun added a commit to oven-sh/bun that referenced this pull request Aug 26, 2026
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from 319c644 to 084e853 Compare August 26, 2026 04:49
@robobun
robobun force-pushed the farm/4ad8a864/module-loader-removed-entry-error branch from 5e159d0 to 3a8db01 Compare August 26, 2026 06:16

@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
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from 084e853 to 18461ab Compare August 26, 2026 21:23
@robobun
robobun force-pushed the farm/4ad8a864/module-loader-removed-entry-error branch from 3a8db01 to 42ea219 Compare August 26, 2026 23:24

@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
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from 18461ab to 3580958 Compare August 27, 2026 09:48
@robobun
robobun force-pushed the farm/4ad8a864/module-loader-removed-entry-error branch from 42ea219 to 6a94e34 Compare August 27, 2026 16:51

@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
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch from 030d206 to d3deb97 Compare August 27, 2026 23:23
@robobun
robobun force-pushed the farm/4ad8a864/module-loader-removed-entry-error branch from 6a94e34 to 2cd2d81 Compare August 28, 2026 00:39

@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
@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch 2 times, most recently from f7cb3cb to 6a17c6a Compare August 28, 2026 08:13
@robobun
robobun force-pushed the farm/4ad8a864/module-loader-removed-entry-error branch 2 times, most recently from b38262c to 0e81736 Compare August 28, 2026 08: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.

This pull request has now been reviewed several times and this review found new issues. Before patching these one by one, step back: would one root-cause fix close several of them? Is the pull request's scope growing with each push? Prefer root-cause fixes, keep scope fixed, and note out-of-scope improvements as follow-ups.

// The entry the key holds now, registered by provideFetch() just above or
// by provideModule() before the load, is the one the loadModule below
// loads; moduleLoadTopRejected stores into it.
context->setEntry(vm, globalObject->moduleLoader()->ensureRegistered(globalObject, specifier, type));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 ensureRegistered() re-registers a removed provideModule() key with a fresh Status::New entry, tripping the hostLoadImportedModule assert

Extended reasoning...

In the Bun provideModule() path, the outer context's entry is now recorded via ensureRegistered(specifier, type) after the !inherits<AbstractModuleRecord>() guard closes but before the inner loadModule call. Unlike the sibling read this PR adds at JSModuleLoader.cpp:852 (getRegisteredMayBeNull + null-check at :860-861), ensureRegistered() creates and inserts a brand-new Status::New entry when the key is absent. That happens whenever an embedder-preregistered entry (provideModule → status Fetched, so ensureFetchPromise fulfilled the fetch promise with the record) is removeEntry()'d in the window between the first loadModule overload scheduling ModuleLoadTopSettled and this microtask running. The freshly-inserted New entry is then found by hostLoadImportedModule (either via the symbol fast path at JSModuleLoader.cpp:674-675 or via the resolved-key lookup at :721) and hits ASSERT(mapEntry->status() != ModuleRegistryEntry::Status::New) at JSModuleLoader.cpp:727. On the base branch nothing was registered here, so hostLoadImportedModule fell through to the…

Verification: normal — the new ensureRegistered() call can insert a Status::New entry that the immediately-following hostLoadImportedModule then finds and asserts against; the base branch inserted nothing at this point. Trigger (provideModule path with removal during the fetch window): 1. Bun pre-registers key K via provideModule() (entry status Fetched, has a record) — the PR body itself cites this…

@robobun
robobun force-pushed the farm/ae0c7496/module-loader-stale-loaded-modules branch 2 times, most recently from cfccea9 to eaacac2 Compare August 28, 2026 14:06
@robobun
robobun force-pushed the farm/4ad8a864/module-loader-removed-entry-error branch from 0e81736 to ed2d1c8 Compare August 28, 2026 14:09
… loaded

moduleLoadTopRejected and moduleLoadStoreError look the entry up by key
when a top-level load (import(), require(esm), the entry point) fails.
The host can remove that entry while the load is in flight
(removeEntry(): delete require.cache[], mock.module(), plugin modules),
and a replacement load of the key can register a new entry before the
removed load fails. The key lookup then stores the removed load's error
into the replacement's entry, and every later import() of the key
rejects with it although the replacement loaded fine. A load whose
fetch was rejected could likewise store its rejection into an entry that
a concurrent load of the key had registered in the meantime.

The two top-level ModuleLoadingContexts now record the entry their load
attaches to: moduleLoadTopSettled records the entry provideFetch()
registered, and the second loadModule overload records the entry it
links and evaluates. The two error sites store into that entry, the way
the per-entry steps (moduleLoadStep, moduleRegistryFetchSettled) already
store into the entry they hold. While the entry is registered this is
the entry the key lookup found, so nothing changes. Once it is removed,
the error stays with the detached entry. A load that failed at its fetch
has no entry and stores nothing; moduleLoadTopSettled registers nothing
for such a load since 319474@main.

Under USE(BUN_JSC_ADDITIONS): only removeEntry() and clearAll() can
make the key's entry differ from the load's.
@robobun
robobun force-pushed the farm/4ad8a864/module-loader-removed-entry-error branch from ed2d1c8 to 56b8426 Compare August 28, 2026 14:10

@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 new issues

No new issues were found in this update; 1 finding from earlier reviews is still open above.

robobun added a commit to oven-sh/bun that referenced this pull request Aug 28, 2026
robobun added a commit to oven-sh/bun that referenced this pull request Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant