Skip to content

module init: nested dep-init calls ignore the topo sort's cycle-breaks — alias exports capture undefined (FindMyWay.make undefined; blocks effect web.ts) #6463

Description

@proggeramlug

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-tsFindMyWay.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:

  1. main calls internal__init first (per the sorted order — correct)
  2. internal__init marks its guard, then calls index__init (the phantom cycle edge)
  3. index__init marks its guard, calls internal__init → guard already set → returns
  4. index's body runsmake = internal.make reads internal's global → still undefined
  5. 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.

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