Skip to content

Module loader: skip the TLA wait when the dynamic import's promise gates the dependency - #543

Merged
Jarred-Sumner merged 2 commits into
mainfrom
robobun/e9d96b6f/dynamic-import-tla-deadlock
Sep 1, 2026
Merged

Module loader: skip the TLA wait when the dynamic import's promise gates the dependency#543
Jarred-Sumner merged 2 commits into
mainfrom
robobun/e9d96b6f/dynamic-import-tla-deadlock

Conversation

@robobun

@robobun robobun commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • ModuleLoaderPayload already carries the dynamic import's capability promise. Pass it into evaluate() and innerModuleEvaluation().
  • At 12.b.v, before waiting on an EvaluatingAsync dependency, importPromiseGatesAsyncDependency walks the pending reactions hanging off that promise: an await in an async function (follows the function's own promise), an await in a module body (the module), .then()/.finally() and resolve-with-promise plumbing (the derived promise), Promise.all/allSettled (the combined promise), and the loader's own ModuleLoad*/DynamicImport* pipeline steps. When a chain ends in the dependency's body, or in a module the dependency waits on through [[AsyncParentModules]], the wait would deadlock and is skipped.
  • This replaces referrerAsyncOrder rather than sitting beside it. The walk recognises every case the lexical check did (a direct await import() reaches the module body through AsyncModuleExecutionResume) without its false positive: a fire-and-forget import() in a TLA module now waits, per spec, and sees the awaiter's post-await bindings. referrerAsyncOrder is removed from importModule, requestImportModule, loadModule, ModuleLoadingContext, ModuleLoaderPayload, and JSModuleLoader::asyncEvaluationOrderForKey is gone. The Bun side drops the three lines in ZigGlobalObject.cpp that computed and passed it.
  • The walk follows an explicit list of reaction kinds and stops on anything else, so a future InternalMicrotask keeps the spec behaviour of waiting instead of silently skipping. Promise.race/any can settle without the import and are not followed; resolving functions captured by closures and async generator queues end the walk.
  • Promise.all is the one followed edge that is not strict: another element rejecting settles it without the import. It is followed anyway because await Promise.all([import(a), import(b)]) in a TLA module is common and would otherwise hang. The cost is that if another element rejects and the awaiter swallows the rejection, the imported module has already evaluated against the awaiter's pre-await bindings (a TDZ error) instead of waiting for them. Promise.allSettled is strict and followed.
  • JSPromise::forEachPendingReaction exposes the reaction list (inline or heap) as (task, promiseOrCapability, contextOrHandler) triples, so the walk does not depend on the promise storage layout.
  • Cost: the walk runs only when a dynamic import's Evaluate() meets an EvaluatingAsync dependency, does not allocate on the JS heap, and is bounded at 4096 promises.

Verification

  • JSTests/modules/dynamic-import-tla-cycle.js (direct, helper, helper-after-await, Promise.all shapes, with a watchdog that throws on deadlock) and JSTests/modules/dynamic-import-tla-siblings-wait.js (two dynamic imports sharing a genuinely async dependency must still wait for it) pass in all 11 run-jsc-stress-tests module configurations. The jsc shell never passed referrerAsyncOrder, so these exercise the walk alone. A Promise.race([import()]) negative control still deadlocks and trips the watchdog.
  • The rest of JSTests/modules.yaml is unchanged by this branch (the pre-existing failures are error-message text and $vm-gated tests).
  • Bun built against the previous revision of this branch: the issue's repro prints child: function and BOOT OK, exit 0; test/js/bun/resolve/dynamic-import-tla-cycle.test.ts in the Bun PR adds seven shapes that all hang on Bun 1.4.1 and pass here, and the six existing tests (including the [Cocoa] Pasting a Genmoji into Mail does not create a HEIC attachment WebKit/WebKit#30259 and Parse "dir" member of the web application manifest WebKit/WebKit#30634 sibling waits) still pass. The Bun PR needs the referrerAsyncOrder call-site removal to build against this revision.

…tes the dependency

innerModuleEvaluation step 12.b.v makes a module wait for a dependency
that is EvaluatingAsync. When the Evaluate() belongs to a dynamic
import() whose result that dependency awaits, the wait is a deadlock:
the dependency resumes only after the import settles, and the import
settles only after the graph being evaluated finishes.

Bun already skips the wait when the module that lexically contains the
import() call is the dependency (referrerAsyncOrder, captured at the
call site). A helper module that issues the import() on behalf of the
suspended module has no async order of its own, so the skip misses and
the process hangs:

  entry.ts:  export function helper() {}
             const { load } = await import("./loader.ts");
             await load();
  loader.ts: export async function load() { await import("./child.ts"); }
  child.ts:  import { helper } from "./entry.ts";

The helper's import() promise has, by the time Evaluate() runs in a
microtask, the reactions that describe what the caller does with it:
an await in an async function (holding that function's promise), an
await in a module body (holding the module), .then()/.finally() and
resolve-with-promise plumbing (holding a derived promise), Promise.all
(holding the combined promise), and the loader's own pipeline steps.

Thread the dynamic import's capability promise through
ModuleLoaderPayload into evaluate() and innerModuleEvaluation. At
12.b.v, before waiting on an EvaluatingAsync dependency, follow those
reactions from the import promise. If a chain ends in the body of the
dependency, or of a module the dependency waits on through
[[AsyncParentModules]], the wait is a guaranteed deadlock and is
skipped. Promise.race/any can settle without the import and are not
followed. Resolving functions captured by closures and async generator
queues are opaque and end the walk, so anything the walk cannot see
keeps the spec behavior of waiting. The referrerAsyncOrder check stays
as it is.

JSPromise::forEachPendingReaction exposes the reaction list (inline or
heap) as (task, promiseOrCapability, contextOrHandler) triples so the
walk does not depend on the storage layout.
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 8623d1e9-6510-4142-9b44-a5640052a220

📥 Commits

Reviewing files that changed from the base of the PR and between 167a4ce and 9c20611.

📒 Files selected for processing (7)
  • Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp
  • Source/JavaScriptCore/runtime/AbstractModuleRecord.h
  • Source/JavaScriptCore/runtime/CyclicModuleRecord.cpp
  • Source/JavaScriptCore/runtime/CyclicModuleRecord.h
  • Source/JavaScriptCore/runtime/JSMicrotask.cpp
  • Source/JavaScriptCore/runtime/JSPromise.cpp
  • Source/JavaScriptCore/runtime/JSPromise.h

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


Walkthrough

Bun-specific module evaluation now carries the dynamic-import promise through cyclic evaluation. JavaScriptCore can inspect pending promise reactions and detect async dependency deadlocks before registering dependencies.

Changes

Dynamic import evaluation

Layer / File(s) Summary
Promise reaction inspection
Source/JavaScriptCore/runtime/JSPromise.h, Source/JavaScriptCore/runtime/JSPromise.cpp
Adds JSPromise::forEachPendingReaction for iterating pending reactions and continuation data.
Dynamic-import evaluation context
Source/JavaScriptCore/runtime/AbstractModuleRecord.h, Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp, Source/JavaScriptCore/runtime/CyclicModuleRecord.h, Source/JavaScriptCore/runtime/CyclicModuleRecord.cpp, Source/JavaScriptCore/runtime/JSMicrotask.cpp
Passes dynamicImportPromise from dynamic-import microtasks through root, cyclic, and dependent module evaluation.
Async dependency deadlock analysis
Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp
Traverses pending reactions, promise combinators, and async continuations. It avoids dependency registration when the dynamic-import promise or referrer async order indicates a cycle.

Merge Risk: ⚪ Minimal · up to 9c206

The PR changes module-evaluation behavior to avoid dynamic-import and top-level-await deadlocks, and no confirmed current-head build or correctness failure is identified; it is merge-ready after normal checks and review with no actionable merge-blocking risk remaining.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description clearly explains the problem, fix, scope, and verification. However, it does not follow the repository template because it omits the Bugzilla bug title and link, the "Reviewed by NOBOD… Add the required Bugzilla URL and bug reference, include the reviewer line, and list each changed path with the affected functions or classes using the repository template. Keep the existing problem, fix, and verification details.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: skipping the top-level-await wait when a dynamic import promise gates an async dependency. It is specific and concise enough for the changeset.
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.
Full details: Description check

Explanation

The description clearly explains the problem, fix, scope, and verification. However, it does not follow the repository template because it omits the Bugzilla bug title and link, the "Reviewed by NOBODY (OOPS!)." line, and the required changed-file and function list.

  • Fix all pre-merge checks with AI

Warning

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


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

Comment on lines +1298 to +1304
case InternalMicrotask::PromiseAllResolveJob:
case InternalMicrotask::PromiseAllSettledResolveJob: {
// The combined promise waits for every element.
JSCell* contextCell = cellOf(cell);
if (auto* globalContext = contextCell ? dynamicDowncast<JSPromiseCombinatorsGlobalContext>(contextCell) : nullptr)
follow(globalContext->promise());
break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Following PromiseAllResolveJob treats the combined promise as gated by the import, but Promise.all short-circuits to rejection when any other element rejects (JSMicrotask.cpp:445-448), so the wait is not a guaranteed deadlock: on base a helper doing await Promise.all([import(plugin), mayReject]) lets plugin wait on the awaiter and see its post-await bindings once mayReject rejects, while after this change plugin runs immediately against TDZ/partial bindings. Fix: only claim gating for combinators whose settlement strictly requires this element — keep PromiseAllSettledResolveJob, but treat PromiseAllResolveJob as terminal (like race/any) or document the deviation.

Extended reasoning...

entry.mjs: import {load} from './loader.mjs'; try { await load(); } catch {} export const x = 1;. loader.mjs: export const load = () => Promise.all([import('./plugin.mjs'), fetch(url)]); where fetch will reject. plugin.mjs: import { x } from './entry.mjs'; use(x);. When plugin's Evaluate() runs, entry is EvaluatingAsync and the walk from capabilityPromise sees PromiseAllResolveJob → follows globalContext->promise()AsyncModuleExecutionResume for entry → resumesDependency(entry) returns true, so 12.b.v skips the wait and plugin executes with x still in TDZ. On the base branch referrerAsyncOrder is -1 (loader is not EvaluatingAsync) so plugin waits; when fetch rejects, promiseAllResolveJob (JSMicrotask.cpp:445) rejects the combined promise, entry catches, assigns x=1 and completes, and plugin then runs with x=1. Promise.allSettled truly needs every input to settle so following it is sound, but Promise.all's reject path settles without importPromise, breaking the invariant the walk relies on and the PR's "guaranteed deadlock" claim.

Verification: normal — The claim checks out against the code. In importPromiseGatesAsyncDependency (Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp), the PromiseAllResolveJob case unconditionally follows the combinator's aggregate promise: ```cpp case InternalMicrotask::PromiseAllResolveJob: case InternalMicrotask::PromiseAllSettledResolveJob: { // The combined promise waits for every element.…

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(Claude wrote this reply on Jarred's behalf.)

Deliberate, and now called out in the PR body. Promise.all is the one followed edge that is not a strict "settles only after the import" edge, because another element rejecting settles it early. Treating it as terminal would put await Promise.all([import(a), import(b)]) in a TLA module — a common shape, and one of the Bun-side test cases — back to hanging forever. The cost of following it is that in the reject-then-swallow shape you describe the plugin evaluates against the awaiter's pre-await bindings (TDZ) instead of waiting; that is the same trade the previous referrerAsyncOrder check made for fire-and-forget imports, and a thrown TDZ error is a better failure than a silent hang. allSettled stays followed (strict), race/any stay terminal.

…ck, and fail safe on unknown reactions

The dynamic import's promise walk added in the previous commit already
recognises every case the lexical referrerAsyncOrder check did (an
`await import()` directly in the suspended module's body reaches that
body through AsyncModuleExecutionResume), and it does not share that
check's false positive: a fire-and-forget `import()` in a TLA module no
longer skips the step 12.b.v wait when nothing would deadlock. Drop
referrerAsyncOrder and its plumbing through importModule,
requestImportModule, loadModule, ModuleLoadingContext,
ModuleLoaderPayload and JSModuleLoader::asyncEvaluationOrderForKey.
Embedders stop passing it.

The walk now lists the reaction kinds it follows (user handlers,
resolve/without-handler/finally plumbing, the ModuleLoad* and
DynamicImport* pipeline steps, ImportModuleNamespace) and stops on
anything else, so a new InternalMicrotask keeps the spec behaviour of
waiting instead of silently skipping the wait.

JSTests/modules/dynamic-import-tla-cycle.js covers the direct, helper,
helper-after-await and Promise.all shapes with a watchdog that fails on
deadlock; dynamic-import-tla-siblings-wait.js checks that two dynamic
imports sharing a genuinely async dependency still wait for it.
@Jarred-Sumner
Jarred-Sumner merged commit 742c886 into main Sep 1, 2026
45 of 46 checks passed
dylan-conway added a commit that referenced this pull request Sep 1, 2026
main (#543) replaced the referrerAsyncOrder TLA-deadlock hint with the
dynamic import()'s promise; the graph-instance evaluate/innerModuleEvaluation
overloads now take (JSPromise* dynamicImportPromise, ModuleGraphInstance*),
instance-internal evaluation passes no hint as before, and the import-promise
walk follows the ModuleGraphInstance*Settled reactions.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

// VM-unique identity, so equality is exact — siblings that
// happen to be EvaluatingAsync (#30259, #30634) never match.
if (cyclic->asyncEvaluationOrder().order() != referrerAsyncOrder) {
if (!dynamicImportPromise || !importPromiseGatesAsyncDependency(dynamicImportPromise, cyclic)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Dropping the referrerAsyncOrder equality check (the PR body says it is "unchanged", but the code removed it) regresses the kick-off-then-await shape: an entry that does const p = import('./child'); await ioPromise; await p; now deadlocks in Bun where the base skipped the wait, because when dynamicImportLoadSettled runs the entry's AsyncModuleExecutionResume reaction is still on ioPromise, so the walk from capabilityPromise reaches the user-visible import promise with no reactions and returns false. Fix: keep a fallback that skips when cyclic is the lexical referrer (thread the referrer record or its asyncEvaluationOrder alongside the promise), or re-run the walk when the import promise later gains a module-resume reaction.

Extended reasoning...

entry.mjs: export function helper(){return 1}; const p = import('./child.mjs'); await new Promise(r=>setTimeout(r,0)); export const ns = await p;. child.mjs: import { helper } from './entry.mjs'; export const v = helper();. The import() call is lexically in entry, so on the base branch Bun's moduleLoaderImportModule hook passed referrerAsyncOrder = asyncEvaluationOrderForKey('entry.mjs') (the removed comment at old line 1330-1341 confirms the order is already assigned before the first await), and at 12.b.v cyclic->asyncEvaluationOrder().order() == referrerAsyncOrder matched, child skipped the wait, ran with helper already hoisted, and entry later resumed. After this diff evaluate() receives only capabilityPromise (the internal statePromise). When dynamicImportLoadSettled runs, the microtask queue has drained the loader hops but the setTimeout macrotask has not fired, so entry is suspended at await ioPromise with its AsyncModuleExecutionResume reaction attached to that timer promise, not to p. importPromiseGatesAsyncDependency walks statePromise →…

Verification: normal — The PR body claims "The referrerAsyncOrder check is unchanged", but the diff removes it entirely and replaces it with the promise-graph walk. Base (d71031a) at AbstractModuleRecord.cpp step 12.b.v: cpp if (cyclic->asyncEvaluationOrder().order() != referrerAsyncOrder) { referrerAsyncOrder was captured at the import() call site (via the now-removed… | normal — The PR body…

Comment on lines +4 to +7
setTimeout(() => {
if (settled != 4)
throw new Error("dynamic import() inside a TLA cycle deadlocked: " + settled + "/4 settled");
}, 1000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit (optional): The 1000 ms watchdog setTimeout stays pending after all four imports settle, so the jsc shell run loop waits the full second before exiting even on success — every configuration of this test runs ≥1000 ms, violating the JSTests rule that new tests must complete in under 200 ms. Fix: drop the timer (a real deadlock already hangs the harness and fails), or clearTimeout it once settled == 4, and use a much shorter delay.

Extended reasoning...

JSTests/CLAUDE.md imports JSTests/README.md, whose §Adding Tests rule 1 requires new tests to run in under 200 ms in all configurations. dynamic-import-tla-cycle.js:4-7 arms setTimeout(..., 1000) before the loop and never clears it. On the happy path the four await import(...) calls resolve in a few ms, module TLA completes, but the shell's run loop still has the 1000 ms timer queued and drains it before exit; the callback then sees settled==4 and does nothing. Net effect: the test's wall time floor is 1 s across every stress configuration, which --report-execution-time will flag. If the shell does not wait for timers, the watchdog never fires at all and is dead code — either way it does not do its job cleanly.

Verification: nit — The 1000 ms watchdog does keep the jsc shell alive for the full second on the happy path. JSTests/modules/dynamic-import-tla-cycle.js:4-7 arms setTimeout(..., 1000) and never cancels it. In the jsc shell, setTimeout (Source/JavaScriptCore/jsc.cpp:3293) calls vm.deferredWorkTimer->addPendingWork(...), which inserts a ticket into m_pendingTickets (DeferredWorkTimer.cpp:211). After…

Comment on lines +95 to 96
JSPromise* loadModule(JSGlobalObject*, const Identifier& moduleName, RefPtr<ScriptFetchParameters>, RefPtr<ScriptFetcher>, OptionSet<ModuleLoadFlag>, const String& referrer = { });
JSPromise* linkAndEvaluateModule(JSGlobalObject*, const Identifier& moduleKey, RefPtr<ScriptFetchParameters>, RefPtr<ScriptFetcher>);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit (optional): Removing asyncEvaluationOrderForKey leaves an empty #if USE(BUN_JSC_ADDITIONS) / #endif pair behind. Fix: delete the dead preprocessor block.

Extended reasoning...

After the JS_EXPORT_PRIVATE int64_t asyncEvaluationOrderForKey(...) declaration is removed, JSModuleLoader.h lines 95-96 read #if USE(BUN_JSC_ADDITIONS) immediately followed by #endif with nothing between them. This compiles fine but is dead noise inconsistent with the rest of the header; the surrounding public API block no longer needs a Bun-specific guard here.

Verification: nit — Source/JavaScriptCore/runtime/JSModuleLoader.h lines 98-99 now read #if USE(BUN_JSC_ADDITIONS) immediately followed by #endif with nothing between them. The diff shows the sole guarded declaration JS_EXPORT_PRIVATE int64_t asyncEvaluationOrderForKey(const Identifier& key); was removed but the surrounding preprocessor guards were left behind. This is dead noise; it compiles fine and…

Comment on lines +1270 to +1274
case InternalMicrotask::AsyncModuleExecutionResume: {
JSCell* driver = unwrapContext(context);
if (auto* module = driver ? dynamicDowncast<AbstractModuleRecord>(driver) : nullptr)
found = resumesDependency(module);
break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟣 pre-existing: The AsyncModuleExecutionResume arm terminates the walk at resumesDependency(module) (which only climbs [[AsyncParentModules]]), so a cycle that goes through two dynamic imports — entry awaits import(mid), mid awaits import(child), child statically imports entry — still deadlocks exactly as on the base branch, because mid.[[AsyncParentModules]] is empty (dynamic imports never populate it) and the outer import chain hanging off mid's topLevelCapability is never followed. Fix: when the reaction resumes a module whose topLevelCapability() is pending, also follow() that capability so the walk can bridge nested dynamic imports; apply the same to the AsyncFunctionResume module branch.

Extended reasoning...

Repro: entry.mjs = export function h(){}; export const ns = await import('./mid.mjs');, mid.mjs = export const ns = await import('./child.mjs');, child.mjs = import { h } from './entry.mjs'; export const v = h();. When dynamicImportLoadSettled for the mid→child import calls child->evaluate(globalObject, mid_statePromise), innerModuleEvaluation reaches step 12.b.v with cyclic == entry (EvaluatingAsync) and calls importPromiseGatesAsyncDependency(mid_statePromise, entry). The DFS walks mid_statePromise → ModuleLoadCombinedStateSettled → combinedPromise → PromiseFulfillWithoutHandlerJob → intermediatePromise → ModuleLoadTopRejected → loadResult → ImportModuleNamespace → requestResult → PromiseResolveWithoutHandlerJob → outerPromise and hits its only reaction, AsyncModuleExecutionResume with context mid. Line 1273 runs resumesDependency(mid): mid != entry, and mid.asyncParentModules() is empty because entry reached mid via import() (no static edge, so 12.b.v.2 never appended entry), so it returns false and the walk ends with found == false. child then…

Verification: pre-existing — The two-level dynamic-import cycle (entry await import(mid), mid await import(child), child statically imports entry) still deadlocks after this change, exactly as it did on the base. Trace against the code: 1. dynamicImportLoadSettled for mid→child passes mid's inner capability promise into child->evaluate(globalObject, capabilityPromise) (JSMicrotask.cpp:1484). 2. That…

@robobun

robobun commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased my for-await follow-up on top of b01e04d (commit 9eaa649): for await over promises goes through the async-from-sync iterator, whose pending step holds the result promise or the driver. The JSTests fixture creates the imports lazily from a sync generator, because an import() created before the loop reaches it can evaluate before anything awaits it, and then no reaction shows the dependency.

One finding from running Bun's suite against b01e04d. Dropping the lexical referrerAsyncOrder check regresses test/js/bun/http/bun-server.test.ts ("should be able to parse source map and fetch small stream"). The fixture (js-sink-sourmap-fixture/index.mjs, a Nitro build) does await fetch(server.url + "/stream") at top level. The request handler lazy-imports chunks/stream.mjs, which statically imports index.mjs back. The await chain from that import() to the suspended module passes through the HTTP round trip, so the walk cannot see it and the chunk waits per spec. The process hangs (Node exits 13 on this graph). With the referrer check in place the chunk evaluated at once, because the import() is written in index.mjs, which was EvaluatingAsync.

The two ways out:

  1. Keep the referrer check as a fallback next to the walk (my first commit did that). Covers opaque chains that start in the suspended module's own body, at the cost of the fire-and-forget false positive for the direct form.
  2. Keep the walk as the only check and change the fixture so the self-test runs from setImmediate, after the module has finished evaluating. module loader: wait on EvaluatingAsync deps in dynamic-import cycles; exit 13 on unsettled TLA bun#36052 already made that change to the same file.

I will follow whichever you prefer in the Bun PR. Everything else in test/js/bun/resolve/, test/js/node/vm/ and test/js/node/module/ passes against 9eaa649, and the direct fire-and-forget form now waits and sees the awaiter's post-await exports (it threw a ReferenceError before).

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
b01e04d2 autobuild-preview-pr-543-b01e04d2 2026-09-01 02:49:45 UTC

Jarred-Sumner added a commit that referenced this pull request Sep 1, 2026
… import-promise walk

The walk from #543 follows the dynamic import()'s promise reactions to
decide whether waiting on an EvaluatingAsync dependency would deadlock.
It cannot follow a chain that leaves JS: an import() issued from a
server request handler while the entry module is suspended on
`await fetch(ownServer)`, or one whose result is handed back through a
resolver captured in a closure and a timer. In those shapes the only
link between the import() and the suspended module is native, the walk
ends, the dependency is waited on, and the process hangs.

Restore referrerAsyncOrder (the asyncEvaluationOrder of the module that
lexically contains the import() call, captured at the call site) and
skip the wait when it equals the dependency's order, in addition to the
walk. The jsc shell's import hook now threads it too so the new
JSTests/modules/dynamic-import-tla-cycle-opaque.js covers the
captured-resolver shape.
@robobun

robobun commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

This change makes Bun's test/js/bun/http/bun-server.test.ts > "should be able to parse source map and fetch small stream" hang on every CI lane (oven-sh/bun#40987, Buildkite build 108969, 11 lanes). Bisected locally: Bun built against 167a4cef86 (the #541 merge) passes, against 742c886cdc (this PR's merge, the next commit on main) the request idles out after 10 s and the fixture exits 1.

The fixture is a Nitro server bundle, test/js/bun/http/js-sink-sourmap-fixture/index.mjs. Its top level ends with const result = await fetch(server.url + "/stream"). The request handler is an async function reached through Bun.serve and h3, and the /stream route is lazy: () => import("./chunks/stream.mjs"). chunks/stream.mjs statically imports { e as eventHandler } from "../index.mjs", which is EvaluatingAsync at that point.

Before this PR, referrerAsyncOrder skipped the 12.b.v wait here because the import() call is lexically in index.mjs, the very module the dependency resolves to (the case oven-sh/bun#32437 added the skip for, and what this test covers). After this PR the walk from the import promise reaches the handler's async-function promise and stops: Bun.serve consumes that promise natively to write the HTTP response, and the fetch that index.mjs awaits is connected to it only through the socket. So the wait is taken, stream.mjs waits for index.mjs, index.mjs waits for the response, and the response waits for stream.mjs.

This is the fire-and-forget shape the PR body describes as now waiting per spec, but it is also the shape Bun has supported since WebKit#32437 and tests for. A skip for an import() issued from code that is lexically inside an EvaluatingAsync module (the old referrer check, next to the walk) would cover it again. Until the fork has that, Bun cannot pin past 167a4cef86; oven-sh/bun#40987 pins there.

Repro with any Bun built against 742c886cdc or later:

cd test/js/bun/http && PORT=0 bun js-sink-sourmap-fixture/index.mjs
# expected: exit 0 after "Listening on ..."; actual: "warn: Bun.serve() timed out a request after 10 seconds", exit 1

robobun added a commit to oven-sh/bun that referenced this pull request Sep 1, 2026
This reverts the move to fork main 01de4c1d (2918d3c). That commit
carries oven-sh/WebKit#543, which replaces the referrer-based skip of
the top-level-await wait in dynamic imports with a walk over the import
promise's reactions. The Nitro fixture in test/js/bun/http/bun-server.test.ts
does import("./chunks/stream.mjs") from a Bun.serve handler while
index.mjs, which the chunk imports back, is awaiting the response. The
walk cannot see through the HTTP hop, the wait is taken, and the request
idles out. Bun built at 742c886cdc (the #543 merge) fails the test on
every platform; at 167a4cef86 it passes.

167a4cef86 is upstream c119008088 merged into the fork's main and
nothing after it. The oven-sh/WebKit#545 tests leave with the pin; they
need that engine change.
@robobun

robobun commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up for readers of this thread: fork main 2718370 restores the referrerAsyncOrder skip next to the import-promise walk, with the previous JSC::importModule signature, and adds JSTests/modules/dynamic-import-tla-cycle-opaque.js for the captured-resolver shape. Bun built against e989e1e48878a011d89a4b4bd7f72ade952fbd55 (fork main after that commit) passes the Nitro fixture in test/js/bun/http/bun-server.test.ts again, and test/js/bun/resolve/dynamic-import-tla-cycle.test.ts stays green. oven-sh/bun#40987 can pin past 167a4ce now.

robobun added a commit to oven-sh/bun that referenced this pull request Sep 1, 2026
…inst the fix

autobuild-preview-pr-548-4b30f36d is fork main e989e1e488 (which has
oven-sh/WebKit#543 and the restored lexical referrer skip from
2718370ec0) plus the for-await step from oven-sh/WebKit#548. The pin
moves to the fork main sha that merged #548 (491b5cc236) once its
autobuild is published.
robobun added a commit to oven-sh/bun that referenced this pull request Sep 1, 2026
491b5cc236 is the fork main commit that merged oven-sh/WebKit#548. It
includes oven-sh/WebKit#543 (the import-promise walk), the restored
lexical referrer skip from 2718370ec0, and the for-await step. Its
autobuild release carries the full platform and flavor matrix.
robobun added a commit to oven-sh/bun that referenced this pull request Sep 1, 2026
A top-level await that reaches a dynamic import() through a helper
module hung forever when the imported module statically imported the
awaiting module back (#41029). The module loader's deadlock skip only
fired when the import() call was written in the awaiting module itself.

oven-sh/WebKit#543 and oven-sh/WebKit#548 make the loader follow the
import() promise's pending reactions (await in an async function or a
module body, then/finally, Promise.all, for-await, loader plumbing) to
the suspended module, so a helper between the awaiter and the import()
no longer matters. The fix reaches Bun through the WebKit pin (#40987).

The tests cover two and three import() levels, an async helper that
awaits I/O before import(), a helper that returns the import() promise
or chains .then() on it, Promise.all, for-await over lazily created
imports, a non-entry awaiter, and a helper's fire-and-forget import()
that must keep waiting for the awaiter.
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.

2 participants