Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 46 additions & 6 deletions crates/perry/src/commands/compile/init_order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand All @@ -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,
);
}
}
}
}
Comment on lines +267 to +268

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Include entry module re-exports in the root DFS traversal.

The new logic successfully roots the DFS at the entry module's imports to preserve order, but it currently overlooks re-exports (e.g., export * from './b.js'). As a result, modules reached only via entry-level re-exports will fall back to alphabetical ordering, defeating the cycle-breaking fix for those branches. Add an iteration over entry_module.exports identical to how non-entry modules collect export dependencies.

🐛 Proposed fix to traverse entry re-exports
             }
         }
+
+        for export in &entry_module.exports {
+            let source = match export {
+                perry_hir::Export::ExportAll { source } => Some(source),
+                perry_hir::Export::ReExport { source, .. } => Some(source),
+                perry_hir::Export::NamespaceReExport { source, .. } => Some(source),
+                perry_hir::Export::Named { .. } => None,
+            };
+            if let Some(src) = source {
+                if let Some((resolved_path, _)) = resolve_import(
+                    src,
+                    entry_path,
+                    &ctx.project_root,
+                    &ctx.compile_packages,
+                    &ctx.compile_package_dirs,
+                ) {
+                    if path_to_name.contains_key(&resolved_path) {
+                        dfs_visit(
+                            &resolved_path,
+                            &deps,
+                            &path_to_name,
+                            &mut visited,
+                            &mut visiting,
+                            &mut sorted,
+                        );
+                    }
+                }
+            }
+        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
}
}
for export in &entry_module.exports {
let source = match export {
perry_hir::Export::ExportAll { source } => Some(source),
perry_hir::Export::ReExport { source, .. } => Some(source),
perry_hir::Export::NamespaceReExport { source, .. } => Some(source),
perry_hir::Export::Named { .. } => None,
};
if let Some(src) = source {
if let Some((resolved_path, _)) = resolve_import(
src,
entry_path,
&ctx.project_root,
&ctx.compile_packages,
&ctx.compile_package_dirs,
) {
if path_to_name.contains_key(&resolved_path) {
dfs_visit(
&resolved_path,
&deps,
&path_to_name,
&mut visited,
&mut visiting,
&mut sorted,
);
}
}
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry/src/commands/compile/init_order.rs` around lines 267 - 268,
Update the root DFS traversal in the initialization-order logic to iterate over
entry_module.exports in addition to the entry module’s imports. Apply the same
export-dependency traversal used for non-entry modules so modules reachable only
through entry-level re-exports are included in DFS ordering.


let mut all_paths: Vec<PathBuf> = path_to_name.keys().cloned().collect();
all_paths.sort();

Expand Down
30 changes: 30 additions & 0 deletions crates/perry/src/commands/compile/run_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, usize> =
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));
}
Comment on lines +2172 to +2180

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Move init_pos computation outside the parallel loop to prevent $O(N^2)$ scaling.

Building the init_pos HashMap inside the par_iter closure means every module's codegen task iterates over all non_entry_module_names, sanitizes the strings, and allocates a new map. For a project with $N$ modules, this creates an $O(N^2)$ performance bottleneck on the hot path.

Precompute the map once before the parallel iterator and share it by reference:

// Add this before `ctx.native_modules.par_iter()` (around line 1976):
let non_entry_module_init_pos: std::collections::HashMap<String, usize> = non_entry_module_names
    .iter()
    .enumerate()
    .map(|(i, name)| (sanitize_module_name(name), i))
    .collect();
⚡ Proposed fix for the parallel map closure
-                let init_pos: std::collections::HashMap<String, usize> =
-                    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));
+                if let Some(&self_pos) = non_entry_module_init_pos.get(&sanitize_name(&hir_module.name)) {
+                    deps.retain(|dep| non_entry_module_init_pos.get(dep).map_or(true, |&p| p < self_pos));
                 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry/src/commands/compile/run_pipeline.rs` around lines 2172 - 2180,
Move the `init_pos`/`non_entry_module_init_pos` construction out of the parallel
module-processing closure and compute it once before
`ctx.native_modules.par_iter()`. Reuse that shared map inside the closure for
the `hir_module.name` lookup and dependency filtering, preserving the existing
sanitized-name ordering behavior while avoiding per-module iteration and
allocation.

deps
};
// Build import → source-prefix table for cross-module
Expand Down
Loading