Skip to content

innerModuleEvaluation 12.b.v: replace watermark heuristic with referrer asyncEvaluationOrder identity#254

Merged
Jarred-Sumner merged 1 commit into
mainfrom
claude/tla-referrer-async-order
Jun 16, 2026
Merged

innerModuleEvaluation 12.b.v: replace watermark heuristic with referrer asyncEvaluationOrder identity#254
Jarred-Sumner merged 1 commit into
mainfrom
claude/tla-referrer-async-order

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Supersedes #228.

The previous skip used three local discriminators (depWasAlreadyEvaluatingAsync, order < asyncOrderWatermark, pendingAsyncDependencies == 0) to guess whether an EvaluatingAsync dep was the dynamic-import referrer (self-deadlock — must skip) or an unrelated TLA sibling (must wait). The same predicate matched the @lexical/react shape (oven-sh/bun#30634): two Promise.all'd dynamic imports sharing a TLA wrapper, where skipping the wait runs the second consumer with the wrapper's post-await exports still in TDZ.

What

Replace the heuristic with an exact identity check. The host hook captures the referrer's asyncEvaluationOrder() (a VM-unique int64_t, set at step 12.b before ExecuteAsyncModule) and threads it through ModuleLoadingContextModuleLoaderPayloadcontinueDynamicImportdynamicImportLoadSettledevaluate()innerModuleEvaluation. At 12.b.v: skip iff cyclic->asyncEvaluationOrder().order() == referrerAsyncOrder. -1 (no TLA referrer / static entry) never matches. No new GC edge — the int64 is the only added state; the target module rides the existing microtask cell slot.

Adds JSModuleLoader::asyncEvaluationOrderForKey() so the embedder can do the registry lookup without coupling it to the resolve() referrer. Adds int64_t referrerAsyncOrder = -1 param to JSC::importModule / requestImportModule / first loadModule overload.

Drops asyncOrderWatermark, depWasAlreadyEvaluatingAsync, and VM::moduleAsyncEvaluationCount() (only consumer was the removed heuristic).

Scope vs the old heuristic

Matches only when import() is lexically inside the TLA referrer. import() from a helper file called by the TLA continuation, and transitive A→import(B)import(C)→A chains, now follow spec/Node behaviour (unsettled TLA) — these were caught by the old heuristic but are indistinguishable from WebKit#30634 without the referrer identity. Registry-key misses (referrer with query string, non-JS type) degrade to -1 (spec behaviour); they cannot reintroduce the TDZ crash.

Tests (Bun-side, oven-sh/bun claude/tla-referrer-async-order)

test/js/bun/resolve/dynamic-import-tla-cycle.test.ts — all 6 pass:

…er asyncEvaluationOrder identity

The previous skip used three local discriminators (depWasAlreadyEvaluatingAsync,
order < asyncOrderWatermark, pendingAsyncDependencies == 0) to guess whether an
EvaluatingAsync dep was the dynamic-import referrer (self-deadlock — must skip)
or an unrelated TLA sibling (must wait). The same predicate matched the
@lexical/react shape (oven-sh/bun#30634): two Promise.all'd dynamic imports
sharing a TLA wrapper, where skipping the wait runs the second consumer with the
wrapper's post-await exports still in TDZ.

Replace the heuristic with an exact identity check. The host hook captures the
referrer's asyncEvaluationOrder() (a VM-unique int64, set at step 12.b before
ExecuteAsyncModule) and threads it through ModuleLoadingContext ->
ModuleLoaderPayload -> continueDynamicImport -> dynamicImportLoadSettled ->
evaluate() -> innerModuleEvaluation. At 12.b.v: skip iff
cyclic->asyncEvaluationOrder().order() == referrerAsyncOrder. -1 (no TLA
referrer / static entry) never matches.

Adds JSModuleLoader::asyncEvaluationOrderForKey() so the embedder can do the
registry lookup without coupling it to the resolve() referrer. Adds
referrerAsyncOrder param to JSC::importModule / requestImportModule / loadModule
(first overload). dynamicImportLoadSettled now receives (module, payload) so the
order survives the microtask hop without a new GC field.

Drops asyncOrderWatermark, depWasAlreadyEvaluatingAsync, and
VM::moduleAsyncEvaluationCount() (only consumer was the removed heuristic).

Scope vs the old heuristic: matches only when import() is lexically inside the
TLA referrer. import() from a helper file called by the TLA continuation, and
transitive A->import(B)->import(C)->A chains, now follow spec/Node behaviour
(unsettled TLA). Registry-key misses (query string, non-JS type) degrade to -1
(spec behaviour); they cannot reintroduce the TDZ crash.
@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Under USE(BUN_JSC_ADDITIONS), an int64_t referrerAsyncOrder parameter is threaded from importModule/loadModule through ModuleLoadingContext, ModuleLoaderPayload, JSMicrotask dispatch, and into CyclicModuleRecord::evaluate and AbstractModuleRecord::innerModuleEvaluation. The prior multi-clause async-wait watermark condition in the cyclic evaluation path is replaced with a direct equality check against the referrer's async evaluation order. VM::incrementModuleAsyncEvaluationCount() replaces the old const getter. continueDynamicImport is refactored to take ModuleLoaderPayload* instead of a separate promise and deferred boolean.

Changes

referrerAsyncOrder propagation through JSC module pipeline

Layer / File(s) Summary
Data carrier types: ModuleLoaderPayload, ModuleLoadingContext, VM counter
Source/JavaScriptCore/runtime/VM.h, Source/JavaScriptCore/runtime/ModuleLoaderPayload.h, Source/JavaScriptCore/runtime/ModuleLoaderPayload.cpp, Source/JavaScriptCore/runtime/ModuleLoadingContext.h, Source/JavaScriptCore/runtime/ModuleLoadingContext.cpp
ModuleLoaderPayload and ModuleLoadingContext gain m_referrerAsyncOrder storage, a referrerAsyncOrder() accessor, and updated constructor/factory signatures under USE(BUN_JSC_ADDITIONS). VM replaces the const moduleAsyncEvaluationCount() getter with a post-increment incrementModuleAsyncEvaluationCount() method.
Public API signature updates
Source/JavaScriptCore/runtime/AbstractModuleRecord.h, Source/JavaScriptCore/runtime/CyclicModuleRecord.h, Source/JavaScriptCore/runtime/Completion.h, Source/JavaScriptCore/runtime/JSModuleLoader.h
evaluate, innerModuleEvaluation, importModule, loadModule, requestImportModule, and asyncEvaluationOrderForKey declarations add the optional int64_t referrerAsyncOrder parameter. continueDynamicImport is re-declared to accept ModuleLoaderPayload* instead of JSPromise* plus a bool deferred.
JSModuleLoader implementation wiring
Source/JavaScriptCore/runtime/JSModuleLoader.cpp, Source/JavaScriptCore/runtime/Completion.cpp
loadModule and requestImportModule forward referrerAsyncOrder into ModuleLoadingContext::create. continueDynamicImport is refactored to source promise capability and deferred behavior from ModuleLoaderPayload*. finishLoadingImportedModule switches to the payload-based continuation. asyncEvaluationOrderForKey is added to retrieve cyclic module async order by registry key.
JSMicrotask dispatch propagation
Source/JavaScriptCore/runtime/JSMicrotask.cpp
moduleLoadTopSettled passes context->referrerAsyncOrder() into ModuleLoaderPayload::create. dynamicImportLoadSettled remaps microtask arguments to extract AbstractModuleRecord and ModuleLoaderPayload, sources capabilityPromise from the payload, and calls module->evaluate with dynamicPayload->referrerAsyncOrder() on both the eager and deferred-dependency paths.
Core cyclic evaluation logic
Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp, Source/JavaScriptCore/runtime/CyclicModuleRecord.cpp
CyclicModuleRecord::evaluate and AbstractModuleRecord::evaluate accept and forward referrerAsyncOrder into innerModuleEvaluation. The recursive call site is updated. The async-wait condition (spec step 12.b.v) replaces the prior multi-clause watermark check with a single equality test: cyclic->asyncEvaluationOrder().order() != referrerAsyncOrder.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive The PR description is comprehensive and well-structured, explaining the problem, solution, scope, and test coverage. However, it does not follow the WebKit template format requiring a Bugzilla reference and structured changelog. Consider adding a Bugzilla bug ID and restructuring the description to match the WebKit template with bug reference, reviewer line, and formatted changelog sections.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately captures the main change: replacing a heuristic-based approach in innerModuleEvaluation step 12.b.v with an exact identity check using referrer asyncEvaluationOrder.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

Review ran into problems

🔥 Problems

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 and usage tips.

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp (1)

1309-1332: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Preserve the original dependency order before rewriting to the cycle root.

Line 1310 replaces cyclic with requiredModule->cycleRoot() before Line 1332 compares async orders. If import() runs before the referrer’s SCC is popped, the captured value can be the referrer module’s own order, not the later cycle-root order; this check then misses the exact referrer and still appends an async parent, leaving the dynamic import waiting on its own TLA chain.

Proposed adjustment
             // 12.b.i. Assert: requiredModule.[[Status]] is one of EVALUATING, EVALUATING-ASYNC, or EVALUATED.
             ASSERT(cyclic->status() == Status::Evaluating || cyclic->status() == Status::EvaluatingAsync || cyclic->status() == Status::Evaluated);
+#if USE(BUN_JSC_ADDITIONS)
+            bool dependencyMatchesReferrerAsyncOrder = cyclic->asyncEvaluationOrder().hasOrder()
+                && cyclic->asyncEvaluationOrder().order() == referrerAsyncOrder;
+#endif
             // 12.b.ii. Assert: requiredModule.[[Status]] is EVALUATING if and only if stack contains requiredModule.
             ASSERT(stack.contains(requiredModule) == (cyclic->status() == Status::Evaluating));
@@
                 // happen to be EvaluatingAsync (`#30259`, `#30634`) never match.
-                if (cyclic->asyncEvaluationOrder().order() != referrerAsyncOrder) {
+                if (!dependencyMatchesReferrerAsyncOrder && cyclic->asyncEvaluationOrder().order() != referrerAsyncOrder) {
 `#endif`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp` around lines 1309 -
1332, The comparison of asyncEvaluationOrder with referrerAsyncOrder at line
1332 should use the original requiredModule's async order, not the cycle root's
order, because referrerAsyncOrder was captured at the import() call site based
on the original referrer module. Currently, the code overwrites cyclic with
requiredModule->cycleRoot() at line 1310 before the comparison, causing a
mismatch when the cycleRoot differs from the original module. Preserve the
original requiredModule's asyncEvaluationOrder value before assigning cyclic to
requiredModule->cycleRoot(), then use the preserved original order value in the
comparison on line 1332 instead of cyclic->asyncEvaluationOrder().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Source/JavaScriptCore/runtime/Completion.cpp`:
- Around line 283-290: The importModule function currently exposes the
referrerAsyncOrder parameter publicly without any Bun-specific guards, even
though this parameter is only consumed by Bun-gated evaluation paths. Guard the
function declaration or create separate overloads using USE(BUN_JSC_ADDITIONS)
preprocessor conditionals to ensure the version with the referrerAsyncOrder
parameter is only exposed in Bun builds, preventing non-Bun JSC API/ABI from
carrying Bun-specific state. This ensures consistency with the coding guideline
that Bun-specific features in Source/JavaScriptCore must be guarded with
USE(BUN_JSC_ADDITIONS).

In `@Source/JavaScriptCore/runtime/JSModuleLoader.cpp`:
- Around line 1099-1103: The helper in this code block returns the order of the
leaf referrer record without normalizing it to the async SCC cycle root, causing
order comparisons in innerModuleEvaluation to fail when non-root TLA modules
resume. After the dynamicDowncast<CyclicModuleRecord> succeeds, normalize the
cyclic module to its cycle root (by calling cycleRoot() on it) before retrieving
the asyncEvaluationOrder, ensuring the returned order matches what will be
compared against requiredModule->cycleRoot() downstream.

---

Outside diff comments:
In `@Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp`:
- Around line 1309-1332: The comparison of asyncEvaluationOrder with
referrerAsyncOrder at line 1332 should use the original requiredModule's async
order, not the cycle root's order, because referrerAsyncOrder was captured at
the import() call site based on the original referrer module. Currently, the
code overwrites cyclic with requiredModule->cycleRoot() at line 1310 before the
comparison, causing a mismatch when the cycleRoot differs from the original
module. Preserve the original requiredModule's asyncEvaluationOrder value before
assigning cyclic to requiredModule->cycleRoot(), then use the preserved original
order value in the comparison on line 1332 instead of
cyclic->asyncEvaluationOrder().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 28ffffe5-755d-4a0c-84b0-8be8f6c40501

📥 Commits

Reviewing files that changed from the base of the PR and between 09f04cd and 061533f.

📒 Files selected for processing (14)
  • Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp
  • Source/JavaScriptCore/runtime/AbstractModuleRecord.h
  • Source/JavaScriptCore/runtime/Completion.cpp
  • Source/JavaScriptCore/runtime/Completion.h
  • Source/JavaScriptCore/runtime/CyclicModuleRecord.cpp
  • Source/JavaScriptCore/runtime/CyclicModuleRecord.h
  • Source/JavaScriptCore/runtime/JSMicrotask.cpp
  • Source/JavaScriptCore/runtime/JSModuleLoader.cpp
  • Source/JavaScriptCore/runtime/JSModuleLoader.h
  • Source/JavaScriptCore/runtime/ModuleLoaderPayload.cpp
  • Source/JavaScriptCore/runtime/ModuleLoaderPayload.h
  • Source/JavaScriptCore/runtime/ModuleLoadingContext.cpp
  • Source/JavaScriptCore/runtime/ModuleLoadingContext.h
  • Source/JavaScriptCore/runtime/VM.h
💤 Files with no reviewable changes (1)
  • Source/JavaScriptCore/runtime/VM.h

Comment on lines +283 to +290
JSPromise* importModule(JSGlobalObject* globalObject, const Identifier& moduleName, const Identifier& referrer, RefPtr<ScriptFetchParameters> parameters, RefPtr<ScriptFetcher> scriptFetcher, bool deferred, int64_t referrerAsyncOrder)
{
VM& vm = globalObject->vm();
JSLockHolder lock(vm);
RELEASE_ASSERT(vm.atomStringTable() == Thread::currentSingleton().atomStringTable());
RELEASE_ASSERT(!vm.isCollectorBusyOnCurrentThread());

return globalObject->moduleLoader()->requestImportModule(globalObject, moduleName, referrer, WTF::move(parameters), WTF::move(scriptFetcher), deferred);
return globalObject->moduleLoader()->requestImportModule(globalObject, moduleName, referrer, WTF::move(parameters), WTF::move(scriptFetcher), deferred, referrerAsyncOrder);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Keep the Bun-only async-order parameter behind the Bun flag.

referrerAsyncOrder is only consumed by the Bun-gated evaluation path, but this public JSC::importModule wrapper now exposes and forwards it for every build. Please keep the extra parameter/overload under USE(BUN_JSC_ADDITIONS) consistently through the loader API so non-Bun JSC API/ABI does not carry Bun-specific state.

As per coding guidelines, "Source/JavaScriptCore/**/*.{cpp,h}: Guard Bun-specific features with USE(BUN_JSC_ADDITIONS) and event-loop integration with USE(BUN_EVENT_LOOP)."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Source/JavaScriptCore/runtime/Completion.cpp` around lines 283 - 290, The
importModule function currently exposes the referrerAsyncOrder parameter
publicly without any Bun-specific guards, even though this parameter is only
consumed by Bun-gated evaluation paths. Guard the function declaration or create
separate overloads using USE(BUN_JSC_ADDITIONS) preprocessor conditionals to
ensure the version with the referrerAsyncOrder parameter is only exposed in Bun
builds, preventing non-Bun JSC API/ABI from carrying Bun-specific state. This
ensures consistency with the coding guideline that Bun-specific features in
Source/JavaScriptCore must be guarded with USE(BUN_JSC_ADDITIONS).

Source: Coding guidelines

Comment on lines +1099 to +1103
auto* cyclic = dynamicDowncast<CyclicModuleRecord>(entry->record());
if (!cyclic)
return -1;
auto order = cyclic->asyncEvaluationOrder();
return order.hasOrder() ? order.order() : -1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize resumed referrers to the async SCC cycle root.

innerModuleEvaluation compares the order after 12.b.iv rewrites an EvaluatingAsync dependency to requiredModule->cycleRoot(), but this helper returns the leaf referrer record’s order. A non-root TLA module that resumes and calls import() can therefore pass an order that never matches the later cycle-root check, so the dynamic import may still wait on its own async SCC.

Proposed adjustment
     auto* cyclic = dynamicDowncast<CyclicModuleRecord>(entry->record());
     if (!cyclic)
         return -1;
+    if (auto* cycleRoot = cyclic->cycleRoot(); cycleRoot && cycleRoot->asyncEvaluationOrder().hasOrder())
+        cyclic = cycleRoot;
     auto order = cyclic->asyncEvaluationOrder();
     return order.hasOrder() ? order.order() : -1;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Source/JavaScriptCore/runtime/JSModuleLoader.cpp` around lines 1099 - 1103,
The helper in this code block returns the order of the leaf referrer record
without normalizing it to the async SCC cycle root, causing order comparisons in
innerModuleEvaluation to fail when non-root TLA modules resume. After the
dynamicDowncast<CyclicModuleRecord> succeeds, normalize the cyclic module to its
cycle root (by calling cycleRoot() on it) before retrieving the
asyncEvaluationOrder, ensuring the returned order matches what will be compared
against requiredModule->cycleRoot() downstream.

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

No bugs found, but this redesigns the TLA self-deadlock heuristic with an acknowledged scope tradeoff (helper-file / transitive chains now hang per spec instead of being caught), threads new state through the whole dynamic-import pipeline, and reorders the DynamicImportLoadSettled microtask args in a way that affects the non-Bun build too — worth a human pass on the design call and the upstream-shared changes.

Extended reasoning...

Overview

Replaces the three-predicate watermark heuristic at innerModuleEvaluation 12.b.v with an exact identity check against the dynamic-import referrer's asyncEvaluationOrder(). To get that identity to the comparison site, an int64_t referrerAsyncOrder is threaded through JSC::importModulerequestImportModuleloadModuleModuleLoadingContextModuleLoaderPayloadcontinueDynamicImportdynamicImportLoadSettledevaluate()innerModuleEvaluation. continueDynamicImport now takes the ModuleLoaderPayload* directly (instead of unpacked promise+deferred), and the DynamicImport{,Defer}LoadSettled microtask argument layout is swapped so arguments[0] is the module record and arguments[2] is the payload. Adds the exported helper asyncEvaluationOrderForKey() and drops VM::moduleAsyncEvaluationCount().

Security risks

None apparent. The added state is a plain int64_t; no new GC edges, no user-controlled input reaching the comparison. Registry-key misses degrade to -1 (spec behaviour). Nothing touches auth/crypto/permissions.

Level of scrutiny

High. This is core ECMA-262 module evaluation machinery and an intentional spec deviation whose semantics are being narrowed. The PR description explicitly calls out that two patterns the old heuristic caught (import() from a helper called by the TLA continuation; transitive A→import(B)→import(C)→A) now follow spec/Node behaviour, i.e. unsettled TLA. That is a deliberate behavior change a human should sign off on. The microtask argument reordering and the continueDynamicImport signature change are not behind USE(BUN_JSC_ADDITIONS), so they affect the upstream build path as well and need to stay in lockstep with both DynamicImportLoadSettled and DynamicImportDeferLoadSettled consumers (they do, from what I checked, but it's the kind of thing a maintainer should eyeball).

Other factors

Supersedes #228; tests live in a companion Bun branch rather than in this repo. 14 files changed across the loader pipeline, plus an exported-API signature addition — well outside the "simple/mechanical" bar for auto-approval.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
061533fb autobuild-preview-pr-254-061533fb 2026-06-16 23:42:23 UTC

@Jarred-Sumner Jarred-Sumner merged commit cd821fe into main Jun 16, 2026
49 checks passed
Jarred-Sumner added a commit to oven-sh/bun that referenced this pull request Jun 17, 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