Summary
A module-level export const alias = ns.member can permanently capture undefined when the two modules form an import cycle — even though the init topo-sort ordered them correctly. Node (--experimental-strip-types) runs the same code fine.
Concrete failure: find-my-way-ts — FindMyWay.make is undefined under perry. This is the startup crash of web.ts in the effect-compiled-experiment repro (TypeError: value is not a function at Layer.launch, from HttpLayerRouter.ts:115 FindMyWay.make(yield* RouterConfig)), after #6449/#6459/#6460 clear the earlier layers.
Two-line repro
import * as FindMyWay from "find-my-way-ts"
console.log(typeof FindMyWay.make) // node: "function" | perry: "undefined"
Root cause
find-my-way-ts/src/index.ts:
import * as internal from "./internal/router.js"
export const make: <A>(options?: Partial<RouterConfig>) => Router<A> = internal.make
find-my-way-ts/src/internal/router.ts:
import * as Router from "../index.js" // used ONLY in type positions — but no `type` keyword
That second import is a syntactically-value namespace import used only for types, so #680's type_only skip doesn't apply — it is a real init edge, and the two files form a cycle index ⇄ internal.
topo_sort_non_entry_modules handles the cycle correctly: it breaks it at the back-edge and orders [internal, index] — which matches node's ESM evaluation order from the entry (HttpLayerRouter imports index; ESM evaluates internal's body first, then index's).
The bug is that each compiled module's __init wrapper also calls all of its deps' __inits at runtime (the #753 mechanism that transitively initializes Deferred deps). Those nested calls re-derive the order dynamically — including the back-edge the sort broke:
- main calls
internal__init first (per the sorted order — correct)
internal__init marks its guard, then calls index__init (the phantom cycle edge)
index__init marks its guard, calls internal__init → guard already set → returns
index's body runs → make = internal.make reads internal's global → still undefined
- unwind:
internal's body finally runs and stores the closure — too late; index.make is undefined forever
Verified with lldb at throw time: perry_global_..._index_ts__0 = 0x7FFC000000000001 (undefined) while perry_global_..._internal_router_ts__2 holds a valid closure pointer.
Fix
Filter module_init_deps (the nested wrapper edges) by topo position: a module's wrapper only init-calls deps the sort placed before it. Back-edges are skipped at runtime exactly as the sort skips them — which is also exactly what ESM does when it encounters a module already on the evaluation stack. Forward edges keep the Deferred-first-reach behavior intact (a lazily-reached module still pulls its whole forward dep tree, guard-protected).
module_init_deps participates in the object-cache key (init_deps field), so cached objects re-key cleanly.
Note
An alternative/complementary fix — eliding syntactically-value imports used only in type positions (tsc-style import elision) — would also remove this particular cycle, but does NOT match the node --experimental-strip-types oracle (amaro keeps such imports; node survives via ESM evaluation-order semantics, not elision). The order fix is the parity-faithful one and covers genuine value cycles too.
Summary
A module-level
export const alias = ns.membercan permanently captureundefinedwhen the two modules form an import cycle — even though the init topo-sort ordered them correctly. Node (--experimental-strip-types) runs the same code fine.Concrete failure:
find-my-way-ts—FindMyWay.makeisundefinedunder perry. This is the startup crash ofweb.tsin the effect-compiled-experiment repro (TypeError: value is not a functionatLayer.launch, fromHttpLayerRouter.ts:115FindMyWay.make(yield* RouterConfig)), after #6449/#6459/#6460 clear the earlier layers.Two-line repro
Root cause
find-my-way-ts/src/index.ts:find-my-way-ts/src/internal/router.ts:That second import is a syntactically-value namespace import used only for types, so #680's
type_onlyskip doesn't apply — it is a real init edge, and the two files form a cycleindex ⇄ internal.topo_sort_non_entry_moduleshandles the cycle correctly: it breaks it at the back-edge and orders[internal, index]— which matches node's ESM evaluation order from the entry (HttpLayerRouter importsindex; ESM evaluatesinternal's body first, thenindex's).The bug is that each compiled module's
__initwrapper also calls all of its deps'__inits at runtime (the #753 mechanism that transitively initializes Deferred deps). Those nested calls re-derive the order dynamically — including the back-edge the sort broke:internal__initfirst (per the sorted order — correct)internal__initmarks its guard, then callsindex__init(the phantom cycle edge)index__initmarks its guard, callsinternal__init→ guard already set → returnsindex's body runs →make = internal.makereadsinternal's global → stillundefinedinternal's body finally runs and stores the closure — too late;index.makeisundefinedforeverVerified with lldb at throw time:
perry_global_..._index_ts__0=0x7FFC000000000001(undefined) whileperry_global_..._internal_router_ts__2holds a valid closure pointer.Fix
Filter
module_init_deps(the nested wrapper edges) by topo position: a module's wrapper only init-calls deps the sort placed before it. Back-edges are skipped at runtime exactly as the sort skips them — which is also exactly what ESM does when it encounters a module already on the evaluation stack. Forward edges keep the Deferred-first-reach behavior intact (a lazily-reached module still pulls its whole forward dep tree, guard-protected).module_init_depsparticipates in the object-cache key (init_depsfield), so cached objects re-key cleanly.Note
An alternative/complementary fix — eliding syntactically-value imports used only in type positions (tsc-style import elision) — would also remove this particular cycle, but does NOT match the
node --experimental-strip-typesoracle (amaro keeps such imports; node survives via ESM evaluation-order semantics, not elision). The order fix is the parity-faithful one and covers genuine value cycles too.