diff --git a/CLAUDE.md b/CLAUDE.md index 0ac66de295..fa770f42e3 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.1438 +**Current Version:** 0.5.1439 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index c16fccd1a5..8e8fe0229b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1438" +version = "0.5.1439" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1438" +version = "0.5.1439" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1438" +version = "0.5.1439" [[package]] name = "perry-ui-tvos" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1438" +version = "0.5.1439" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 6f879b886c..76ae4a417e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1438" +version = "0.5.1439" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7751-deforest-growth-forwarding.md b/changelog.d/7751-deforest-growth-forwarding.md new file mode 100644 index 0000000000..7d8fe07147 --- /dev/null +++ b/changelog.d/7751-deforest-growth-forwarding.md @@ -0,0 +1,13 @@ +### Fixed + +- **deforestation: the caller's binding kept a growth-forwarding stub after a deforested call (#7661).** `const keep = build(1000)` left `keep` pointing at the array's *pre-growth* address. `js_array_grow` does not grow in place — it allocates elsewhere, copies, and leaves a forwarding stub behind — so the binding was stale the moment the array outgrew `MIN_ARRAY_CAPACITY` (16). + + The producer half of the hazard #7660 fixed on the consumer side. Deforestation rewrites `const keep = f(n)` into `let keep = []; f(n, keep)`, handing the callee the array the caller allocated. The callee's `out.push` write-back re-points its **own** out-param slot; nothing re-pointed the caller's. The producer's `return out` was *dropped* by the rewrite, so it fell through to an implicit `return undefined` and there was no value to store back — visible in the emitted IR as `for.exit: ret double 0x7FFC000000000001` with the call's result discarded. + + This is invisible to behaviour. Every runtime entry point resolves the chain through `clean_arr_ptr`, so the program prints the right answer either way, and only emitted code that dereferences the head directly can see it — which is why it surfaced as #7612's element-shape clone reading a pre-growth buffer and `SIGBUS`ing at exactly N = 17, and why #7660 had to repair that consumer individually. + + **Fix:** the producer now KEEPS its `return out` — step 4's substitution turns it into `return `, the live head after every realloc write-back — and all three call-site rewrites store the result back over the caller's binding: the non-consumer site (`keep = f(n, keep)`), the consumer-fuse site (`outer = f(args, outer)`), and pass-through recursion inside the producer. `detect.rs` already guarantees exactly one top-level `return LocalGet(out_id)` as the last statement, so there is nothing else the kept return could be. The seeded binding is emitted `mutable` because it is now written twice; leaving it `const` while storing through it would make every analysis that trusts `mutable: false` wrong. + + That turns `js_array_refresh_local_head` from a correctness obligation every future consumer of a raw array head has to remember into an optimization. + + **Coverage is structural, because behaviour cannot see this.** Two new `deforest/tests.rs` cases assert the producer returns its out-param and that the call site stores the result back over the same binding; both were verified to FAIL against the pre-fix transform and pass after, so they have a subject. `test-files/test_deforest_growth_forwarding.ts` exercises all three call-site shapes end-to-end (including N = 17) and says in its header that it is a smoke test, not a detector. diff --git a/crates/perry-transform/src/deforest/call_sites.rs b/crates/perry-transform/src/deforest/call_sites.rs index 00831485f2..aaebfd2db2 100644 --- a/crates/perry-transform/src/deforest/call_sites.rs +++ b/crates/perry-transform/src/deforest/call_sites.rs @@ -246,7 +246,13 @@ fn try_consumer_fuse_pattern( return None; } - // Build replacement: `f(args, outer);` + // Build replacement: `outer = f(args, outer);` + // + // #7661: the assignment is not cosmetic. The callee may grow the array, + // and `js_array_grow` relocates — leaving a forwarding stub at the address + // `outer` still holds. The producer returns its own (written-back) head; + // storing it back is what keeps `outer` live for the caller's own later + // pushes and element reads. let mut new_args = call_args; new_args.push(Expr::LocalGet(outer_id)); let _ = out_param_ids.get(&callee_id)?; // sanity check producer was sized @@ -257,7 +263,10 @@ fn try_consumer_fuse_pattern( // #5247: deforestation-fused call; no single source offset. byte_offset: 0, }; - Some((2, vec![Stmt::Expr(new_call)])) + Some(( + 2, + vec![Stmt::Expr(Expr::LocalSet(outer_id, Box::new(new_call)))], + )) } /// Match the for-loop shape `for (let j = 0; j < child.length; j++) @@ -401,19 +410,35 @@ fn try_rewrite_single_stmt( let info = producers.get(fid)?; let mut new_args = args.clone(); new_args.push(Expr::LocalGet(*id)); - let call_stmt = Stmt::Expr(Expr::Call { - callee: callee.clone(), - args: new_args, - type_args: type_args.clone(), - byte_offset: 0, - }); + // #7661: `id = f(args, id)`, not a bare `f(args, id)`. The + // callee grows the array it was handed, and growth RELOCATES — + // the old address becomes a forwarding stub. Without the + // assignment the caller's binding keeps that stub, which the + // runtime resolves transparently and emitted code does not + // (#7612 SIGBUS'd on exactly this, at N = 17). + let call_stmt = Stmt::Expr(Expr::LocalSet( + *id, + Box::new(Expr::Call { + callee: callee.clone(), + args: new_args, + type_args: type_args.clone(), + byte_offset: 0, + }), + )); let let_stmt = Stmt::Let { id: *id, name: name.clone(), ty: Type::Array(Box::new(info.elem_ty.clone())), - mutable: *mutable, + // The binding is written twice now (the empty literal, then + // the returned live head), so it must be mutable even when + // the source said `const`. It is a compiler-generated + // binding at this point; the source-level `const` contract + // is unobservable because the second write stores the same + // array's current address. + mutable: true, init: Some(Expr::Array(Vec::new())), }; + let _ = mutable; let _ = out_param_ids; // already validated producer let _ = next_local; Some((let_stmt, vec![call_stmt])) diff --git a/crates/perry-transform/src/deforest/producer_rewrite.rs b/crates/perry-transform/src/deforest/producer_rewrite.rs index 3c9fb093bb..894860e1fd 100644 --- a/crates/perry-transform/src/deforest/producer_rewrite.rs +++ b/crates/perry-transform/src/deforest/producer_rewrite.rs @@ -6,9 +6,10 @@ use super::*; /// Phase 2 — rewrite a producer's body to use the new out-param. /// Removes the `let out = []` line, replaces every `LocalGet(out)` /// that survives the analyzer (push targets, array reads on the -/// accumulator) with `LocalGet(out_param)`, drops the trailing -/// `return out`, and rewrites recursive calls within the consume -/// pattern to pass the param through. +/// accumulator) with `LocalGet(out_param)`, KEEPS the trailing +/// `return out` (which step 4 turns into `return out_param`), and +/// rewrites recursive calls within the consume pattern to pass the +/// param through. pub fn rewrite_producer_body( func: &mut Function, info: &ProducerInfo, @@ -27,10 +28,30 @@ pub fn rewrite_producer_body( arguments_object: None, }); - // 2. Drop the trailing `return out`. - if matches!(func.body.last(), Some(Stmt::Return(_))) { - func.body.pop(); - } + // 2. KEEP the trailing `return out` (#7661). + // + // It used to be dropped, so the producer fell through to an implicit + // `return undefined` and the caller kept the head it allocated before the + // call. `js_array_grow` does not grow in place: it allocates elsewhere, + // copies, and leaves a FORWARDING STUB at the old address. The producer's + // push write-back re-points its OWN slot — `out_param` — but nothing + // re-points the caller's binding, so `const keep = build(1000)` left `keep` + // holding a stub the moment the array outgrew `MIN_ARRAY_CAPACITY`. + // + // That is invisible through the runtime (every entry point resolves the + // chain via `clean_arr_ptr`) and fatal to any consumer that dereferences + // the head itself — #7612's element-shape clone read the pre-growth buffer + // and SIGBUS'd, and #7660 had to fix that consumer one at a time. Fixing + // the producer makes `js_array_refresh_local_head` an optimization for + // those consumers instead of a correctness obligation. + // + // Step 4 substitutes `out_local_id` -> `out_param`, so this becomes + // `return out_param` — the live head, after every write-back. The call-site + // rewrites in `call_sites.rs` assign it back over the caller's binding. + // + // `detect.rs` guarantees the shape: exactly one top-level + // `Stmt::Return(Some(Expr::LocalGet(out_id)))`, and it is the last + // top-level statement. So there is nothing else this could be keeping. // 3. Drop the leading `let out = []` (or any position where the // out-local is bound). diff --git a/crates/perry-transform/src/deforest/tests.rs b/crates/perry-transform/src/deforest/tests.rs index 89cff88600..c259a3a38c 100644 --- a/crates/perry-transform/src/deforest/tests.rs +++ b/crates/perry-transform/src/deforest/tests.rs @@ -377,8 +377,15 @@ fn deforests_producer_called_from_class_method() { // Every call to the producer (id=1) in the method body must now match // the rewritten arity (1). The rewrite turns `let v = helper()` into - // `let v = []; helper(v);`, so the surviving call is a `Stmt::Expr` - // passing the accumulator. A stale `[0]` here is exactly the miscompile. + // `let v = []; v = helper(v);`, so the surviving call is a `Stmt::Expr` + // wrapping a `LocalSet` whose rhs passes the accumulator. A stale `[0]` + // here is exactly the miscompile. + // + // #7661 moved the call one level deeper (inside the `LocalSet`). Unwrapping + // it is not cosmetic: before, this walk found the call directly under + // `Stmt::Expr`, and a walk that did NOT unwrap would silently collect + // nothing and compare `[] == [1]` — a failure, which is the safe + // direction, but only because the expectation is non-empty. let method_after = &module.classes[0].methods[0]; let mut arities = Vec::new(); for stmt in &method_after.body { @@ -388,6 +395,10 @@ fn deforests_producer_called_from_class_method() { Stmt::Return(Some(e)) => Some(e), _ => None, }; + let init = match init { + Some(Expr::LocalSet(_, rhs)) => Some(rhs.as_ref()), + other => other, + }; if let Some(Expr::Call { callee, args, .. }) = init { if matches!(callee.as_ref(), Expr::FuncRef(1)) { arities.push(args.len()); @@ -401,6 +412,130 @@ fn deforests_producer_called_from_class_method() { ); } +// --------------------------------------------------------------------------- +// #7661: the producer must hand its (possibly relocated) head back, and every +// call site must store it over the caller's binding. +// +// `js_array_grow` does not grow in place — it allocates elsewhere, copies, and +// leaves a FORWARDING STUB at the old address. The producer's push write-back +// re-points its own out-param slot; nothing re-points the caller's binding. So +// before this fix `const keep = build(1000)` left `keep` holding a stub as soon +// as the array outgrew `MIN_ARRAY_CAPACITY` (16) — invisible through the +// runtime, which resolves the chain at every entry point, and fatal to emitted +// code that dereferences the head (the #7612 / #7660 SIGBUS, at N = 17). +// +// These assert the HIR shape rather than behaviour on purpose: behaviour cannot +// see it. Every runtime path resolves the chain and prints the right answer +// either way, which is exactly why it went unnoticed. +// --------------------------------------------------------------------------- + +/// A module with `function f() { const out = []; out.push(1); return out; }` +/// and a caller `function g() { const v = f(); return v; }`. +fn producer_and_plain_caller() -> Module { + let caller = Function { + id: 2, + name: "g".to_string(), + type_params: vec![], + params: vec![], + return_type: Type::Array(Box::new(Type::Number)), + body: vec![ + Stmt::Let { + id: 30, + name: "v".to_string(), + ty: Type::Array(Box::new(Type::Number)), + mutable: false, + init: Some(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: vec![], + type_args: vec![], + byte_offset: 0, + }), + }, + Stmt::Return(Some(Expr::LocalGet(30))), + ], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: vec![], + decorators: vec![], + was_plain_async: false, + was_unrolled: false, + }; + let mut module = Module::new("m"); + module.functions = vec![make_simple_producer(), caller]; + module +} + +#[test] +fn producer_returns_the_out_param_not_undefined() { + let mut module = producer_and_plain_caller(); + run(&mut module); + + let producer = module.functions.iter().find(|f| f.id == 1).unwrap(); + let out_param = producer.params.last().expect("synthetic param").id; + match producer.body.last() { + Some(Stmt::Return(Some(Expr::LocalGet(id)))) => assert_eq!( + *id, out_param, + "the producer must return its out-param — the head AFTER every \ + realloc write-back — not the caller's pre-growth pointer" + ), + other => panic!( + "producer must end in `return `, got {other:?}. Dropping \ + the return (the pre-#7661 behaviour) leaves the caller holding a \ + growth-forwarding stub." + ), + } +} + +#[test] +fn plain_call_site_stores_the_returned_head_back_over_the_binding() { + let mut module = producer_and_plain_caller(); + run(&mut module); + + let caller = module.functions.iter().find(|f| f.id == 2).unwrap(); + // `let v = []` then `v = f(v)`. + match &caller.body[0] { + Stmt::Let { + id, + init: Some(Expr::Array(elems)), + mutable, + .. + } => { + assert_eq!(*id, 30); + assert!(elems.is_empty(), "binding is seeded with an empty literal"); + assert!( + *mutable, + "the binding is written twice now (literal, then the returned \ + live head), so it must be mutable even though the source said \ + `const`" + ); + } + other => panic!("expected the seeding `let`, got {other:?}"), + } + match &caller.body[1] { + Stmt::Expr(Expr::LocalSet(id, rhs)) => { + assert_eq!( + *id, 30, + "the returned head must be stored back over the SAME binding" + ); + match rhs.as_ref() { + Expr::Call { callee, args, .. } => { + assert!(matches!(callee.as_ref(), Expr::FuncRef(1))); + assert_eq!(args.len(), 1, "the binding is passed as the out-param"); + assert!(matches!(args[0], Expr::LocalGet(30))); + } + other => panic!("expected the producer call, got {other:?}"), + } + } + other => panic!( + "expected `v = f(v)`, got {other:?}. A bare `Stmt::Expr(Call)` here \ + is the #7661 bug: the call grows the array, growth relocates, and \ + the binding keeps the stub." + ), + } +} + #[test] fn rejects_deforest_when_class_method_uses_super() { // Regression for #5780 cluster A / #5772. A producer called from a diff --git a/test-files/test_deforest_growth_forwarding.ts b/test-files/test_deforest_growth_forwarding.ts new file mode 100644 index 0000000000..752eab4e1c --- /dev/null +++ b/test-files/test_deforest_growth_forwarding.ts @@ -0,0 +1,55 @@ +// #7661: deforestation turns `const keep = build(n)` into +// `let keep = []; keep = build(n, keep)`. The assignment is load-bearing: +// `js_array_grow` does not grow in place — it allocates elsewhere, copies, and +// leaves a forwarding stub at the old address — so a caller that keeps the +// head it allocated before the call is holding a stub the moment the array +// outgrows MIN_ARRAY_CAPACITY (16). +// +// This file is a SMOKE TEST of the three call-site shapes, not a detector. +// A stale head is invisible to behaviour: every runtime entry point resolves +// the forwarding chain via `clean_arr_ptr`, so the printed answers are right +// either way. The load-bearing coverage is structural and lives in +// `crates/perry-transform/src/deforest/tests.rs`. + +class Node { + v: number; + constructor(v: number) { + this.v = v; + } +} + +// Shape 1 — non-consumer call site: `const keep = build(n)`. +function build(n: number): Node[] { + const out: Node[] = []; + for (let i = 0; i < n; i++) out.push(new Node(i)); + return out; +} + +// Shapes 2 and 3 — consumer-fuse call sites and pass-through recursion +// (the ABC451D shape deforestation exists for). +function tree(depth: number, label: number): number[] { + const out: number[] = []; + out.push(label); + if (depth > 0) { + const left = tree(depth - 1, label * 2 + 1); + for (let j = 0; j < left.length; j++) out.push(left[j]); + const right = tree(depth - 1, label * 2 + 2); + for (let j = 0; j < right.length; j++) out.push(right[j]); + } + return out; +} + +const keep = build(1000); +let sum = 0; +for (let i = 0; i < keep.length; i++) sum += keep[i].v; +console.log("build:", keep.length, sum, keep[0].v, keep[999].v); + +const t = tree(9, 0); +let tsum = 0; +for (let i = 0; i < t.length; i++) tsum += t[i]; +console.log("tree:", t.length, tsum, t[0], t[t.length - 1]); + +// Right at the growth threshold: MIN_ARRAY_CAPACITY is 16, so 17 is the +// smallest N that reallocates. #7612's SIGBUS reproduced at exactly this size. +const small = build(17); +console.log("17:", small.length, small[16].v);