Skip to content

fix: prevent circular runtime helper imports during facade elimination (#8989)#9057

Merged
graphite-app[bot] merged 1 commit intomainfrom
04-03-fix_8989
Apr 10, 2026
Merged

fix: prevent circular runtime helper imports during facade elimination (#8989)#9057
graphite-app[bot] merged 1 commit intomainfrom
04-03-fix_8989

Conversation

@IWANABETHATGUY
Copy link
Copy Markdown
Member

@IWANABETHATGUY IWANABETHATGUY commented Apr 10, 2026

Fixes #8989, a circular import that arises when facade chunk elimination adds new runtime-helper consumers (__exportAll, __toESM, etc.) to a chunk that already has a transitive forward path back to the chunk currently hosting the runtime module.

Root cause

optimize_facade_entry_chunks runs after the merge phase has already placed the runtime module into some chunk based on bitset assignment. When facade elimination later calls include_symbol(namespace_object_ref) for an eliminated facade, it sets target_chunk.depended_runtime_helper.insert(ExportAll) and adds the target to runtime_dependent_chunks. If the target chunk transitively reaches the chunk currently hosting the runtime, the new helper-import edge closes a cycle.

For example, in the regression test the merge phase puts runtime in chunk(node2) which has a forward path node2 -> node3 -> node4, and facade elimination then makes chunk(node4) need __exportAll from chunk(node2) — closing entry2 -> entry3 -> node4 -> entry2.

Algorithm

The fix replaces the original "place runtime once and never move it" branch in optimize_facade_entry_chunks with a two-step decision that runs whenever runtime_dependent_chunks is non-empty (i.e., facade elimination has actually added new consumers).

Step 1 — Peel decision (cycle prevention):

The runtime is peeled out of its current host chunk only when both predicates hold:

  • host_has_other_modules — the host chunk contains modules besides the runtime. If runtime is alone in its chunk, that chunk is already a leaf with no outgoing imports and cannot participate in a cycle. Skipping this case avoids leaving an empty chunk that would crash downstream code expecting chunk.modules[0] to exist.

  • has_external_consumerruntime_dependent_chunks contains a chunk that is not the host. If every facade-elim consumer IS the host itself, then helpers don't have to cross any chunk boundary and no new edges are introduced.

When both are true, the implementation removes runtime_module_idx from the host chunk's modules vec via swap_remove (ordering doesn't matter — sort_chunk_modules re-establishes it later) and clears module_to_chunk[runtime_module_idx].

Step 2 — Placement decision (consumer-set count):

When the runtime is unplaced (either because Step 1 just peeled it, or because the merge phase never placed it), the implementation computes the full set of consumer chunks as:

consumer_chunks = (non-removed chunks with non-empty depended_runtime_helper) ∪ runtime_dependent_chunks

The first term picks up chunks that already required helpers from the linking stage; the second term picks up chunks that facade elimination just announced. Deduplication is automatic via FxHashSet. Then:

  • consumer_chunks.len() == 1 → runtime moves into that single consumer chunk. No extra chunk is created; the lone consumer hosts both its own modules and the runtime.
  • consumer_chunks.len() > 1 → runtime is placed in a fresh rolldown-runtime.js leaf chunk created with the runtime's bitset. Every consumer imports from it; the dedicated chunk has zero outgoing edges so cycles are structurally impossible.

Why both steps are needed

The peel gate exists because relying on the consumer-count alone over-triggers — many tests have multiple consumers that don't actually form cycles (e.g., import_missing_neither_es6_nor_common_js, where runtime lives in foo.js and require.js imports __toCommonJS from it without any back-edge). The peel gate keeps these untouched.

The consumer-set count exists because relying on runtime_dependent_chunks.len() alone (the original code's optimization) undercounts: it ignores chunks that already required helpers from the linking stage. After peeling, the unioned set correctly identifies whether runtime can piggyback on a single consumer or needs its own home.

Test results and snapshot impact

Net change against main: only chunk_optimizer.rs and a new regression fixture at crates/rolldown/tests/rolldown/issues/8989/. Zero existing snapshots are modified. All 1677 integration tests pass.

The 8989 fixture covers the cycle: 4 entries (node0node3), node4 dynamically imported from node3, node1 namespace-importing node2 (forcing __exportAll for node2's namespace materialization). The output places runtime into node4.js (the leaf), which both entry2 and entry3 reach through forward-only edges. Acyclic ✓.

Copy link
Copy Markdown
Member Author

IWANABETHATGUY commented Apr 10, 2026


How to use the Graphite Merge Queue

Add the label graphite: merge-when-ready to this PR to add it to the merge queue.

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 10, 2026

Deploy Preview for rolldown-rs canceled.

Name Link
🔨 Latest commit b254f93
🔍 Latest deploy log https://app.netlify.com/projects/rolldown-rs/deploys/69d8e7e6e16c9600086cdea1

@IWANABETHATGUY IWANABETHATGUY changed the title fix: 8989 fix: prevent circular runtime helper imports during facade elimination (#8989) Apr 10, 2026
@IWANABETHATGUY IWANABETHATGUY force-pushed the 04-03-fix_8989 branch 2 times, most recently from a64c868 to a11baef Compare April 10, 2026 10:15
@IWANABETHATGUY IWANABETHATGUY marked this pull request as ready for review April 10, 2026 10:46
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 10, 2026

Merging this PR will not alter performance

✅ 4 untouched benchmarks
⏩ 10 skipped benchmarks1


Comparing 04-03-fix_8989 (b254f93) with main (f4aed8f)2

Open in CodSpeed

Footnotes

  1. 10 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (b254f93) during the generation of this report, so f4aed8f was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@h-a-n-a
Copy link
Copy Markdown
Member

h-a-n-a commented Apr 10, 2026

I though that this check in chunk_optimizer already does this job. Is this PR considered as an optimization for the case where runtime module is not inserted to any chunk yet?

@IWANABETHATGUY
Copy link
Copy Markdown
Member Author

IWANABETHATGUY commented Apr 10, 2026

I though that this check in chunk_optimizer already does this job. Is this PR considered as an optimization for the case where runtime module is not inserted to any chunk yet?

I ran the test case on the main branch, and it still failed. This is a bug fix, not an optimization. You can find more details in the PR description.

@IWANABETHATGUY
Copy link
Copy Markdown
Member Author

I though that this check in chunk_optimizer already does this job. Is this PR considered as an optimization for the case where runtime module is not inserted to any chunk yet?

For #8989, with the PR's fix reverted:

runtime in chunk_graph = Some(2) ← runtime is in entry2 chunk
temp_runtime_chunk_idx = Some(2) ← translates via identity (entry chunk)

facade-elim from=4 target=5
temp(2) → temp(6)
is_reachable(2, 6) = false ← check passes, elimination is allowed

is_reachable(2, 6) returns false in the temp graph even though the real chunk graph has entry2 → entry3 → node4 (chunk_graph indices 2 → 3 → 5). The temp
graph dependencies are computed once via calc_chunk_dependencies from the per-module metas[m].dependencies snapshot; they reflect what the merge phase
saw, not what the post-elimination chunk graph looks like.

Why the check is structurally insufficient

The check only knows two things at decision time:

  1. Where the runtime currently lives (temp_runtime_chunk_idx)
  2. The temp graph's pre-elimination reachability

What it can't know:

  • Which chunks will newly depend on the runtime after the elimination loop runs target_chunk.depended_runtime_helper.insert(ExportAll) and
    runtime_dependent_chunks.insert(to_chunk_idx). Those new helper-import edges are exactly what closes the cycle.
  • The post-elimination chunk graph topology, because chunk indices and dependency edges shift once eliminations are committed.

In #8989, no single in-progress elimination by itself sees a forward path from runtime to target in the temp graph. The cycle only emerges after facade
elimination has added all the new helper-import edges and the placement step has decided where the runtime ends up. That's a post-condition that a
pre-condition check cannot model — the check is operating on stale graph state.

@graphite-app
Copy link
Copy Markdown
Contributor

graphite-app Bot commented Apr 10, 2026

Merge activity

#8989) (#9057)

Fixes #8989, a circular import that arises when facade chunk elimination adds new runtime-helper consumers (`__exportAll`, `__toESM`, etc.) to a chunk that already has a transitive forward path back to the chunk currently hosting the runtime module.

### Root cause

`optimize_facade_entry_chunks` runs after the merge phase has already placed the runtime module into some chunk based on bitset assignment. When facade elimination later calls `include_symbol(namespace_object_ref)` for an eliminated facade, it sets `target_chunk.depended_runtime_helper.insert(ExportAll)` and adds the target to `runtime_dependent_chunks`. If the target chunk transitively reaches the chunk currently hosting the runtime, the new helper-import edge closes a cycle.

For example, in the regression test the merge phase puts runtime in `chunk(node2)` which has a forward path `node2 -> node3 -> node4`, and facade elimination then makes `chunk(node4)` need `__exportAll` from `chunk(node2)` — closing `entry2 -> entry3 -> node4 -> entry2`.

### Algorithm

The fix replaces the original "place runtime once and never move it" branch in `optimize_facade_entry_chunks` with a two-step decision that runs whenever `runtime_dependent_chunks` is non-empty (i.e., facade elimination has actually added new consumers).

**Step 1 — Peel decision (cycle prevention):**

The runtime is peeled out of its current host chunk only when both predicates hold:

- `host_has_other_modules` — the host chunk contains modules besides the runtime. If runtime is alone in its chunk, that chunk is already a leaf with no outgoing imports and cannot participate in a cycle. Skipping this case avoids leaving an empty chunk that would crash downstream code expecting `chunk.modules[0]` to exist.

- `has_external_consumer` — `runtime_dependent_chunks` contains a chunk that is *not* the host. If every facade-elim consumer IS the host itself, then helpers don't have to cross any chunk boundary and no new edges are introduced.

When both are true, the implementation removes `runtime_module_idx` from the host chunk's `modules` vec via `swap_remove` (ordering doesn't matter — `sort_chunk_modules` re-establishes it later) and clears `module_to_chunk[runtime_module_idx]`.

**Step 2 — Placement decision (consumer-set count):**

When the runtime is unplaced (either because Step 1 just peeled it, or because the merge phase never placed it), the implementation computes the full set of consumer chunks as:

```
consumer_chunks = (non-removed chunks with non-empty depended_runtime_helper) ∪ runtime_dependent_chunks
```

The first term picks up chunks that already required helpers from the linking stage; the second term picks up chunks that facade elimination just announced. Deduplication is automatic via `FxHashSet`. Then:

- `consumer_chunks.len() == 1` → runtime moves into that single consumer chunk. No extra chunk is created; the lone consumer hosts both its own modules and the runtime.
- `consumer_chunks.len() > 1` → runtime is placed in a fresh `rolldown-runtime.js` leaf chunk created with the runtime's bitset. Every consumer imports from it; the dedicated chunk has zero outgoing edges so cycles are structurally impossible.

### Why both steps are needed

The peel gate exists because relying on the consumer-count alone over-triggers — many tests have multiple consumers that don't actually form cycles (e.g., `import_missing_neither_es6_nor_common_js`, where runtime lives in `foo.js` and `require.js` imports `__toCommonJS` from it without any back-edge). The peel gate keeps these untouched.

The consumer-set count exists because relying on `runtime_dependent_chunks.len()` alone (the original code's optimization) undercounts: it ignores chunks that already required helpers from the linking stage. After peeling, the unioned set correctly identifies whether runtime can piggyback on a single consumer or needs its own home.

### Test results and snapshot impact

Net change against `main`: only `chunk_optimizer.rs` and a new regression fixture at `crates/rolldown/tests/rolldown/issues/8989/`. **Zero existing snapshots are modified.** All 1677 integration tests pass.

The 8989 fixture covers the cycle: 4 entries (`node0`–`node3`), `node4` dynamically imported from `node3`, `node1` namespace-importing `node2` (forcing `__exportAll` for `node2`'s namespace materialization). The output places runtime into `node4.js` (the leaf), which both `entry2` and `entry3` reach through forward-only edges. Acyclic ✓.
@graphite-app graphite-app Bot merged commit b254f93 into main Apr 10, 2026
32 checks passed
@graphite-app graphite-app Bot deleted the 04-03-fix_8989 branch April 10, 2026 12:11
graphite-app Bot pushed a commit that referenced this pull request Apr 10, 2026
…sign (#9062)

Adds a **Runtime Module Placement** subsection to `meta/design/code-splitting.md` documenting the two-step peel + consumer-set placement algorithm introduced in #9057 (fix for #8989), including the cycle it prevents, why both steps are needed, and a pointer to the regression fixture. Documentation-only.
This was referenced Apr 15, 2026
shulaoda added a commit that referenced this pull request Apr 16, 2026
## [1.0.0-rc.16] - 2026-04-16

### 🚀 Features

- const enum cross-module inlining support (#8796) by @Dunqing
- implement module tagging system for code splitting (#9045) by @hyf0

### 🐛 Bug Fixes

- rolldown_plugin_vite_manifest: handle duplicate chunk names for CSS entries (#9059) by @sapphi-red
- improve error message for invalid return values in function options (#9125) by @shulaoda
- await async export-star init wrappers (#9101) by @thezzisu
- never panic during diagnostic emission (#9091) by @IWANABETHATGUY
- include array rest pattern in binding_identifiers (#9112) by @IWANABETHATGUY
- rolldown: set worker thread count with ROLLDOWN_WORKER_THREADS (#9086) by @fpotter
- rolldown_plugin_lazy_compilation: escape request ID in proxy modules (#9102) by @h-a-n-a
- treat namespace member access as side-effect-free (#9099) by @IWANABETHATGUY
- relax overly conservative side-effect leak check in chunk optimizer (#9085) by @IWANABETHATGUY
- runtime: release `cb` reference after `__commonJS` factory initialization (#9067) by @hyf0-agent
- `@__NO_SIDE_EFFECTS__` wrapper should not remove dynamic imports (#9075) by @IWANABETHATGUY
- rolldown_plugin_vite_import_glob: use POSIX path join/normalize for glob resolution (#9077) by @shulaoda
- emit REQUIRE_TLA error when require() loads a module with top-level await (#9071) by @jaehafe
- emit namespace declaration for empty modules in manual chunks (#8993) by @privatenumber
- rolldown_plugin_vite_import_glob: keep common base on path segment boundary (#9070) by @shulaoda
- prevent circular runtime helper imports during facade elimination (#8989) (#9057) by @IWANABETHATGUY
- correct circular dependency check in facade elimination (#9047) by @h-a-n-a
- docs: correct dead link in CodeSplittingGroup.tags JSDoc (#9051) by @hyf0
- emit DUPLICATE_SHEBANG warning when banner contains shebang (#9026) by @IWANABETHATGUY

### 🚜 Refactor

- use semantic reference flags for member write detection (#9060) by @Dunqing
- extract UsedSymbolRefs newtype wrapper (#9130) by @IWANABETHATGUY
- dedupe await wrapping in export-star init emit (#9119) by @IWANABETHATGUY
- calculate side-effect-free function symbols on demand (#9120) by @IWANABETHATGUY
- extract duplicated top-level await handling into shared helper (#9087) by @IWANABETHATGUY
- rolldown_plugin_vite_import_glob: use split_first for get_common_base (#9069) by @shulaoda
- simplify ESM init deduplication with idiomatic insert check (#9044) by @IWANABETHATGUY

### 📚 Documentation

- document runtime module placement strategy in code-splitting design (#9062) by @IWANABETHATGUY
- clarify `options` hook behavior difference with Rollup in watch mode (#9053) by @sapphi-red
- meta/design: introduce module tags (#9017) by @hyf0

### ⚡ Performance

- convert `generate_transitive_esm_init` to iterative (#9046) by @IWANABETHATGUY

### 🧪 Testing

- merge strict/non_strict test variants using configVariants (#9089) by @IWANABETHATGUY

### ⚙️ Miscellaneous Tasks

- disable Renovate auto-updates for oxc packages (#9129) by @IWANABETHATGUY
- upgrade oxc@0.126.0 (#9127) by @Dunqing
- deps: update napi to v3.8.5 (#9126) by @renovate[bot]
- deps: update dependency @napi-rs/cli to v3.6.2 (#9123) by @renovate[bot]
- move lazy-compilation design doc (#9117) by @h-a-n-a
- deps: update dependency vite-plus to v0.1.18 (#9118) by @renovate[bot]
- deps: update dependency vite-plus to v0.1.17 (#9113) by @renovate[bot]
- deps: update oxc to v0.125.0 (#9094) by @renovate[bot]
- deps: update dependency follow-redirects to v1.16.0 [security] (#9103) by @renovate[bot]
- deps: update test262 submodule for tests (#9097) by @sapphi-red
- deps: update crate-ci/typos action to v1.45.1 (#9096) by @renovate[bot]
- deps: update rust crates (#9081) by @renovate[bot]
- deps: update npm packages (#9080) by @renovate[bot]
- remove outdated TODO in determine_module_exports_kind (#9072) by @jaehafe
- rust/test: support `extendedTests: false` shorthand in test config (#9050) by @hyf0
- ci: extract shared infra-changes anchor in path filters (#9054) by @hyf0
- add docs build check to catch dead links in PRs (#9052) by @hyf0

### ❤️ New Contributors

* @thezzisu made their first contribution in [#9101](#9101)
* @fpotter made their first contribution in [#9086](#9086)
* @jaehafe made their first contribution in [#9071](#9071)
* @privatenumber made their first contribution in [#8993](#8993)

Co-authored-by: shulaoda <165626830+shulaoda@users.noreply.github.com>
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.

[Bug]: circular import generated when using dynamic import and namespace exports

3 participants