From 4edf65b6b02b51229a07900513fd8c335f567272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 6 Jul 2026 00:43:56 +0200 Subject: [PATCH] =?UTF-8?q?fix(codegen):=20#5982=20=E2=80=94=20module-glob?= =?UTF-8?q?al=20captures=20must=20not=20feed=20the=20typed-ABI=20closure?= =?UTF-8?q?=20specialization=20(#5466=20regression)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A closure capturing a MODULE-LEVEL local reads it through `@perry_global_*` — `closure.rs` filters module globals OUT of the capture array, so the closure is `alloc_singleton` with no capture slots. But #5466's representation-aware lowering put the local's declared type into `module_local_types`, which drives the typed-ABI closure specialization: the `__typed_f64`/i32/… body then read `js_closure_get_capture_bits(this, 0)` — an UNSET slot (0) — while only the generic variant loaded the global. The dispatcher picked the typed body, so every closure returned 0: for (let i=0;i<5;i++){ const c=i; fns.push(()=>c); } // → 0,0,0,0,0 Fix mirrors the #5869 boxed-slot exclusion: a module-global capture has no capture-slot representation, so it must not feed the type-directed unboxed capture path — filtered out of `module_local_types`. Bisected to ce0117a60 (#5466). Restores `test_edge_closures` parity; adds a crates/perry guard (#5960: gap tests don't run in PR CI). --- crates/perry-codegen/src/codegen/mod.rs | 13 ++++ .../tests/issue_5982_loop_capture_typed.rs | 78 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 crates/perry/tests/issue_5982_loop_capture_typed.rs diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 77202c4f38..22c02475fb 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -1760,6 +1760,19 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // disqualifies every type-directed unboxed access on a boxed slot in // one place; consumers fall back to the generic (box-aware) paths. module_local_types.retain(|id, _| !module_boxed_vars.contains(id)); + // #5982 (#5466 regression): a MODULE-GLOBAL captured local is read by a + // closure through `@perry_global_*`, NOT the closure's capture array — + // `closure.rs` filters module globals OUT of `closure_captures`, so the + // closure is `alloc_singleton` with no capture slots. But advertising the + // local's type here made the typed-ABI specialization + // (`__typed_f64`/i32/…) read `js_closure_get_capture_bits(this, 0)` — an + // UNSET slot (0) — while the generic variant correctly loads the global; + // the dispatcher picked the typed body, so every closure returned 0. + // Repro (bisected to #5466 representation lowering): + // for (let i=0;i<5;i++){ const c=i; fns.push(()=>c); } // → 0,0,0,0,0 + // A module-global capture has no capture-slot representation, so — like a + // boxed slot — it must not feed the type-directed unboxed capture path. + module_local_types.retain(|id, _| !module_globals.contains_key(id)); // Cross-module function declares are emitted lazily by `lower_call` // via `FnCtx.pending_declares` (drained back into `llmod` at the diff --git a/crates/perry/tests/issue_5982_loop_capture_typed.rs b/crates/perry/tests/issue_5982_loop_capture_typed.rs new file mode 100644 index 0000000000..fde4ac5f79 --- /dev/null +++ b/crates/perry/tests/issue_5982_loop_capture_typed.rs @@ -0,0 +1,78 @@ +//! Regression test for #5982 (a #5466 representation-lowering regression): +//! a closure capturing a MODULE-LEVEL `const` bound to a typed value read +//! the wrong slot. +//! +//! `for (let i…) { const c = i; fns.push(() => c); }` returned `0,0,0,0,0` +//! instead of `0,1,2,3,4`. The captured module-level `c` is read by the +//! closure through `@perry_global_*` (module globals are filtered out of the +//! capture array), but its declared numeric type made the typed-ABI closure +//! specialization read `js_closure_get_capture_bits(this, 0)` — an unset slot +//! (0) — and the dispatcher picked that typed body. Module-global captures no +//! longer feed the type-directed unboxed capture path. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(dir: &std::path::Path, src: &str) -> String { + let entry = dir.join("main.ts"); + let out = dir.join("main_bin"); + std::fs::write(&entry, src).expect("write"); + let c = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&out) + .arg("--no-cache") + .output() + .expect("compile"); + assert!( + c.status.success(), + "compile failed\n{}", + String::from_utf8_lossy(&c.stderr) + ); + let r = Command::new(&out).current_dir(dir).output().expect("run"); + assert!( + r.status.success(), + "run failed\n{}", + String::from_utf8_lossy(&r.stderr) + ); + String::from_utf8_lossy(&r.stdout).into_owned() +} + +#[test] +fn loop_captured_module_const_reads_own_iteration_value() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = compile_and_run( + dir.path(), + r#" +const fns: Array<() => number> = []; +for (let i = 0; i < 5; i++) { + const captured = i; + fns.push(() => captured); +} +console.log(fns[0](), fns[1](), fns[2](), fns[3](), fns[4]()); +"#, + ); + assert_eq!(out, "0 1 2 3 4\n"); +} + +#[test] +fn loop_direct_capture_of_let_var_still_works() { + let dir = tempfile::tempdir().expect("tempdir"); + let out = compile_and_run( + dir.path(), + r#" +const fns: Array<() => number> = []; +for (let i = 0; i < 5; i++) { + fns.push(() => i); +} +console.log(fns[0](), fns[1](), fns[2](), fns[3](), fns[4]()); +"#, + ); + assert_eq!(out, "0 1 2 3 4\n"); +}