Skip to content

fix(compile): initialize the cycle partner of a dynamically imported module - #10279

Closed
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/cycle-init-deferred
Closed

proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/cycle-init-deferred

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #10278. Refs #10107 (OpenCode v1.18.30 native bring-up).

The defect

A module reached only through a dynamic import() — perry's ModuleInitKind::Deferred — that takes part in an import cycle never initialized its cycle partner. The partner's body never ran, so every export the body assigns at run time stayed undefined, and the first call through such a binding threw TypeError: value is not a function.

Entering the same cycle through a static import is correct, which is what makes this easy to miss.

                    bun / node                 perry 0.5.1571
static entry        a body, b body, values     identical
dynamic entry       a body, b body, values     b body only; a's runtime-assigned
                                               exports undefined

The full repro is in the issue; the regression test in this PR is the same shape.

Cause

module_init_deps in run_pipeline.rs drops init-call back-edges (#6463):

deps.retain(|dep| init_pos.get(dep).map_or(true, |&p| p < self_pos));

That is sound only because the entry's main emits an eager __init call for every Eager module in init_pos order, so a dep positioned earlier has already initialized and re-entering it would be the ordering bug #6463 fixed.

Deferred modules are filtered out of that loop in codegen/entry.rs, so nothing runs them but a dynamic-import dispatch site or another wrapper. Dropping a wrapper's edge to a Deferred dep therefore leaves it with no caller at all. In the repro the topological sort places b before a, so b's edge to a is a back-edge, it is dropped, and a is stranded.

The fix

Apply the positional drop to Eager deps only:

deps.retain(|dep| {
    deferred_module_prefixes.contains(dep)
        || init_pos.get(dep).map_or(true, |&p| p < self_pos)
});

Two properties make this safe rather than a re-opening of #6463:

Tests

crates/perry/tests/issue_10278_dynamic_import_cycle_init.rs is a deliberate pair:

  • dynamic_import_into_cycle_initializes_the_partner is the defect. On the pre-fix compiler it fails with assigned-UNDEFINED; it asserts byte-for-byte equality with node/bun output.
  • static_import_into_cycle_keeps_the_6463_order is the control that pins the ordering the fix must not disturb.

The cycle member assigns its exports at run time on purpose. A plain function declaration would not witness the bug, since those resolve without the body ever running.

test result: ok. 2 passed; 0 failed; finished in 112.61s

cargo fmt --all -- --check and scripts/check_file_size.sh pass.

Why it was found

OpenCode v1.18.30 defers every heavy subsystem behind await import(...) inside its command handlers, so its TUI, serve and run paths are Deferred subgraphs, and those subgraphs (solid-js, @opentui/*, effect) contain import cycles. Reproduced on linux-x64 and darwin-arm64.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed initialization for deferred modules involved in dynamic import cycles.
    • Ensured deferred module bodies run and runtime-assigned exports remain available.
    • Preserved existing initialization ordering for eager dependencies.
  • Tests

    • Added coverage for dynamic-import cycle initialization.
    • Verified deferred exports remain callable and static-import behavior is unchanged.
  • Documentation

    • Documented the deferred cycle initialization fix.

…module

A module reached only through a dynamic `import()` that takes part in an
import cycle never initialized its partner. The partner body did not run,
so every export it assigns at run time stayed undefined and the first call
through such a binding threw `TypeError: value is not a function`.

`module_init_deps` drops init-call back-edges (PerryTS#6463) so an `__init` wrapper
does not re-enter a cycle member the entry has already run. That is sound
only because the entry emits an eager init call for every Eager module in
topological order. Deferred modules are filtered out of that loop, so nothing
else runs them: dropping a wrapper edge to a Deferred dep left it with no
caller at all. Apply the positional drop to Eager deps only.

This cannot perturb the ordering PerryTS#6463 fixed. A module statically imported by
an Eager module is itself statically reachable from the entry and therefore
Eager, so the new arm never fires for it. Inside a deferred cycle the existing
`__perry_init_done_*` guard keeps the extra call idempotent and reproduces
ESM order: the partner body runs first and the re-entrant call returns.

The regression test pairs the defect with its control, the same cycle entered
statically, which pins the PerryTS#6463 ordering the fix must not disturb.

Fixes PerryTS#10278. Refs PerryTS#10107.
@coderabbitai

coderabbitai Bot commented Sep 15, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 5886b51f-2301-47b4-b5f1-03772bbc317c

📥 Commits

Reviewing files that changed from the base of the PR and between 1cd160f and 01c9f43.

📒 Files selected for processing (3)
  • changelog.d/10278-deferred-cycle-init.md
  • crates/perry/src/commands/compile/run_pipeline.rs
  • crates/perry/tests/issue_10278_dynamic_import_cycle_init.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The compiler now retains initialization edges to Deferred modules when positional filtering would remove a cycle back-edge. A regression test verifies deferred cycle initialization, runtime-assigned exports, and existing Eager cycle ordering. The changelog documents the fix.

Changes

Deferred cycle initialization

Layer / File(s) Summary
Retain Deferred initialization edges
crates/perry/src/commands/compile/run_pipeline.rs
module_init_deps retains edges to Deferred modules across positional back-edges. Eager dependencies remain subject to forward-only ordering.
Validate dynamic cycle initialization
crates/perry/tests/issue_10278_dynamic_import_cycle_init.rs, changelog.d/10278-deferred-cycle-init.md
The regression test checks deferred cycle-partner execution, runtime-assigned exports, and static cycle ordering. The changelog records the compiler change.

Priority: ➖ Normal

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant DynamicImport
  participant ModuleInitWrapper
  participant DeferredPartner
  participant RuntimeExports
  DynamicImport->>ModuleInitWrapper: start deferred module initialization
  ModuleInitWrapper->>DeferredPartner: retain and call cycle back-edge
  DeferredPartner->>RuntimeExports: assign exports during module body execution
  RuntimeExports-->>DynamicImport: expose initialized exports
Loading

Merge Risk: ⚪ Minimal · up to 01c9f

Deferred cycle partners initialize correctly on dynamic import while existing static cycle ordering remains covered. No actionable merge-blocking risk is established.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 2 files. (1 skipped: 1… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: initializing the cycle partner of a dynamically imported module.
Description check ✅ Passed The description provides the issue reference, defect, cause, fix, safety rationale, regression tests, verification results, and motivation. It does not use the template headings or explicitly complete…
Linked Issues check ✅ Passed The implementation satisfies #10278. In crates/perry/src/commands/compile/run_pipeline.rs, the init back-edge filter retains dependencies in deferred_module_prefixes and applies positional filteri…
Out of Scope Changes check ✅ Passed The changes stay within #10278. The implementation change fixes Deferred cycle initialization, the regression tests cover the dynamic defect and static ordering, and the changelog documents the fix. N…
Full details: Docstring Coverage

Explanation

Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 2 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train #10284 (v0.5.1572). All source commits preserve authorship; merged main matches the validated train exactly.

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.

A dynamically imported module in an import cycle never initializes its cycle partner — runtime-assigned exports stay undefined

1 participant