Skip to content

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

Description

@proggeramlug

Summary

A module that is reached only through a dynamic import() (perry's ModuleInitKind::Deferred) and that takes part in an import cycle never initializes its cycle partner. The partner's module body does not run, so every export the body assigns at runtime stays undefined. Calling one of them throws TypeError: value is not a function — or, as in the repro below, Cannot read properties of undefined.

Static-import entry into the same cycle is correct. Only the dynamic-import entry is wrong.

Repro

Three files. ca and cb import each other; ca assigns two of its exports at runtime, which is what a cycle partner's body is for.

ca.ts

console.log("[init] ca body start")
import { bFn } from "./cb"
export let assigned: (() => string) | undefined
assigned = () => "ca-assigned-ok"
export const obj: any = {}
obj.method = () => "ca-obj-method-ok"
export function aFn() { return "a:" + bFn() }
console.log("[init] ca body end")

cb.ts

console.log("[init] cb body start")
import { aFn, assigned, obj } from "./ca"
export function bFn() { return "b" }
export function useA() { return aFn() }
export function useAssigned() { return assigned ? assigned() : "assigned-UNDEFINED" }
export function useObj() { return typeof obj.method === "function" ? obj.method() : "obj.method-NOT-A-FUNCTION" }
console.log("[init] cb body end")

main-dyn.ts

console.log("[main] start")
const m = await import("./cb")
console.log("[main] bFn =", m.bFn())
console.log("[main] useA =", m.useA())
console.log("[main] useAssigned =", m.useAssigned())
console.log("[main] useObj =", m.useObj())

bun 1.3.14 and node:

[main] start
[init] ca body start
[init] ca body end
[init] cb body start
[init] cb body end
[main] bFn = b
[main] useA = a:b
[main] useAssigned = ca-assigned-ok
[main] useObj = ca-obj-method-ok

perry 0.5.1571, perry compile main-dyn.ts (reproduced on both linux-x64 and darwin-arm64):

[main] start
[init] cb body start
[init] cb body end
[main] bFn = b
[main] useA = a:b
[main] useAssigned = assigned-UNDEFINED
TypeError: Cannot read properties of undefined (reading 'method')
    at useObj (<anonymous>)

ca's body never runs. Swap main-dyn.ts for a static import { ... } from "./cb" and perry matches bun exactly, which is what makes this easy to miss.

Cause

crates/perry/src/commands/compile/run_pipeline.rs, in the module_init_deps computation, drops init-call back-edges (#6463):

if let Some(&self_pos) = init_pos.get(&sanitize_name(&hir_module.name)) {
    deps.retain(|dep| init_pos.get(dep).map_or(true, |&p| p < self_pos));
}

That is sound only for Eager modules, because the entry's main runs every Eager module's __init in init_pos order (crates/perry-codegen/src/codegen/entry.rs), so a dep positioned before this module has already initialized by the time the wrapper runs, and re-calling it would be the #6463 ordering bug.

Deferred modules are explicitly filtered out of that eager loop:

for (index, prefix) in non_entry_module_prefixes.iter().enumerate() {
    if cross_module.deferred_module_prefixes.contains(prefix) { continue; }
    blk.call_void(&format!("{}__init", prefix), &[]);
}

so nothing runs a Deferred module but a dynamic-import dispatch site or another module's __init wrapper. Dropping the wrapper's edge to a Deferred dep therefore strands it permanently. In the repro the topological sort places cb before ca, so cb's edge to ca is a back-edge, it is dropped, and no other caller exists.

Suggested fix

Keep the edge whenever the dep is Deferred, and apply the positional drop only to Eager deps:

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

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

Why it matters

Found while bringing up OpenCode v1.18.30 natively (tracker #10107). OpenCode's CLI defers every heavy subsystem behind await import(...) inside its command handlers, so its TUI, serve and run paths are all Deferred subgraphs, and those subgraphs (solid-js, @opentui/*, effect) contain import cycles. The #6463 comment in the same function already records TypeError: value is not a function as the symptom of the mirror-image ordering bug; this is the same symptom reached from the other side.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions