diff --git a/CLAUDE.md b/CLAUDE.md index 189a4c3055..9ca2f2b4bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1327 +**Current Version:** 0.5.1328 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index a4c8f3ad8c..f93ee20ec9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1327" +version = "0.5.1328" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1327" +version = "0.5.1328" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1327" +version = "0.5.1328" [[package]] name = "perry-ui-tvos" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1327" +version = "0.5.1328" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index ccd9962c86..1913529c47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1327" +version = "0.5.1328" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7580-prealloc-boxes-module-global.md b/changelog.d/7580-prealloc-boxes-module-global.md new file mode 100644 index 0000000000..13de671716 --- /dev/null +++ b/changelog.d/7580-prealloc-boxes-module-global.md @@ -0,0 +1,72 @@ +**fix(codegen): `PreallocateBoxes` no longer shadows a module-level global (#7521)** + +Every ES module with a top-level `{ … }` block containing a `function` +declaration that read a sibling `let`/`const` compiled to a silently-empty +binding: + +```ts +import { anything } from "node:path"; // any import — it just makes the file strict +{ + const events: string[] = []; + function t(a: any) { events.push("fn:" + a); } + t("A"); + console.log(events.length); // node: 1 perry: 0 +} +``` + +Nothing threw, so the breakage was invisible except where a test happened to +print the accumulated value. Object, number and string accumulators lost the +same way — inside `t`, the captured binding simply read `undefined`, so +`acc.n++` threw `Cannot read properties of undefined`. It surfaced as +`test-files/test_gap_diagchannel_3082_3084_3085_3086.ts` printing an empty +`events` array for the `#3086` `traceCallback` block, but neither +`traceCallback`, nor closures, nor arrays were involved. + +Root cause: `emit_preallocate_boxes` (`crates/perry-codegen/src/stmt/mod.rs`) +allocated a heap box — and, decisively, a `ctx.locals` slot — for every id in a +`Stmt::PreallocateBoxes` directive, including ids that +`codegen/module_globals_emit.rs` had already promoted to +`@perry_global___`. Codegen already encodes the rule that the module +global wins over the box (`ctx.boxed_vars.contains(id) && +!ctx.module_globals.contains_key(id)`, repeated at every read/write site in +`expr/literals_vars.rs` and `stmt/let_stmt.rs`); this was the one place that did +not check. The stale `ctx.locals` entry is consulted *before* +`ctx.module_globals` on the `Stmt::Let` reuse path and the `LocalGet`/`LocalSet` +store paths, so the declaration wrote into the local box-pointer slot, the +module global was never stored, and the closure — which reads through the +global, with zero captures — saw the `undefined` the global was defined with. + +The trigger was #7105's `lower_strict_block_fn_decls`, which made a bare block +at the top level of a strict module emit `PreallocateBoxes` for the block's +lexical bindings — the first time a promoted module-level id was ever handed to +`emit_preallocate_boxes`. (#6853's window, suspected on the issue, is not +involved.) + +Fix: skip promoted ids. The global is statically initialized to +`TAG_UNDEFINED`, exactly what a non-TDZ prealloc box seeds. The TDZ variant is +skipped too — module-global reads are a raw `load double @g` with no +`js_box_get_bits` choke point, so seeding `TAG_TDZ` would leak the sentinel into +arithmetic instead of throwing a `ReferenceError`. + +Regression coverage is an in-`src` unit module +(`crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs`), not a +`crates/*/tests/*.rs` suite, so it runs in the per-PR `cargo-test` gate rather +than only nightly (#5960) — the gap test that surfaced this is absent from +`gap_snapshot.json` and *suppressed* by a stale `known_failures.json` entry (see +below), and `parity` is tag-gated, which is why it sat unnoticed from +2026-07-30. The two positive tests are +sabotage-checked: reverting the `ctx.module_globals` guard takes both red while +the fixture-premise test and the "#569/#6044 still gets its box" test stay +green. The store assertion counts `store … ptr @perry_global_…` lines inside +`main()` rather than using `contains` — `main` also takes the global's address +for `js_gc_register_global_root`, which is emitted whether or not the +declaration ever writes the cell, and that reference made the first draft of the +test pass under sabotage. + +The stale `test-parity/known_failures.json` entry for +`test_gap_diagchannel_3082_3084_3085_3086` (added 2026-07-04 for the original +`#3082/#3084/#3085/#3086` feature cluster, long since implemented) is retired in +the same change — that list is a pure suppression, so an entry whose test has +started passing silences the regression forever rather than failing the way the +`gc_root_dominance_allowlist` ratchet does. The test now byte-matches +`node --experimental-strip-types` with exit 0 across all four blocks. diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index fe9f193369..a574e8cdf3 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -20,6 +20,8 @@ mod let_stmt; mod let_stmt_facts; mod loops; mod masked_window_region; +#[cfg(test)] +mod prealloc_module_global_tests; mod switch_stmt; mod try_stmt; mod unused_expr; @@ -595,6 +597,33 @@ pub(crate) fn lower_stmt(ctx: &mut FnCtx<'_>, stmt: &Stmt) -> Result<()> { fn emit_preallocate_boxes(ctx: &mut FnCtx<'_>, ids: &[u32], tdz: bool) -> Result<()> { for id in ids { + // #7521: a module-level binding promoted to `@perry_global___` + // ALREADY has the shared, forward-visible, GC-rooted cell a prealloc box + // would provide, and every read/write site in codegen treats + // `module_globals` as winning over `boxed_vars` (`ctx.boxed_vars + // .contains(id) && !ctx.module_globals.contains_key(id)`). Allocating a + // box here anyway is not merely redundant — it registers a + // `ctx.locals` slot for the id, and `ctx.locals` is consulted BEFORE + // `ctx.module_globals` on both the `Stmt::Let` reuse path + // (`let_stmt.rs`) and the `LocalGet`/`LocalSet` store paths + // (`expr/literals_vars.rs`). The declaration then writes the value into + // the local box-pointer slot and the module global is never stored, so + // any function or closure that reads the binding through the global + // sees the `undefined` it was defined with. That is how a strict-mode + // block-scoped `function` declaration lost its entire captured + // environment once #7105 started emitting `PreallocateBoxes` for + // module-top-level blocks (`{ const events = []; function t(){ + // events.push(x) } }` in any ES module — every push landed nowhere). + // + // The global is statically initialized to `TAG_UNDEFINED`, which is + // exactly what a non-TDZ prealloc box seeds. The TDZ variant is skipped + // too: module-global reads are raw `load double @g` with no + // `js_box_get_bits` choke point, so seeding `TAG_TDZ` there would leak + // the sentinel into arithmetic instead of throwing a ReferenceError — + // strictly worse than the `undefined` a forward read gets today. + if ctx.module_globals.contains_key(id) { + continue; + } if ctx.locals.contains_key(id) { // A previous PreallocateBoxes (or an unusual nesting) // already set this up -- skip to keep the existing slot. diff --git a/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs b/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs new file mode 100644 index 0000000000..9baf8b5950 --- /dev/null +++ b/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs @@ -0,0 +1,308 @@ +//! #7521: `Stmt::PreallocateBoxes` must not shadow a module-level global. +//! +//! A module-level binding that any function or closure reads is promoted to +//! `@perry_global___` (`codegen/module_globals_emit.rs`) — a single, +//! shared, GC-rooted, forward-visible cell. That is *also* what a prealloc box +//! provides, so the two mechanisms are alternatives, and every read/write site +//! in codegen already spells out which one wins: +//! +//! ```text +//! ctx.boxed_vars.contains(id) && !ctx.module_globals.contains_key(id) +//! ``` +//! +//! `emit_preallocate_boxes` was the one place that did not check. Allocating a +//! box for a promoted id is not merely redundant: it registers a `ctx.locals` +//! slot, and `ctx.locals` is consulted BEFORE `ctx.module_globals` on the +//! `Stmt::Let` reuse path (`let_stmt.rs`) and on the `LocalGet`/`LocalSet` +//! store paths (`expr/literals_vars.rs`). The declaration then writes its value +//! into the box-pointer slot, the global is never stored, and every closure +//! that reads the binding through the global sees the `undefined` it was +//! defined with. +//! +//! The user-visible shape (test-files/test_gap_diagchannel_3082_3084_3085_3086.ts, +//! and any ES module at all after #7105 started emitting `PreallocateBoxes` for +//! module-top-level blocks): +//! +//! ```js +//! { const events = []; function t() { events.push("x"); } t(); events.length } +//! ``` +//! +//! `t()` ran, `events.length` was 0, and nothing threw — the push landed in a +//! cell nobody else read. +//! +//! The assertions below are on emitted IR rather than on a flag, because the +//! bug is precisely that a live, correct-looking box was built for a binding +//! that stores somewhere else (CLAUDE.md: "a gate must assert its subject was +//! live"). + +use crate::{compile_module, AppMetadata, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Expr, Module, ModuleInitKind, Param, Stmt}; + +fn ir_opts() -> CompileOptions { + CompileOptions { + target: None, + is_entry_module: true, + non_entry_module_prefixes: Vec::new(), + nextjs_path_init_modules: Vec::new(), + import_function_prefixes: std::collections::HashMap::new(), + import_function_ffi_aliases: std::collections::HashMap::new(), + import_function_origin_names: std::collections::HashMap::new(), + import_function_v8_specifiers: std::collections::HashMap::new(), + import_function_node_submodule: std::collections::HashMap::new(), + namespace_node_submodules: std::collections::HashMap::new(), + namespace_v8_specifiers: std::collections::HashMap::new(), + namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), + emit_ir_only: true, + verify_native_regions: false, + disable_buffer_fast_path: false, + namespace_imports: Vec::new(), + imported_classes: Vec::new(), + imported_enums: Vec::new(), + imported_async_funcs: std::collections::HashSet::new(), + type_aliases: std::collections::HashMap::new(), + imported_func_param_counts: std::collections::HashMap::new(), + imported_func_has_rest: std::collections::HashSet::new(), + imported_func_synthetic_arguments: std::collections::HashSet::new(), + imported_func_return_types: std::collections::HashMap::new(), + imported_vars: std::collections::HashSet::new(), + output_type: "executable".to_string(), + needs_stdlib: false, + needs_ui: false, + needs_geisterhand: false, + geisterhand_port: 7676, + enabled_features: Vec::new(), + native_module_init_names: Vec::new(), + js_module_specifiers: Vec::new(), + bundled_extensions: Vec::new(), + native_library_functions: Vec::new(), + i18n_table: None, + fast_math: false, + fp_contract_mode: crate::FpContractMode::Off, + app_metadata: AppMetadata::default(), + namespace_entries: Vec::new(), + dynamic_import_path_to_prefix: std::collections::HashMap::new(), + deferred_module_prefixes: std::collections::HashSet::new(), + module_init_deps: Vec::new(), + is_dynamic_import_target: false, + debug_locations: false, + module_source: None, + debug_source_line_offset: 0, + } +} + +fn emit(m: &Module) -> String { + String::from_utf8(compile_module(m, ir_opts()).unwrap()).expect("LLVM IR should be UTF-8") +} + +/// `function t(a) { events = a; }` — the hoisted block-level function +/// declaration, as `lower_strict_block_fn_decls` leaves it: a `Stmt::Let` +/// bound to a closure that both reads and writes the block's `const`, placed +/// AHEAD of that const's own declaration. +fn hoisted_writer(fn_local: u32, target: u32) -> Stmt { + Stmt::Let { + id: fn_local, + name: "t".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Closure { + func_id: 1, + params: vec![Param { + id: 40, + name: "a".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }], + return_type: Type::Any, + body: vec![Stmt::Expr(Expr::LocalSet( + target, + Box::new(Expr::LocalGet(40)), + ))], + captures: vec![target], + mutable_captures: vec![target], + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: false, + is_async: false, + is_generator: false, + is_strict: true, + }), + } +} + +/// The exact module-init shape `lower_strict_block_fn_decls` produces for +/// `{ const events = []; function t(a) { events = a } t(1) }` at the top level +/// of an ES module: the prealloc directive, then the hoisted closure, then the +/// binding's own declaration. +/// +/// `prealloc` is a parameter so the "with" and "without" arms below are the +/// same fixture, differing only in the statement under test. +fn module_with_hoisted_block_fn(prealloc: bool) -> Module { + let mut m = Module::new("prealloc_global.ts"); + let mut init = Vec::new(); + if prealloc { + init.push(Stmt::PreallocateBoxes(vec![1])); + } + init.push(hoisted_writer(0, 1)); + init.push(Stmt::Let { + id: 1, + name: "events".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Array(Vec::new())), + }); + init.push(Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(0)), + args: vec![Expr::Integer(1)], + type_args: Vec::new(), + byte_offset: 0, + })); + m.init = init; + m.init_kind = ModuleInitKind::Eager; + m +} + +/// The slice of `ir` that is the entry module's init function (`main`), which +/// is where the binding's own `Stmt::Let` is emitted. Deliberately excludes the +/// closure body — the closure ALWAYS stored to the global, before and after the +/// fix, so an assertion over the whole module would have been green throughout. +fn main_body(ir: &str) -> &str { + let start = ir + .find("define i32 @main()") + .expect("entry module must emit main()"); + let rest = &ir[start..]; + let end = rest.find("\n}\n").map(|e| e + start).unwrap_or(ir.len()); + &ir[start..end] +} + +const GLOBAL: &str = "@perry_global_prealloc_global_ts__1"; + +/// Count the STORES to the module global in `body`. A plain `contains` is not +/// enough: `main` also takes the global's address for +/// `js_gc_register_global_root`, and that reference is emitted whether or not +/// the declaration ever writes the cell — it kept the pre-fix IR looking fine. +fn stores_to_global(body: &str) -> usize { + body.lines() + .filter(|l| { + let l = l.trim(); + l.starts_with("store ") && l.ends_with(&format!("ptr {GLOBAL}")) + }) + .count() +} + +/// Fixture premise. If `events` ever stops being promoted, the tests below +/// would pass vacuously — they would be asserting about a global that no longer +/// participates. +#[test] +fn the_captured_module_binding_is_promoted_to_a_global() { + for prealloc in [false, true] { + let ir = emit(&module_with_hoisted_block_fn(prealloc)); + assert!( + ir.contains(&format!("{GLOBAL} = ")), + "premise (prealloc={prealloc}): a module-level binding read from a \ + closure must be promoted to a module global" + ); + } +} + +/// The regression. `PreallocateBoxes` must not divert the declaration's store +/// away from the shared cell. +/// +/// Sabotage: delete the `ctx.module_globals.contains_key(id)` early-continue in +/// `emit_preallocate_boxes` (`stmt/mod.rs`) and this goes red — the box-pointer +/// slot swallows the store, `main` never touches the global, and every closure +/// reading it sees `undefined`. That is #7521. +#[test] +fn a_preallocated_box_does_not_swallow_the_module_global_store() { + let ir = emit(&module_with_hoisted_block_fn(true)); + let main = main_body(&ir); + assert!( + stores_to_global(main) > 0, + "#7521: the declaration `const events = []` must store into the shared \ + module global, but main() never references {GLOBAL} as a store target. \ + `emit_preallocate_boxes` registered a ctx.locals slot for a promoted \ + id, and ctx.locals is consulted before ctx.module_globals on the \ + Stmt::Let / LocalSet paths.\n--- main() ---\n{main}" + ); +} + +/// The prealloc arm must emit exactly what the no-prealloc arm emits for this +/// binding: the directive is a no-op for a promoted id, not a different +/// lowering that happens to also store the global. +#[test] +fn prealloc_is_a_no_op_for_a_promoted_binding() { + let with = emit(&module_with_hoisted_block_fn(true)); + let without = emit(&module_with_hoisted_block_fn(false)); + let count = |ir: &str| stores_to_global(main_body(ir)); + assert_eq!( + count(&with), + count(&without), + "#7521: main() must STORE the module global the same number of times \ + with and without PreallocateBoxes" + ); + assert!( + !main_body(&with).contains("js_box_alloc_bits"), + "#7521: no box may be allocated in main() for a binding whose storage \ + is already a module global\n--- main() ---\n{}", + main_body(&with) + ); +} + +/// The fix must not gut #569 / #6044. A prealloc'd id that is NOT a module +/// global — the ordinary case, a `let`/`const` inside a function body captured +/// by a hoisted sibling `function` — still needs its box. +#[test] +fn a_function_local_prealloc_still_gets_its_box() { + let mut m = Module::new("prealloc_local.ts"); + // An arrow IIFE at module scope: its body's ids are function-locals, so + // nothing inside is eligible for module-global promotion. + let inner = vec![ + Stmt::PreallocateBoxes(vec![11]), + hoisted_writer(10, 11), + Stmt::Let { + id: 11, + name: "events".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Array(Vec::new())), + }, + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(10)), + args: vec![Expr::Integer(1)], + type_args: Vec::new(), + byte_offset: 0, + }), + ]; + m.init = vec![Stmt::Expr(Expr::Call { + callee: Box::new(Expr::Closure { + func_id: 2, + params: Vec::new(), + return_type: Type::Any, + body: inner, + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: true, + }), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + })]; + m.init_kind = ModuleInitKind::Eager; + let ir = emit(&m); + assert!( + ir.contains("js_box_alloc_bits"), + "#569/#6044: a prealloc'd id with no module global must still get a \ + heap box — the #7521 guard is scoped to promoted ids only" + ); +} diff --git a/test-parity/known_failures.json b/test-parity/known_failures.json index 8609ce6486..18088a03f0 100644 --- a/test-parity/known_failures.json +++ b/test-parity/known_failures.json @@ -82,12 +82,6 @@ "category": "gap-categorical", "reason": "console write validation — categorical console gap; standing per #5917." }, - "test_gap_diagchannel_3082_3084_3085_3086": { - "issue": "3082", - "added": "2026-07-04", - "category": "bug-open", - "reason": "diagnostics_channel cluster (#3082/#3084/#3085/#3086); standing per #5917." - }, "test_gap_dyn_index_get_denormal_safe": { "issue": "5917", "added": "2026-07-04",