diff --git a/CLAUDE.md b/CLAUDE.md index 7362dc7f8c..1f6ca5b5c9 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.1415 +**Current Version:** 0.5.1416 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 3bda2629e6..521a8cf991 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1415" +version = "0.5.1416" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1415" +version = "0.5.1416" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1415" +version = "0.5.1416" [[package]] name = "perry-ui-tvos" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1415" +version = "0.5.1416" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 9631a4fd83..089d3ca889 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1415" +version = "0.5.1416" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7719-builtin-ctor-roots.md b/changelog.d/7719-builtin-ctor-roots.md new file mode 100644 index 0000000000..613ab28da3 --- /dev/null +++ b/changelog.d/7719-builtin-ctor-roots.md @@ -0,0 +1,11 @@ +### Fixed + +- **`lower_call/builtin.rs`'s 30 built-in constructor arms held their earlier arguments in bare SSA registers across later arguments' (or a discard loop's) lowering — the same #6969 shape #7699 fixed in `lower_new.rs`'s three non-class branches, left open by that PR for this file.** `new RegExp(pattern, flags)`, `new DataView(buffer, offset, length)`, `new EventEmitter(opts)`, `new WeakMap(iterable)`, and 26 more all lowered `args[0]` then `args[1]` (then, for several, discarded the rest for side effects) with plain `lower_expr` and no rooting decision at all — a value already finished sat unprotected while an arbitrary later expression, or the runtime allocation the arm itself makes, ran. `new WeakMap`/`WeakSet` are a variant of the same bug: the iterable was lowered, then `js_weakmap_new`/`js_weakset_new` (an unconditional allocation) ran, and only then was the iterable's now-possibly-stale register read. + + `lower_builtin_new` now takes the caller's `RootedGroup` (threaded in from `lower_new_impl_inner`, which already opens one per #6969/#7699) and three helpers — `adopt_optional_arg`, `adopt_leading_arg_discard_rest`, `adopt_two_leading_args_discard_rest` — adopt each operand into it **as it is produced**, never after the fact: rooting a finished list publishes an already-dangling argument 0 to the scanner, which is strictly worse than not rooting at all (#6969's own lesson, repeated here). `CronJob` needed a bespoke ordering: its `cronTime` argument's raw-pointer derivation (`js_get_string_pointer_unified`, which can itself allocate to materialize an SSO string) has to run before `onTick`/`start` are re-read, not after, or the fix would trade one unrooted register for another. + + Left explicitly out of scope: the `extract_options_fields`-based arms (`Response`, `Request`, `Blob`, `File`, `Headers`, `ReadableStream`, `WritableStream`, `TransformStream`) share the same underlying hazard — an earlier field's lowered value can sit unrooted across a later field's — but their per-property-match loop is a structurally different shape than the fixed `args[0]`/`args[1]`/`args[2]` sequence, and needs its own audit rather than reusing these three helpers as-is. + + Six new unit tests in `perry-codegen`'s `temp_root_coverage::builtin_ctor` (per-PR `cargo-test`-visible, per #6988's precedent) cover the three helper shapes plus the `WeakMap` variant, sabotage-confirmed: all five positive assertions fail against the pre-fix code, and the paired negative gate (`RegExp` with two non-allocating arguments) passes on both, so the positives are not vacuously satisfied by a compiler that roots everything. + + `scripts/gc_root_dominance_check.py` over the existing `scripts/gc_root_dominance_corpus.sh` corpus (`test_gap_gc_*`, `test_gap_new*`, etc.) reads 0 violations before and after — that corpus contains no source that constructs any of these built-ins, so it cannot show a reduction either way. A scoped probe compiled by hand with the corpus script's exact recipe (`PERRY_RS4GC=<0|1> PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_INLINE_SHADOW_SLOT=0`, `--trace llvm`, plus the production `rewrite-statepoints-for-gc` pass for the native arm) using object/array-literal arguments *also* read 0 both ways — not because the fix is a no-op, but because Phase 3 closed-shape synthesis (object literals) and the array-literal inline-bump-allocator diamond both bind their OWN shadow slot for unrelated reasons, which happens to also cover a plain constructor argument end to end. Switching to the shape the project's own reproducers use elsewhere (`fresh(k)` / `"x" + churn(N)`, matching #6969's `new Function(fresh(0), "return " + churn(N))`) found the checker's actual reach: on `new DataView(fresh(1), "o"+churn(N), "l"+churn(N))` and `new SuppressedError(fresh(3), "y"+churn(N), "z"+churn(N))`, the middle argument's own producing call (`js_string_concat_value`) is itself in `ALLOC_RE`, and the checker reports it stale — **2 violations before the fix, 0 after, on both lowerings** (`--stale-registers`: 2→0; `--statepoints`: 2 unrooted→0). The 2-argument arms in the same probe (`RegExp`, `EventEmitter`) read 0 both ways because their first argument comes from a plain user-function call, which the checker's `ALLOC_RE` pattern (by design — it matches `js_*` runtime symbols, not arbitrary closures) does not recognize as a source; their fix is the same shape and is covered by the unit tests above, not by this probe. diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index 628728b8ca..dae0331a30 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -18,14 +18,86 @@ use perry_hir::Expr; use crate::expr::{lower_array_literal, lower_expr, nanbox_pointer_inline, unbox_to_i64, FnCtx}; use crate::nanbox::double_literal; +use crate::rooting::{self, RootedGroup}; use crate::types::{DOUBLE, I32, I64}; use super::{build_headers_from_object, extract_options_fields, get_raw_string_ptr}; -pub(super) fn lower_builtin_new( +/// Lower `args[idx]` into `group`, rooted across everything from +/// `args[idx + 1..]` — the same #6969/#6986 discipline `new.rs`'s +/// `adopt_constructor_args` uses: adopt **as the value is produced**, not +/// after the fact (a group holding an already-dangling operand turns a +/// silent wrong answer into a SIGSEGV), and hand back only an index for +/// [`RootedGroup::reread`] at the point of use. `None` when the argument is +/// absent — callers supply their own default in that case, and it never +/// needs rooting: slice indexing guarantees every argument after an absent +/// one is absent too, so there is nothing left to hold it across. +fn adopt_optional_arg<'a>( + ctx: &mut FnCtx<'_>, + args: &'a [Expr], + idx: usize, + group: &mut RootedGroup<'a>, +) -> Result> { + let Some(a) = args.get(idx) else { + return Ok(None); + }; + let collects = rooting::any_operand_may_collect(ctx, args[idx + 1..].iter()); + Ok(Some(group.lower(ctx, a, collects)?)) +} + +/// The single-leading-options-argument shape repeated by more than a dozen +/// arms below: lower `args[0]` (defaulting to `undefined` when absent), then +/// lower every remaining argument for its side effects only, then hand back +/// `args[0]`'s value. Pre-#6986 the discard loop ran with `args[0]`'s value +/// sitting in a bare SSA register — any of those side-effect expressions can +/// allocate. +fn adopt_leading_arg_discard_rest<'a>( + ctx: &mut FnCtx<'_>, + args: &'a [Expr], + group: &mut RootedGroup<'a>, +) -> Result { + let idx = adopt_optional_arg(ctx, args, 0, group)?; + for a in args.iter().skip(1) { + let _ = lower_expr(ctx, a)?; + } + Ok(match idx { + Some(i) => group.reread(ctx, i)?, + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }) +} + +/// The two-leading-arguments shape (`Event`/`CustomEvent`/`DOMException` +/// below): lower `args[0]` then `args[1]` (each defaulting to `undefined`), +/// then lower every remaining argument for its side effects only, then hand +/// both values back in argument order. Pre-#6986 `args[0]`'s value sat in a +/// bare SSA register across `args[1]`'s lowering AND the discard loop. +fn adopt_two_leading_args_discard_rest<'a>( + ctx: &mut FnCtx<'_>, + args: &'a [Expr], + group: &mut RootedGroup<'a>, +) -> Result<(String, String)> { + let idx0 = adopt_optional_arg(ctx, args, 0, group)?; + let idx1 = adopt_optional_arg(ctx, args, 1, group)?; + for a in args.iter().skip(2) { + let _ = lower_expr(ctx, a)?; + } + let undef = || double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let v0 = match idx0 { + Some(i) => group.reread(ctx, i)?, + None => undef(), + }; + let v1 = match idx1 { + Some(i) => group.reread(ctx, i)?, + None => undef(), + }; + Ok((v0, v1)) +} + +pub(super) fn lower_builtin_new<'a>( ctx: &mut FnCtx<'_>, class_name: &str, - args: &[Expr], + args: &'a [Expr], + group: &mut RootedGroup<'a>, ) -> Result> { // Issue #602: ambiguously-named built-in constructors (Client / Pool / // Database / Redis / MongoClient / Decimal) collide with bindings from @@ -66,14 +138,16 @@ pub(super) fn lower_builtin_new( .map(|source| source.strip_prefix("node:").unwrap_or(source) == "fs") .unwrap_or(false) => { - let options = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; + // #6986: `options` was live across the discard loop below, which + // lowers arbitrary user expressions for their side effects. + let options_idx = adopt_optional_arg(ctx, args, 0, group)?; for arg in args.iter().skip(1) { let _ = lower_expr(ctx, arg)?; } + let options = match options_idx { + Some(i) => group.reread(ctx, i)?, + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }; Ok(Some(ctx.block().call( DOUBLE, "js_fs_utf8_stream_new", @@ -81,14 +155,17 @@ pub(super) fn lower_builtin_new( ))) } "EvalError" | "URIError" => { - let msg_box = if let Some(message) = args.first() { - lower_expr(ctx, message)? - } else { - lower_expr(ctx, &Expr::String(String::new()))? - }; + // #6986: `msg_box` was live across the discard loop below. The + // `None` fallback needs no rooting — an absent first argument + // means `args` is empty, so the loop is empty too. + let msg_idx = adopt_optional_arg(ctx, args, 0, group)?; for arg in args.iter().skip(1) { let _ = lower_expr(ctx, arg)?; } + let msg_box = match msg_idx { + Some(i) => group.reread(ctx, i)?, + None => lower_expr(ctx, &Expr::String(String::new()))?, + }; let blk = ctx.block(); let msg_handle = unbox_to_i64(blk, &msg_box); let runtime = if class_name == "EvalError" { @@ -145,12 +222,21 @@ pub(super) fn lower_builtin_new( // the spec's post-coercion detached/bounds checks. The old // `fptosi` cast silently turned object arguments into garbage // without ever running their coercion. - let source = lower_expr(ctx, &args[0])?; - let offset_box = lower_expr(ctx, &args[1])?; - let length_box = if args.len() >= 3 { - lower_expr(ctx, &args[2])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + // #6986: `source` (and `offset_box`) were held in bare SSA + // registers across the later operands' lowering — each an + // arbitrary expression (ToIndex can run user `valueOf`). + // `args.len() >= 2` is this arm's guard, so args[0]/args[1] are + // always present. + let source_collects = rooting::any_operand_may_collect(ctx, args[1..].iter()); + let source_idx = group.lower(ctx, &args[0], source_collects)?; + let offset_collects = rooting::any_operand_may_collect(ctx, args[2..].iter()); + let offset_idx = group.lower(ctx, &args[1], offset_collects)?; + let length_idx = adopt_optional_arg(ctx, args, 2, group)?; + let source = group.reread(ctx, source_idx)?; + let offset_box = group.reread(ctx, offset_idx)?; + let length_box = match length_idx { + Some(i) => group.reread(ctx, i)?, + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), }; let handle = ctx.block().call( I64, @@ -174,12 +260,17 @@ pub(super) fn lower_builtin_new( if args.len() >= 2 => { let kind = typed_array_view_kind(name); - let source = lower_expr(ctx, &args[0])?; - let offset_box = lower_expr(ctx, &args[1])?; - let length_box = if args.len() >= 3 { - lower_expr(ctx, &args[2])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + // #6986: same hazard as the Uint8Array view arm above. + let source_collects = rooting::any_operand_may_collect(ctx, args[1..].iter()); + let source_idx = group.lower(ctx, &args[0], source_collects)?; + let offset_collects = rooting::any_operand_may_collect(ctx, args[2..].iter()); + let offset_idx = group.lower(ctx, &args[1], offset_collects)?; + let length_idx = adopt_optional_arg(ctx, args, 2, group)?; + let source = group.reread(ctx, source_idx)?; + let offset_box = group.reread(ctx, offset_idx)?; + let length_box = match length_idx { + Some(i) => group.reread(ctx, i)?, + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), }; let handle = ctx.block().call( I64, @@ -201,20 +292,24 @@ pub(super) fn lower_builtin_new( // Pass the raw NaN-boxed arguments (undefined when absent) so the // runtime can apply the spec's ToIndex/range validation and throw // TypeError/RangeError where required (#3657). - let view_box = if !args.is_empty() { - lower_expr(ctx, &args[0])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + // + // #6986: `view_box` (and `offset_box`) were held in bare SSA + // registers across the later operands' lowering. + let view_idx = adopt_optional_arg(ctx, args, 0, group)?; + let offset_idx = adopt_optional_arg(ctx, args, 1, group)?; + let length_idx = adopt_optional_arg(ctx, args, 2, group)?; + let undef = || double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let view_box = match view_idx { + Some(i) => group.reread(ctx, i)?, + None => undef(), }; - let offset_box = if args.len() >= 2 { - lower_expr(ctx, &args[1])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + let offset_box = match offset_idx { + Some(i) => group.reread(ctx, i)?, + None => undef(), }; - let length_box = if args.len() >= 3 { - lower_expr(ctx, &args[2])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + let length_box = match length_idx { + Some(i) => group.reread(ctx, i)?, + None => undef(), }; Ok(Some(ctx.block().call( DOUBLE, @@ -232,15 +327,17 @@ pub(super) fn lower_builtin_new( // or an object `flags` (ToString → SyntaxError) are all handled per // spec. Defaults are `undefined` (NOT 0.0) so `new RegExp()` builds // an empty source. - let pattern_box = if !args.is_empty() { - lower_expr(ctx, &args[0])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + // #6986: `pattern_box` was held in a bare SSA register across + // `flags_box`'s lowering (an arbitrary user expression). + let pattern_idx = adopt_optional_arg(ctx, args, 0, group)?; + let flags_idx = adopt_optional_arg(ctx, args, 1, group)?; + let pattern_box = match pattern_idx { + Some(i) => group.reread(ctx, i)?, + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), }; - let flags_box = if args.len() > 1 { - lower_expr(ctx, &args[1])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + let flags_box = match flags_idx { + Some(i) => group.reread(ctx, i)?, + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), }; let blk = ctx.block(); let handle = blk.call( @@ -270,14 +367,8 @@ pub(super) fn lower_builtin_new( // every .on/.emit call dispatched against a junk pointer and // silently registered nothing / fired nothing. "EventEmitter" => { - let opts = if let Some(a) = args.first() { - lower_expr(ctx, a)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - for a in args.iter().skip(1) { - let _ = lower_expr(ctx, a)?; - } + // #6986: `opts` was held across the discard loop's lowering. + let opts = adopt_leading_arg_discard_rest(ctx, args, group)?; let blk = ctx.block(); let handle = blk.call(I64, "js_event_emitter_new_with_options", &[(DOUBLE, &opts)]); Ok(Some(nanbox_pointer_inline(blk, &handle))) @@ -292,14 +383,8 @@ pub(super) fn lower_builtin_new( Ok(Some(ctx.block().call(DOUBLE, "js_child_process_new", &[]))) } "EventEmitterAsyncResource" => { - let opts = if let Some(a) = args.first() { - lower_expr(ctx, a)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - for a in args.iter().skip(1) { - let _ = lower_expr(ctx, a)?; - } + // #6986: `opts` was held across the discard loop's lowering. + let opts = adopt_leading_arg_discard_rest(ctx, args, group)?; let blk = ctx.block(); let handle = blk.call( I64, @@ -317,14 +402,8 @@ pub(super) fn lower_builtin_new( Ok(Some(nanbox_pointer_inline(blk, &handle))) } "SocketAddress" => { - let options = if let Some(a) = args.first() { - lower_expr(ctx, a)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - for a in args.iter().skip(1) { - let _ = lower_expr(ctx, a)?; - } + // #6986: `options` was held across the discard loop's lowering. + let options = adopt_leading_arg_discard_rest(ctx, args, group)?; let blk = ctx.block(); let handle = blk.call(I64, "js_net_socket_address_new", &[(DOUBLE, &options)]); Ok(Some(nanbox_pointer_inline(blk, &handle))) @@ -356,14 +435,8 @@ pub(super) fn lower_builtin_new( ))) } "BroadcastChannel" => { - let name = if let Some(a) = args.first() { - lower_expr(ctx, a)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - for a in args.iter().skip(1) { - let _ = lower_expr(ctx, a)?; - } + // #6986: `name` was held across the discard loop's lowering. + let name = adopt_leading_arg_discard_rest(ctx, args, group)?; let blk = ctx.block(); Ok(Some(blk.call( DOUBLE, @@ -372,19 +445,9 @@ pub(super) fn lower_builtin_new( ))) } "Event" => { - let event_type = if let Some(a) = args.first() { - lower_expr(ctx, a)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - let options = if let Some(a) = args.get(1) { - lower_expr(ctx, a)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - for a in args.iter().skip(2) { - let _ = lower_expr(ctx, a)?; - } + // #6986: `event_type` was held across `options`' lowering AND the + // discard loop. + let (event_type, options) = adopt_two_leading_args_discard_rest(ctx, args, group)?; let blk = ctx.block(); let argc = args.len().to_string(); let handle = blk.call( @@ -395,19 +458,9 @@ pub(super) fn lower_builtin_new( Ok(Some(nanbox_pointer_inline(blk, &handle))) } "CustomEvent" => { - let event_type = if let Some(a) = args.first() { - lower_expr(ctx, a)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - let options = if let Some(a) = args.get(1) { - lower_expr(ctx, a)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - for a in args.iter().skip(2) { - let _ = lower_expr(ctx, a)?; - } + // #6986: `event_type` was held across `options`' lowering AND the + // discard loop. + let (event_type, options) = adopt_two_leading_args_discard_rest(ctx, args, group)?; let blk = ctx.block(); let argc = args.len().to_string(); let handle = blk.call( @@ -418,19 +471,9 @@ pub(super) fn lower_builtin_new( Ok(Some(nanbox_pointer_inline(blk, &handle))) } "DOMException" => { - let message = if let Some(a) = args.first() { - lower_expr(ctx, a)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - let name = if let Some(a) = args.get(1) { - lower_expr(ctx, a)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - for a in args.iter().skip(2) { - let _ = lower_expr(ctx, a)?; - } + // #6986: `message` was held across `name`'s lowering AND the + // discard loop. + let (message, name) = adopt_two_leading_args_discard_rest(ctx, args, group)?; let blk = ctx.block(); let handle = blk.call( I64, @@ -440,16 +483,10 @@ pub(super) fn lower_builtin_new( Ok(Some(nanbox_pointer_inline(blk, &handle))) } "Console" => { - let opts = if let Some(a) = args.first() { - lower_expr(ctx, a)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - if let Some(stderr_arg) = args.get(1) { - let stderr = lower_expr(ctx, stderr_arg)?; - for a in args.iter().skip(2) { - let _ = lower_expr(ctx, a)?; - } + // #6986: `opts` was held across `stderr`'s lowering AND the + // discard loop in the two-argument branch. + let (opts, stderr) = adopt_two_leading_args_discard_rest(ctx, args, group)?; + if args.get(1).is_some() { return Ok(Some(ctx.block().call( DOUBLE, "js_console_new2", @@ -486,14 +523,8 @@ pub(super) fn lower_builtin_new( // arg is passed through so future non-UTF-8 backends can switch on it; // the current impl only tracks the UTF-8 partial-codepoint state. "StringDecoder" => { - let enc_box = if !args.is_empty() { - lower_expr(ctx, &args[0])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - for a in args.iter().skip(1) { - let _ = lower_expr(ctx, a)?; - } + // #6986: `enc_box` was held across the discard loop's lowering. + let enc_box = adopt_leading_arg_discard_rest(ctx, args, group)?; let blk = ctx.block(); let enc_handle = unbox_to_i64(blk, &enc_box); let handle = blk.call(I64, "js_string_decoder_new", &[(I64, &enc_handle)]); @@ -510,15 +541,8 @@ pub(super) fn lower_builtin_new( // `.on(...).on(...).pipe(...)` calls return `this` so the chain // doesn't lose identity. Stub semantics only: no real data pump. "Readable" | "Writable" | "Duplex" | "Transform" | "PassThrough" => { - let opts_box = if !args.is_empty() { - lower_expr(ctx, &args[0])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - // Lower any extra args for side effects. - for a in args.iter().skip(1) { - let _ = lower_expr(ctx, a)?; - } + // #6986: `opts_box` was held across the discard loop's lowering. + let opts_box = adopt_leading_arg_discard_rest(ctx, args, group)?; let runtime_fn = match class_name { "Readable" => "js_node_stream_readable_new", "Writable" => "js_node_stream_writable_new", @@ -538,17 +562,11 @@ pub(super) fn lower_builtin_new( // argument passes `undefined`, which the runtime rejects with the // same `TypeError` npm's constructor destructuring raises. "LRUCache" => { - let opts_val = if let Some(opts_arg) = args.first() { - lower_expr(ctx, opts_arg)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; // npm's constructor ignores everything past the options object, - // but the arguments are still evaluated — lower the tail for its - // side effects so `new LRUCache(opts, f())` still calls `f`. - for arg in args.iter().skip(1) { - let _ = lower_expr(ctx, arg)?; - } + // but the arguments are still evaluated — the tail is lowered + // for its side effects so `new LRUCache(opts, f())` still calls + // `f`. #6986: `opts_val` was held across that lowering. + let opts_val = adopt_leading_arg_discard_rest(ctx, args, group)?; let blk = ctx.block(); let handle = blk.call(I64, "js_lru_cache_new", &[(DOUBLE, &opts_val)]); Ok(Some(nanbox_pointer_inline(blk, &handle))) @@ -610,15 +628,18 @@ pub(super) fn lower_builtin_new( // node:sqlite DatabaseSync — keep full NaN-boxed values for path and // options so the runtime can preserve Node-shaped validation errors. "DatabaseSync" => { - let path_value = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + // #6986: `path_value` was held in a bare SSA register across + // `options_value`'s lowering (an arbitrary user expression). + let path_idx = adopt_optional_arg(ctx, args, 0, group)?; + let options_idx = adopt_optional_arg(ctx, args, 1, group)?; + let undef = || double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let path_value = match path_idx { + Some(i) => group.reread(ctx, i)?, + None => undef(), }; - let options_value = if let Some(arg) = args.get(1) { - lower_expr(ctx, arg)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + let options_value = match options_idx { + Some(i) => group.reread(ctx, i)?, + None => undef(), }; let blk = ctx.block(); let handle = blk.call( @@ -629,15 +650,18 @@ pub(super) fn lower_builtin_new( Ok(Some(nanbox_pointer_inline(blk, &handle))) } "StatementSync" => { - let arg0 = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + // #6986: `arg0` was held in a bare SSA register across `arg1`'s + // lowering (an arbitrary user expression). + let idx0 = adopt_optional_arg(ctx, args, 0, group)?; + let idx1 = adopt_optional_arg(ctx, args, 1, group)?; + let undef = || double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let arg0 = match idx0 { + Some(i) => group.reread(ctx, i)?, + None => undef(), }; - let arg1 = if let Some(arg) = args.get(1) { - lower_expr(ctx, arg)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + let arg1 = match idx1 { + Some(i) => group.reread(ctx, i)?, + None => undef(), }; let blk = ctx.block(); let handle = blk.call( @@ -648,15 +672,18 @@ pub(super) fn lower_builtin_new( Ok(Some(nanbox_pointer_inline(blk, &handle))) } "Session" => { - let arg0 = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + // #6986: `arg0` was held in a bare SSA register across `arg1`'s + // lowering (an arbitrary user expression). + let idx0 = adopt_optional_arg(ctx, args, 0, group)?; + let idx1 = adopt_optional_arg(ctx, args, 1, group)?; + let undef = || double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let arg0 = match idx0 { + Some(i) => group.reread(ctx, i)?, + None => undef(), }; - let arg1 = if let Some(arg) = args.get(1) { - lower_expr(ctx, arg)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + let arg1 = match idx1 { + Some(i) => group.reread(ctx, i)?, + None => undef(), }; let blk = ctx.block(); let handle = blk.call( @@ -708,14 +735,8 @@ pub(super) fn lower_builtin_new( // (consume/get/delete/block/penalty/reward) are wired in // NATIVE_MODULE_TABLE for module "rate-limiter-flexible". "RateLimiterMemory" => { - let options = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - for arg in args.iter().skip(1) { - let _ = lower_expr(ctx, arg)?; - } + // #6986: `options` was held across the discard loop's lowering. + let options = adopt_leading_arg_discard_rest(ctx, args, group)?; let blk = ctx.block(); let options_bits = blk.bitcast_double_to_i64(&options); let handle = blk.call( @@ -737,29 +758,53 @@ pub(super) fn lower_builtin_new( // start/stop/isRunning/nextDate dispatch via the existing // ("cron", true, …) NATIVE_MODULE_TABLE rows. "CronJob" => { - let expr_ptr = if let Some(arg) = args.first() { - get_raw_string_ptr(ctx, arg)? - } else { - "0".to_string() - }; - let on_tick = if let Some(arg) = args.get(1) { - lower_expr(ctx, arg)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; + // #6986: `expr_ptr` (via `get_raw_string_ptr`, itself a + // lower_expr + immediate derive with no window of its own) and + // `on_tick` were both held across every later argument's + // lowering. Adopt each boxed operand into `group` as it is + // produced — argument order preserved — then re-read right + // before use. `expr_ptr`'s derivation (`js_get_string_pointer_unified`) + // can itself allocate (SSO materialize, nanbox.rs), so it runs + // FIRST among the final derivations: `on_tick`'s `unbox_to_i64` + // is pure bitwise (no collect) and `start` needs no further + // derivation, so re-reading them after is safe. + let cron_time_idx = adopt_optional_arg(ctx, args, 0, group)?; + let on_tick_idx = adopt_optional_arg(ctx, args, 1, group)?; if let Some(arg) = args.get(2) { let _ = lower_expr(ctx, arg)?; } - let start = if let Some(arg) = args.get(3) { - lower_expr(ctx, arg)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; + let start_idx = adopt_optional_arg(ctx, args, 3, group)?; for arg in args.iter().skip(4) { let _ = lower_expr(ctx, arg)?; } + let expr_ptr = match cron_time_idx { + Some(i) => { + let cron_time = group.reread(ctx, i)?; + ctx.block().call( + I64, + "js_get_string_pointer_unified", + &[(DOUBLE, &cron_time)], + ) + } + None => "0".to_string(), + }; + let cb_ptr = match on_tick_idx { + Some(i) => { + let on_tick = group.reread(ctx, i)?; + let blk = ctx.block(); + unbox_to_i64(blk, &on_tick) + } + None => { + let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let blk = ctx.block(); + unbox_to_i64(blk, &undef) + } + }; + let start = match start_idx { + Some(i) => group.reread(ctx, i)?, + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }; let blk = ctx.block(); - let cb_ptr = unbox_to_i64(blk, &on_tick); let handle = blk.call( I64, "js_cron_job_new", @@ -801,15 +846,18 @@ pub(super) fn lower_builtin_new( ))) } "AsyncResource" => { - let type_value = if let Some(arg) = args.first() { - lower_expr(ctx, arg)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + // #6986: `type_value` was held in a bare SSA register across + // `options_value`'s lowering (an arbitrary user expression). + let type_idx = adopt_optional_arg(ctx, args, 0, group)?; + let options_idx = adopt_optional_arg(ctx, args, 1, group)?; + let undef = || double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let type_value = match type_idx { + Some(i) => group.reread(ctx, i)?, + None => undef(), }; - let options_value = if let Some(arg) = args.get(1) { - lower_expr(ctx, arg)? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + let options_value = match options_idx { + Some(i) => group.reread(ctx, i)?, + None => undef(), }; let blk = ctx.block(); let handle = blk.call( @@ -846,21 +894,23 @@ pub(super) fn lower_builtin_new( // extending Error (once) so `instanceof Error` holds; the property // reads flow through the ordinary by-name object getter. "SuppressedError" => { - let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); - let error = if let Some(a) = args.first() { - lower_expr(ctx, a)? - } else { - undef.clone() + // #6986: `error` (and `suppressed`) were held in bare SSA + // registers across the later operands' lowering. + let error_idx = adopt_optional_arg(ctx, args, 0, group)?; + let suppressed_idx = adopt_optional_arg(ctx, args, 1, group)?; + let message_idx = adopt_optional_arg(ctx, args, 2, group)?; + let undef = || double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let error = match error_idx { + Some(i) => group.reread(ctx, i)?, + None => undef(), }; - let suppressed = if let Some(a) = args.get(1) { - lower_expr(ctx, a)? - } else { - undef.clone() + let suppressed = match suppressed_idx { + Some(i) => group.reread(ctx, i)?, + None => undef(), }; - let message = if let Some(a) = args.get(2) { - lower_expr(ctx, a)? - } else { - undef.clone() + let message = match message_idx { + Some(i) => group.reread(ctx, i)?, + None => undef(), }; let blk = ctx.block(); Ok(Some(blk.call( @@ -1487,19 +1537,9 @@ pub(super) fn lower_builtin_new( } "TextDecoderStream" => { - let label = if !args.is_empty() { - lower_expr(ctx, &args[0])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - let options = if args.len() >= 2 { - lower_expr(ctx, &args[1])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - for a in args.iter().skip(2) { - let _ = lower_expr(ctx, a)?; - } + // #6986: `label` was held across `options`' lowering AND the + // discard loop. + let (label, options) = adopt_two_leading_args_discard_rest(ctx, args, group)?; let h = ctx.block().call( DOUBLE, "js_stream_web_text_decoder_stream_new", @@ -1509,14 +1549,8 @@ pub(super) fn lower_builtin_new( } "CompressionStream" | "DecompressionStream" => { - let format = if !args.is_empty() { - lower_expr(ctx, &args[0])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - for a in args.iter().skip(1) { - let _ = lower_expr(ctx, a)?; - } + // #6986: `format` was held across the discard loop's lowering. + let format = adopt_leading_arg_discard_rest(ctx, args, group)?; let runtime = if class_name == "CompressionStream" { "js_stream_web_compression_stream_new" } else { @@ -1531,14 +1565,8 @@ pub(super) fn lower_builtin_new( // the argument is a byte stream (TypeError otherwise) and returns a // reader handle whose `read(view)` fills the caller's buffer. "ReadableStreamBYOBReader" => { - let stream = if !args.is_empty() { - lower_expr(ctx, &args[0])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - for a in args.iter().skip(1) { - let _ = lower_expr(ctx, a)?; - } + // #6986: `stream` was held across the discard loop's lowering. + let stream = adopt_leading_arg_discard_rest(ctx, args, group)?; let h = ctx.block().call( DOUBLE, "js_readable_stream_get_byob_reader", @@ -1554,14 +1582,8 @@ pub(super) fn lower_builtin_new( // returns chunk.byteLength. Pass the whole options expression through so // both literal (`{ highWaterMark: 5 }`) and dynamic option objects work. "CountQueuingStrategy" | "ByteLengthQueuingStrategy" => { - let opts = if !args.is_empty() { - lower_expr(ctx, &args[0])? - } else { - double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) - }; - for a in args.iter().skip(1) { - let _ = lower_expr(ctx, a)?; - } + // #6986: `opts` was held across the discard loop's lowering. + let opts = adopt_leading_arg_discard_rest(ctx, args, group)?; let func = if class_name == "CountQueuingStrategy" { "js_count_queuing_strategy_new" } else { @@ -1589,40 +1611,58 @@ pub(super) fn lower_builtin_new( Ok(Some(nanbox_pointer_inline(blk, &p))) } "WeakMap" => { - let lowered_args = args - .iter() - .map(|a| lower_expr(ctx, a)) - .collect::>>()?; + // #6986: the iterable was lowered into a bare SSA register and + // held across `js_weakmap_new`'s allocation — the eager + // `.map(lower_expr)` computed every argument up front, then the + // allocating call ran, then `lowered_args.first()` was read back + // from that now-possibly-stale register. `collects: true` + // unconditionally — the allocation always follows, regardless of + // how many extra arguments there are. + let iterable_idx = match args.first() { + Some(a) => Some(group.lower(ctx, a, true)?), + None => None, + }; + for a in args.iter().skip(1) { + let _ = lower_expr(ctx, a)?; + } let handle = ctx.block().call(I64, "js_weakmap_new", &[]); // js_weakmap_new returns a raw `*mut ObjectHeader` — NaN-box // with POINTER_TAG so subsequent `js_weakmap_*` calls can // `js_nanbox_get_pointer` on the f64. let boxed = nanbox_pointer_inline(ctx.block(), &handle); - if let Some(iterable) = lowered_args.first() { - Ok(Some(ctx.block().call( - DOUBLE, - "js_weakmap_init_iterable", - &[(DOUBLE, &boxed), (DOUBLE, iterable)], - ))) - } else { - Ok(Some(boxed)) + match iterable_idx { + Some(i) => { + let iterable = group.reread(ctx, i)?; + Ok(Some(ctx.block().call( + DOUBLE, + "js_weakmap_init_iterable", + &[(DOUBLE, &boxed), (DOUBLE, &iterable)], + ))) + } + None => Ok(Some(boxed)), } } "WeakSet" => { - let lowered_args = args - .iter() - .map(|a| lower_expr(ctx, a)) - .collect::>>()?; + // #6986: same hazard as WeakMap above. + let iterable_idx = match args.first() { + Some(a) => Some(group.lower(ctx, a, true)?), + None => None, + }; + for a in args.iter().skip(1) { + let _ = lower_expr(ctx, a)?; + } let handle = ctx.block().call(I64, "js_weakset_new", &[]); let boxed = nanbox_pointer_inline(ctx.block(), &handle); - if let Some(iterable) = lowered_args.first() { - Ok(Some(ctx.block().call( - DOUBLE, - "js_weakset_init_iterable", - &[(DOUBLE, &boxed), (DOUBLE, iterable)], - ))) - } else { - Ok(Some(boxed)) + match iterable_idx { + Some(i) => { + let iterable = group.reread(ctx, i)?; + Ok(Some(ctx.block().call( + DOUBLE, + "js_weakset_init_iterable", + &[(DOUBLE, &boxed), (DOUBLE, &iterable)], + ))) + } + None => Ok(Some(boxed)), } } "AbortController" => { diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 7211c8de8e..ce25c7dfb2 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -317,7 +317,7 @@ fn lower_new_impl_inner<'a>( )); } } - if let Some(val) = lower_builtin_new(ctx, class_name, args)? { + if let Some(val) = lower_builtin_new(ctx, class_name, args, group)? { return Ok(val); } // Aliased built-in import: a minified bundle renames a node built-in @@ -333,7 +333,7 @@ fn lower_new_impl_inner<'a>( // can't shadow a real local class. if let Some(original) = ctx.imported_class_original_names.get(class_name).cloned() { if original != class_name { - if let Some(val) = lower_builtin_new(ctx, &original, args)? { + if let Some(val) = lower_builtin_new(ctx, &original, args, group)? { return Ok(val); } } diff --git a/crates/perry-codegen/src/temp_root_coverage/builtin_ctor.rs b/crates/perry-codegen/src/temp_root_coverage/builtin_ctor.rs new file mode 100644 index 0000000000..badf647d05 --- /dev/null +++ b/crates/perry-codegen/src/temp_root_coverage/builtin_ctor.rs @@ -0,0 +1,151 @@ +//! #6986: `lower_builtin_new`'s (`lower_call/builtin.rs`) multi-argument +//! constructor arms threaded plain `lower_expr` sequences the same way +//! `lower_new_impl_inner`'s three non-class branches did before #7699 — +//! argument 0 finished, sat in a bare SSA register, and a later argument's +//! lowering (or a discard loop lowering the tail for side effects) could +//! collect before argument 0 was read. +//! +//! `Expr::New { class_name, .. }` reaches `lower_builtin_new` whenever +//! `class_name` is not a key in `module.classes` — every fixture below +//! declares no classes at all, so a plain, ungated builtin name (`RegExp`, +//! `EventEmitter`, `DataView`, `SuppressedError`, `WeakMap`) dispatches +//! straight through the built-in path without the `imported_class_sources` +//! setup the pg/sqlite/redis/mongo/decimal/rate-limiter/cron arms need. +//! +//! Each positive test below picks one of the three `adopt_*` shapes +//! `builtin.rs` now uses and asserts the #6951 contract through +//! [`assert_rooted_across`]: the first operand is stored into a rooted slot +//! before the later operand's (or the discard loop's) allocation, and the +//! consuming runtime call re-reads it from that slot rather than taking the +//! producer's own now-possibly-stale register. One negative gate (`RegExp` +//! with two non-allocating arguments) is the differential control every test +//! in this family is paired against — it proves the positive is not vacuous +//! by showing the same lowering emits nothing when nothing can collect. + +use super::{allocating, main_ir_for, under_both_lowerings}; +use crate::testing::temp_slots::{assert_no_temp_rooting, assert_rooted_across, first_call_result}; +use perry_hir::{Expr, Stmt}; + +/// `Expr::New` for a built-in constructor name — no `Class` is declared for +/// it anywhere in the module, so `ctx.classes.contains_key(class_name)` is +/// false and `lower_new_impl_inner` falls through to `lower_builtin_new`. +fn new_builtin(class_name: &str, args: Vec) -> Stmt { + Stmt::Expr(Expr::New { + class_name: class_name.to_string(), + args, + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }) +} + +/// `new RegExp(, )`: `pattern_box` must survive +/// `flags_box`'s lowering, which is itself an allocation. +#[test] +fn regexp_pattern_is_rooted_across_flags_lowering() { + under_both_lowerings(|lowering| { + let ir = main_ir_for( + "regexp_ctor_rooted.ts", + vec![new_builtin("RegExp", vec![allocating(), allocating()])], + ); + let pattern = first_call_result(&ir, "js_object_alloc") + .unwrap_or_else(|| panic!("{lowering}: pattern must allocate:\n{ir}")); + assert_rooted_across(&ir, &pattern, "js_regexp_construct", lowering); + }); +} + +/// The differential control: two non-allocating `RegExp` arguments must cost +/// nothing at all, so the positive test above is not vacuously satisfied by a +/// compiler that roots everything. +#[test] +fn regexp_with_non_allocating_args_emits_no_rooting() { + under_both_lowerings(|lowering| { + let ir = main_ir_for( + "regexp_ctor_no_gc.ts", + vec![new_builtin( + "RegExp", + vec![ + Expr::String("a+".to_string()), + Expr::String("g".to_string()), + ], + )], + ); + assert_no_temp_rooting(&ir, lowering); + }); +} + +/// `new EventEmitter(, )`: this arm's second +/// argument is lowered for its side effects only (`adopt_leading_arg_discard_rest`'s +/// discard loop), never named in the call — `opts` still has to survive that +/// lowering. +#[test] +fn event_emitter_options_is_rooted_across_the_discard_loop() { + under_both_lowerings(|lowering| { + let ir = main_ir_for( + "event_emitter_ctor_rooted.ts", + vec![new_builtin( + "EventEmitter", + vec![allocating(), allocating()], + )], + ); + let opts = first_call_result(&ir, "js_object_alloc") + .unwrap_or_else(|| panic!("{lowering}: opts must allocate:\n{ir}")); + assert_rooted_across(&ir, &opts, "js_event_emitter_new_with_options", lowering); + }); +} + +/// `new DataView(, , )`: three plain +/// sequential operands, no discard loop — `view_box` must survive both +/// `offset_box`'s and `length_box`'s lowering. +#[test] +fn data_view_view_arg_is_rooted_across_offset_and_length() { + under_both_lowerings(|lowering| { + let ir = main_ir_for( + "dataview_ctor_rooted.ts", + vec![new_builtin( + "DataView", + vec![allocating(), allocating(), allocating()], + )], + ); + let view = first_call_result(&ir, "js_object_alloc") + .unwrap_or_else(|| panic!("{lowering}: view_box must allocate:\n{ir}")); + assert_rooted_across(&ir, &view, "js_data_view_new", lowering); + }); +} + +/// `new SuppressedError(, , )`: `error` +/// must survive both `suppressed`'s and `message`'s lowering. +#[test] +fn suppressed_error_first_arg_is_rooted_across_the_others() { + under_both_lowerings(|lowering| { + let ir = main_ir_for( + "suppressed_error_ctor_rooted.ts", + vec![new_builtin( + "SuppressedError", + vec![allocating(), allocating(), allocating()], + )], + ); + let error = first_call_result(&ir, "js_object_alloc") + .unwrap_or_else(|| panic!("{lowering}: error must allocate:\n{ir}")); + assert_rooted_across(&ir, &error, "js_suppressed_error_new", lowering); + }); +} + +/// `new WeakMap()`: the hazard here is not another ARGUMENT — +/// `WeakMap` takes one — it is `js_weakmap_new` itself, an unconditional +/// allocation that always runs between the iterable's lowering and its use in +/// `js_weakmap_init_iterable`. Pre-fix this arm eagerly `.map(lower_expr)`'d +/// every argument, called `js_weakmap_new`, and only THEN read +/// `lowered_args.first()` back out of its now-possibly-stale register. +#[test] +fn weakmap_iterable_is_rooted_across_the_allocation_call() { + under_both_lowerings(|lowering| { + let ir = main_ir_for( + "weakmap_ctor_rooted.ts", + vec![new_builtin("WeakMap", vec![allocating()])], + ); + let iterable = first_call_result(&ir, "js_object_alloc") + .unwrap_or_else(|| panic!("{lowering}: iterable must allocate:\n{ir}")); + assert_rooted_across(&ir, &iterable, "js_weakmap_init_iterable", lowering); + }); +} diff --git a/crates/perry-codegen/src/temp_root_coverage/mod.rs b/crates/perry-codegen/src/temp_root_coverage/mod.rs index f54ee69e44..c0bf93a5de 100644 --- a/crates/perry-codegen/src/temp_root_coverage/mod.rs +++ b/crates/perry-codegen/src/temp_root_coverage/mod.rs @@ -43,6 +43,7 @@ use crate::testing::NativeRootsPin; use crate::{compile_module, AppMetadata, CompileOptions}; use perry_hir::{Expr, Module, ModuleInitKind, Stmt}; +mod builtin_ctor; mod operands; fn entry_opts() -> CompileOptions {