Skip to content

Module loader: follow a for-await step in the import-promise walk - #548

Merged
Jarred-Sumner merged 1 commit into
mainfrom
robobun/e9d96b6f/for-await-import-walk
Sep 1, 2026
Merged

Module loader: follow a for-await step in the import-promise walk#548
Jarred-Sumner merged 1 commit into
mainfrom
robobun/e9d96b6f/for-await-import-walk

Conversation

@robobun

@robobun robobun commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • The import-promise walk from Module loader: skip the TLA wait when the dynamic import's promise gates the dependency #543 stops at a for await step. A loader that does for await (const ns of lazyImports()) inside the suspended module's await chain still deadlocks: the walk reaches the AsyncFromSyncIteratorContinue reaction on the import promise and ends there, so the chunk waits on the suspended module per 12.b.v and the module waits on the loop.
  • for await over sync values that are promises awaits each value through the async-from-sync iterator. The iterator holds what the pending step settles or resumes (m_target): the result promise of next(), or the driver it resumes directly. There was no read accessor for it.

Fix

  • JSAsyncFromSyncIterator::target() exposes the pending target without clearing it.
  • The walk follows AsyncFromSyncIteratorContinue/Done through target(): a promise is queued, an async function generator contributes its own promise, a module body is checked against the dependency. The same helper serves AsyncFunctionResume, AsyncModuleExecutionResume and AsyncGeneratorDriverResume.
  • JSTests/modules/dynamic-import-tla-cycle.js gains the for-await shape. The fixture creates the import lazily from a sync generator on purpose: an import() created before the loop reaches it can evaluate before anything awaits it, and then no reaction shows that the entry waits for it. That ordering is inherent to a snapshot of the reaction graph and is the same limit as const p = import(x); await other; await p.

Verification

  • Bun built against this change: for await (const m of plugins()) where plugins is a sync generator yielding import() calls, each plugin importing the entry back, prints every plugin and exits 0 (hangs without it). The remaining test/js/bun/resolve/dynamic-import-tla-cycle.test.ts shapes still pass.

This was pushed to the #543 branch after that PR merged, so it is re-submitted here on top of main (including 2718370).

for-await over sync values that are promises awaits each value through
the async-from-sync iterator. Its pending step is an
AsyncFromSyncIteratorContinue/Done reaction whose context is the
iterator; the iterator holds the result promise the step settles, or
the driver it resumes directly. Follow that target like an await in an
async function, so a loader that does

  for await (const ns of lazyImports()) ...

inside the suspended module's await chain is recognised.

JSAsyncFromSyncIterator gains a const target() accessor. The JSTests
fixture adds the shape; the imports are created lazily by a sync
generator because an import() created before the loop reaches it can
evaluate before anything awaits 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. It extends the Bun-specific TLA import-promise walk in module evaluation, and both Source/JavaScriptCore and JSTests are CODEOWNERS-covered, so a human look is still worthwhile.

What was reviewed:

  • followPromiseOrDriver refactor: it is a superset of the three prior case bodies (adds a JSPromise branch to the resume paths), so the walk only widens — no case lost coverage, and found still cannot flip true→false because visitReaction returns !found.
  • New AsyncFromSyncIterator{Continue,Done} handling: unwrapContext null-guarded before dynamicDowncast, and target() returning null short-circuits in followPromiseOrDriver.
  • JSAsyncFromSyncIterator::target(): read-only over m_target.pointer() (unlike extractTarget() it doesn't clear), correctly gated by USE(BUN_JSC_ADDITIONS), as is the new include.
  • Test fixtures follow the existing entry-*/child-* pattern; the harness count and scenario list stay in sync at 5.
Extended reasoning...

Overview

The PR extends importPromiseGatesAsyncDependency in Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp — the Bun-only heuristic that walks the reaction graph off a dynamic import() promise to detect when a suspended TLA module is transitively waiting on itself. Two new InternalMicrotask reaction kinds (AsyncFromSyncIteratorContinue/Done) are handled by reading the async-from-sync iterator's pending target and following it. The three existing resume cases are folded into a shared followPromiseOrDriver lambda. JSAsyncFromSyncIterator.h gains a read-only target() accessor under USE(BUN_JSC_ADDITIONS). New JSTests fixtures add a for-await-over-sync-generator scenario, and the top-level test bumps its expected settle count from 4 to 5.

Security risks

None identified. All code is inside #if USE(BUN_JSC_ADDITIONS) and only reads existing GC-managed fields via dynamicDowncast with null guards, matching the surrounding pattern. The new accessor is a plain const read of a CompactPointerTuple pointer with no write barrier or lifetime implications. There is no untrusted input parsing, no allocation, and no change to module evaluation ordering outside the existing heuristic's true/false result.

Level of scrutiny

Moderate-to-high. The diff is small and mechanical, but it lives in InnerModuleEvaluation's cycle-vs-deadlock heuristic, where widening the walk changes when step 12.b.v is skipped. The followPromiseOrDriver consolidation adds a JSPromise branch that the prior AsyncFunctionResume/AsyncModuleExecutionResume/AsyncGeneratorDriverResume bodies did not have; this only broadens what the walk can reach and cannot cause it to miss a previously-detected dependency, but confirming that broadening is intentional (and cannot produce a false positive that skips a wait it shouldn't) is best done by someone who owns this heuristic. Both changed source trees are covered by .github/CODEOWNERS (/Source/JavaScriptCore and /JSTests → WebKit/jsc-reviewers), which per policy means I should not auto-approve.

Other factors

The change ships with a regression test that exercises the exact shape (sync generator yielding import() consumed by for await), and the PR description states it was verified to hang without the fix. The test follows the existing dynamic-import-tla-cycle fixture pattern and updates the settle count consistently. The include of JSAsyncFromSyncIterator.h is placed inside the existing #if USE(BUN_JSC_ADDITIONS) include block, so non-Bun builds are unaffected.

@coderabbitai

coderabbitai Bot commented Sep 1, 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: 024f573b-12d0-4fee-97b4-a1c4cc103b32

📥 Commits

Reviewing files that changed from the base of the PR and between e989e1e and 4b30f36.

📒 Files selected for processing (6)
  • JSTests/modules/dynamic-import-tla-cycle.js
  • JSTests/modules/dynamic-import-tla-cycle/child-for-await.js
  • JSTests/modules/dynamic-import-tla-cycle/entry-for-await.js
  • JSTests/modules/dynamic-import-tla-cycle/loader.js
  • Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp
  • Source/JavaScriptCore/runtime/JSAsyncFromSyncIterator.h

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


Walkthrough

Changes

Dynamic import TLA cycle handling

Layer / File(s) Summary
Async dependency traversal
Source/JavaScriptCore/runtime/AbstractModuleRecord.cpp, Source/JavaScriptCore/runtime/JSAsyncFromSyncIterator.h
Async dependency tracing now follows promises, generators, module records, and async-from-sync iterator targets.
For-await cycle regression
JSTests/modules/dynamic-import-tla-cycle.js, JSTests/modules/dynamic-import-tla-cycle/loader.js, JSTests/modules/dynamic-import-tla-cycle/entry-for-await.js, JSTests/modules/dynamic-import-tla-cycle/child-for-await.js
The test adds a for-await dynamic-import cycle and expects five imports to settle.

Merge Risk: ⚪ Minimal · up to 4b30f

The PR extends the import-promise walk through async-from-sync iterator steps and adds regression coverage; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description clearly explains the problem, implementation, test changes, and verification. However, it does not include the required Bugzilla bug title and URL, Reviewed by NOBODY (OOPS!)., or th… Add the associated Bugzilla URL and bug title, include Reviewed by NOBODY (OOPS!). or the applicable reviewer, and list the changed files and relevant functions according to the repository template.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: extending the module loader's import-promise walk through a for-await step.
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, implementation, test changes, and verification. However, it does not include the required Bugzilla bug title and URL, Reviewed by NOBODY (OOPS!)., or the template-style changed-file 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.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
4b30f36d autobuild-preview-pr-548-4b30f36d 2026-09-01 04:16:30 UTC

@Jarred-Sumner
Jarred-Sumner merged commit 491b5cc into main Sep 1, 2026
48 checks passed
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.
dylan-conway added a commit that referenced this pull request Sep 1, 2026
main (#548) folds the async-driver reactions into followPromiseOrDriver and
follows for-await steps; the instance-aware driver mapping (moduleForDriver)
now lives there.
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