From d52ae8c37e7933408ea49e56aff55c7ddb3afd63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 16 Jul 2026 10:26:33 +0200 Subject: [PATCH 1/2] fix(compile): drop init-call back-edges the topo sort already broke (#6463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FindMyWay.make` was permanently `undefined` under perry — node runs the same code fine. This was the startup crash of effect's web.ts ("TypeError: value is not a function" at Layer.launch). find-my-way-ts forms an import cycle: index.ts value-imports internal/router.ts, and internal/router.ts has `import * as Router from "../index.js"` used only in type positions — but with no `type` keyword, so it is a real value edge (#680's type_only skip doesn't apply). `topo_sort_non_entry_modules` handles the cycle correctly: it breaks it at the back-edge and orders [internal, index], matching node's ESM evaluation order from the entry. But each module's `__init` wrapper also calls all of its deps' `__init`s at runtime (the #753 mechanism that transitively initializes Deferred deps) — 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 cycle edge) 3. index__init marks its guard, calls internal__init → guard set → returns 4. index's BODY runs → `export const make = internal.make` reads internal's global → still undefined 5. internal's body finally runs and stores the closure — too late Verified with lldb at throw time: index's global held TAG_UNDEFINED while internal's global held a valid closure pointer. Fix: filter `module_init_deps` by topo position — a 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 meets a module already on the evaluation stack. Forward edges keep the Deferred-first-reach behavior intact. `module_init_deps` participates in the object-cache key (`init_deps`), so cached objects re-key cleanly. Repro (2 lines): import * as FindMyWay from "find-my-way-ts" console.log(typeof FindMyWay.make) // node: "function" | perry pre-fix: "undefined" --- .../src/commands/compile/run_pipeline.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 1c2b2beeca..bde9a85fe5 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -2148,6 +2148,36 @@ pub fn run_with_parse_cache( } } } + // Drop init-call back-edges (#6463). `topo_sort_non_entry_modules` + // breaks import cycles at the back-edge and the eager main + // sequence runs inits in that order — but the wrapper's nested + // dep-init calls re-derive the order dynamically at runtime. + // When the cycle member the sort placed FIRST runs, its broken + // edge to the member placed second pulled that module's BODY in + // early, before this module's own body had populated anything. + // Effect's web.ts died on this: find-my-way-ts + // internal/router.ts has `import * as Router from "../index.js"` + // used only in type positions (no `type` keyword, so it is a + // value edge), forming a cycle index ⇄ internal. The sort + // correctly placed internal first — matching node's ESM + // evaluation order from the entry — but internal's wrapper then + // called index's init, whose body copied + // `export const make = internal.make` while internal's global + // was still undefined. `FindMyWay.make` stayed undefined + // forever: "TypeError: value is not a function" at + // Layer.launch. Keeping only forward edges (dep positioned + // before this module) is exactly ESM's behavior of skipping a + // module already on the evaluation stack. A dep missing from + // the position map keeps its edge (conservative). + let init_pos: std::collections::HashMap = + non_entry_module_names + .iter() + .enumerate() + .map(|(i, name)| (sanitize_name(name), i)) + .collect(); + 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)); + } deps }; // Build import → source-prefix table for cross-module From 8d3ab7d00e67004fcb1259b03714a6e960078aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 16 Jul 2026 12:48:06 +0200 Subject: [PATCH 2/2] fix(compile): visit init-order deps in import-declaration order, rooted at the entry (#6463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a DAG, alphabetical dep visiting produces an equally valid topological order — but inside an import cycle the visit order decides WHICH edge becomes the broken back-edge, i.e. which module's body runs first. Node's ESM evaluation visits requested modules in declaration order starting from the entry, so the alphabetical order could break a cycle in the OPPOSITE direction: the module node evaluates first gets ordered last by perry, its init-call edge is dropped as a back-edge (the #6463 filter), and any alias binding read from it (`export const x = internal.x`) captures undefined. Two changes, same principle: - deps are visited in source order (imports first, then re-export sources) — the map is already built that way; stop sorting it. - the DFS is rooted at the ENTRY module's imports in declaration order; modules not reachable through collected edges (Deferred dynamic-import targets) are appended afterwards alphabetically for determinism. Validated: find-my-way-ts cycle repro (probe35) and the dynamic-construct suite (probe42) unchanged-green; effect logger/forking/api remain byte-identical to node. --- .../perry/src/commands/compile/init_order.rs | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/crates/perry/src/commands/compile/init_order.rs b/crates/perry/src/commands/compile/init_order.rs index 5c1324ccc8..6844e8ef3d 100644 --- a/crates/perry/src/commands/compile/init_order.rs +++ b/crates/perry/src/commands/compile/init_order.rs @@ -209,12 +209,23 @@ pub(super) fn topo_sort_non_entry_modules( } visiting.insert(path.clone()); - // Visit dependencies first (so they get initialized before us) + // Visit dependencies first (so they get initialized before us). + // + // #6463 follow-up (effect web.ts "Not a valid effect: undefined"): + // visit deps in IMPORT-DECLARATION order, not alphabetically. For a + // DAG the two produce equally valid topological orders, but inside a + // cycle the visit order decides WHICH edge becomes the broken + // back-edge — i.e. which module's body runs first. Node's ESM + // evaluation visits requested modules in declaration order, so an + // alphabetical order here can break a cycle in the OPPOSITE + // direction from node: the module node evaluates first is ordered + // last by perry, its init-call edge is dropped as a back-edge + // (run_pipeline's #6463 filter), and every alias binding read from + // it (`export const x = internal.x`) captures undefined. `deps` is + // built in source order (imports first, then re-export sources), so + // simply not sorting preserves the ESM visit order. if let Some(module_deps) = deps.get(path) { - // Sort deps for deterministic order - let mut sorted_deps = module_deps.clone(); - sorted_deps.sort(); - for dep in &sorted_deps { + for dep in module_deps { dfs_visit(dep, deps, path_to_name, visited, visiting, sorted); } } @@ -226,7 +237,36 @@ pub(super) fn topo_sort_non_entry_modules( } } - // Sort starting nodes for deterministic iteration order + // #6463 follow-up: root the DFS at the ENTRY module's imports, in + // declaration order — the same place node's ESM evaluation starts. The + // previous alphabetical all-paths iteration produced a valid topological + // order for DAGs, but whichever alphabetically-early module first reached + // a cycle decided its break direction, which could invert node's + // evaluation order for that cycle (see the dep-order comment above). + // Any module not reachable from the entry through the collected edges + // (Deferred dynamic-import targets, etc.) is appended afterwards in + // alphabetical order for determinism. + if let Some(entry_module) = ctx.native_modules.get(entry_path) { + for import in &entry_module.imports { + if import.is_dynamic || import.type_only || import.is_deferred_require { + continue; + } + if let Some(ref resolved) = import.resolved_path { + let resolved_path = PathBuf::from(resolved); + if path_to_name.contains_key(&resolved_path) { + dfs_visit( + &resolved_path, + &deps, + &path_to_name, + &mut visited, + &mut visiting, + &mut sorted, + ); + } + } + } + } + let mut all_paths: Vec = path_to_name.keys().cloned().collect(); all_paths.sort();