Skip to content

Commit 09f2f24

Browse files
proggeramlugRalph Küpper
andauthored
fix(codegen): #5982 — module-global captures must not feed the typed-ABI closure specialization (#5466 regression) (#6039)
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 ce0117a (#5466). Restores `test_edge_closures` parity; adds a crates/perry guard (#5960: gap tests don't run in PR CI). Co-authored-by: Ralph Küpper <ralph@skelpo.com>
1 parent 38f6eb0 commit 09f2f24

2 files changed

Lines changed: 91 additions & 0 deletions

File tree

crates/perry-codegen/src/codegen/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1760,6 +1760,19 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
17601760
// disqualifies every type-directed unboxed access on a boxed slot in
17611761
// one place; consumers fall back to the generic (box-aware) paths.
17621762
module_local_types.retain(|id, _| !module_boxed_vars.contains(id));
1763+
// #5982 (#5466 regression): a MODULE-GLOBAL captured local is read by a
1764+
// closure through `@perry_global_*`, NOT the closure's capture array —
1765+
// `closure.rs` filters module globals OUT of `closure_captures`, so the
1766+
// closure is `alloc_singleton` with no capture slots. But advertising the
1767+
// local's type here made the typed-ABI specialization
1768+
// (`__typed_f64`/i32/…) read `js_closure_get_capture_bits(this, 0)` — an
1769+
// UNSET slot (0) — while the generic variant correctly loads the global;
1770+
// the dispatcher picked the typed body, so every closure returned 0.
1771+
// Repro (bisected to #5466 representation lowering):
1772+
// for (let i=0;i<5;i++){ const c=i; fns.push(()=>c); } // → 0,0,0,0,0
1773+
// A module-global capture has no capture-slot representation, so — like a
1774+
// boxed slot — it must not feed the type-directed unboxed capture path.
1775+
module_local_types.retain(|id, _| !module_globals.contains_key(id));
17631776

17641777
// Cross-module function declares are emitted lazily by `lower_call`
17651778
// via `FnCtx.pending_declares` (drained back into `llmod` at the
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
//! Regression test for #5982 (a #5466 representation-lowering regression):
2+
//! a closure capturing a MODULE-LEVEL `const` bound to a typed value read
3+
//! the wrong slot.
4+
//!
5+
//! `for (let i…) { const c = i; fns.push(() => c); }` returned `0,0,0,0,0`
6+
//! instead of `0,1,2,3,4`. The captured module-level `c` is read by the
7+
//! closure through `@perry_global_*` (module globals are filtered out of the
8+
//! capture array), but its declared numeric type made the typed-ABI closure
9+
//! specialization read `js_closure_get_capture_bits(this, 0)` — an unset slot
10+
//! (0) — and the dispatcher picked that typed body. Module-global captures no
11+
//! longer feed the type-directed unboxed capture path.
12+
13+
use std::path::PathBuf;
14+
use std::process::Command;
15+
16+
fn perry_bin() -> PathBuf {
17+
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
18+
}
19+
20+
fn compile_and_run(dir: &std::path::Path, src: &str) -> String {
21+
let entry = dir.join("main.ts");
22+
let out = dir.join("main_bin");
23+
std::fs::write(&entry, src).expect("write");
24+
let c = Command::new(perry_bin())
25+
.current_dir(dir)
26+
.arg("compile")
27+
.arg(&entry)
28+
.arg("-o")
29+
.arg(&out)
30+
.arg("--no-cache")
31+
.output()
32+
.expect("compile");
33+
assert!(
34+
c.status.success(),
35+
"compile failed\n{}",
36+
String::from_utf8_lossy(&c.stderr)
37+
);
38+
let r = Command::new(&out).current_dir(dir).output().expect("run");
39+
assert!(
40+
r.status.success(),
41+
"run failed\n{}",
42+
String::from_utf8_lossy(&r.stderr)
43+
);
44+
String::from_utf8_lossy(&r.stdout).into_owned()
45+
}
46+
47+
#[test]
48+
fn loop_captured_module_const_reads_own_iteration_value() {
49+
let dir = tempfile::tempdir().expect("tempdir");
50+
let out = compile_and_run(
51+
dir.path(),
52+
r#"
53+
const fns: Array<() => number> = [];
54+
for (let i = 0; i < 5; i++) {
55+
const captured = i;
56+
fns.push(() => captured);
57+
}
58+
console.log(fns[0](), fns[1](), fns[2](), fns[3](), fns[4]());
59+
"#,
60+
);
61+
assert_eq!(out, "0 1 2 3 4\n");
62+
}
63+
64+
#[test]
65+
fn loop_direct_capture_of_let_var_still_works() {
66+
let dir = tempfile::tempdir().expect("tempdir");
67+
let out = compile_and_run(
68+
dir.path(),
69+
r#"
70+
const fns: Array<() => number> = [];
71+
for (let i = 0; i < 5; i++) {
72+
fns.push(() => i);
73+
}
74+
console.log(fns[0](), fns[1](), fns[2](), fns[3](), fns[4]());
75+
"#,
76+
);
77+
assert_eq!(out, "0 1 2 3 4\n");
78+
}

0 commit comments

Comments
 (0)