diff --git a/CLAUDE.md b/CLAUDE.md index bd8719cfcd..187f6f5543 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.1377 +**Current Version:** 0.5.1378 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index b292fd183c..3f8e223833 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1377" +version = "0.5.1378" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1377" +version = "0.5.1378" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1377" +version = "0.5.1378" [[package]] name = "perry-ui-tvos" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1377" +version = "0.5.1378" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index ecef104bbb..ea9bed40e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1377" +version = "0.5.1378" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7662-layer1-slice7-expr-modules.md b/changelog.d/7662-layer1-slice7-expr-modules.md new file mode 100644 index 0000000000..5697eeac64 --- /dev/null +++ b/changelog.d/7662-layer1-slice7-expr-modules.md @@ -0,0 +1,126 @@ +### Layer 1 rooting migration, slice 7 — the `expr/` operand-list modules (#7615) + +`expr/child_proc.rs`, `expr/proxy_reflect.rs` and `expr/fs_await.rs` are migrated +onto `crate::rooting` and listed in the `MIGRATED_MODULES` ledger. All three +named `expr::temp_root` before the migration +(`temp_root_{push,get}_double`, `temp_root_truncate`, +`guard_store_operand{,_across}`, `reread_store_operand`, +`release_store_operand`, `expr_may_trigger_gc`), so all three lines are +load-bearing on the committed source and not only under sabotage. + +**One node-visible bug, A/B'd byte-for-byte, and it is 14 dropped side effects.** +`child_process` validated each argument the instant it was lowered — lower +`command`, throw `ERR_INVALID_ARG_TYPE`, never evaluate `options`. JS evaluates a +call's whole argument list *before* the callee is entered, so node runs those +side effects and only then throws. Against node 26.5.1 with a baseline compiler +built from `main` in a separate target directory, a probe covering all seven +entry points (`execSync`, `spawnSync`, `spawn`, `fork`, `exec`, `execFile`, +`execFileSync`) diffs **empty** against the fixed arm and **14 missing lines** +against the baseline — every `args`, `options` and `callback` expression, never +evaluated. The relative order of the validators among themselves is unchanged +(`file`, then `args`, then `options`), which is node's own order in +`normalizeSpawnArguments`. + +**The unprotected windows, in three families.** Nine across five +`child_process` arms, twenty-eight `Proxy.*` / `Reflect.*` lowerings, and one +accumulator shared by four call sites. + +*Raw pointers held across user code — #7280 taxonomy (a), which `root_reload` +structurally cannot repair, because the value has already left the NaN-boxed +representation a slot can be re-read into:* + +* `execSync` / `spawnSync` / `spawn` / `execFile` / `execFileSync` stripped + `command`/`file` to a bare `StringHeader*` and then lowered `args` and + `options` — arbitrary user code — before `js_child_process_*` dereferenced it; +* `fork` is #7453's shape at a second site: `js_jsvalue_to_string_coerce` (which + runs a user `toString` on an object module path) produced a raw string pointer + that was then held across two more lowerings; +* `spawnBackground` re-tagged `log_file`'s stripped pointer and carried it across + `env_json`'s lowering; +* **`process.env[k] = v`** was the worst of them. The computed-key branch coerced + the key with `js_to_property_key` — a **fresh** heap string with no other root + at all — stripped it to a raw pointer, and only then lowered the value; + `js_setenv` dereferenced whatever was left. The literal-key branch is #7114 + one operand over from the `PutValueSet` key #7201 fixed: the key's + `__perry_init_strings_*` handle global is a registered root that evacuation + *rewrites*, and the load sat above the window. ES2022 moved `ToPropertyKey` + before the RHS, so the coercion cannot simply be sunk below the value — the + coerced key is what has to survive. + +*Operand-to-operand — taxonomy (c):* twenty-eight `Proxy.*` / `Reflect.*` arms. +`Reflect.has(target, key)` lowered `target`, lowered `key`, and handed the +pre-collection `target` register to `js_reflect_has`; `Reflect.set` does it with +four operands. Exactly one arm in that file made a rooting decision before this +slice (the `PutValueSet` write-IC), which is what "listed ≠ audited" looks like +from the other side — nobody had read the module. + +*Accumulators — #7154:* `expr::helpers::proxy_build_args_array` threaded the +argument array's raw `*mut ArrayHeader` through its push loop in a bare SSA +register while each element was lowered, and had no way to root its **caller's** +receiver across the same loop. It is deleted; its four call sites now build the +array inside a `RootedGroup` that holds the receiver too. + +**`fs_await.rs`'s root was correct and never released.** `temp_root_push_double` +ran unconditionally at the top of the `Await` arm and no path emitted a truncate. +Over-retention on every path in the alloca lowering, and in the FFI fallback +(`temp_pool_acquire` returns `None` when the function has no shadow frame) a +`js_gc_temp_root_push` per **execution** with no matching truncate — #7462's +unbounded-growth shape for an `await` inside a loop. The scope is now a +`RootedGroup` whose release `with_rooted_group` owns, emitted in the merge block. +The test asserts the consequence rather than the emission: three sequential +awaits must reserve the same number of rooted slots as one. + +**The one API addition, and why it arrived now rather than earlier.** +`rooting.rs` deleted a `root_i64` combinator unused and wrote down the terms on +which a replacement could return — "with its caller and with a written argument +for why `call_rooted` cannot serve". This slice found two callers and they are +the same shape: a GC-managed value produced by an **emitted step** rather than by +lowering an `Expr` (the coerced `process.env` key; the assimilated promise the +await loop polls). `RootedGroup::adopt_emitted` is that combinator. +`call_rooted` cannot serve because it fuses the root store to a call it emits +itself with `Repr::Ptr` hardcoded, and neither value is the direct `i64` result +of one call. There is no protection *flag*: with no `Expr` to ask, +`Reload` cannot re-derive the value (re-emitting the producing call would call it +twice, and both producers are observable) and `Reuse` is the bug, so the answer +is always `Root` and a caller cannot pick the wrong one. What it weakens is +stated in its doc: `value` is a caller-produced register, so #7192's ordering is +writable here exactly as it already is in `with_rooted_accumulator`. + +**Two judgement calls, recorded because they are decisions rather than +mechanics.** + +* `child_proc.rs` re-reads its operands **twice** — once for the validators, + once for the strip and the consuming call. `js_child_process_validate_options` + reads a dozen own properties off a user object, allocating a key string per + read; rather than depend on that being a non-collecting window (#7198's + position), the second re-read removes the question. It is free where nothing is + protected — `RootedGroup::reread` on an unprotected operand emits no IR — and + one `load` where something is. +* `spawnBackground`'s `args` slot is lowered into the group with `collects = + false`. Its value is evaluated for its side effects and then discarded + (`js_child_process_spawn_background` takes no argument vector), and a value + with no consumer has no window, so it is protected not at all. + +**Cost, measured.** Over the `gc_root_dominance_corpus.sh` corpus (149 modules) +the root-store count moves **9799 → 9805**, +6, with violations 0 in both arms +and `--unrooted-allocas --moving-only` at 0 over 7864 gc-capable allocas. Three +zero-cost pins are committed so a future "root everything" change goes red: a +no-options `execSync`, a single-operand `Reflect.ownKeys` and a string-literal +`process.env` key must each emit no temp-root traffic at all. + +**Tests.** `expr/slice7_rooting_tests.rs`, 13 cases, asserting IR *ordering* +rather than slot counts — a count lets one operand's rooting pay for another's +assertion — with a by-callee-name liveness assertion first in every one, so a +shape measured over a lowering that never ran cannot pass. Sabotage-verified +against the pre-fix source of all five touched files restored from `HEAD`: +`error[` count 0 (so the pre-fix source compiled and the run means something), +`Running unittests` present, **11 of 13 red**. The two that stay green are the +deliberate zero-cost pins, which are correct in both arms. The ledger sabotage +arm was run once per newly listed module — a compiling +`temp_root_push_double` / `temp_root_truncate` pair planted in each, `error[` +count 0 in all three, ledger test red and naming both planted lines. + +**No runtime fault is claimed.** Every window here is demonstrated in IR. Whether +a stale pointer is *observably* wrong depends on what is recycled into those +bytes; the IR ordering is the evidence the window exists, and a fault would only +have been evidence that one arrangement reaches it. diff --git a/crates/perry-codegen/src/expr/child_proc.rs b/crates/perry-codegen/src/expr/child_proc.rs index e9e50f7180..b430c64892 100644 --- a/crates/perry-codegen/src/expr/child_proc.rs +++ b/crates/perry-codegen/src/expr/child_proc.rs @@ -1,20 +1,133 @@ //! ChildProcess execSync/spawnSync/spawn/exec/etc. //! //! Extracted from `expr/mod.rs` to keep that file under the 2000-line cap. -//! Pure mechanical move — match arm bodies are verbatim copies, called from -//! `lower_expr`'s outer dispatch. +//! +//! # Layer 1 rooting (#7615 slice 7) +//! +//! Every entry point here has the same skeleton — an ordered argument list with +//! optional slots, a run of setup-time validators, a run of +//! [`unbox_to_i64`] strips, and one consuming `js_child_process_*` call — and +//! before this slice each arm re-implemented that skeleton by hand. Three of the +//! eight rooted unconditionally through `expr::temp_root`; the other five rooted +//! nothing at all while holding **raw heap pointers** across arbitrary user +//! lowerings. [`lower_cp_args`] is the skeleton, and every arm is now an +//! argument list plus a consuming call. +//! +//! Two properties are worth stating because they are decisions rather than +//! mechanics: +//! +//! * **Validators run below the whole argument list.** They used to be +//! interleaved — lower `command`, validate it, lower `options` — which throws +//! `ERR_INVALID_ARG_TYPE` *before* evaluating the later arguments. JS +//! evaluates a call's whole argument list before the callee is entered, so +//! node runs those side effects and only then throws. The relative order of +//! the validators among themselves is unchanged (`file`, then `args`, then +//! `options`), which is node's own order in `normalizeSpawnArguments`. +//! * **The validators get their own re-read.** Each arm re-reads its operands +//! once for the validators and again for the unbox + consuming call. +//! `js_child_process_validate_options` reads a dozen own properties off a user +//! object, allocating a key string per read; rather than depend on that being +//! a non-collecting window (#7198's position), the second re-read simply +//! removes the question. It is free where nothing is protected — +//! [`crate::rooting::RootedGroup::reread`] on an unprotected operand emits no +//! IR at all — and one `load` where something is. use anyhow::Result; use perry_hir::Expr; use crate::nanbox::double_literal; +use crate::rooting::{any_operand_may_collect, with_rooted_group, RootedGroup}; use crate::types::{DOUBLE, I32, I64, PTR}; use super::{ - emit_string_literal_global, lower_expr, nanbox_pointer_inline, nanbox_string_inline, - unbox_to_i64, FnCtx, + emit_string_literal_global, nanbox_pointer_inline, nanbox_string_inline, unbox_to_i64, FnCtx, }; +/// Flatten an argument list's optional slots into one ordered operand list. +/// +/// Returns the present operands in evaluation order, plus a per-slot index into +/// that list — `None` for an absent slot. The flattening is what lets the +/// positional protection rule ("operand `i` is live across every operand after +/// it") be answered by [`any_operand_may_collect`], the same predicate every +/// other lowering consults, instead of by an `if let Some(..)` ladder per arm. +fn operand_slots<'a>(slots: &[Option<&'a Expr>]) -> (Vec<&'a Expr>, Vec>) { + let mut exprs = Vec::with_capacity(slots.len()); + let mut at = Vec::with_capacity(slots.len()); + for slot in slots { + match slot { + Some(expr) => { + exprs.push(*expr); + at.push(Some(exprs.len() - 1)); + } + None => at.push(None), + } + } + (exprs, at) +} + +/// Lower one `child_process` argument list into `group`, rooting each operand +/// across the lowering of the operands that follow it. +/// +/// `discarded` names a slot whose lowered value has no consumer — only +/// `spawnBackground`'s `args`, which is evaluated for its side effects and then +/// dropped. A value with no consumer has no window, so it is lowered in place +/// (evaluation order is observable) and protected not at all. +/// +/// `across_call` is the caller's statement that an **emitted** step below the +/// list can reach a collection point. Only `fork` sets it: that arm emits +/// `js_jsvalue_to_string_coerce`, which runs a user `toString` on an object +/// module path. `any_operand_may_collect` reads expressions and cannot see an +/// emitted call, which is why the answer is stated rather than derived — the +/// precedent is `crate::rooting::with_operands_rooted_across_call`. +fn lower_cp_args<'a>( + ctx: &mut FnCtx<'_>, + group: &mut RootedGroup<'a>, + exprs: &[&'a Expr], + discarded: Option, + across_call: bool, +) -> Result<()> { + for (i, expr) in exprs.iter().enumerate() { + let consumed = Some(i) != discarded; + let collects = consumed + && (across_call || any_operand_may_collect(ctx, exprs[i + 1..].iter().copied())); + group.lower(ctx, expr, collects)?; + } + Ok(()) +} + +/// Re-read the operand in `slot`, or the `undefined` literal when the slot is +/// absent — the NaN-boxed argument form (`exec`'s `options`/`callback`, +/// `execFile`'s `args`/`options`/`callback`). +fn slot_box( + ctx: &mut FnCtx<'_>, + group: &RootedGroup<'_>, + slot: Option, + undef: &str, +) -> Result { + match slot { + Some(i) => group.reread(ctx, i), + None => Ok(undef.to_string()), + } +} + +/// [`slot_box`] followed by the raw-pointer strip, or the `0` sentinel the +/// `i64`-argument entry points use for an absent slot. +/// +/// The strip is deliberately fused to the re-read: `unbox_to_i64` produces a +/// bare heap pointer, and #7280's taxonomy (a) is precisely a pointer that has +/// already left the NaN-boxed representation a rooted slot can be re-read into. +/// Holding one across anything is unrepairable, so it is produced immediately +/// above its use and never crosses another operand. +fn slot_ptr(ctx: &mut FnCtx<'_>, group: &RootedGroup<'_>, slot: Option) -> Result { + match slot { + Some(i) => { + let boxed = group.reread(ctx, i)?; + Ok(unbox_to_i64(ctx.block(), &boxed)) + } + None => Ok("0".to_string()), + } +} + /// #3079: emit a setup-time `command`/`file` validation call. `cmd_box` is the /// original NaN-boxed value; `name` is the static argument name (`"command"` /// for exec/execSync, `"file"` for execFile/execFileSync/spawn/spawnSync). The @@ -87,30 +200,58 @@ fn emit_cp_validate_spawn_args(ctx: &mut FnCtx<'_>, args_box: &str, sync: bool, ); } +/// The `spawn` / `spawnSync` validator run: command, then args, then options. +/// +/// That order is node's own (`normalizeSpawnArguments` validates `file` first), +/// and it is now the order the emissions appear in rather than an accident of +/// where each operand happened to be lowered. Each validator reads a freshly +/// re-read operand, so none of them observes a register produced above the one +/// before it. +fn emit_cp_validators( + ctx: &mut FnCtx<'_>, + group: &RootedGroup<'_>, + at: &[Option], + command_name: &str, + sync: bool, + allow_null: bool, +) -> Result<()> { + let cmd_box = group.reread(ctx, 0)?; + emit_cp_validate_command(ctx, &cmd_box, command_name); + if let Some(i) = at[1] { + let v = group.reread(ctx, i)?; + emit_cp_validate_spawn_args(ctx, &v, sync, allow_null); + } + if let Some(i) = at[2] { + let v = group.reread(ctx, i)?; + emit_cp_validate_options(ctx, &v, sync, allow_null); + } + Ok(()) +} + pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { match expr { Expr::ChildProcessExecSync { command, options } => { - let cmd_box = lower_expr(ctx, command)?; - // #3079: throw `ERR_INVALID_ARG_TYPE` for a missing/non-string command. - emit_cp_validate_command(ctx, &cmd_box, "command"); - let blk = ctx.block(); - let cmd_str = unbox_to_i64(blk, &cmd_box); - let opts_str = if let Some(opts) = options { - let o = lower_expr(ctx, opts)?; - unbox_to_i64(ctx.block(), &o) - } else { - "0".to_string() - }; - // js_child_process_exec_sync(cmd: i64, opts: i64) -> f64. - // #1937/#1938: the runtime returns an already-NaN-boxed value - // (Buffer by default, string with `encoding`) and throws on a - // non-zero exit, so we pass the result straight through. - let result = ctx.block().call( - DOUBLE, - "js_child_process_exec_sync", - &[(I64, &cmd_str), (I64, &opts_str)], - ); - Ok(result) + let (exprs, at) = operand_slots(&[Some(command), options.as_deref()]); + with_rooted_group(ctx, exprs.len(), |ctx, g| { + lower_cp_args(ctx, g, &exprs, None, false)?; + // #3079: throw `ERR_INVALID_ARG_TYPE` for a missing/non-string + // command -- below the whole argument list, which is where node + // throws it (the callee is not entered until every argument has + // been evaluated). + let cmd_box = g.reread(ctx, 0)?; + emit_cp_validate_command(ctx, &cmd_box, "command"); + let cmd_str = slot_ptr(ctx, g, at[0])?; + let opts_str = slot_ptr(ctx, g, at[1])?; + // js_child_process_exec_sync(cmd: i64, opts: i64) -> f64. + // #1937/#1938: the runtime returns an already-NaN-boxed value + // (Buffer by default, string with `encoding`) and throws on a + // non-zero exit, so we pass the result straight through. + Ok(ctx.block().call( + DOUBLE, + "js_child_process_exec_sync", + &[(I64, &cmd_str), (I64, &opts_str)], + )) + }) } Expr::ChildProcessSpawnSync { @@ -118,32 +259,21 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { args, options, } => { - let cmd_box = lower_expr(ctx, command)?; - // #3079: spawnSync's command argument is reported as "file". - emit_cp_validate_command(ctx, &cmd_box, "file"); - let blk = ctx.block(); - let cmd_str = unbox_to_i64(blk, &cmd_box); - let args_str = if let Some(a) = args { - let v = lower_expr(ctx, a)?; - emit_cp_validate_spawn_args(ctx, &v, true, false); - unbox_to_i64(ctx.block(), &v) - } else { - "0".to_string() - }; - let opts_str = if let Some(o) = options { - let v = lower_expr(ctx, o)?; - emit_cp_validate_options(ctx, &v, true, false); - unbox_to_i64(ctx.block(), &v) - } else { - "0".to_string() - }; - // js_child_process_spawn_sync(cmd: i64, args: i64, opts: i64) -> i64 - let result = ctx.block().call( - I64, - "js_child_process_spawn_sync", - &[(I64, &cmd_str), (I64, &args_str), (I64, &opts_str)], - ); - Ok(nanbox_pointer_inline(ctx.block(), &result)) + let (exprs, at) = operand_slots(&[Some(command), args.as_deref(), options.as_deref()]); + with_rooted_group(ctx, exprs.len(), |ctx, g| { + lower_cp_args(ctx, g, &exprs, None, false)?; + emit_cp_validators(ctx, g, &at, "file", true, false)?; + let cmd_str = slot_ptr(ctx, g, at[0])?; + let args_str = slot_ptr(ctx, g, at[1])?; + let opts_str = slot_ptr(ctx, g, at[2])?; + // js_child_process_spawn_sync(cmd: i64, args: i64, opts: i64) -> i64 + let result = ctx.block().call( + I64, + "js_child_process_spawn_sync", + &[(I64, &cmd_str), (I64, &args_str), (I64, &opts_str)], + ); + Ok(nanbox_pointer_inline(ctx.block(), &result)) + }) } Expr::ChildProcessSpawnBackground { @@ -152,35 +282,36 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { log_file, env_json, } => { - let cmd_box = lower_expr(ctx, command)?; - let _args_box = if let Some(a) = args { - lower_expr(ctx, a)? - } else { - double_literal(0.0) - }; - let log_box = lower_expr(ctx, log_file)?; - let blk = ctx.block(); - let log_str = unbox_to_i64(blk, &log_box); - let log_nanbox = nanbox_string_inline(ctx.block(), &log_str); - let env_box = if let Some(e) = env_json { - lower_expr(ctx, e)? - } else { - double_literal(0.0) - }; - // js_child_process_spawn_background(cmd: f64, args_arr: i64, logFile: f64, envJson: f64) -> i64 - let blk = ctx.block(); - let cmd_str = unbox_to_i64(blk, &cmd_box); - let result = ctx.block().call( - I64, - "js_child_process_spawn_background", - &[ - (DOUBLE, &cmd_box), - (I64, &cmd_str), - (DOUBLE, &log_nanbox), - (DOUBLE, &env_box), - ], - ); - Ok(nanbox_pointer_inline(ctx.block(), &result)) + let (exprs, at) = operand_slots(&[ + Some(command), + args.as_deref(), + Some(log_file), + env_json.as_deref(), + ]); + with_rooted_group(ctx, exprs.len(), |ctx, g| { + lower_cp_args(ctx, g, &exprs, at[1], false)?; + let log_box = g.reread(ctx, at[2].expect("log_file is not optional"))?; + let log_str = unbox_to_i64(ctx.block(), &log_box); + let log_nanbox = nanbox_string_inline(ctx.block(), &log_str); + let env_box = match at[3] { + Some(i) => g.reread(ctx, i)?, + None => double_literal(0.0), + }; + // js_child_process_spawn_background(cmd: f64, args_arr: i64, logFile: f64, envJson: f64) -> i64 + let cmd_box = g.reread(ctx, 0)?; + let cmd_str = unbox_to_i64(ctx.block(), &cmd_box); + let result = ctx.block().call( + I64, + "js_child_process_spawn_background", + &[ + (DOUBLE, &cmd_box), + (I64, &cmd_str), + (DOUBLE, &log_nanbox), + (DOUBLE, &env_box), + ], + ); + Ok(nanbox_pointer_inline(ctx.block(), &result)) + }) } Expr::ChildProcessSpawn { @@ -188,34 +319,22 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { args, options, } => { - let cmd_box = lower_expr(ctx, command)?; - // #3079: spawn's command argument is reported as "file". - emit_cp_validate_command(ctx, &cmd_box, "file"); - let blk = ctx.block(); - let cmd_str = unbox_to_i64(blk, &cmd_box); - let args_str = if let Some(a) = args { - let v = lower_expr(ctx, a)?; - emit_cp_validate_spawn_args(ctx, &v, false, false); - unbox_to_i64(ctx.block(), &v) - } else { - "0".to_string() - }; - let opts_str = if let Some(o) = options { - let v = lower_expr(ctx, o)?; - emit_cp_validate_options(ctx, &v, false, false); - unbox_to_i64(ctx.block(), &v) - } else { - "0".to_string() - }; - // #1780: spawn returns a streaming ChildProcess (EventEmitter with - // Readable stdout/stderr), not the spawnSync result object. The - // runtime returns an already-NaN-boxed pointer value. - let result = ctx.block().call( - DOUBLE, - "js_child_process_spawn_streams", - &[(I64, &cmd_str), (I64, &args_str), (I64, &opts_str)], - ); - Ok(result) + let (exprs, at) = operand_slots(&[Some(command), args.as_deref(), options.as_deref()]); + with_rooted_group(ctx, exprs.len(), |ctx, g| { + lower_cp_args(ctx, g, &exprs, None, false)?; + emit_cp_validators(ctx, g, &at, "file", false, false)?; + let cmd_str = slot_ptr(ctx, g, at[0])?; + let args_str = slot_ptr(ctx, g, at[1])?; + let opts_str = slot_ptr(ctx, g, at[2])?; + // #1780: spawn returns a streaming ChildProcess (EventEmitter with + // Readable stdout/stderr), not the spawnSync result object. The + // runtime returns an already-NaN-boxed pointer value. + Ok(ctx.block().call( + DOUBLE, + "js_child_process_spawn_streams", + &[(I64, &cmd_str), (I64, &args_str), (I64, &opts_str)], + )) + }) } Expr::ChildProcessFork { @@ -226,31 +345,41 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // `fork(modulePath[, args][, options])` — like spawn, but the // runtime wires up an IPC channel + send/disconnect/'message'. The // runtime returns an already-NaN-boxed ChildProcess pointer. #1933. - let mod_box = lower_expr(ctx, module)?; - emit_cp_validate_fork_module(ctx, &mod_box); - let mod_str = - ctx.block() - .call(I64, "js_jsvalue_to_string_coerce", &[(DOUBLE, &mod_box)]); - let args_str = if let Some(a) = args { - let v = lower_expr(ctx, a)?; - emit_cp_validate_spawn_args(ctx, &v, false, true); - unbox_to_i64(ctx.block(), &v) - } else { - "0".to_string() - }; - let opts_str = if let Some(o) = options { - let v = lower_expr(ctx, o)?; - emit_cp_validate_options(ctx, &v, false, true); - unbox_to_i64(ctx.block(), &v) - } else { - "0".to_string() - }; - let result = ctx.block().call( - DOUBLE, - "js_child_process_fork", - &[(I64, &mod_str), (I64, &args_str), (I64, &opts_str)], - ); - Ok(result) + let (exprs, at) = operand_slots(&[Some(module), args.as_deref(), options.as_deref()]); + with_rooted_group(ctx, exprs.len(), |ctx, g| { + // `across_call`: `js_jsvalue_to_string_coerce` below runs a user + // `toString` on an object module path (`fork(new URL(..))` is the + // documented overload), so every operand's window collects + // whatever the argument expressions themselves do. + lower_cp_args(ctx, g, &exprs, None, true)?; + let mod_box = g.reread(ctx, 0)?; + emit_cp_validate_fork_module(ctx, &mod_box); + if let Some(i) = at[1] { + let v = g.reread(ctx, i)?; + emit_cp_validate_spawn_args(ctx, &v, false, true); + } + if let Some(i) = at[2] { + let v = g.reread(ctx, i)?; + emit_cp_validate_options(ctx, &v, false, true); + } + // The coercion is the collection point the roots exist for, so + // `module` is re-read immediately above it and `args`/`options` + // strictly below it. Before this slice `mod_str` — a RAW string + // pointer — was produced here and then held across both of those + // lowerings, which is #7280 taxonomy (a): no re-read of a + // NaN-boxed slot can repair an already-stripped pointer. + let mod_box = g.reread(ctx, 0)?; + let mod_str = + ctx.block() + .call(I64, "js_jsvalue_to_string_coerce", &[(DOUBLE, &mod_box)]); + let args_str = slot_ptr(ctx, g, at[1])?; + let opts_str = slot_ptr(ctx, g, at[2])?; + Ok(ctx.block().call( + DOUBLE, + "js_child_process_fork", + &[(I64, &mod_str), (I64, &args_str), (I64, &opts_str)], + )) + }) } Expr::ChildProcessExec { @@ -265,41 +394,22 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // NaN-boxed f64 and let the runtime locate the closure. With no // callback the runtime returns the stdout string (legacy shape). let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); - let cmd_box = lower_expr(ctx, command)?; - // #3079: throw `ERR_INVALID_ARG_TYPE` for a missing/non-string command. - emit_cp_validate_command(ctx, &cmd_box, "command"); - // `cmd_box` is a heap string and the `options`/`callback` - // lowerings below run arbitrary user code, so the raw pointer must - // not be taken until after them. Root the validated command, lower - // the rest, then unbox from the reload. `arg1` needs the same - // treatment: it is a NaN-boxed heap value crossing the `callback` - // lowering. Truncating to `cmd_slot` drops both (temp roots are a - // stack), so one release covers them. - let cmd_slot = super::temp_root::temp_root_push_double(ctx, &cmd_box); - let arg1_slot = if let Some(o) = options { - let v = lower_expr(ctx, o)?; - Some(super::temp_root::temp_root_push_double(ctx, &v)) - } else { - None - }; - let arg2 = if let Some(cb) = callback { - lower_expr(ctx, cb)? - } else { - undef.clone() - }; - let arg1 = match &arg1_slot { - Some(slot) => super::temp_root::temp_root_get_double(ctx, slot), - None => undef.clone(), - }; - let cmd_box = super::temp_root::temp_root_get_double(ctx, &cmd_slot); - let cmd_str = unbox_to_i64(ctx.block(), &cmd_box); - let result = ctx.block().call( - DOUBLE, - "js_child_process_exec", - &[(I64, &cmd_str), (DOUBLE, &arg1), (DOUBLE, &arg2)], - ); - super::temp_root::temp_root_truncate(ctx, &cmd_slot); - Ok(result) + let (exprs, at) = + operand_slots(&[Some(command), options.as_deref(), callback.as_deref()]); + with_rooted_group(ctx, exprs.len(), |ctx, g| { + lower_cp_args(ctx, g, &exprs, None, false)?; + // #3079: throw `ERR_INVALID_ARG_TYPE` for a missing/non-string command. + let cmd_box = g.reread(ctx, 0)?; + emit_cp_validate_command(ctx, &cmd_box, "command"); + let arg1 = slot_box(ctx, g, at[1], &undef)?; + let arg2 = slot_box(ctx, g, at[2], &undef)?; + let cmd_str = slot_ptr(ctx, g, at[0])?; + Ok(ctx.block().call( + DOUBLE, + "js_child_process_exec", + &[(I64, &cmd_str), (DOUBLE, &arg1), (DOUBLE, &arg2)], + )) + }) } Expr::ChildProcessExecFile { @@ -314,54 +424,36 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // boxed f64 (the runtime locates the array + closure). See // `js_child_process_exec_file`. let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); - let file_box = lower_expr(ctx, file)?; - // #3079: throw `ERR_INVALID_ARG_TYPE` for a missing/non-string file. - emit_cp_validate_command(ctx, &file_box, "file"); - // Same window as the `exec` arm above: `file_box` is a heap string - // and every optional operand below lowers arbitrary user code. Root - // each as it is produced, lower the rest, then reload. One truncate - // to `file_slot` releases them all -- temp roots are a stack. - let file_slot = super::temp_root::temp_root_push_double(ctx, &file_box); - let args_slot = if let Some(a) = args { - let v = lower_expr(ctx, a)?; - emit_cp_validate_args(ctx, &v); - Some(super::temp_root::temp_root_push_double(ctx, &v)) - } else { - None - }; - let opts_slot = if let Some(o) = options { - let v = lower_expr(ctx, o)?; - Some(super::temp_root::temp_root_push_double(ctx, &v)) - } else { - None - }; - let cb_v = if let Some(c) = callback { - lower_expr(ctx, c)? - } else { - undef.clone() - }; - let args_v = match &args_slot { - Some(slot) => super::temp_root::temp_root_get_double(ctx, slot), - None => undef.clone(), - }; - let opts_v = match &opts_slot { - Some(slot) => super::temp_root::temp_root_get_double(ctx, slot), - None => undef.clone(), - }; - let file_box = super::temp_root::temp_root_get_double(ctx, &file_slot); - let file_str = unbox_to_i64(ctx.block(), &file_box); - let result = ctx.block().call( - DOUBLE, - "js_child_process_exec_file", - &[ - (I64, &file_str), - (DOUBLE, &args_v), - (DOUBLE, &opts_v), - (DOUBLE, &cb_v), - ], - ); - super::temp_root::temp_root_truncate(ctx, &file_slot); - Ok(result) + let (exprs, at) = operand_slots(&[ + Some(file), + args.as_deref(), + options.as_deref(), + callback.as_deref(), + ]); + with_rooted_group(ctx, exprs.len(), |ctx, g| { + lower_cp_args(ctx, g, &exprs, None, false)?; + // #3079: throw `ERR_INVALID_ARG_TYPE` for a missing/non-string file. + let file_box = g.reread(ctx, 0)?; + emit_cp_validate_command(ctx, &file_box, "file"); + if let Some(i) = at[1] { + let v = g.reread(ctx, i)?; + emit_cp_validate_args(ctx, &v); + } + let args_v = slot_box(ctx, g, at[1], &undef)?; + let opts_v = slot_box(ctx, g, at[2], &undef)?; + let cb_v = slot_box(ctx, g, at[3], &undef)?; + let file_str = slot_ptr(ctx, g, at[0])?; + Ok(ctx.block().call( + DOUBLE, + "js_child_process_exec_file", + &[ + (I64, &file_str), + (DOUBLE, &args_v), + (DOUBLE, &opts_v), + (DOUBLE, &cb_v), + ], + )) + }) } Expr::ChildProcessExecFileSync { @@ -374,48 +466,30 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // string with `encoding`) and throws on a non-zero exit, so we pass // the result straight through. let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); - let file_box = lower_expr(ctx, file)?; - // #3079: throw `ERR_INVALID_ARG_TYPE` for a missing/non-string file. - emit_cp_validate_command(ctx, &file_box, "file"); - // Same window as the `exec` arm above: `file_box` is a heap string - // and every optional operand below lowers arbitrary user code. Root - // each as it is produced, lower the rest, then reload. One truncate - // to `file_slot` releases them all -- temp roots are a stack. - let file_slot = super::temp_root::temp_root_push_double(ctx, &file_box); - let args_slot = if let Some(a) = args { - let v = lower_expr(ctx, a)?; - emit_cp_validate_args(ctx, &v); - Some(super::temp_root::temp_root_push_double(ctx, &v)) - } else { - None - }; - let opts_slot = if let Some(o) = options { - let v = lower_expr(ctx, o)?; - Some(super::temp_root::temp_root_push_double(ctx, &v)) - } else { - None - }; - let args_v = match &args_slot { - Some(slot) => super::temp_root::temp_root_get_double(ctx, slot), - None => undef.clone(), - }; - let opts_v = match &opts_slot { - Some(slot) => super::temp_root::temp_root_get_double(ctx, slot), - None => undef.clone(), - }; - let file_box = super::temp_root::temp_root_get_double(ctx, &file_slot); - let file_str = unbox_to_i64(ctx.block(), &file_box); - let result = ctx.block().call( - DOUBLE, - "js_child_process_exec_file_sync", - &[(I64, &file_str), (DOUBLE, &args_v), (DOUBLE, &opts_v)], - ); - super::temp_root::temp_root_truncate(ctx, &file_slot); - Ok(result) + let (exprs, at) = operand_slots(&[Some(file), args.as_deref(), options.as_deref()]); + with_rooted_group(ctx, exprs.len(), |ctx, g| { + lower_cp_args(ctx, g, &exprs, None, false)?; + // #3079: throw `ERR_INVALID_ARG_TYPE` for a missing/non-string file. + let file_box = g.reread(ctx, 0)?; + emit_cp_validate_command(ctx, &file_box, "file"); + if let Some(i) = at[1] { + let v = g.reread(ctx, i)?; + emit_cp_validate_args(ctx, &v); + } + let args_v = slot_box(ctx, g, at[1], &undef)?; + let opts_v = slot_box(ctx, g, at[2], &undef)?; + let file_str = slot_ptr(ctx, g, at[0])?; + Ok(ctx.block().call( + DOUBLE, + "js_child_process_exec_file_sync", + &[(I64, &file_str), (DOUBLE, &args_v), (DOUBLE, &opts_v)], + )) + }) } Expr::ChildProcessGetProcessStatus(handle) => { - let h = lower_expr(ctx, handle)?; + // One operand, consumed by the very next emission: no window. + let h = super::lower_expr(ctx, handle)?; let result = ctx.block() .call(I64, "js_child_process_get_process_status", &[(DOUBLE, &h)]); @@ -423,7 +497,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } Expr::ChildProcessKillProcess(handle) => { - let h = lower_expr(ctx, handle)?; + // One operand, consumed by the very next emission: no window. + let h = super::lower_expr(ctx, handle)?; let _ = ctx .block() .call(I32, "js_child_process_kill_process", &[(DOUBLE, &h)]); diff --git a/crates/perry-codegen/src/expr/fs_await.rs b/crates/perry-codegen/src/expr/fs_await.rs index af6a9ecc91..3ed158496a 100644 --- a/crates/perry-codegen/src/expr/fs_await.rs +++ b/crates/perry-codegen/src/expr/fs_await.rs @@ -1,13 +1,29 @@ //! FsUnlinkSync + Await. //! //! Extracted from `expr/mod.rs` to keep that file under the 2000-line cap. -//! Pure mechanical move — match arm bodies are verbatim copies, called from -//! `lower_expr`'s outer dispatch. +//! +//! # Layer 1 rooting (#7615 slice 7) +//! +//! The await loop's promise root was correct in every respect but one: it was +//! **never released**. `temp_root_push_double` ran unconditionally at the top of +//! the arm and no path emitted a truncate, so the slot stayed bound for the rest +//! of the function on every path out — and in the FFI-fallback lowering +//! (`temp_pool_acquire` returns `None` when the function has no shadow frame) +//! that is a `js_gc_temp_root_push` per EXECUTION with no matching +//! `js_gc_temp_root_truncate`, which is #7462's unbounded-growth shape for an +//! `await` inside a loop. +//! +//! The scope is now a `crate::rooting::RootedGroup` whose release is owned by +//! `with_rooted_group`, so it is emitted in the merge block on every path that +//! reaches it and cannot be forgotten. The two paths that do NOT reach the merge +//! (`await.reject`'s `js_throw`, `await.unsettled_exit`) end in `unreachable` +//! or a `ret`, where over-retention is unobservable. use anyhow::Result; use perry_hir::Expr; use crate::nanbox::double_literal; +use crate::rooting::{with_rooted_group, Repr}; use crate::types::{DOUBLE, I1, I32, I64}; use super::{lower_expr, unbox_to_i64, FnCtx}; @@ -15,6 +31,7 @@ use super::{lower_expr, unbox_to_i64, FnCtx}; pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { match expr { Expr::FsUnlinkSync(path) => { + // One operand, consumed by the very next emission: no window. let p = lower_expr(ctx, path)?; let _ = ctx.block().call(I32, "js_fs_unlink_sync", &[(DOUBLE, &p)]); Ok(double_literal(0.0)) @@ -74,209 +91,210 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_await_any_promise", &[(DOUBLE, &assimilated_box)], ); + with_rooted_group(ctx, 1, |ctx, g| { + // Defensive guard: if the operand is not actually a + // Promise (e.g. `await someNumber` or an unsupported + // runtime function that returned a raw handle), fall + // back to JS semantics — "await non-promise returns + // the value itself" — instead of unboxing garbage bits + // and polling `js_promise_state` on a random pointer. + // + // We call `js_value_is_promise(f64) -> i32` (GC-type + // check) and branch: truthy → existing polling path, + // falsy → store the box into a result slot and jump + // straight to the merge block. + // + // The result is materialized via an `alloca` slot so the + // merge block can reload a single SSA value without + // having to thread explicit phi nodes through every + // intermediate block. Hoisted to the entry block so the + // slot dominates the merge block even when this Await is + // itself nested inside an if-arm. + // #7341: root the promise for the whole await loop. + // + // `wait` calls `js_promise_run_microtasks_await_loop`, + // `js_run_stdlib_pump` and `js_await_loop_tick_timers`, every one of + // which allocates and can drive an evacuating minor — then branches + // back to `check`, which re-unboxes the SAME SSA value. The comment + // below about unboxing per block solves LLVM dominance, not GC + // movement: the box names the pre-collection promise, so after one + // pump the loop polls retired from-space and `js_promise_state` + // dereferences it. + // + // 4 of the 31 catches in #7341 are this, all `obj_type=5` + // (GC_TYPE_PROMISE) with frame #1 in generated code. The temp-root + // slot is what the collector rewrites, so every block re-reads it + // instead of reusing the register. + let promise_root = g.adopt_emitted(ctx, Repr::Boxed, &promise_box); + let result_slot = ctx.func.alloca_entry(DOUBLE); + // Pre-seed with the boxed operand so the non-promise + // branch just needs to jump to merge. + ctx.block().store(DOUBLE, &promise_box, &result_slot); - // Defensive guard: if the operand is not actually a - // Promise (e.g. `await someNumber` or an unsupported - // runtime function that returned a raw handle), fall - // back to JS semantics — "await non-promise returns - // the value itself" — instead of unboxing garbage bits - // and polling `js_promise_state` on a random pointer. - // - // We call `js_value_is_promise(f64) -> i32` (GC-type - // check) and branch: truthy → existing polling path, - // falsy → store the box into a result slot and jump - // straight to the merge block. - // - // The result is materialized via an `alloca` slot so the - // merge block can reload a single SSA value without - // having to thread explicit phi nodes through every - // intermediate block. Hoisted to the entry block so the - // slot dominates the merge block even when this Await is - // itself nested inside an if-arm. - // #7341: root the promise for the whole await loop. - // - // `wait` calls `js_promise_run_microtasks_await_loop`, - // `js_run_stdlib_pump` and `js_await_loop_tick_timers`, every one of - // which allocates and can drive an evacuating minor — then branches - // back to `check`, which re-unboxes the SAME SSA value. The comment - // below about unboxing per block solves LLVM dominance, not GC - // movement: the box names the pre-collection promise, so after one - // pump the loop polls retired from-space and `js_promise_state` - // dereferences it. - // - // 4 of the 31 catches in #7341 are this, all `obj_type=5` - // (GC_TYPE_PROMISE) with frame #1 in generated code. The temp-root - // slot is what the collector rewrites, so every block re-reads it - // instead of reusing the register. - let promise_root = crate::expr::temp_root::temp_root_push_double(ctx, &promise_box); - let result_slot = ctx.func.alloca_entry(DOUBLE); - // Pre-seed with the boxed operand so the non-promise - // branch just needs to jump to merge. - ctx.block().store(DOUBLE, &promise_box, &result_slot); - - let is_promise_i32 = - ctx.block() - .call(I32, "js_value_is_promise", &[(DOUBLE, &promise_box)]); - let is_promise_bool = ctx.block().icmp_ne(I32, &is_promise_i32, "0"); + let is_promise_i32 = + ctx.block() + .call(I32, "js_value_is_promise", &[(DOUBLE, &promise_box)]); + let is_promise_bool = ctx.block().icmp_ne(I32, &is_promise_i32, "0"); - let drain_once_idx = ctx.new_block("await.drain_once"); - let check_idx = ctx.new_block("await.check"); - let wait_idx = ctx.new_block("await.wait"); - let settled_idx = ctx.new_block("await.settled"); - let reject_idx = ctx.new_block("await.reject"); - let done_idx = ctx.new_block("await.done"); - let merge_idx = ctx.new_block("await.merge"); + let drain_once_idx = ctx.new_block("await.drain_once"); + let check_idx = ctx.new_block("await.check"); + let wait_idx = ctx.new_block("await.wait"); + let settled_idx = ctx.new_block("await.settled"); + let reject_idx = ctx.new_block("await.reject"); + let done_idx = ctx.new_block("await.done"); + let merge_idx = ctx.new_block("await.merge"); - let drain_once_label = ctx.block_label(drain_once_idx); - let check_label = ctx.block_label(check_idx); - let wait_label = ctx.block_label(wait_idx); - let settled_label = ctx.block_label(settled_idx); - let reject_label = ctx.block_label(reject_idx); - let done_label = ctx.block_label(done_idx); - let merge_label = ctx.block_label(merge_idx); + let drain_once_label = ctx.block_label(drain_once_idx); + let check_label = ctx.block_label(check_idx); + let wait_label = ctx.block_label(wait_idx); + let settled_label = ctx.block_label(settled_idx); + let reject_label = ctx.block_label(reject_idx); + let done_label = ctx.block_label(done_idx); + let merge_label = ctx.block_label(merge_idx); - ctx.block() - .cond_br(&is_promise_bool, &drain_once_label, &merge_label); + ctx.block() + .cond_br(&is_promise_bool, &drain_once_label, &merge_label); - // === drain_once === - // Run pending promise/queueMicrotask jobs before the first state - // check. When the promise is already settled (e.g. - // `await Promise.resolve()`) the wait loop below is never - // entered, so jobs queued before this await would never fire - // before execution continues. Promise jobs ONLY — nextTick - // callbacks queued in the same synchronous stretch wait for the - // next real tick boundary, matching Node's checkpoint ordering - // (#788; previously this drained the tick queue instead, so a - // nextTick callback overtook earlier-queued microtasks). The - // wait loop covers ticks/timers for pending promises. - ctx.current_block = drain_once_idx; - let _ = ctx.block().call(I32, "js_promise_run_promise_jobs", &[]); - ctx.block().br(&check_label); + // === drain_once === + // Run pending promise/queueMicrotask jobs before the first state + // check. When the promise is already settled (e.g. + // `await Promise.resolve()`) the wait loop below is never + // entered, so jobs queued before this await would never fire + // before execution continues. Promise jobs ONLY — nextTick + // callbacks queued in the same synchronous stretch wait for the + // next real tick boundary, matching Node's checkpoint ordering + // (#788; previously this drained the tick queue instead, so a + // nextTick callback overtook earlier-queued microtasks). The + // wait loop covers ticks/timers for pending promises. + ctx.current_block = drain_once_idx; + let _ = ctx.block().call(I32, "js_promise_run_promise_jobs", &[]); + ctx.block().br(&check_label); - // === check === - // Unbox the promise in each block that uses it — LLVM's - // SSA form requires every value definition to dominate - // its uses, and there's no single predecessor block we - // could hoist the unbox into (check is reachable from - // both the initial branch AND from `wait`). - ctx.current_block = check_idx; - let promise_box = crate::expr::temp_root::temp_root_get_double(ctx, &promise_root); - let promise_handle = unbox_to_i64(ctx.block(), &promise_box); - let state = ctx - .block() - .call(I32, "js_promise_state", &[(I64, &promise_handle)]); - let is_pending = ctx.block().icmp_eq(I32, &state, "0"); - ctx.block() - .cond_br(&is_pending, &wait_label, &settled_label); + // === check === + // Unbox the promise in each block that uses it — LLVM's + // SSA form requires every value definition to dominate + // its uses, and there's no single predecessor block we + // could hoist the unbox into (check is reachable from + // both the initial branch AND from `wait`). + ctx.current_block = check_idx; + let promise_box = g.reread_emitted(ctx, promise_root); + let promise_handle = unbox_to_i64(ctx.block(), &promise_box); + let state = ctx + .block() + .call(I32, "js_promise_state", &[(I64, &promise_handle)]); + let is_pending = ctx.block().icmp_eq(I32, &state, "0"); + ctx.block() + .cond_br(&is_pending, &wait_label, &settled_label); - // === wait === - // Drive microtasks AND pending timers on each tick so that - // `await new Promise(r => setTimeout(r, 1))` and similar - // patterns eventually resolve. Without the timer ticks the - // await loop busy-waits forever. - ctx.current_block = wait_idx; - ctx.block() - .call_void("js_promise_run_microtasks_await_loop", &[]); - // Drain the stdlib's tokio async queue — fetch, database - // queries, and other async stdlib operations queue their - // results via queue_promise_resolution and need this pump - // to actually resolve the promises on the main thread. - ctx.block().call_void("js_run_stdlib_pump", &[]); - // #5437: tick through the await-loop entry, which suspends the - // timer dispatch guard — a busy-wait await inside a timer / - // setImmediate callback (every HTTP request handler) must still - // fire due timers or a setImmediate-scheduled resolution (React's - // server renderer) can never settle. - let _ = ctx.block().call(I32, "js_await_loop_tick_timers", &[]); + // === wait === + // Drive microtasks AND pending timers on each tick so that + // `await new Promise(r => setTimeout(r, 1))` and similar + // patterns eventually resolve. Without the timer ticks the + // await loop busy-waits forever. + ctx.current_block = wait_idx; + ctx.block() + .call_void("js_promise_run_microtasks_await_loop", &[]); + // Drain the stdlib's tokio async queue — fetch, database + // queries, and other async stdlib operations queue their + // results via queue_promise_resolution and need this pump + // to actually resolve the promises on the main thread. + ctx.block().call_void("js_run_stdlib_pump", &[]); + // #5437: tick through the await-loop entry, which suspends the + // timer dispatch guard — a busy-wait await inside a timer / + // setImmediate callback (every HTTP request handler) must still + // fire due timers or a setImmediate-scheduled resolution (React's + // server renderer) can never settle. + let _ = ctx.block().call(I32, "js_await_loop_tick_timers", &[]); - if !ctx.is_async_fn { - let wait_for_event_idx = ctx.new_block("await.wait_for_event"); - let unsettled_exit_idx = ctx.new_block("await.unsettled_exit"); - let wait_for_event_label = ctx.block_label(wait_for_event_idx); - let unsettled_exit_label = ctx.block_label(unsettled_exit_idx); + if !ctx.is_async_fn { + let wait_for_event_idx = ctx.new_block("await.wait_for_event"); + let unsettled_exit_idx = ctx.new_block("await.unsettled_exit"); + let wait_for_event_label = ctx.block_label(wait_for_event_idx); + let unsettled_exit_label = ctx.block_label(unsettled_exit_idx); - let promise_box = crate::expr::temp_root::temp_root_get_double(ctx, &promise_root); - let promise_handle_wait = unbox_to_i64(ctx.block(), &promise_box); - let state_after_tick = + let promise_box = g.reread_emitted(ctx, promise_root); + let promise_handle_wait = unbox_to_i64(ctx.block(), &promise_box); + let state_after_tick = + ctx.block() + .call(I32, "js_promise_state", &[(I64, &promise_handle_wait)]); + let still_pending = ctx.block().icmp_eq(I32, &state_after_tick, "0"); + let has_timers = ctx.block().call(I32, "js_timer_has_pending", &[]); + let has_callbacks = ctx.block().call(I32, "js_callback_timer_has_pending", &[]); + let has_intervals = ctx.block().call(I32, "js_interval_timer_has_pending", &[]); + let has_stdlib = ctx.block().call(I32, "js_stdlib_has_active_handles", &[]); + let has_microtasks = ctx.block().call(I32, "js_microtasks_pending", &[]); + let any1 = ctx.block().or(I32, &has_timers, &has_callbacks); + let any2 = ctx.block().or(I32, &has_intervals, &has_stdlib); + let any3 = ctx.block().or(I32, &any1, &any2); + let any = ctx.block().or(I32, &any3, &has_microtasks); + let no_refed_work = ctx.block().icmp_eq(I32, &any, "0"); + let should_exit = ctx.block().and(I1, &still_pending, &no_refed_work); ctx.block() - .call(I32, "js_promise_state", &[(I64, &promise_handle_wait)]); - let still_pending = ctx.block().icmp_eq(I32, &state_after_tick, "0"); - let has_timers = ctx.block().call(I32, "js_timer_has_pending", &[]); - let has_callbacks = ctx.block().call(I32, "js_callback_timer_has_pending", &[]); - let has_intervals = ctx.block().call(I32, "js_interval_timer_has_pending", &[]); - let has_stdlib = ctx.block().call(I32, "js_stdlib_has_active_handles", &[]); - let has_microtasks = ctx.block().call(I32, "js_microtasks_pending", &[]); - let any1 = ctx.block().or(I32, &has_timers, &has_callbacks); - let any2 = ctx.block().or(I32, &has_intervals, &has_stdlib); - let any3 = ctx.block().or(I32, &any1, &any2); - let any = ctx.block().or(I32, &any3, &has_microtasks); - let no_refed_work = ctx.block().icmp_eq(I32, &any, "0"); - let should_exit = ctx.block().and(I1, &still_pending, &no_refed_work); - ctx.block() - .cond_br(&should_exit, &unsettled_exit_label, &wait_for_event_label); + .cond_br(&should_exit, &unsettled_exit_label, &wait_for_event_label); - ctx.current_block = unsettled_exit_idx; - ctx.block() - .call_void("js_unsettled_top_level_await_exit", &[]); - ctx.block().unreachable(); + ctx.current_block = unsettled_exit_idx; + ctx.block() + .call_void("js_unsettled_top_level_await_exit", &[]); + ctx.block().unreachable(); - ctx.current_block = wait_for_event_idx; - } + ctx.current_block = wait_for_event_idx; + } - // Issue #84: condvar wait — wakes the instant the awaited - // promise's resolver (or any other tokio queue push) calls - // js_notify_main_thread, instead of paying the old 1 ms - // hard-sleep quantum per await iteration. - ctx.block().call_void("js_wait_for_event", &[]); - ctx.block().br(&check_label); + // Issue #84: condvar wait — wakes the instant the awaited + // promise's resolver (or any other tokio queue push) calls + // js_notify_main_thread, instead of paying the old 1 ms + // hard-sleep quantum per await iteration. + ctx.block().call_void("js_wait_for_event", &[]); + ctx.block().br(&check_label); - // === settled === - ctx.current_block = settled_idx; - let promise_box = crate::expr::temp_root::temp_root_get_double(ctx, &promise_root); - let promise_handle2 = unbox_to_i64(ctx.block(), &promise_box); - let state2 = ctx - .block() - .call(I32, "js_promise_state", &[(I64, &promise_handle2)]); - let is_rejected = ctx.block().icmp_eq(I32, &state2, "2"); - ctx.block() - .cond_br(&is_rejected, &reject_label, &done_label); + // === settled === + ctx.current_block = settled_idx; + let promise_box = g.reread_emitted(ctx, promise_root); + let promise_handle2 = unbox_to_i64(ctx.block(), &promise_box); + let state2 = ctx + .block() + .call(I32, "js_promise_state", &[(I64, &promise_handle2)]); + let is_rejected = ctx.block().icmp_eq(I32, &state2, "2"); + ctx.block() + .cond_br(&is_rejected, &reject_label, &done_label); - // === reject === - // Same spec-corner as `Stmt::Throw`: inside an async function - // with no enclosing user try-frame, an awaited rejection must - // settle the caller's promise as rejected — not unwind. Without - // this, `async function f() { await Promise.reject(e); }` - // would terminate the process because `js_throw` longjmps - // through a non-existent handler frame. - ctx.current_block = reject_idx; - let promise_box = crate::expr::temp_root::temp_root_get_double(ctx, &promise_root); - let promise_handle3 = unbox_to_i64(ctx.block(), &promise_box); - let reason = ctx - .block() - .call(DOUBLE, "js_promise_reason", &[(I64, &promise_handle3)]); - if ctx.is_async_fn && ctx.try_depth == 0 { - let blk = ctx.block(); - let handle = blk.call(I64, "js_promise_rejected", &[(DOUBLE, &reason)]); - let boxed = crate::expr::nanbox_pointer_inline_pub(blk, &handle); - blk.ret(DOUBLE, &boxed); - } else { - ctx.block().call_void("js_throw", &[(DOUBLE, &reason)]); - ctx.block().unreachable(); - } + // === reject === + // Same spec-corner as `Stmt::Throw`: inside an async function + // with no enclosing user try-frame, an awaited rejection must + // settle the caller's promise as rejected — not unwind. Without + // this, `async function f() { await Promise.reject(e); }` + // would terminate the process because `js_throw` longjmps + // through a non-existent handler frame. + ctx.current_block = reject_idx; + let promise_box = g.reread_emitted(ctx, promise_root); + let promise_handle3 = unbox_to_i64(ctx.block(), &promise_box); + let reason = + ctx.block() + .call(DOUBLE, "js_promise_reason", &[(I64, &promise_handle3)]); + if ctx.is_async_fn && ctx.try_depth == 0 { + let blk = ctx.block(); + let handle = blk.call(I64, "js_promise_rejected", &[(DOUBLE, &reason)]); + let boxed = crate::expr::nanbox_pointer_inline_pub(blk, &handle); + blk.ret(DOUBLE, &boxed); + } else { + ctx.block().call_void("js_throw", &[(DOUBLE, &reason)]); + ctx.block().unreachable(); + } - // === done === - ctx.current_block = done_idx; - let promise_box = crate::expr::temp_root::temp_root_get_double(ctx, &promise_root); - let promise_handle4 = unbox_to_i64(ctx.block(), &promise_box); - let value = ctx - .block() - .call(DOUBLE, "js_promise_value", &[(I64, &promise_handle4)]); - ctx.block().store(DOUBLE, &value, &result_slot); - ctx.block().br(&merge_label); + // === done === + ctx.current_block = done_idx; + let promise_box = g.reread_emitted(ctx, promise_root); + let promise_handle4 = unbox_to_i64(ctx.block(), &promise_box); + let value = + ctx.block() + .call(DOUBLE, "js_promise_value", &[(I64, &promise_handle4)]); + ctx.block().store(DOUBLE, &value, &result_slot); + ctx.block().br(&merge_label); - // === merge === - ctx.current_block = merge_idx; - Ok(ctx.block().load(DOUBLE, &result_slot)) + // === merge === + ctx.current_block = merge_idx; + Ok(ctx.block().load(DOUBLE, &result_slot)) + }) } // -------- StaticFieldGet/Set -------- diff --git a/crates/perry-codegen/src/expr/helpers.rs b/crates/perry-codegen/src/expr/helpers.rs index a8f1763fdc..166400d138 100644 --- a/crates/perry-codegen/src/expr/helpers.rs +++ b/crates/perry-codegen/src/expr/helpers.rs @@ -9,7 +9,7 @@ use perry_hir::{BinaryOp, Expr, UnaryOp}; use super::{lower_expr, FnCtx}; use crate::block::LlBlock; use crate::nanbox::POINTER_MASK_I64; -use crate::types::{DOUBLE, I32, I64}; +use crate::types::{DOUBLE, I64}; /// Static-type predicate: the type's runtime array layout has no pointer /// payloads, so a pointer-mask layout note isn't necessary for stores. @@ -335,19 +335,13 @@ pub(crate) fn lower_expr_with_expected_type( } } -/// Build a NaN-boxed Array JSValue from a slice of Expr arguments. -pub(crate) fn proxy_build_args_array(ctx: &mut FnCtx<'_>, args: &[Expr]) -> Result { - let cap = (args.len() as u32).to_string(); - let arr = ctx.block().call(I64, "js_array_alloc", &[(I32, &cap)]); - let mut current = arr; - for a in args { - let v = lower_expr(ctx, a)?; - current = ctx - .block() - .call(I64, "js_array_push_f64", &[(I64, ¤t), (DOUBLE, &v)]); - } - Ok(current) -} +// `proxy_build_args_array` lived here and was deleted by #7615 slice 7. It +// threaded the array's raw `*mut ArrayHeader` through its push loop in a bare +// SSA register while each element — arbitrary user code — was lowered, which is +// #7154's accumulator bug, and it had no way to root the CALLER's receiver +// across the same loop. Its four call sites now build the array inside a +// `crate::rooting::RootedGroup` that holds the receiver too +// (`expr::proxy_reflect::build_args_array`). /// Build the `, !alias.scope !N, !noalias !M` suffix attached to Buffer /// load/store instructions on the GEP fast path. `scope_idx` is the per- diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 7cc92f3954..1900983249 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -76,8 +76,8 @@ pub(crate) use helpers::{ emit_all_pointer_array_declaration, expr_has_numeric_pointer_free_array_layout, expr_produces_fresh_heap_allocation, expr_produces_non_pointer_bits_by_construction, is_global_this_builtin_function_name, is_global_this_builtin_name, - lower_expr_with_expected_type, lower_js_args_array, proxy_build_args_array, - store_needs_string_addref, unbox_str_handle, unbox_to_i64, + lower_expr_with_expected_type, lower_js_args_array, store_needs_string_addref, + unbox_str_handle, unbox_to_i64, }; pub(crate) use i32_fast_path::{ can_lower_expr_as_i32, can_lower_expr_as_i32_in_current_region, @@ -137,6 +137,8 @@ mod repsel_gates; mod scalar_slot_root; pub(crate) mod shadow_inline; mod shadow_slot; +#[cfg(test)] +mod slice7_rooting_tests; mod slot_rep; pub(crate) mod temp_root; // #7128: the env-knob table and the pure `gates -> context flags` derivation. diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index d31fb9ac33..cdb2131d9a 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -1,23 +1,159 @@ //! Proxy / Reflect metaprogramming. //! //! Extracted from `expr/mod.rs` to keep that file under the 2000-line cap. -//! Pure mechanical move — match arm bodies are verbatim copies, called from -//! `lower_expr`'s outer dispatch. +//! +//! # Layer 1 rooting (#7615 slice 7) +//! +//! Before this slice exactly one arm in the file made a rooting decision — the +//! `PutValueSet` write-IC, which #7201 fixed — and the other **twenty-eight** +//! made none. Every `Proxy.*` and `Reflect.*` entry point is the same shape: +//! two to four operands, each an arbitrary user expression, lowered in order +//! and then handed together to one runtime helper. `Reflect.has(target, key)` +//! lowered `target`, then lowered `key` — which can run a `Symbol.toPrimitive`, +//! a getter, or any other JS — and then passed the pre-collection `target` +//! register to `js_reflect_has`. That is #7280 taxonomy (c), operand-to-operand, +//! and `root_reload` cannot repair it. +//! +//! The arms are now `crate::rooting::with_operands_rooted` and read as an +//! operand list plus a consuming call. Four shapes needed more than that and +//! are commented where they sit: +//! +//! * `Proxy.apply` / `Proxy.construct` and the two proxy-callee lowerers build +//! an **argument array** while lowering the arguments, so they take a +//! [`crate::rooting::RootedGroup`] that holds the receiver and the +//! accumulator in one scope (the #7154 shape — `proxy_build_args_array` +//! threaded a raw `*mut ArrayHeader` through its push loop in a bare SSA +//! register, and is deleted here in favour of the group); +//! * `process.env[k] = v` stripped its key to a **raw** `StringHeader*` above +//! the value's lowering, which is taxonomy (a); +//! * the eight `reflect-metadata` arms had eight copies of one body and now +//! share [`lower_reflect_metadata`]; +//! * `Reflect.construct`'s capture write-back reads the call's own result, so +//! it needs no operand root — it is below every lowering. use anyhow::Result; use perry_hir::Expr; use crate::nanbox::{double_literal, POINTER_MASK_I64}; use crate::native_value::MaterializationReason; +use crate::rooting::{any_operand_may_collect, with_operands_rooted, with_rooted_group, Repr}; use crate::type_analysis::{is_array_expr, is_numeric_expr, is_string_expr, receiver_class_name}; -use crate::types::{DOUBLE, I1, I16, I32, I64, I8, PTR}; +use crate::types::{LlvmType, DOUBLE, I1, I16, I32, I64, I8, PTR}; use super::{ downgrade_buffer_aliases_in_expr, emit_jsvalue_slot_store_scalar_aware_on_block, expr_produces_non_pointer_bits_by_construction, lower_expr, nanbox_pointer_inline, - proxy_build_args_array, unbox_str_handle, unbox_to_i64, FnCtx, + unbox_str_handle, unbox_to_i64, FnCtx, }; +/// The NaN-boxed `undefined` literal, for an absent optional operand. +fn undefined_literal() -> String { + double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) +} + +/// Borrow a re-read operand list as call arguments, every one a `double`. +/// +/// Every helper in this file takes NaN-boxed operands and nothing else, so the +/// argument vector is always this and the per-arm code is the callee name plus +/// the operand list. +fn boxed_args<'v>(values: &'v [String]) -> Vec<(LlvmType, &'v str)> { + values.iter().map(|v| (DOUBLE, v.as_str())).collect() +} + +/// The whole shape of most of this file: lower `operands` in order, keep each +/// one valid across the ones after it, and hand the re-read list to `callee`. +/// +/// Sixteen arms are exactly this and were sixteen copies of the unrooted form. +fn lower_boxed_operand_call( + ctx: &mut FnCtx<'_>, + callee: &str, + operands: &[&Expr], +) -> Result { + with_operands_rooted(ctx, operands, |ctx, values| { + Ok(ctx.block().call(DOUBLE, callee, &boxed_args(values))) + }) +} + +/// Where the argument array sits in a trap helper's parameter list. +enum TrapArgOrder { + /// `js_proxy_apply(proxy, thisArg, argArray)`. + ThisFirst, + /// `js_proxy_construct(proxy, argArray, newTarget)`. + ArrayFirst, +} + +/// `Proxy.apply` / `Proxy.construct`: the proxy, plus an argument array built +/// out of the call's own arguments. +/// +/// One group holds both. The proxy is live across `js_array_alloc` and across +/// every argument's lowering; the array is live across every argument after the +/// first. Before this slice neither was rooted. +fn lower_proxy_trap_with_args( + ctx: &mut FnCtx<'_>, + callee: &str, + proxy: &Expr, + args: &[Expr], + order: TrapArgOrder, +) -> Result { + let arg_exprs: Vec<&Expr> = args.iter().collect(); + with_rooted_group(ctx, 1, |ctx, g| { + // Unconditional: the array build emits `js_array_alloc` plus one + // `js_array_push_f64` per argument even when the arguments themselves + // are inert, so there is no argument list for which this window is + // empty. + let p = g.lower(ctx, proxy, true)?; + let arr = build_args_array(ctx, g, &arg_exprs)?; + let arr_box = nanbox_pointer_inline(ctx.block(), &arr); + let p = g.reread(ctx, p)?; + let undef = undefined_literal(); + let call_args = match order { + TrapArgOrder::ThisFirst => [ + (DOUBLE, p.as_str()), + (DOUBLE, undef.as_str()), + (DOUBLE, arr_box.as_str()), + ], + TrapArgOrder::ArrayFirst => [ + (DOUBLE, p.as_str()), + (DOUBLE, arr_box.as_str()), + (DOUBLE, undef.as_str()), + ], + }; + Ok(ctx.block().call(DOUBLE, callee, &call_args)) + }) +} + +/// The `reflect-metadata` family: two or three required operands plus an +/// optional `propertyKey`, all NaN-boxed, all fed to one runtime helper with +/// `undefined` filling an absent key. +/// +/// Eight arms carried eight copies of this body, and therefore eight copies of +/// the same window: `key` live across `target`'s lowering and `propertyKey`'s, +/// `target` live across `propertyKey`'s, each of them arbitrary user code. +fn lower_reflect_metadata( + ctx: &mut FnCtx<'_>, + callee: &str, + required: &[&Expr], + property_key: Option<&Expr>, +) -> Result { + for operand in required { + downgrade_unknown_call_expr(ctx, operand); + } + if let Some(property_key) = property_key { + downgrade_unknown_call_expr(ctx, property_key); + } + let mut operands: Vec<&Expr> = required.to_vec(); + operands.extend(property_key); + let fill_undefined = property_key.is_none(); + with_operands_rooted(ctx, &operands, |ctx, values| { + let undef = undefined_literal(); + let mut args = boxed_args(values); + if fill_undefined { + args.push((DOUBLE, undef.as_str())); + } + Ok(ctx.block().call(DOUBLE, callee, &args)) + }) +} + fn downgrade_unknown_call_expr(ctx: &mut FnCtx<'_>, expr: &Expr) { downgrade_buffer_aliases_in_expr(ctx, expr, MaterializationReason::UnknownCallEscape); } @@ -50,33 +186,70 @@ pub(crate) fn try_lower_proxy_fn_call_apply( }; downgrade_unknown_call_expr(ctx, proxy); downgrade_unknown_call_args(ctx, args); - let p = lower_expr(ctx, proxy)?; - let this_arg = match args.first() { - Some(a) => lower_expr(ctx, a)?, - None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + // The proxy is live across `thisArg`'s lowering AND across the argument + // array's construction, and `thisArg` across the latter; the array is the + // #7154 accumulator on top of that. One group holds all three. + let rest: Vec<&Expr> = if is_apply { + Vec::new() + } else { + args.iter().skip(1).collect() }; - let arr_box = if is_apply { - // 2nd arg is the already-built argument array (a JSValue). When absent, - // synthesize an empty array so the trap receives a real argArray. - match args.get(1) { + let this_expr = args.first(); + let apply_array_expr = if is_apply { args.get(1) } else { None }; + let result = with_rooted_group(ctx, 2, |ctx, g| { + let p = g.lower(ctx, proxy, true)?; + let this_slot = match this_expr { + Some(a) => Some(g.lower(ctx, a, true)?), + None => None, + }; + let arr_box = match apply_array_expr { + // 2nd arg is the already-built argument array (a JSValue), and it + // is the LAST lowering in the arm — only the two group re-reads + // below it, which are loads. Some(a) => lower_expr(ctx, a)?, None => { - let arr_handle = proxy_build_args_array(ctx, &[])?; - let blk = ctx.block(); - nanbox_pointer_inline(blk, &arr_handle) + let arr = build_args_array(ctx, g, &rest)?; + nanbox_pointer_inline(ctx.block(), &arr) } - } - } else { - let rest: Vec = args.iter().skip(1).cloned().collect(); - let arr_handle = proxy_build_args_array(ctx, &rest)?; - let blk = ctx.block(); - nanbox_pointer_inline(blk, &arr_handle) - }; - Ok(Some(ctx.block().call( - DOUBLE, - "js_proxy_apply", - &[(DOUBLE, &p), (DOUBLE, &this_arg), (DOUBLE, &arr_box)], - ))) + }; + let p = g.reread(ctx, p)?; + let this_arg = match this_slot { + Some(i) => g.reread(ctx, i)?, + None => undefined_literal(), + }; + Ok(ctx.block().call( + DOUBLE, + "js_proxy_apply", + &[(DOUBLE, &p), (DOUBLE, &this_arg), (DOUBLE, &arr_box)], + )) + })?; + Ok(Some(result)) +} + +/// Build a NaN-boxed argument array inside `group`'s scope. +/// +/// Replaces `expr::helpers::proxy_build_args_array`, which threaded the array's +/// raw `*mut ArrayHeader` through its push loop in a bare SSA register while +/// each element — arbitrary user code — was lowered. That is #7154's canonical +/// accumulator bug: the register held the ONLY reference to everything pushed +/// so far, and every `js_array_push_f64` allocates. The array now lives in the +/// group, so the push re-reads it and publishes the possibly-reallocated +/// pointer back. +/// +/// Returns the raw handle, read below the last push and above the consuming +/// call — the group is still holding it, and is released by the caller. +fn build_args_array( + ctx: &mut FnCtx<'_>, + group: &mut crate::rooting::RootedGroup<'_>, + args: &[&Expr], +) -> Result { + let cap = args.len().to_string(); + let acc = group.begin_array(ctx, &cap); + for a in args { + let v = lower_expr(ctx, a)?; + group.push_array(ctx, acc, &v); + } + Ok(group.read_array(ctx, acc)) } /// `proxy.method(args)` for a method name other than `call`/`apply` — the @@ -107,40 +280,45 @@ pub(crate) fn try_lower_proxy_method_call( } downgrade_unknown_call_expr(ctx, proxy); downgrade_unknown_call_args(ctx, args); - let recv_box = lower_expr(ctx, proxy)?; - let mut lowered_args: Vec = Vec::with_capacity(args.len()); - for a in args { - lowered_args.push(lower_expr(ctx, a)?); - } - let (args_ptr, args_len) = if lowered_args.is_empty() { - ("null".to_string(), "0".to_string()) - } else { - let n = lowered_args.len(); - let buf = ctx.func.alloca_entry_array(DOUBLE, n); - { - let blk = ctx.block(); - for (i, value) in lowered_args.iter().enumerate() { - let slot = blk.gep(DOUBLE, &buf, &[(I64, &i.to_string())]); - blk.store(DOUBLE, value, &slot); + // The receiver is live across EVERY argument's lowering, and argument `i` + // across every argument after it. The stack buffer below is not a root — + // `alloca_entry_array` is the plain-alloca shape `--unrooted-allocas` + // reports (#7210) — so the values must be current when they are stored into + // it, which means the whole list is re-read below the last lowering. + let operands: Vec<&Expr> = std::iter::once(proxy.as_ref()).chain(args.iter()).collect(); + let result = with_operands_rooted(ctx, &operands, |ctx, values| { + let (recv_box, lowered_args) = values.split_first().expect("receiver is always present"); + let (args_ptr, args_len) = if lowered_args.is_empty() { + ("null".to_string(), "0".to_string()) + } else { + let n = lowered_args.len(); + let buf = ctx.func.alloca_entry_array(DOUBLE, n); + { + let blk = ctx.block(); + for (i, value) in lowered_args.iter().enumerate() { + let slot = blk.gep(DOUBLE, &buf, &[(I64, &i.to_string())]); + blk.store(DOUBLE, value, &slot); + } } - } - (buf, n.to_string()) - }; - let method_idx = ctx.strings.intern(method_name); - let entry = ctx.strings.entry(method_idx); - let bytes_global = format!("@{}", entry.bytes_global); - let name_len = entry.byte_len.to_string(); - Ok(Some(ctx.block().call( - DOUBLE, - "js_native_call_method", - &[ - (DOUBLE, &recv_box), - (PTR, &bytes_global), - (I64, &name_len), - (PTR, &args_ptr), - (I64, &args_len), - ], - ))) + (buf, n.to_string()) + }; + let method_idx = ctx.strings.intern(method_name); + let entry = ctx.strings.entry(method_idx); + let bytes_global = format!("@{}", entry.bytes_global); + let name_len = entry.byte_len.to_string(); + Ok(ctx.block().call( + DOUBLE, + "js_native_call_method", + &[ + (DOUBLE, recv_box.as_str()), + (PTR, &bytes_global), + (I64, &name_len), + (PTR, &args_ptr), + (I64, &args_len), + ], + )) + })?; + Ok(Some(result)) } fn put_value_static_property_fast_path( @@ -1026,25 +1204,63 @@ fn try_lower_process_env_put_value_set( return Ok(None); } - let key_handle = match key { - Expr::String(property) => { - let key_idx = ctx.strings.intern(property); - let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); - let blk = ctx.block(); - let key_box = blk.load(DOUBLE, &key_handle_global); - unbox_to_i64(blk, &key_box) - } - _ => { - let key_box = lower_expr(ctx, key)?; - let blk = ctx.block(); - let property_key = blk.call(DOUBLE, "js_to_property_key", &[(DOUBLE, &key_box)]); - unbox_str_handle(blk, &property_key) - } - }; - let val_double = lower_expr(ctx, value)?; - ctx.block() - .call_void("js_setenv", &[(I64, &key_handle), (DOUBLE, &val_double)]); - Ok(Some(val_double)) + // #7615 slice 7. `key_handle` used to be stripped to a RAW `StringHeader*` + // here and then held across `value`'s lowering — arbitrary user code — + // before `js_setenv` dereferenced it. That is #7280 taxonomy (a): once a + // pointer has left the NaN-boxed representation, no re-read of a rooted + // slot can repair it, because the slot holds the box and the register holds + // the address. + // + // Both branches were exposed and for different reasons. The literal branch + // loads the key's `__perry_init_strings_*` handle global, which is a + // registered root that evacuation REWRITES — #7114 exactly, one operand + // over from the `PutValueSet` key that #7201 fixed. The computed branch is + // worse: `js_to_property_key` returns a FRESH string with no other root at + // all, so a sweep between here and `js_setenv` frees it. + // + // The fix is to keep the key NaN-boxed across the value's lowering and take + // the raw pointer below it. The literal branch is `Expr::String`, which + // `operand_protection` answers with `Reload` — no runtime slot, just the + // load emitted again below the window. + if let Expr::String(property) = key { + // Literal key. Its `__perry_init_strings_*` handle global is a + // registered root, so nothing needs a slot — but the global is one the + // collector REWRITES, so the load has to sit below `value`'s lowering + // or the strip reads a pre-move address. That is #7114, and #7201 fixed + // the same shape one operand over in `PutValueSet`. + // + // The load stays an explicit `handle_global` read rather than becoming + // `lower_expr(key)`: a short string literal lowers to an inline + // SHORT_STRING_TAG immediate, and `unbox_to_i64` is documented garbage + // for those. + let val_double = lower_expr(ctx, value)?; + let key_idx = ctx.strings.intern(property); + let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); + let blk = ctx.block(); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_handle = unbox_to_i64(blk, &key_box); + blk.call_void("js_setenv", &[(I64, &key_handle), (DOUBLE, &val_double)]); + return Ok(Some(val_double)); + } + // Computed key. `js_to_property_key` must run ABOVE the value's evaluation + // — ES2022 moved `ToPropertyKey` before the RHS — so the value that has to + // survive that evaluation is the COERCED key, a fresh heap string produced + // by an emitted call rather than by lowering an expression. That is what + // `RootedGroup::adopt_emitted` is for. + with_rooted_group(ctx, 1, |ctx, g| { + let key_box = lower_expr(ctx, key)?; + let property_key = ctx + .block() + .call(DOUBLE, "js_to_property_key", &[(DOUBLE, &key_box)]); + let key_slot = g.adopt_emitted(ctx, Repr::Boxed, &property_key); + let val_double = lower_expr(ctx, value)?; + // The strip happens BELOW the window, never above it. + let key_box = g.reread_emitted(ctx, key_slot); + let key_handle = unbox_str_handle(ctx.block(), &key_box); + ctx.block() + .call_void("js_setenv", &[(I64, &key_handle), (DOUBLE, &val_double)]); + Ok(Some(val_double)) + }) } pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { @@ -1052,93 +1268,70 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { Expr::ProxyNew { target, handler } => { downgrade_unknown_call_expr(ctx, target); downgrade_unknown_call_expr(ctx, handler); - let t = lower_expr(ctx, target)?; - let h = lower_expr(ctx, handler)?; - Ok(ctx - .block() - .call(DOUBLE, "js_proxy_new", &[(DOUBLE, &t), (DOUBLE, &h)])) + lower_boxed_operand_call(ctx, "js_proxy_new", &[target, handler]) } Expr::ProxyGet { proxy, key } => { downgrade_unknown_call_expr(ctx, proxy); downgrade_unknown_call_expr(ctx, key); - let p = lower_expr(ctx, proxy)?; - let k = lower_expr(ctx, key)?; - Ok(ctx - .block() - .call(DOUBLE, "js_proxy_get", &[(DOUBLE, &p), (DOUBLE, &k)])) + lower_boxed_operand_call(ctx, "js_proxy_get", &[proxy, key]) } Expr::ProxySet { proxy, key, value } => { downgrade_unknown_call_expr(ctx, proxy); downgrade_unknown_call_expr(ctx, key); downgrade_unknown_call_expr(ctx, value); - let p = lower_expr(ctx, proxy)?; - let k = lower_expr(ctx, key)?; - let v = lower_expr(ctx, value)?; - let _ = ctx.block().call( - DOUBLE, - "js_proxy_set", - &[(DOUBLE, &p), (DOUBLE, &k), (DOUBLE, &v)], - ); - Ok(v) + with_operands_rooted(ctx, &[proxy, key, value], |ctx, v| { + let _ = ctx.block().call(DOUBLE, "js_proxy_set", &boxed_args(v)); + // The assignment's value is `value` as the trap OBSERVED it, so + // it is the re-read register rather than the pre-call one. + Ok(v[2].clone()) + }) } Expr::ProxyHas { proxy, key } => { downgrade_unknown_call_expr(ctx, proxy); downgrade_unknown_call_expr(ctx, key); - let p = lower_expr(ctx, proxy)?; - let k = lower_expr(ctx, key)?; - Ok(ctx - .block() - .call(DOUBLE, "js_proxy_has", &[(DOUBLE, &p), (DOUBLE, &k)])) + lower_boxed_operand_call(ctx, "js_proxy_has", &[proxy, key]) } Expr::ProxyDelete { proxy, key } => { downgrade_unknown_call_expr(ctx, proxy); downgrade_unknown_call_expr(ctx, key); let strict = if ctx.is_strict_fn { "1" } else { "0" }; - let p = lower_expr(ctx, proxy)?; - let k = lower_expr(ctx, key)?; - let blk = ctx.block(); - // `js_proxy_delete` reports the `[[Delete]]` boolean; a strict-mode - // `delete proxy.key` that resolves to `false` (non-configurable - // property, forwarded through the trap chain) must throw a TypeError - // just like the ordinary member-delete path. Route the boolean - // through `js_delete_result` so both modes match spec (test262 - // Proxy/deleteProperty/*-target-is-proxy `delete funcProxy.prototype` - // under "use strict"). - let deleted_box = blk.call(DOUBLE, "js_proxy_delete", &[(DOUBLE, &p), (DOUBLE, &k)]); - let deleted_i32 = blk.call(I32, "js_is_truthy", &[(DOUBLE, &deleted_box)]); - Ok(blk.call( - DOUBLE, - "js_delete_result", - &[(I32, &deleted_i32), (I32, strict)], - )) + with_operands_rooted(ctx, &[proxy, key], |ctx, v| { + let blk = ctx.block(); + // `js_proxy_delete` reports the `[[Delete]]` boolean; a strict-mode + // `delete proxy.key` that resolves to `false` (non-configurable + // property, forwarded through the trap chain) must throw a TypeError + // just like the ordinary member-delete path. Route the boolean + // through `js_delete_result` so both modes match spec (test262 + // Proxy/deleteProperty/*-target-is-proxy `delete funcProxy.prototype` + // under "use strict"). + let deleted_box = blk.call( + DOUBLE, + "js_proxy_delete", + &[(DOUBLE, &v[0]), (DOUBLE, &v[1])], + ); + let deleted_i32 = blk.call(I32, "js_is_truthy", &[(DOUBLE, &deleted_box)]); + Ok(blk.call( + DOUBLE, + "js_delete_result", + &[(I32, &deleted_i32), (I32, strict)], + )) + }) } Expr::ProxyApply { proxy, args } => { downgrade_unknown_call_expr(ctx, proxy); downgrade_unknown_call_args(ctx, args); - let p = lower_expr(ctx, proxy)?; - let arr_handle = proxy_build_args_array(ctx, args)?; - let blk = ctx.block(); - let arr_box = nanbox_pointer_inline(blk, &arr_handle); - let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); - Ok(ctx.block().call( - DOUBLE, - "js_proxy_apply", - &[(DOUBLE, &p), (DOUBLE, &undef), (DOUBLE, &arr_box)], - )) + lower_proxy_trap_with_args(ctx, "js_proxy_apply", proxy, args, TrapArgOrder::ThisFirst) } Expr::ProxyConstruct { proxy, args } => { downgrade_unknown_call_expr(ctx, proxy); downgrade_unknown_call_args(ctx, args); - let p = lower_expr(ctx, proxy)?; - let arr_handle = proxy_build_args_array(ctx, args)?; - let blk = ctx.block(); - let arr_box = nanbox_pointer_inline(blk, &arr_handle); - let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); - Ok(ctx.block().call( - DOUBLE, + lower_proxy_trap_with_args( + ctx, "js_proxy_construct", - &[(DOUBLE, &p), (DOUBLE, &arr_box), (DOUBLE, &undef)], - )) + proxy, + args, + TrapArgOrder::ArrayFirst, + ) } Expr::ProxyRevocable { target, handler } => { // #2846: return a real `{ proxy, revoke }` record so `typeof @@ -1146,11 +1339,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // revoke function survives aliasing/storage. downgrade_unknown_call_expr(ctx, target); downgrade_unknown_call_expr(ctx, handler); - let t = lower_expr(ctx, target)?; - let h = lower_expr(ctx, handler)?; - Ok(ctx - .block() - .call(DOUBLE, "js_proxy_revocable", &[(DOUBLE, &t), (DOUBLE, &h)])) + lower_boxed_operand_call(ctx, "js_proxy_revocable", &[target, handler]) } Expr::ProxyRevoke(proxy) => { downgrade_unknown_call_expr(ctx, proxy); @@ -1169,14 +1358,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { downgrade_unknown_call_expr(ctx, target); downgrade_unknown_call_expr(ctx, key); downgrade_unknown_call_expr(ctx, receiver); - let t = lower_expr(ctx, target)?; - let k = lower_expr(ctx, key)?; - let r = lower_expr(ctx, receiver)?; - Ok(ctx.block().call( - DOUBLE, - "js_reflect_get", - &[(DOUBLE, &t), (DOUBLE, &k), (DOUBLE, &r)], - )) + lower_boxed_operand_call(ctx, "js_reflect_get", &[target, key, receiver]) } Expr::ReflectSet { target, @@ -1192,15 +1374,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { downgrade_unknown_call_expr(ctx, key); downgrade_unknown_call_expr(ctx, value); downgrade_unknown_call_expr(ctx, receiver); - let t = lower_expr(ctx, target)?; - let k = lower_expr(ctx, key)?; - let v = lower_expr(ctx, value)?; - let r = lower_expr(ctx, receiver)?; - Ok(ctx.block().call( - DOUBLE, - "js_reflect_set", - &[(DOUBLE, &t), (DOUBLE, &k), (DOUBLE, &v), (DOUBLE, &r)], - )) + lower_boxed_operand_call(ctx, "js_reflect_set", &[target, key, value, receiver]) } Expr::PutValueSet { target, @@ -1310,102 +1484,97 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let dyn_inline = same_put_value_receiver_expr(target, receiver) && matches!(target.as_ref(), Expr::LocalGet(_) | Expr::This); if dyn_inline { - let k = lower_expr(ctx, key)?; - let key_guard = super::temp_root::guard_store_operand(ctx, key, &k, value); - let v = lower_expr(ctx, value)?; - let k = super::temp_root::reread_store_operand(ctx, &key_guard, key, &k)?; - let t = lower_expr(ctx, target)?; - let result = lower_put_value_dyn_ic_inline(ctx, &t, &k, &v, strict_i32)?; - // After the store: the outlined helper allocates while reading - // the key (interning, keys-array growth, shape transition). - super::temp_root::release_store_operand(ctx, key_guard); - return Ok(result); + // Migrated onto `RootedGroup` without moving an emission: the + // key's window is still derived from `value` alone, and the + // re-read still sits above `target`'s lowering, which the + // comment above argues is safe because `target` is a + // `LocalGet` / `This`. + return with_rooted_group(ctx, 1, |ctx, g| { + let collects = any_operand_may_collect(ctx, std::iter::once(value.as_ref())); + let key_slot = g.lower(ctx, key, collects)?; + let v = lower_expr(ctx, value)?; + let k = g.reread(ctx, key_slot)?; + let t = lower_expr(ctx, target)?; + // Released by the group below the store: the outlined + // helper allocates while reading the key (interning, + // keys-array growth, shape transition). + lower_put_value_dyn_ic_inline(ctx, &t, &k, &v, strict_i32) + }); } // #7201, outlined arms: `t` is lowered FIRST here, so it is live // across both `k`'s and `v`'s lowering, and `k` across `v`'s. Both // are consumed by helpers that dereference them. - let t = lower_expr(ctx, target)?; - // The receiver's window covers BOTH the key's lowering and the - // value's, so its `collects` is the disjunction — `o[f()] = 1` has - // an inert value and a collecting key. - let recv_collects = super::temp_root::expr_may_trigger_gc(ctx, key) - || super::temp_root::expr_may_trigger_gc(ctx, value) - || super::temp_root::expr_may_trigger_gc(ctx, receiver); - let recv_guard = - super::temp_root::guard_store_operand_across(ctx, target, &t, recv_collects); - let k = lower_expr(ctx, key)?; - // Pushed AFTER the receiver's so the two `temp_root_truncate` cuts - // nest: a release of the outer one drops the inner. - let key_collects = super::temp_root::expr_may_trigger_gc(ctx, value) - || super::temp_root::expr_may_trigger_gc(ctx, receiver); - let key_guard = - super::temp_root::guard_store_operand_across(ctx, key, &k, key_collects); - let v = lower_expr(ctx, value)?; - // #6812 (w12): same-receiver dynamic-key stores that failed the - // inline gate (computed target expressions) still take the - // outlined 3-way IC helper. - let result = if same_put_value_receiver_expr(target, receiver) { - let k = super::temp_root::reread_store_operand(ctx, &key_guard, key, &k)?; - let t = super::temp_root::reread_store_operand(ctx, &recv_guard, target, &t)?; - let site_id = ctx.ic_site_counter; - ctx.ic_site_counter += 1; - let cache_name = format!("perry_ic_{}", site_id); - ctx.ic_globals.push(cache_name.clone()); - let cache_ref = format!("@{}", cache_name); - ctx.block().call( - DOUBLE, - "js_put_value_set_dyn_ic", - &[ - (crate::types::PTR, &cache_ref), - (DOUBLE, &t), - (DOUBLE, &k), - (DOUBLE, &v), - (I32, strict_i32), - ], - ) - } else { - // The explicit-receiver form lowers a FOURTH operand, so the - // re-reads have to sit below it, not above. - let r = lower_expr(ctx, receiver)?; - let k = super::temp_root::reread_store_operand(ctx, &key_guard, key, &k)?; - let t = super::temp_root::reread_store_operand(ctx, &recv_guard, target, &t)?; - ctx.block().call( - DOUBLE, - "js_put_value_set", - &[ - (DOUBLE, &t), - (DOUBLE, &k), - (DOUBLE, &v), - (DOUBLE, &r), - (I32, strict_i32), - ], - ) - }; - // Released after the store: every one of these helpers allocates - // while reading the operands. - super::temp_root::release_store_operand(ctx, key_guard); - super::temp_root::release_store_operand(ctx, recv_guard); - Ok(result) + // + // The two operands go into ONE group rather than two nested guards. + // A group release is a single truncate at the LOWEST slot, which is + // the same stack cut the nested pair performed — with the ordering + // no longer expressible wrongly, since the caller never holds + // either index. + with_rooted_group(ctx, 2, |ctx, g| { + // The receiver's window covers BOTH the key's lowering and the + // value's, so its `collects` is the disjunction — `o[f()] = 1` + // has an inert value and a collecting key. + let recv_collects = + any_operand_may_collect(ctx, [key.as_ref(), value.as_ref(), receiver.as_ref()]); + let recv_slot = g.lower(ctx, target, recv_collects)?; + let key_collects = + any_operand_may_collect(ctx, [value.as_ref(), receiver.as_ref()]); + let key_slot = g.lower(ctx, key, key_collects)?; + let v = lower_expr(ctx, value)?; + // #6812 (w12): same-receiver dynamic-key stores that failed the + // inline gate (computed target expressions) still take the + // outlined 3-way IC helper. + if same_put_value_receiver_expr(target, receiver) { + let k = g.reread(ctx, key_slot)?; + let t = g.reread(ctx, recv_slot)?; + let site_id = ctx.ic_site_counter; + ctx.ic_site_counter += 1; + let cache_name = format!("perry_ic_{}", site_id); + ctx.ic_globals.push(cache_name.clone()); + let cache_ref = format!("@{}", cache_name); + Ok(ctx.block().call( + DOUBLE, + "js_put_value_set_dyn_ic", + &[ + (crate::types::PTR, &cache_ref), + (DOUBLE, &t), + (DOUBLE, &k), + (DOUBLE, &v), + (I32, strict_i32), + ], + )) + } else { + // The explicit-receiver form lowers a FOURTH operand, so the + // re-reads have to sit below it, not above. + let r = lower_expr(ctx, receiver)?; + let k = g.reread(ctx, key_slot)?; + let t = g.reread(ctx, recv_slot)?; + Ok(ctx.block().call( + DOUBLE, + "js_put_value_set", + &[ + (DOUBLE, &t), + (DOUBLE, &k), + (DOUBLE, &v), + (DOUBLE, &r), + (I32, strict_i32), + ], + )) + } + }) } Expr::ReflectHas { target, key } => { downgrade_unknown_call_expr(ctx, target); downgrade_unknown_call_expr(ctx, key); - let t = lower_expr(ctx, target)?; - let k = lower_expr(ctx, key)?; - Ok(ctx - .block() - .call(DOUBLE, "js_reflect_has", &[(DOUBLE, &t), (DOUBLE, &k)])) + lower_boxed_operand_call(ctx, "js_reflect_has", &[target, key]) } Expr::ReflectDelete { target, key } => { downgrade_unknown_call_expr(ctx, target); downgrade_unknown_call_expr(ctx, key); - let t = lower_expr(ctx, target)?; - let k = lower_expr(ctx, key)?; - Ok(ctx - .block() - .call(DOUBLE, "js_reflect_delete", &[(DOUBLE, &t), (DOUBLE, &k)])) + lower_boxed_operand_call(ctx, "js_reflect_delete", &[target, key]) } Expr::ReflectOwnKeys(target) => { + // One operand, consumed by the very next emission: no window. downgrade_unknown_call_expr(ctx, target); let t = lower_expr(ctx, target)?; Ok(ctx @@ -1420,14 +1589,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { downgrade_unknown_call_expr(ctx, func); downgrade_unknown_call_expr(ctx, this_arg); downgrade_unknown_call_expr(ctx, args); - let f = lower_expr(ctx, func)?; - let ta = lower_expr(ctx, this_arg)?; - let a = lower_expr(ctx, args)?; - Ok(ctx.block().call( - DOUBLE, - "js_reflect_apply", - &[(DOUBLE, &f), (DOUBLE, &ta), (DOUBLE, &a)], - )) + lower_boxed_operand_call(ctx, "js_reflect_apply", &[func, this_arg, args]) } Expr::ReflectConstruct { target, @@ -1437,14 +1599,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { downgrade_unknown_call_expr(ctx, target); downgrade_unknown_call_expr(ctx, args); downgrade_unknown_call_expr(ctx, new_target); - let t = lower_expr(ctx, target)?; - let a = lower_expr(ctx, args)?; - let nt = lower_expr(ctx, new_target)?; - let result = ctx.block().call( - DOUBLE, - "js_reflect_construct", - &[(DOUBLE, &t), (DOUBLE, &a), (DOUBLE, &nt)], - ); + let result = + lower_boxed_operand_call(ctx, "js_reflect_construct", &[target, args, new_target])?; // Write-back captured outer locals: when `target` is a // statically-known user class, the constructor body stores // mutations to `this.__perry_cap_*` but can't reach the @@ -1477,38 +1633,27 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { downgrade_unknown_call_expr(ctx, target); downgrade_unknown_call_expr(ctx, key); downgrade_unknown_call_expr(ctx, descriptor); - let t = lower_expr(ctx, target)?; - let k = lower_expr(ctx, key)?; - let d = lower_expr(ctx, descriptor)?; - Ok(ctx.block().call( - DOUBLE, + lower_boxed_operand_call( + ctx, "js_reflect_define_property", - &[(DOUBLE, &t), (DOUBLE, &k), (DOUBLE, &d)], - )) + &[target, key, descriptor], + ) } Expr::ReflectGetOwnPropertyDescriptor { target, key } => { downgrade_unknown_call_expr(ctx, target); downgrade_unknown_call_expr(ctx, key); - let t = lower_expr(ctx, target)?; - let k = lower_expr(ctx, key)?; - Ok(ctx.block().call( - DOUBLE, + lower_boxed_operand_call( + ctx, "js_reflect_get_own_property_descriptor", - &[(DOUBLE, &t), (DOUBLE, &k)], - )) + &[target, key], + ) } Expr::ReflectSetPrototypeOf { target, proto } => { // #2761: Reflect-specific boolean result (false on rejected change) // + TypeError on bad args, distinct from Object.setPrototypeOf. downgrade_unknown_call_expr(ctx, target); downgrade_unknown_call_expr(ctx, proto); - let t = lower_expr(ctx, target)?; - let p = lower_expr(ctx, proto)?; - Ok(ctx.block().call( - DOUBLE, - "js_reflect_set_prototype_of", - &[(DOUBLE, &t), (DOUBLE, &p)], - )) + lower_boxed_operand_call(ctx, "js_reflect_set_prototype_of", &[target, proto]) } Expr::ReflectGetPrototypeOf(target) => { // #2757: return the actual [[Prototype]] (shared with @@ -1546,182 +1691,80 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { value, target, property_key, - } => { - downgrade_unknown_call_expr(ctx, key); - downgrade_unknown_call_expr(ctx, value); - downgrade_unknown_call_expr(ctx, target); - if let Some(property_key) = property_key { - downgrade_unknown_call_expr(ctx, property_key); - } - let k = lower_expr(ctx, key)?; - let v = lower_expr(ctx, value)?; - let t = lower_expr(ctx, target)?; - let p = property_key - .as_ref() - .map(|p| lower_expr(ctx, p)) - .transpose()? - .unwrap_or_else(|| double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); - Ok(ctx.block().call( - DOUBLE, - "js_reflect_define_metadata", - &[(DOUBLE, &k), (DOUBLE, &v), (DOUBLE, &t), (DOUBLE, &p)], - )) - } + } => lower_reflect_metadata( + ctx, + "js_reflect_define_metadata", + &[key, value, target], + property_key.as_deref(), + ), Expr::ReflectGetMetadata { key, target, property_key, - } => { - downgrade_unknown_call_expr(ctx, key); - downgrade_unknown_call_expr(ctx, target); - if let Some(property_key) = property_key { - downgrade_unknown_call_expr(ctx, property_key); - } - let k = lower_expr(ctx, key)?; - let t = lower_expr(ctx, target)?; - let p = property_key - .as_ref() - .map(|p| lower_expr(ctx, p)) - .transpose()? - .unwrap_or_else(|| double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); - Ok(ctx.block().call( - DOUBLE, - "js_reflect_get_metadata", - &[(DOUBLE, &k), (DOUBLE, &t), (DOUBLE, &p)], - )) - } + } => lower_reflect_metadata( + ctx, + "js_reflect_get_metadata", + &[key, target], + property_key.as_deref(), + ), Expr::ReflectGetOwnMetadata { key, target, property_key, - } => { - downgrade_unknown_call_expr(ctx, key); - downgrade_unknown_call_expr(ctx, target); - if let Some(property_key) = property_key { - downgrade_unknown_call_expr(ctx, property_key); - } - let k = lower_expr(ctx, key)?; - let t = lower_expr(ctx, target)?; - let p = property_key - .as_ref() - .map(|p| lower_expr(ctx, p)) - .transpose()? - .unwrap_or_else(|| double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); - Ok(ctx.block().call( - DOUBLE, - "js_reflect_get_own_metadata", - &[(DOUBLE, &k), (DOUBLE, &t), (DOUBLE, &p)], - )) - } + } => lower_reflect_metadata( + ctx, + "js_reflect_get_own_metadata", + &[key, target], + property_key.as_deref(), + ), Expr::ReflectHasMetadata { key, target, property_key, - } => { - downgrade_unknown_call_expr(ctx, key); - downgrade_unknown_call_expr(ctx, target); - if let Some(property_key) = property_key { - downgrade_unknown_call_expr(ctx, property_key); - } - let k = lower_expr(ctx, key)?; - let t = lower_expr(ctx, target)?; - let p = property_key - .as_ref() - .map(|p| lower_expr(ctx, p)) - .transpose()? - .unwrap_or_else(|| double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); - Ok(ctx.block().call( - DOUBLE, - "js_reflect_has_metadata", - &[(DOUBLE, &k), (DOUBLE, &t), (DOUBLE, &p)], - )) - } + } => lower_reflect_metadata( + ctx, + "js_reflect_has_metadata", + &[key, target], + property_key.as_deref(), + ), Expr::ReflectHasOwnMetadata { key, target, property_key, - } => { - downgrade_unknown_call_expr(ctx, key); - downgrade_unknown_call_expr(ctx, target); - if let Some(property_key) = property_key { - downgrade_unknown_call_expr(ctx, property_key); - } - let k = lower_expr(ctx, key)?; - let t = lower_expr(ctx, target)?; - let p = property_key - .as_ref() - .map(|p| lower_expr(ctx, p)) - .transpose()? - .unwrap_or_else(|| double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); - Ok(ctx.block().call( - DOUBLE, - "js_reflect_has_own_metadata", - &[(DOUBLE, &k), (DOUBLE, &t), (DOUBLE, &p)], - )) - } + } => lower_reflect_metadata( + ctx, + "js_reflect_has_own_metadata", + &[key, target], + property_key.as_deref(), + ), Expr::ReflectGetMetadataKeys { target, property_key, - } => { - downgrade_unknown_call_expr(ctx, target); - if let Some(property_key) = property_key { - downgrade_unknown_call_expr(ctx, property_key); - } - let t = lower_expr(ctx, target)?; - let p = property_key - .as_ref() - .map(|p| lower_expr(ctx, p)) - .transpose()? - .unwrap_or_else(|| double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); - Ok(ctx.block().call( - DOUBLE, - "js_reflect_get_metadata_keys", - &[(DOUBLE, &t), (DOUBLE, &p)], - )) - } + } => lower_reflect_metadata( + ctx, + "js_reflect_get_metadata_keys", + &[target], + property_key.as_deref(), + ), Expr::ReflectGetOwnMetadataKeys { target, property_key, - } => { - downgrade_unknown_call_expr(ctx, target); - if let Some(property_key) = property_key { - downgrade_unknown_call_expr(ctx, property_key); - } - let t = lower_expr(ctx, target)?; - let p = property_key - .as_ref() - .map(|p| lower_expr(ctx, p)) - .transpose()? - .unwrap_or_else(|| double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); - Ok(ctx.block().call( - DOUBLE, - "js_reflect_get_own_metadata_keys", - &[(DOUBLE, &t), (DOUBLE, &p)], - )) - } + } => lower_reflect_metadata( + ctx, + "js_reflect_get_own_metadata_keys", + &[target], + property_key.as_deref(), + ), Expr::ReflectDeleteMetadata { key, target, property_key, - } => { - downgrade_unknown_call_expr(ctx, key); - downgrade_unknown_call_expr(ctx, target); - if let Some(property_key) = property_key { - downgrade_unknown_call_expr(ctx, property_key); - } - let k = lower_expr(ctx, key)?; - let t = lower_expr(ctx, target)?; - let p = property_key - .as_ref() - .map(|p| lower_expr(ctx, p)) - .transpose()? - .unwrap_or_else(|| double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); - Ok(ctx.block().call( - DOUBLE, - "js_reflect_delete_metadata", - &[(DOUBLE, &k), (DOUBLE, &t), (DOUBLE, &p)], - )) - } + } => lower_reflect_metadata( + ctx, + "js_reflect_delete_metadata", + &[key, target], + property_key.as_deref(), + ), // Issue #100: compile-time-resolved dynamic `import()`. // diff --git a/crates/perry-codegen/src/expr/slice7_rooting_tests.rs b/crates/perry-codegen/src/expr/slice7_rooting_tests.rs new file mode 100644 index 0000000000..7f8d9c8cf5 --- /dev/null +++ b/crates/perry-codegen/src/expr/slice7_rooting_tests.rs @@ -0,0 +1,537 @@ +//! Rooting and evaluation-order coverage for the three `expr/` modules slice 7 +//! of the Layer 1 migration (#7615) repaired. +//! +//! # What is asserted, and why it cannot pass vacuously +//! +//! **Ordering, never slot counts.** A count lets one operand's rooting pay for +//! another's assertion; the property that IS the bug is that the register a +//! consuming call reads must be defined BELOW the allocation it has to survive, +//! which can only happen if the value was rooted above the window and re-read +//! below it. Every test first asserts, by callee name, that the arm under test +//! was reached at all — a shape measured over a lowering that never ran is +//! CLAUDE.md hazard 4 wearing the subject's name. +//! +//! **Raw pointers get their own tests.** Half of what this slice fixed is +//! #7280 taxonomy (a) — a value stripped to a bare heap pointer ABOVE a +//! collection point. `root_reload` structurally cannot repair those, so the +//! assertion is on the `and i64 …, POINTER_MASK` (or the +//! `js_jsvalue_to_string_coerce` call) rather than on the boxed operand. +//! +//! **The zero-cost arm is a test too.** A single-operand `Reflect.ownKeys` and +//! a no-options `execSync` cannot collect between their operand and the call, +//! so `operand_protection` must route them to `Reuse` and emit no temp-root +//! traffic at all. Without that pin a future "root everything" change would tax +//! every call site and nothing would notice. + +use perry_hir::types::Type; +use perry_hir::{Expr, Function, Module as HirModule, Stmt}; + +/// Compile a one-function module and return its LLVM IR. +fn compile_body(name: &str, body: Vec) -> String { + let mut hir = HirModule::new(name); + hir.functions.push(Function { + id: 0, + name: "build".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + let opts = crate::CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + let bytes = crate::compile_module(&hir, opts).expect("test module compiles"); + String::from_utf8(bytes).expect("LLVM IR is UTF-8") +} + +/// A heap value whose lowering allocates, so the window it sits in collects. +fn allocating(tag: &str) -> Expr { + Expr::Object(vec![(tag.to_string(), Expr::Number(1.0))]) +} + +/// Line index of a **call** to `callee`, or `None`. +/// +/// Excluding `declare` is load-bearing rather than tidy: the module carries a +/// `declare` for the helper whether or not anything calls it, so a liveness +/// check that accepted it would be satisfied by a lowering that never ran. +fn call_line(ir: &str, callee: &str) -> Option { + let needle = format!("@{callee}("); + ir.lines() + .position(|l| l.contains(&needle) && !l.trim_start().starts_with("declare")) +} + +fn require_call_line(ir: &str, callee: &str) -> usize { + call_line(ir, callee).unwrap_or_else(|| panic!("no call to {callee} in:\n{ir}")) +} + +/// The `n`-th SSA operand of the call to `callee`, whatever its type. +fn call_operand(ir: &str, callee: &str, n: usize) -> String { + let idx = require_call_line(ir, callee); + let line = ir.lines().nth(idx).expect("line index came from this IR"); + let args = line + .rsplit_once('(') + .unwrap_or_else(|| panic!("{callee} call has no argument list: {line}")) + .1; + args.split(',') + .nth(n) + .unwrap_or_else(|| panic!("{callee} has no operand {n}: {line}")) + .trim() + .rsplit(' ') + .next() + .expect("an operand is a type followed by a register") + .trim_end_matches(')') + .to_string() +} + +/// Line index at which `reg` is defined, or `None` for a literal / constant. +fn definition_line(ir: &str, reg: &str) -> Option { + let prefix = format!("{reg} = "); + ir.lines().position(|l| l.trim_start().starts_with(&prefix)) +} + +fn require_definition_line(ir: &str, reg: &str) -> usize { + definition_line(ir, reg).unwrap_or_else(|| panic!("no definition for {reg} in:\n{ir}")) +} + +/// Line index of the LAST object allocation emitted before `before`. +fn last_alloc_before(ir: &str, before: usize) -> usize { + ir.lines() + .enumerate() + .take(before) + .filter(|(_, l)| l.contains("@js_object_alloc")) + .map(|(i, _)| i) + .last() + .unwrap_or_else(|| panic!("no object allocation above line {before} in:\n{ir}")) +} + +/// Line index of the last object allocation anywhere in the module. +fn last_alloc(ir: &str) -> usize { + last_alloc_before(ir, ir.lines().count()) +} + +/// Temp-root traffic, excluding the `declare` lines that name the helpers +/// whether or not anything calls them. +fn temp_root_calls(ir: &str) -> usize { + ir.lines() + .filter(|l| !l.trim_start().starts_with("declare")) + .filter(|l| l.contains("js_gc_temp_root")) + .count() +} + +/// Assert that the register `callee` reads as operand `n` is defined below the +/// last allocation above the call — i.e. that the value was re-read after the +/// window rather than carried across it. +fn assert_operand_survives_the_window(ir: &str, callee: &str, n: usize, what: &str) { + let call = require_call_line(ir, callee); + let reg = call_operand(ir, callee, n); + let def = require_definition_line(ir, ®); + let alloc = last_alloc_before(ir, call); + assert!( + def > alloc, + "{what}: {callee} reads {reg}, defined at line {def}, ABOVE the allocation at line \ + {alloc}. That is the unrooted window — an evacuating minor there relocates the value \ + and the helper dereferences from-space. It must be rooted above the window and \ + re-read below it.\n{ir}" + ); +} + +// --------------------------------------------------------------------------- +// expr/child_proc.rs +// --------------------------------------------------------------------------- + +/// The raw command pointer used to be stripped out of `cmd_box` ABOVE the +/// options lowering — taxonomy (a), unrepairable by any re-read. +#[test] +fn exec_sync_strips_its_command_pointer_below_the_options_operand() { + let ir = compile_body( + "exec_sync_window", + vec![Stmt::Expr(Expr::ChildProcessExecSync { + command: Box::new(Expr::String("ls".to_string())), + options: Some(Box::new(allocating("opts"))), + })], + ); + assert_operand_survives_the_window( + &ir, + "js_child_process_exec_sync", + 0, + "execSync's command is a heap string and `options` is arbitrary user code", + ); +} + +/// The zero-cost counterpart: with no options nothing follows the command, so +/// `operand_protection` must answer `Reuse` and emit nothing. +#[test] +fn exec_sync_without_options_emits_no_rooting_traffic() { + let ir = compile_body( + "exec_sync_cold", + vec![Stmt::Expr(Expr::ChildProcessExecSync { + command: Box::new(Expr::String("ls".to_string())), + options: None, + })], + ); + require_call_line(&ir, "js_child_process_exec_sync"); + assert_eq!( + temp_root_calls(&ir), + 0, + "a no-options execSync cannot collect between its operand and the call, so \ + operand_protection must route it to Reuse\n{ir}" + ); +} + +/// Evaluation order: node evaluates a call's whole argument list before the +/// callee is entered, so `spawnSync(badFile, sideEffect())` runs the side +/// effect and only then throws. Perry validated `command` between the two +/// lowerings. +#[test] +fn spawn_sync_validates_below_the_whole_argument_list() { + let ir = compile_body( + "spawn_sync_order", + vec![Stmt::Expr(Expr::ChildProcessSpawnSync { + command: Box::new(Expr::String("ls".to_string())), + args: Some(Box::new(allocating("args"))), + options: Some(Box::new(allocating("opts"))), + })], + ); + let validate = require_call_line(&ir, "js_child_process_validate_command"); + let last_operand = last_alloc(&ir); + assert!( + validate > last_operand, + "js_child_process_validate_command is emitted at line {validate}, ABOVE the last \ + argument's allocation at line {last_operand}. A throw there skips evaluation of the \ + later arguments, which JS performs before the callee is entered.\n{ir}" + ); +} + +/// `fork` coerced the module path to a RAW `StringHeader*` and then held it +/// across `args`' and `options`' lowering — #7453's shape at a second site. +#[test] +fn fork_coerces_its_module_path_below_every_other_operand() { + let ir = compile_body( + "fork_window", + vec![Stmt::Expr(Expr::ChildProcessFork { + module: Box::new(Expr::String("./w.js".to_string())), + args: Some(Box::new(allocating("args"))), + options: Some(Box::new(allocating("opts"))), + })], + ); + let coerce = require_call_line(&ir, "js_jsvalue_to_string_coerce"); + let last_operand = last_alloc(&ir); + assert!( + coerce > last_operand, + "fork's `js_jsvalue_to_string_coerce` runs at line {coerce}, ABOVE the last operand's \ + allocation at line {last_operand}. Its result is a raw string pointer; carrying one \ + across a lowering is taxonomy (a) and no re-read can repair it.\n{ir}" + ); +} + +// --------------------------------------------------------------------------- +// expr/proxy_reflect.rs +// --------------------------------------------------------------------------- + +/// The canonical two-operand window: `target` live across `key`'s lowering. +#[test] +fn reflect_has_roots_its_target_across_the_key_operand() { + let ir = compile_body( + "reflect_has_window", + vec![Stmt::Expr(Expr::ReflectHas { + target: Box::new(allocating("t")), + key: Box::new(allocating("k")), + })], + ); + assert_operand_survives_the_window( + &ir, + "js_reflect_has", + 0, + "Reflect.has's target is live across the key's lowering", + ); +} + +/// The zero-cost counterpart for the same family. +#[test] +fn reflect_own_keys_emits_no_rooting_traffic() { + let ir = compile_body( + "reflect_own_keys_cold", + vec![Stmt::Expr(Expr::ReflectOwnKeys(Box::new(allocating("t"))))], + ); + require_call_line(&ir, "js_reflect_own_keys"); + assert_eq!( + temp_root_calls(&ir), + 0, + "a single-operand Reflect helper has no window at all\n{ir}" + ); +} + +/// Four operands, so `target` is live across three lowerings. +#[test] +fn reflect_set_roots_its_target_across_three_later_operands() { + let ir = compile_body( + "reflect_set_window", + vec![Stmt::Expr(Expr::ReflectSet { + target: Box::new(allocating("t")), + key: Box::new(allocating("k")), + value: Box::new(allocating("v")), + receiver: Box::new(allocating("r")), + })], + ); + assert_operand_survives_the_window( + &ir, + "js_reflect_set", + 0, + "Reflect.set's target is live across key, value and receiver", + ); +} + +/// The `reflect-metadata` family shared one body and therefore one window. +#[test] +fn reflect_define_metadata_roots_its_key_across_the_later_operands() { + let ir = compile_body( + "define_metadata_window", + vec![Stmt::Expr(Expr::ReflectDefineMetadata { + key: Box::new(allocating("k")), + value: Box::new(allocating("v")), + target: Box::new(allocating("t")), + property_key: Some(Box::new(allocating("p"))), + })], + ); + assert_operand_survives_the_window( + &ir, + "js_reflect_define_metadata", + 0, + "Reflect.defineMetadata's key is live across value, target and propertyKey", + ); +} + +/// `Proxy.apply` held the proxy in a bare register across `js_array_alloc` and +/// every argument's lowering, while the array itself was the #7154 accumulator. +#[test] +fn proxy_apply_rereads_its_receiver_below_the_argument_array() { + let ir = compile_body( + "proxy_apply_window", + vec![Stmt::Expr(Expr::ProxyApply { + proxy: Box::new(allocating("p")), + args: vec![allocating("a"), allocating("b")], + })], + ); + let call = require_call_line(&ir, "js_proxy_apply"); + let proxy = call_operand(&ir, "js_proxy_apply", 0); + let proxy_def = require_definition_line(&ir, &proxy); + let last_push = ir + .lines() + .enumerate() + .take(call) + .filter(|(_, l)| l.contains("@js_array_push_f64")) + .map(|(i, _)| i) + .last() + .unwrap_or_else(|| panic!("no argument push above the trap call in:\n{ir}")); + assert!( + proxy_def > last_push, + "js_proxy_apply reads {proxy}, defined at line {proxy_def}, ABOVE the last argument \ + push at line {last_push}. Every push allocates, so the receiver must be re-read \ + below them.\n{ir}" + ); +} + +/// The argument array is the accumulator: each push must READ it from the +/// rooted slot rather than from the previous push's return register, because +/// an unrelated collection during the NEXT argument's lowering moves it. +#[test] +fn proxy_apply_pushes_read_the_array_from_its_slot() { + let ir = compile_body( + "proxy_apply_accumulator", + vec![Stmt::Expr(Expr::ProxyApply { + proxy: Box::new(allocating("p")), + args: vec![allocating("a"), allocating("b")], + })], + ); + require_call_line(&ir, "js_array_alloc"); + let pushes: Vec = ir + .lines() + .enumerate() + .filter(|(_, l)| l.contains("@js_array_push_f64") && !l.trim_start().starts_with("declare")) + .map(|(i, _)| i) + .collect(); + assert_eq!(pushes.len(), 2, "expected one push per argument\n{ir}"); + + // The array register the SECOND push reads must be defined below the + // second argument's own allocation. Pre-fix it was the FIRST push's return + // register, threaded across that lowering — the accumulator held the only + // reference to argument `a` while argument `b` was evaluated. + let second_line = ir.lines().nth(pushes[1]).expect("push line exists"); + let array_reg = second_line + .rsplit_once('(') + .expect("push has an argument list") + .1 + .split(',') + .next() + .expect("push takes the array first") + .trim() + .rsplit(' ') + .next() + .expect("an operand is a type followed by a register") + .to_string(); + let array_def = require_definition_line(&ir, &array_reg); + let b_alloc = last_alloc_before(&ir, pushes[1]); + assert!( + array_def > b_alloc, + "the second push reads {array_reg}, defined at line {array_def}, ABOVE the second \ + argument's allocation at line {b_alloc}. The accumulator held the ONLY reference to \ + everything pushed so far while that argument was lowered, and every push allocates — \ + it must be re-read from its rooted slot between pushes (#7154).\n{ir}" + ); +} + +/// `process.env[computed] = value` stripped the COERCED key to a raw +/// `StringHeader*` above the value's lowering. Taxonomy (a) on a value with no +/// other root at all: `js_to_property_key` returns a fresh string. +#[test] +fn process_env_computed_key_strips_below_the_value() { + let ir = compile_body( + "process_env_computed", + vec![Stmt::Expr(Expr::PutValueSet { + target: Box::new(Expr::ProcessEnv), + key: Box::new(allocating("k")), + value: Box::new(allocating("v")), + receiver: Box::new(Expr::ProcessEnv), + strict: true, + })], + ); + require_call_line(&ir, "js_to_property_key"); + assert_operand_survives_the_window( + &ir, + "js_setenv", + 0, + "process.env's coerced key is a fresh heap string with no other root", + ); +} + +/// The literal-key branch needs no slot — its handle global is a registered +/// root — but the LOAD has to sit below the value's lowering, because +/// evacuation rewrites that global (#7114). +#[test] +fn process_env_literal_key_loads_below_the_value() { + let ir = compile_body( + "process_env_literal", + vec![Stmt::Expr(Expr::PutValueSet { + target: Box::new(Expr::ProcessEnv), + key: Box::new(Expr::String("PATH".to_string())), + value: Box::new(allocating("v")), + receiver: Box::new(Expr::ProcessEnv), + strict: true, + })], + ); + assert_operand_survives_the_window( + &ir, + "js_setenv", + 0, + "the key handle global is one evacuation rewrites, so the load must follow the window", + ); + assert_eq!( + temp_root_calls(&ir), + 0, + "a string-literal key takes `Reload`, not `Root`: no runtime call should be emitted\n{ir}" + ); +} + +// --------------------------------------------------------------------------- +// expr/fs_await.rs +// --------------------------------------------------------------------------- + +fn await_body(n: usize) -> Vec { + (0..n) + .map(|i| Stmt::Expr(Expr::Await(Box::new(allocating(&format!("p{i}")))))) + .collect() +} + +/// The await loop's temp root was pushed and never released, so N awaits in one +/// function held N slots to the end of the function — and in the FFI-fallback +/// lowering pushed N runtime entries per EXECUTION with no truncate. +/// +/// Asserted as slot REUSE rather than by matching push/truncate text, because +/// reuse is the property the release buys in both lowerings: a released slot +/// returns to the pool, so three awaits cost exactly what one does. +#[test] +fn sequential_awaits_reuse_one_rooted_slot() { + let one = compile_body("await_one", await_body(1)); + let three = compile_body("await_three", await_body(3)); + + // Liveness: the arm under test must actually have run in both, and the + // three-await module must really contain three of them. Without this a + // pair of modules that both lowered nothing would compare equal. + require_call_line(&one, "js_await_any_promise"); + assert_eq!( + call_count(&one, "js_await_any_promise"), + 1, + "expected exactly one await lowering\n{one}" + ); + assert_eq!( + call_count(&three, "js_await_any_promise"), + 3, + "expected exactly three await lowerings\n{three}" + ); + + let one_slots = temp_root_slot_width(&one); + let three_slots = temp_root_slot_width(&three); + assert_eq!( + one_slots, three_slots, + "one await reserves {one_slots} rooted slot(s) and three reserve {three_slots}. The \ + await scope is not being released, so each await consumes a fresh slot instead of \ + reusing the pool — over-retention on every path, and in the FFI-fallback lowering an \ + unbounded runtime push per execution (#7462's shape).\n{three}" + ); +} + +/// How many rooted slots the function reserves. +/// +/// All three lowerings are covered, because which one runs is a build-time +/// property (`native_stack_roots_enabled`, `shadow_frame_requested`) and a +/// measure that only worked under one of them would silently stop measuring: +/// +/// * **statepoint / RS4GC** (what this build uses): every pooled slot is an +/// entry alloca the retype pass rewrote to `alloca ptr addrspace(1)`; +/// * **shadow frame**: the width is `js_shadow_frame_enter`'s argument; +/// * **FFI fallback**: one `js_gc_temp_root_push` per acquisition. +fn temp_root_slot_width(ir: &str) -> usize { + let gc_allocas = ir + .lines() + .filter(|l| l.contains("alloca ptr addrspace(1)")) + .count(); + if gc_allocas > 0 { + return gc_allocas; + } + if let Some(line) = ir + .lines() + .find(|l| l.contains("@js_shadow_frame_enter(") && !l.trim_start().starts_with("declare")) + { + let width = line + .trim_end() + .trim_end_matches(')') + .rsplit(' ') + .next() + .and_then(|n| n.parse::().ok()); + if let Some(width) = width { + return width; + } + } + ir.lines() + .filter(|l| !l.trim_start().starts_with("declare")) + .filter(|l| l.contains("@js_gc_temp_root_push(")) + .count() +} + +/// How many non-`declare` calls to `callee` the module emits. +fn call_count(ir: &str, callee: &str) -> usize { + let needle = format!("@{callee}("); + ir.lines() + .filter(|l| !l.trim_start().starts_with("declare")) + .filter(|l| l.contains(&needle)) + .count() +} diff --git a/crates/perry-codegen/src/rooting.rs b/crates/perry-codegen/src/rooting.rs index bf820b1b4e..0e745938fe 100644 --- a/crates/perry-codegen/src/rooting.rs +++ b/crates/perry-codegen/src/rooting.rs @@ -709,11 +709,21 @@ pub(crate) struct RootedGroup<'a> { operands: crate::expr::temp_root::RootedOperands, exprs: Vec<&'a Expr>, accs: Vec, + emitted: Vec, /// The LOWEST slot this group pushed, of either kind. One truncate at it /// drops the whole scope, because a truncate is a stack cut. first_slot: Option, } +/// A handle on one **emitted** value inside a [`RootedGroup`] — +/// see [`RootedGroup::adopt_emitted`]. +/// +/// Opaque and `Copy`, for the same reason [`AccArray`] is: it is not a slot +/// index, so it cannot be truncated, mis-ordered or released. The same +/// not-branded-per-group caveat applies. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct EmittedValue(usize); + /// A handle on one accumulator array inside a [`RootedGroup`]. /// /// Opaque and `Copy`. What it buys is the half that matters: it is not a slot @@ -736,6 +746,7 @@ impl<'a> RootedGroup<'a> { operands: crate::expr::temp_root::root_operands_begin(capacity), exprs: Vec::with_capacity(capacity), accs: Vec::new(), + emitted: Vec::new(), first_slot: None, } } @@ -811,6 +822,69 @@ impl<'a> RootedGroup<'a> { self.exprs.len() } + /// Root a value that an **emitted step** produced, rather than one lowered + /// from an expression. + /// + /// # Why this exists, and why it took until slice 7 + /// + /// This file deleted a `root_i64(ctx, reg) -> RootedSlot` combinator unused + /// and recorded the terms on which a replacement could return: "it should + /// arrive with its caller and with a written argument for why + /// [`call_rooted`] cannot serve". Slice 7 found two callers, and they are + /// the same shape: + /// + /// * `expr/proxy_reflect.rs` — `process.env[k] = v` must coerce the key + /// (`js_to_property_key`, which runs a user `Symbol.toPrimitive`) ABOVE + /// the value's evaluation, because ES2022 moved `ToPropertyKey` before + /// the RHS. The **coerced** key is what has to survive that evaluation, + /// and it is a fresh heap string with no other root; + /// * `expr/fs_await.rs` — the await loop polls the promise that + /// `js_assimilate_thenable` + `js_await_any_promise` produced, which for + /// a thenable is a **wrapper** the assimilation allocated, not the + /// operand. + /// + /// **Why [`call_rooted`] cannot serve.** It fuses the root store to a call + /// it emits itself and hardcodes [`Repr::Ptr`], so it can only root the + /// direct `i64` result of one call. Neither value is that: the property key + /// is a `double` whose raw pointer must be taken BELOW the window, and the + /// promise is used boxed (`js_value_is_promise`) and unboxed + /// (`js_promise_state`) in six different basic blocks. + /// + /// **Why there is no protection decision to make.** Every other entry point + /// asks `operand_protection`; this value has no `Expr` to ask about, and + /// two of the three answers are unavailable on principle rather than by + /// choice. `Reload` cannot re-derive it — re-emitting the producing call + /// would call it a *second* time, and both producers are observable. And + /// `Reuse` is the bug. So the answer is always `Root`, there is no flag, + /// and a caller cannot pick the wrong one. + /// + /// **What it does weaken, stated plainly.** `value` is a register the + /// caller produced, so "produce it, let something collect, THEN root it" — + /// #7192 — is writable here, exactly as it is in + /// [`with_rooted_accumulator`], which has taken a caller-produced `initial` + /// since slice 3. Call this on the line below the emission that produced + /// the value. + pub(crate) fn adopt_emitted( + &mut self, + ctx: &mut FnCtx<'_>, + repr: Repr, + value: &str, + ) -> EmittedValue { + let idx = match repr { + Repr::Ptr => crate::expr::temp_root::temp_root_push_i64(ctx, value), + Repr::Boxed => crate::expr::temp_root::temp_root_push_double(ctx, value), + }; + self.note_slot(Some(idx.clone())); + self.emitted.push(RootedSlot { idx, repr }); + EmittedValue(self.emitted.len() - 1) + } + + /// Re-read an [`adopt_emitted`](RootedGroup::adopt_emitted) value **here**, + /// in the representation it was pushed with. + pub(crate) fn reread_emitted(&self, ctx: &mut FnCtx<'_>, value: EmittedValue) -> String { + read_slot(ctx, &self.emitted[value.0]) + } + /// Allocate an argument-accumulator array of capacity `cap` and root it in /// this scope. /// @@ -1241,6 +1315,30 @@ pub(crate) fn with_rooted_accumulator<'f, R>( /// every rooting decision through this API, and nobody has read those four /// modules for windows with no decision at all. An unlisted module is honest; /// a listed unaudited one is the distinction slice 4 had to draw the hard way. +/// +/// Slice 7 lists three `expr/` modules, all load-bearing on the committed +/// source (`temp_root_{push,get}_double`, `temp_root_truncate`, +/// `guard_store_operand{,_across}`, `reread_store_operand`, +/// `release_store_operand`, `expr_may_trigger_gc`): +/// +/// * `expr/child_proc.rs` — every `child_process` entry point. Three arms +/// rooted unconditionally through the raw API and five rooted nothing at +/// all while holding RAW heap pointers across arbitrary user lowerings. +/// * `expr/proxy_reflect.rs` — the densest unaudited module in the campaign. +/// One arm made a rooting decision (the `PutValueSet` write-IC, #7201) and +/// twenty-eight made none. +/// * `expr/fs_await.rs` — the await loop, whose root was correct and simply +/// never released. +/// +/// **Slice 7 added the one combinator this file had refused to add ahead of a +/// caller**, and the refusal was right: the shape it predicted is the shape +/// that turned up. [`RootedGroup::adopt_emitted`] roots a GC-managed value that +/// an emitted step produced rather than one lowered from an `Expr` — the +/// coerced key of `process.env[k] = v`, and the assimilated promise the await +/// loop polls. Its doc carries the argument for why [`call_rooted`] cannot +/// serve and the note on what it weakens. Which is the shape of the answer this +/// campaign keeps arriving at: an API gap recorded in slice N is a combinator +/// in slice N+1, and writing the gap down is what makes the next slice cheap. #[cfg(test)] const MIGRATED_MODULES: &[(&str, &str)] = &[ ( @@ -1343,6 +1441,18 @@ const MIGRATED_MODULES: &[(&str, &str)] = &[ "crates/perry-codegen/src/lower_call/console_promise.rs", include_str!("lower_call/console_promise.rs"), ), + ( + "crates/perry-codegen/src/expr/child_proc.rs", + include_str!("expr/child_proc.rs"), + ), + ( + "crates/perry-codegen/src/expr/proxy_reflect.rs", + include_str!("expr/proxy_reflect.rs"), + ), + ( + "crates/perry-codegen/src/expr/fs_await.rs", + include_str!("expr/fs_await.rs"), + ), ]; /// Lines in `src` that reach past [`crate::rooting`] into the raw rooting API.