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.
Summary
A module that is reached only through a dynamic
import()(perry'sModuleInitKind::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 staysundefined. Calling one of them throwsTypeError: 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.
caandcbimport each other;caassigns two of its exports at runtime, which is what a cycle partner's body is for.ca.tscb.tsmain-dyn.tsbun 1.3.14 and node:
perry 0.5.1571,
perry compile main-dyn.ts(reproduced on both linux-x64 and darwin-arm64):ca's body never runs. Swapmain-dyn.tsfor a staticimport { ... } 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 themodule_init_depscomputation, drops init-call back-edges (#6463):That is sound only for Eager modules, because the entry's
mainruns every Eager module's__initininit_posorder (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:
so nothing runs a Deferred module but a dynamic-import dispatch site or another module's
__initwrapper. Dropping the wrapper's edge to a Deferred dep therefore strands it permanently. In the repro the topological sort placescbbeforeca, socb's edge tocais 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:
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,serveandrunpaths are all Deferred subgraphs, and those subgraphs (solid-js,@opentui/*, effect) contain import cycles. The #6463 comment in the same function already recordsTypeError: value is not a functionas the symptom of the mirror-image ordering bug; this is the same symptom reached from the other side.