diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index a52962ff88..647a99eab2 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -47,7 +47,7 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install cargo-llvm-cov - uses: taiki-e/install-action@0758d235715de2f3551eacc980d9ae8fce9342c3 # v2.87.3 + uses: taiki-e/install-action@d438492cf8a250514fa2d34b30bc3c0dc37c65ff # v2.87.8 with: tool: cargo-llvm-cov diff --git a/CLAUDE.md b/CLAUDE.md index a7e70885cb..a3d019fcab 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.1581 +**Current Version:** 0.5.1582 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 7e9311b096..f9c816f59c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,9 +79,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -526,9 +526,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -536,14 +536,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -986,9 +987,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.4" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "jobserver", @@ -1183,9 +1184,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "futures-core", @@ -1315,6 +1316,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -1686,9 +1693,9 @@ dependencies = [ [[package]] name = "der" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" dependencies = [ "const-oid 0.10.2", "der_derive", @@ -2073,11 +2080,17 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" dependencies = [ "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -2295,9 +2308,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "fixed_decimal" @@ -3217,9 +3230,9 @@ dependencies = [ [[package]] name = "hickory-proto" -version = "0.26.1" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +checksum = "7e2da0694c15b44c6f68a6b05e0233617008c54080e31d6eb848d858a9c5b38d" dependencies = [ "data-encoding", "idna", @@ -3816,9 +3829,9 @@ checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" [[package]] name = "indexmap" -version = "2.14.1" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -4444,9 +4457,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.3" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" dependencies = [ "hashbrown 0.17.1", ] @@ -4744,9 +4757,9 @@ checksum = "851fac73f7fe22f6a3ab87f720ce509cae7c9fd08e7dd27866cc232dee07ccf4" [[package]] name = "mongodb" -version = "3.8.2" +version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d220eb9ba80bad420e1f9efc4e895fede7418f8779a8e38a3b750e57ff397720" +checksum = "fd64784a1fdbcf2a334717446edc583061159067a2e7cc326ee567ecf035f07b" dependencies = [ "base64 0.22.1", "bitflags 2.12.1", @@ -4790,9 +4803,9 @@ dependencies = [ [[package]] name = "mongodb-internal-macros" -version = "3.8.2" +version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e38ff3c46c59c2f4d9b26a86e6980dc23583e9d4b45aa579cef6584642093128" +checksum = "f243f9039382cd06378f13980b79700236468b6fb22f24abfa99783e9569f1a3" dependencies = [ "macro_magic", "proc-macro2", @@ -4829,6 +4842,33 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "956787520e75e9bd233246045d19f42fb73242759cc57fba9611d940ae96d4b0" +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.3", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "mutate_once" version = "0.1.2" @@ -5583,7 +5623,7 @@ checksum = "1542e48011813fbdf3c075da4a4ed53ee93c816eef62e36eb5064a6fd2be10a5" [[package]] name = "perry" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "base64 0.22.1", @@ -5647,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-dispatch", "serde", @@ -5655,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "cc", "libc", @@ -5664,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "aho-corasick", "anyhow", @@ -5681,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "perry-hir", @@ -5689,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "perry-hir", @@ -5697,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "perry-dispatch", @@ -5706,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "perry-hir", @@ -5714,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "base64 0.22.1", @@ -5726,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "perry-hir", @@ -5734,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "async-trait", "clap", @@ -5758,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "serde", "serde_json", @@ -5773,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1581" +version = "0.5.1582" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5784,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "clap", @@ -5799,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "block2", "objc2", @@ -5809,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "argon2", "perry-ffi", @@ -5818,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "reqwest", @@ -5827,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "bcrypt", "perry-ffi", @@ -5835,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "rusqlite", @@ -5843,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "scraper", @@ -5851,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "perry-runtime", @@ -5859,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "chrono", "cron", @@ -5869,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "chrono", "perry-ffi", @@ -5877,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "rust_decimal", @@ -5885,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "serde_json", @@ -5893,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5901,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "perry-runtime", @@ -5909,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "bytes", "http-body-util", @@ -5933,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "bytes", "lazy_static", @@ -5946,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "bytes", @@ -5978,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "lazy_static", "perry-ffi", @@ -5988,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -5999,7 +6039,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "lru", "perry-ffi", @@ -6008,7 +6048,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "chrono", "perry-ffi", @@ -6016,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "bson", "futures-util", @@ -6028,7 +6068,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "chrono", "perry-ffi", @@ -6040,7 +6080,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "nanoid", "perry-ffi", @@ -6049,7 +6089,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "bytes", "perry-ffi", @@ -6064,10 +6104,10 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "const-oid 0.10.2", - "der 0.8.1", + "der 0.8.2", "pem 4.0.0", "perry-ffi", "perry-runtime", @@ -6083,7 +6123,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "lettre", "perry-ffi", @@ -6093,7 +6133,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "notify", "perry-ffi", @@ -6105,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "printpdf", @@ -6113,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "sqlx", @@ -6122,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "perry-runtime", @@ -6131,7 +6171,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "governor", "perry-ffi", @@ -6139,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "fast_image_resize", "image", @@ -6150,7 +6190,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "lazy_static", "perry-ffi", @@ -6159,7 +6199,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "perry-ffi", @@ -6179,7 +6219,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "perry-runtime", @@ -6188,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "uuid", @@ -6196,7 +6236,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "perry-validation", @@ -6205,7 +6245,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "futures-util", "lazy_static", @@ -6218,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "brotli", "flate2", @@ -6228,7 +6268,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6238,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "perry-api-manifest", @@ -6258,11 +6298,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1581" +version = "0.5.1582" [[package]] name = "perry-parser" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "perry-diagnostics", @@ -6275,7 +6315,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perex", "regex", @@ -6283,7 +6323,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "ahash", "base64 0.22.1", @@ -6341,14 +6381,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6437,21 +6477,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "dirs", "perry-ffi", @@ -6461,7 +6501,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "jni", @@ -6476,7 +6516,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "rand 0.10.2", "serde", @@ -6486,7 +6526,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6509,7 +6549,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "block2", @@ -6526,7 +6566,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "block2", @@ -6543,7 +6583,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1581" +version = "0.5.1582" [[package]] name = "perry-ui-test" @@ -6554,11 +6594,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1581" +version = "0.5.1582" [[package]] name = "perry-ui-tvos" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "block2", @@ -6575,7 +6615,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "block2", @@ -6592,7 +6632,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "block2", "libc", @@ -6606,7 +6646,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "libc", @@ -6625,7 +6665,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "libc", @@ -6638,7 +6678,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "base64 0.22.1", @@ -6653,7 +6693,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "idna", "regex", @@ -6663,7 +6703,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "wasmi", ] @@ -6807,7 +6847,7 @@ version = "0.8.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" dependencies = [ - "der 0.8.1", + "der 0.8.2", "spki 0.8.0", ] @@ -6844,7 +6884,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der 0.8.1", + "der 0.8.2", "spki 0.8.0", ] @@ -7422,9 +7462,9 @@ dependencies = [ [[package]] name = "redis" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f" +checksum = "2acbc41a996f7652b2ddd9dfd98cc4ff602cfd742ae35382f07f608405ab50ed" dependencies = [ "arc-swap", "arcstr", @@ -7724,9 +7764,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ "aws-lc-rs", "log", @@ -7771,9 +7811,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -7899,7 +7939,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct 1.0.0", "ctutils", - "der 0.8.1", + "der 0.8.2", "hybrid-array", "subtle", "zeroize", @@ -8418,7 +8458,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der 0.8.1", + "der 0.8.2", ] [[package]] @@ -9484,9 +9524,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -9536,9 +9576,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" dependencies = [ "indexmap", "serde_core", @@ -11102,7 +11142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "105ef4642d9cb137ef83d623d0e4bf08b8adf69e9918ca904a174adb6d3d038b" dependencies = [ "const-oid 0.10.2", - "der 0.8.1", + "der 0.8.2", "sha1 0.11.0", "signature 3.0.0", "spki 0.8.0", diff --git a/Cargo.toml b/Cargo.toml index b603b4c993..3506d2a1da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1581" +version = "0.5.1582" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10287-store-plan-per-key.md b/changelog.d/10287-store-plan-per-key.md new file mode 100644 index 0000000000..aa746331f2 --- /dev/null +++ b/changelog.d/10287-store-plan-per-key.md @@ -0,0 +1,16 @@ +The store-plan cache is vetted per key rather than per receiver. It exists so a +property store need not re-run the interception vet — the prototype-chain walk, +the class-registry lookups and the `Object.prototype` probe — but it refused any +receiver carrying a property descriptor at all. zod installs `_zod` on every +schema object, so none of them ever held a plan and every one of their stores +paid the full vet again. + +Own descriptors disqualified the receiver for a real reason: an own accessor +must dispatch through a short-circuit that a plan hit skips. That is a per-key +fact, and the same path already proves the key uncovered, so the plan is now +denied only for the keys a descriptor can actually cover. + +Constructing 300 real zod v4 `z.object` schemas drops a further 7.3%, and a +fixture building 2,000 receivers that define one non-enumerable property and +then assign 40 properties drops 19.7% (1.02 to 0.82 billion instructions). The +same fixture without a descriptor is unchanged. diff --git a/changelog.d/10355-builder-fold-gap.md b/changelog.d/10355-builder-fold-gap.md new file mode 100644 index 0000000000..b6a597f2be --- /dev/null +++ b/changelog.d/10355-builder-fold-gap.md @@ -0,0 +1,37 @@ +Fixed a 75× property-store cliff on `const o = {}; const X = 1; o.a = X;` +(#10353). The straight-line builder fold (#6812) rewrites `const o = {}` +plus its following `o.k = v` assignments into the object literal they spell +out, which is what gives the object a closed anon shape, a shape-stamped +allocation and direct stores. It only matched when the assignments followed +the binding *immediately*, so a single ordinary declaration in between — the +usual way initialisation code names its constants — dropped the whole +sequence back onto the dynamic `js_put_value_set` path, where every store +re-interns and re-coerces the key and transitions the object's shape. The +same program with the value passed as a parameter, or with the constants +written inline, was 75× faster, which is what made the cliff look like a +property of the stored *value*. + +`fold_builder_sequences` now skips up to 64 statements between an **empty** +`{}` binding and its first assignment, sinking the allocation below them. A +statement is skippable only when moving the allocation past it is +unobservable, which is the pair of conditions the value side already carries +(`gap_stmt_is_hoistable`): it must not name the binding, and it must not be +able to execute user code — a call can reach a hoisted +`function peek() { return o; }` that names the binding without the statement +naming it, which would turn a successful read into a TDZ `ReferenceError`. +Destructuring patterns (getter-bearing property reads) and populated +literals are excluded; sinking `const o = { a: y }` below `const y = 1` +would hide a TDZ throw. Skipped statements keep their relative order and +still run before every folded value. + +Measured with `perf stat -e instructions:u` on x86_64, 2400 iterations +building a six-property object with `--no-auto-optimize`: 108,447,339 → +1,399,772 instructions (77×), matching the same program with the constants +written inline (1,401,872) or the value passed as a parameter (1,411,774). +Nothing that folded before folds differently — the gap is an additional +match, and a statement that fails the test leaves the original dynamic +writes exactly as they were: `benchmarks/object-write-6812` and the +`bench_*` corpus move by at most 0.006%, and a 12k-line file whose gaps +never reach an assignment (maximum pre-scan work, zero folds) costs 0.019% +more to compile. A file where the fold now applies compiles 51% cheaper, +because 1,200 dynamic store sites become 200 stamped allocations. diff --git a/changelog.d/10371-gc-slot-iterator-memcpy.md b/changelog.d/10371-gc-slot-iterator-memcpy.md new file mode 100644 index 0000000000..d38f7df7cd --- /dev/null +++ b/changelog.d/10371-gc-slot-iterator-memcpy.md @@ -0,0 +1,7 @@ +### Performance + +- **The GC slot visitor no longer copies a 152-byte iterator for every traced object (#10362).** `gc_child_slots` built a `HeapChildSlotIterator` per object, carrying a 40-byte `ShapeDescriptor` lifted out of the shape table (#8122). Two by-value moves turned it into an out-of-line `memcpy`: the Array/Closure arms' `Option::map(..).unwrap_or_else(..)` temporary, and `for .. in child_slots` in the masked arm. On a retained-object-graph workload that was 6,181,945 copies of exactly 152 bytes, and after this change it is 6. + The iterator now carries `ShapeRecordRef`, an 8-byte handle to the shape table's slab record (the iterator shrinks to 120 bytes). The Array/Closure arms build the iterator in place with `let-else`, and the masked arm iterates `&mut child_slots`. + Soundness: this is still ONE shape-table probe per receiver (#8122). The handle is read at the same points the lifted copy was (the carrier notes and the keys edge), before any slot is visited. It depends on the same record-address validity that `note_old_generation_carrier` already writes through (#9706: slab records never move, and a chunk is released only at the end of a major collection). The #8112 old-carrier/ephemeron gate and the #9726 full-trace note are unchanged. + Measured (instructions:u, exact counts): gc3 12.572G → 12.276G (−2.36%); smaller retained sets −0.78% to −1.97%; old→young churn −1.45%; allocation-only 0.00%. + The issue's profile had put memmove at 14% because `instructions:u` sampling skids on this CPU. Precise `cycles:pp` sampling shows 1.3%, which matches the measured win. Use a precise event for attribution and exact counters for totals. diff --git a/changelog.d/10374-ambient-declare-binds-nothing.md b/changelog.d/10374-ambient-declare-binds-nothing.md new file mode 100644 index 0000000000..38df2e8535 --- /dev/null +++ b/changelog.d/10374-ambient-declare-binds-nothing.md @@ -0,0 +1,7 @@ +### Fixed + +- **`declare const` / `declare let` / `declare var` no longer shadow the global they describe (#10363).** An ambient declaration is erased by TypeScript, but perry lowered it as a real binding initialized to `undefined`. Reads never reached the globalThis data property or accessor, `typeof` said `"undefined"` (also from functions declared before the `declare` line), and a `declare var` in a Script entry became a non-configurable global property, so a later `Object.defineProperty(globalThis, …)` threw. Every pass that models bindings now treats an ambient declarator as binding nothing: lowering, `export declare const`, forward pre-registration, `var` hoisting, closure forward-capture, TDZ and Annex B names, and the `new Function` constant-argument scan, which had folded `declare var src; new Function(src)` to `new Function("undefined")`. The references resolve through the global object exactly as if the line were absent. A name nothing defines now throws `ReferenceError` like Node, and ambient names no longer print the unknown-identifier warning. + + `declare const __platform__` / `__plugins__` keep their binding, and **`__platform__` works again**: #6871's explicit `undefined` initializer had hidden it from the backends' compile-time constant fold, so it read `undefined` on every target. + + No performance tradeoff: across all 1645 `test-files` programs, the HIR that codegen consumes (`stable_hash::hash_module`) is identical for every program without an ambient declaration. Compile-time lowering cost is within run-to-run noise (+0.01% / +0.05% instructions). diff --git a/crates/perry-hir/src/lower/ambient.rs b/crates/perry-hir/src/lower/ambient.rs new file mode 100644 index 0000000000..4ce3b7bcc6 --- /dev/null +++ b/crates/perry-hir/src/lower/ambient.rs @@ -0,0 +1,68 @@ +//! #10363: TypeScript ambient variable declarations. +//! +//! `declare const x: T;` (and `declare let` / `declare var`) describes a +//! binding the host supplies: a global object property installed by the +//! runtime, a polyfill, or a script loaded earlier. TypeScript erases the +//! declaration, so it binds NOTHING, and every reference must resolve exactly +//! as if the line were absent, which means through the global object. Lowering +//! it as a real binding shadows that global with `undefined`, and a +//! `declare var` would also publish a non-configurable global property that +//! makes a later `Object.defineProperty(globalThis, "x", …)` throw. +//! +//! Every pass that models the module's bindings (source-position lowering, +//! the forward pre-registration passes, `var` hoisting, TDZ and shadow scans) +//! has to agree on this, so each one asks [`declarator_binds_nothing`]. +//! +//! The one exception is Perry's compile-time constants. `declare const +//! __platform__: number` is the documented way to ask for the target's +//! platform id: the backends fold it from the declaration's `Stmt::Let`, which +//! must carry no initializer (see `compile_time_constants` in perry-codegen, +//! and the JS, WASM and ArkTS emitters). + +use swc_ecma_ast as ast; + +use crate::ir::Stmt; + +/// A compile-time constant whose ambient declaration keeps its binding. +fn is_compile_time_constant(name: &str) -> bool { + matches!(name, "__platform__" | "__plugins__") +} + +/// Whether `decl` is an ambient declarator of a compile-time constant. +pub(crate) fn declarator_is_compile_time_constant( + var: &ast::VarDecl, + decl: &ast::VarDeclarator, +) -> bool { + var.declare + && matches!(&decl.name, ast::Pat::Ident(ident) + if is_compile_time_constant(ident.id.sym.as_ref())) +} + +/// Whether `decl` (one declarator of `var`) is erased at run time: it declares +/// no binding, and references to its names resolve to the global object. +pub(crate) fn declarator_binds_nothing(var: &ast::VarDecl, decl: &ast::VarDeclarator) -> bool { + var.declare && !declarator_is_compile_time_constant(var, decl) +} + +/// Record the names of a declarator that [`declarator_binds_nothing`] as known +/// globals, so a reference to them does not print the unknown-identifier +/// warning. They use the same by-name runtime lookup as any other global. +pub(crate) fn note_ambient_globals(ctx: &mut super::LoweringContext, decl: &ast::VarDeclarator) { + let mut names = Vec::new(); + crate::lower_patterns::collect_binding_names(&decl.name, &mut names); + ctx.platform_globals.extend(names); +} + +/// Give a compile-time constant's lowered `Stmt::Let` back the no-initializer +/// shape the backends key on. #6871 made every uninitialized `let`/`const` +/// materialize `undefined`, which is right for real bindings but hid the +/// declaration from constant folding, so `__platform__` read `undefined`. +pub(crate) fn restore_compile_time_constant_shape(stmts: &mut [Stmt]) { + for stmt in stmts { + if let Stmt::Let { init, .. } = stmt { + if matches!(init, Some(crate::ir::Expr::Undefined)) { + *init = None; + } + } + } +} diff --git a/crates/perry-hir/src/lower/builder_fold.rs b/crates/perry-hir/src/lower/builder_fold.rs index 936b51ee74..e91b777692 100644 --- a/crates/perry-hir/src/lower/builder_fold.rs +++ b/crates/perry-hir/src/lower/builder_fold.rs @@ -15,6 +15,21 @@ //! - The appended value expressions run in the same order at the same //! sequence points; only the allocation moves AFTER them, and a bare //! object allocation has no user-visible effects. +//! - #10353: the assignments need not follow the binding IMMEDIATELY. The +//! scan skips up to `MAX_FOLD_GAP_STMTS` statements in between when +//! moving the allocation below them is unobservable by the same argument +//! — `gap_stmt_is_hoistable` requires exactly what the value side already +//! requires: the statement must not name the binding, and it must not be +//! able to execute user code. Skipped statements keep their relative +//! order and still run before every appended value, so +//! `const o = {}; const X = 1; o.a = X;` folds to +//! `const X = 1; const o = { a: X };`. That gap is the ordinary shape of +//! initialisation code, and before #10353 it cost 75×: the unfolded form +//! leaves a 0-field anon shape that denies `Ptr` containment, so +//! every store takes the dynamic `PutValueSet` path. A gap is allowed only +//! for an EMPTY literal — sinking a populated one would move its own value +//! expressions below the skipped statements, and `const o = { a: y }; +//! const y = 1; o.b = 2;` must keep throwing on `y`'s TDZ. //! - Values must not reference the bound name (checked conservatively by //! symbol name anywhere in the value expression, ignoring shadowing), so //! no expression can observe the half-built object. @@ -47,6 +62,12 @@ use swc_ecma_visit::{Visit, VisitWith}; /// literal machinery's inline-slot benefits taper off anyway. const MAX_FOLDED_PROPS: usize = 64; +/// How many statements the scan may skip between the binding and its first +/// assignment (#10353). Real builders separate the two by a handful of +/// constant bindings at most; the cap keeps the forward scan O(n) over a +/// statement list instead of O(n²) on a long run of hoistable declarations. +const MAX_FOLD_GAP_STMTS: usize = 64; + /// Returns a folded clone when at least one builder sequence was folded; /// `None` means "nothing to do — lower the original". pub(crate) fn fold_builder_sequences(module: &ast::Module) -> Option { @@ -155,17 +176,25 @@ fn is_object_prototype_expr(expr: &ast::Expr) -> bool { /// Cheap read-only pre-scan: is any statement list anywhere (including /// function bodies nested in expressions) a `const/let/var x = {…}` -/// immediately followed by a static member assignment to the same name? -/// False positives only cost the clone; a false negative would skip a -/// fold, so the walk mirrors the mutating one's reach. +/// followed — across a hoistable gap (#10353) — by a static member +/// assignment to the same name? False positives only cost the clone; a +/// false negative would skip a fold, so the walk mirrors the mutating +/// one's reach, gap included. fn module_has_candidate(module: &ast::Module) -> bool { - for pair in module.body.windows(2) { - if let (ast::ModuleItem::Stmt(a), ast::ModuleItem::Stmt(b)) = (&pair[0], &pair[1]) { - if let (Some(name), _) = decl_object_binding(a) { - if assign_to_name_key(b, name.as_str()).is_some() { - return true; - } - } + for (i, item) in module.body.iter().enumerate() { + let ast::ModuleItem::Stmt(a) = item else { + continue; + }; + let (Some(name), _) = decl_object_binding(a) else { + continue; + }; + let item_stmt = |k: usize| match module.body.get(i + 1 + k) { + Some(ast::ModuleItem::Stmt(s)) => Some(s), + _ => None, + }; + let gap = fold_gap_len(name.as_str(), item_stmt); + if item_stmt(gap).is_some_and(|b| assign_to_name_key(b, name.as_str()).is_some()) { + return true; } } module.body.iter().any(|item| match item { @@ -177,11 +206,16 @@ fn module_has_candidate(module: &ast::Module) -> bool { } fn stmts_have_candidate(stmts: &[ast::Stmt]) -> bool { - for pair in stmts.windows(2) { - if let (Some(name), _) = decl_object_binding(&pair[0]) { - if assign_to_name_key(&pair[1], name.as_str()).is_some() { - return true; - } + for (i, s) in stmts.iter().enumerate() { + let (Some(name), _) = decl_object_binding(s) else { + continue; + }; + let gap = fold_gap_len(name.as_str(), |k| stmts.get(i + 1 + k)); + if stmts + .get(i + 1 + gap) + .is_some_and(|b| assign_to_name_key(b, name.as_str()).is_some()) + { + return true; } } stmts.iter().any(scan_stmt) @@ -368,10 +402,23 @@ fn fold_module_stmt_run(items: &mut [ast::ModuleItem], changed: &mut bool) { idx += 1; continue; } + // A gap is only skippable for an EMPTY literal: sinking a populated + // one would move its own value expressions below the skipped + // statements, and `const o = { a: y }; const y = 1; o.b = 2;` must + // keep throwing on `y`'s TDZ. + let gap = if existing.is_empty() { + fold_gap_len(&name_start, |k| match items.get(idx + 1 + k) { + Some(ast::ModuleItem::Stmt(s)) => Some(s), + _ => None, + }) + } else { + 0 + }; + let first = idx + 1 + gap; let mut keys = existing_keys(existing); let mut appended: Vec<(ast::PropName, Box)> = Vec::new(); let mut consumed = 0usize; - for follower in items[idx + 1..].iter() { + for follower in items[first..].iter() { let ast::ModuleItem::Stmt(fs) = follower else { break; }; @@ -392,16 +439,28 @@ fn fold_module_stmt_run(items: &mut [ast::ModuleItem], changed: &mut bool) { idx += 1; continue; } - // Apply: extend the literal, blank out the consumed statements. + // Apply: extend the literal, sink the declaration below the skipped + // statements so the appended values still evaluate after them, and + // blank out the consumed statements. if let ast::ModuleItem::Stmt(s) = &mut items[idx] { append_props(s, appended); } - for follower in items[idx + 1..idx + 1 + consumed].iter_mut() { + if gap > 0 { + items[idx..first].rotate_left(1); + } + for follower in items[first..first + consumed].iter_mut() { *follower = ast::ModuleItem::Stmt(ast::Stmt::Empty(ast::EmptyStmt { span: swc_common::DUMMY_SP, })); } *changed = true; + if gap > 0 { + // `items[idx]` is now the first skipped statement, which may open + // a builder of its own (`const a = {}; const b = {}; a.x = 1; + // b.y = 2;`). Re-examining it terminates: each fold blanks at + // least one assignment statement, and the run holds finitely many. + continue; + } idx += 1 + consumed; } } @@ -419,9 +478,16 @@ fn fold_stmts(stmts: &mut Vec, changed: &mut bool) { idx += 1; continue; }; + // Empty literals only — see `fold_module_stmt_run`. + let gap = if existing_len == 0 { + fold_gap_len(&name, |k| stmts.get(idx + 1 + k)) + } else { + 0 + }; + let first = idx + 1 + gap; let mut appended: Vec<(ast::PropName, Box)> = Vec::new(); let mut consumed = 0usize; - for follower in stmts[idx + 1..].iter() { + for follower in stmts[first..].iter() { let Some((key, value)) = assign_to_name_key(follower, &name) else { break; }; @@ -437,8 +503,20 @@ fn fold_stmts(stmts: &mut Vec, changed: &mut bool) { } if consumed > 0 { append_props(&mut stmts[idx], appended); - stmts.drain(idx + 1..idx + 1 + consumed); + // Sink the declaration below the skipped statements: the appended + // values evaluate where the literal now sits, so they must still + // run after everything that used to precede them. + if gap > 0 { + stmts[idx..first].rotate_left(1); + } + stmts.drain(first..first + consumed); *changed = true; + if gap > 0 { + // `stmts[idx]` is now the first skipped statement, which may + // open a builder of its own. Re-examining it terminates: each + // fold removes at least one statement from the list. + continue; + } } idx += 1; } @@ -500,6 +578,66 @@ fn assign_to_name_key<'a>( Some((key, &a.right)) } +/// How many statements between the `{ … }` binding and its first fold-able +/// assignment the scan may skip (#10353). +/// +/// `at(k)` yields the k-th follower of the binding, or `None` when the run +/// ends (a non-`Stmt` module item, or the end of the list). The walk stops at +/// the first statement that is an assignment to `name` — that is where the +/// fold proper takes over — and at the first statement the declaration may +/// not move below. +fn fold_gap_len<'a>(name: &str, at: impl Fn(usize) -> Option<&'a ast::Stmt>) -> usize { + let mut gap = 0usize; + while gap < MAX_FOLD_GAP_STMTS { + let Some(s) = at(gap) else { break }; + if assign_to_name_key(s, name).is_some() || !gap_stmt_is_hoistable(s, name) { + break; + } + gap += 1; + } + gap +} + +/// May the `{ … }` declaration move BELOW this statement? +/// +/// The fold evaluates the appended values where the literal ends up, so a +/// statement standing between the binding and its first assignment is only +/// skippable when moving the ALLOCATION past it is unobservable. That is the +/// same pair of conditions `value_is_fold_safe` already enforces on the value +/// side, for the same two reasons: +/// +/// - the statement must not NAME the binding — it would otherwise read, write +/// or capture an object that no longer exists at that point (`const o = {}; +/// f(o); o.a = 1` keeps its dynamic writes); +/// - the statement must not be able to execute user code, because a call can +/// reach a hoisted `function peek() { return o; }` that names the binding +/// WITHOUT the statement naming it, turning a successful read into a TDZ +/// `ReferenceError`. Reusing `value_is_fold_safe` for initializers and +/// expression statements buys exactly that test, and deliberately shares +/// its precision: that predicate admits the implicit conversions (`a + b`, +/// a template substitution) that can still reach a user `valueOf`, which +/// is a pre-existing imprecision of the value side, tracked separately — +/// the two sides must not drift apart here. +/// +/// Destructuring patterns are excluded: the binding itself performs property +/// reads, which can run a getter. Type-only declarations are erased before +/// codegen, so they carry no runtime effect at all and always qualify — +/// `enum` and `namespace` do emit code and do not. +fn gap_stmt_is_hoistable(s: &ast::Stmt, name: &str) -> bool { + match s { + ast::Stmt::Empty(_) => true, + ast::Stmt::Decl(ast::Decl::TsInterface(_) | ast::Decl::TsTypeAlias(_)) => true, + ast::Stmt::Expr(es) => value_is_fold_safe(&es.expr, name), + ast::Stmt::Decl(ast::Decl::Var(var)) => var.decls.iter().all(|d| { + matches!(&d.name, ast::Pat::Ident(bi) if bi.id.sym.as_ref() != name) + && d.init + .as_deref() + .is_none_or(|init| value_is_fold_safe(init, name)) + }), + _ => false, + } +} + /// The literal may only contain plain key/value + shorthand props; anything /// else (accessors, spreads, computed keys, methods) disables folding. fn literal_is_foldable(props: &[ast::PropOrSpread]) -> bool { diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index 03a1798a94..a8e8996d3e 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -809,6 +809,10 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul if let ast::Stmt::Decl(ast::Decl::Var(var_decl)) = stmt { if var_decl.kind == ast::VarDeclKind::Var { for decl in &var_decl.decls { + // #10363: an ambient declarator binds nothing. + if super::ambient::declarator_binds_nothing(var_decl, decl) { + continue; + } // Collect every binding name introduced by this // declarator. Plain `var x` is a single `Pat::Ident`; // a destructuring `var { t: dSq } = re()` (the esbuild @@ -978,6 +982,9 @@ fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Resul ast::VarDeclKind::Let | ast::VarDeclKind::Const ) { for decl in &var_decl.decls { + if super::ambient::declarator_binds_nothing(var_decl, decl) { + continue; + } if let ast::Pat::Ident(ident) = &decl.name { let name = ident.id.sym.to_string(); let already_in_scope = ctx diff --git a/crates/perry-hir/src/lower/fn_ctor_env.rs b/crates/perry-hir/src/lower/fn_ctor_env.rs index cb75722c01..c8dac9ef53 100644 --- a/crates/perry-hir/src/lower/fn_ctor_env.rs +++ b/crates/perry-hir/src/lower/fn_ctor_env.rs @@ -845,7 +845,9 @@ fn collect_fn_scope_names(stmts: &[ast::Stmt], out: &mut Shadow) { match stmt { ast::Stmt::Decl(ast::Decl::Var(var)) => { for d in &var.decls { - collect_pat_names(&d.name, out); + if !super::ambient::declarator_binds_nothing(var, d) { + collect_pat_names(&d.name, out); + } } } ast::Stmt::Decl(ast::Decl::Fn(f)) => { @@ -955,6 +957,11 @@ fn scan_stmt( match stmt { ast::Stmt::Decl(ast::Decl::Var(var)) => { for d in &var.decls { + // #10363: an ambient `declare var x` is not a never-written + // `var x;`. It names a global whose value is unknown here. + if super::ambient::declarator_binds_nothing(var, d) { + continue; + } if let ast::Pat::Ident(b) = &d.name { record_decl(&b.id.sym, d.init.as_deref(), decls); } else { @@ -1402,4 +1409,54 @@ mod tests { let init = first_var_initializer("const factory = function* (ev) { return ev(src); };"); assert!(indirect_eval_factory_shape(&init).is_none()); } + + /// #10363: `declare var body: string` names a global whose value is not + /// known at compile time. Recording it as a never-written `var body;` + /// would fold `new Function(body)` to `new Function("undefined")`. + #[test] + fn ambient_var_is_not_a_never_written_var() { + let module = perry_parser::parse_typescript( + "declare var body: string; var control; new Function(body); new Function(control);", + "fn-ctor-ambient.ts", + ) + .unwrap(); + let env = build_fn_ctor_env(&module); + assert!( + matches!(env.entries.get("control"), Some(FnCtorShape::UndefinedVar)), + "a real never-written `var` still resolves to undefined: {:?}", + env.entries + ); + assert!( + !env.entries.contains_key("body"), + "an ambient `declare var` must not resolve to a constant: {:?}", + env.entries + ); + } + + /// An erased `declare var` inside a function does not shadow the module + /// binding of that name, so a write there still reaches the module `var`. + #[test] + fn ambient_var_in_a_function_does_not_shadow_a_module_write() { + let module = perry_parser::parse_typescript( + r#" + var counter = "a"; + function bump() { declare var counter: string; counter = "b"; } + var control = "a"; + function other() { var control = "x"; control = "b"; } + "#, + "fn-ctor-ambient-shadow.ts", + ) + .unwrap(); + let env = build_fn_ctor_env(&module); + assert!( + matches!(env.entries.get("control"), Some(FnCtorShape::Str(s)) if s == "a"), + "a real inner `var` shadows the write: {:?}", + env.entries + ); + assert!( + !env.entries.contains_key("counter"), + "the write behind an ambient `declare var` reaches the module binding: {:?}", + env.entries + ); + } } diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 801be23439..755b16dbc3 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -1163,6 +1163,14 @@ pub fn lower_module_full_with_platform_globals( }; if let Some(var_decl) = var_decl { for decl in &var_decl.decls { + // #10363: an ambient declarator binds nothing. Pre-registering + // it would resolve every earlier reference to a local that is + // never written, and a `var` would also be reflected onto + // globalThis as a non-configurable property. + if super::ambient::declarator_binds_nothing(var_decl, decl) { + super::ambient::note_ambient_globals(&mut ctx, decl); + continue; + } // #4461: `var X = class { ... }` is lowered as a class // expression bound to the name `X` (see stmt.rs) — the class // itself takes the role of the value referenced by name, and diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 67fc367fb1..d00b76034f 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -1001,8 +1001,10 @@ pub struct LoweringContext { /// by `lower_module_with_class_id_types_seed_and_entry`; default false. pub(crate) is_entry_module: bool, /// Names installed on globalThis by the selected platform before module - /// initialization. They still use the by-name runtime lookup; this set - /// only prevents unknown-identifier diagnostics after lexical resolution. + /// initialization, plus names the source declares ambiently + /// (`declare const/let/var`, #10363). They still use the by-name runtime + /// lookup; this set only prevents unknown-identifier diagnostics after + /// lexical resolution. pub(crate) platform_globals: HashSet, /// #5833: true once lowering has produced at least one `Expr::GlobalThisExpr` /// from a top-level `this` (global-script mode, `PERRY_GLOBAL_SCRIPT_THIS`). diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index a48bbaa5d0..1831d4b68f 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -34,6 +34,7 @@ // the largest single arm extracted so far). // - `expr_member.rs` / `expr_assign.rs` / `expr_new.rs` (v0.5.339): // property access, assignment, and `new C()` constructor calls. +pub(crate) mod ambient; pub(crate) mod builder_fold; mod context; pub(crate) use context::perry_ui_factory_returns_handle; diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index fe9f1ab23f..256d9e6870 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -611,6 +611,11 @@ pub(crate) fn lower_module_decl( ast::Decl::Var(var_decl) => { // Handle exported variables for decl in &var_decl.decls { + // #10363: `export declare const x` is erased like + // `export declare function`: no binding, no export. + if super::ambient::declarator_binds_nothing(var_decl, decl) { + continue; + } if is_destructuring_pattern(&decl.name) { let mut names = Vec::new(); collect_binding_names(&decl.name, &mut names); diff --git a/crates/perry-hir/src/lower/module_decl/namespace.rs b/crates/perry-hir/src/lower/module_decl/namespace.rs index edd70a2790..504cf7dfc9 100644 --- a/crates/perry-hir/src/lower/module_decl/namespace.rs +++ b/crates/perry-hir/src/lower/module_decl/namespace.rs @@ -249,6 +249,9 @@ pub(crate) fn lower_namespace_as_class( // Pre-register non-exported variables ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Var(var_decl))) => { for decl in &var_decl.decls { + if crate::lower::ambient::declarator_binds_nothing(var_decl, decl) { + continue; + } if let ast::Pat::Ident(ident) = &decl.name { let name = ident.id.sym.to_string(); if ctx.lookup_local(&name).is_none() { diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index 7159c9469d..4e6bd026fa 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -454,6 +454,12 @@ pub(crate) fn lower_stmt( let mutable = var_decl.kind != ast::VarDeclKind::Const; let is_var = var_decl.kind == ast::VarDeclKind::Var; for decl in &var_decl.decls { + // #10363: `declare const/let/var` binds nothing, so + // references reach the global it describes. + if super::ambient::declarator_binds_nothing(var_decl, decl) { + super::ambient::note_ambient_globals(ctx, decl); + continue; + } // Check if this is a Widget({...}) call from perry/widget if let Some(init) = &decl.init { if let ast::Expr::Call(call_expr) = init.as_ref() { @@ -988,7 +994,11 @@ pub(crate) fn lower_stmt( // top-level path is reached for any `let X = T = class` // not handled by the direct `class` fast path above. record_chained_class_self_aliases(ctx, decl); - let stmts = lower_var_decl_with_destructuring(ctx, decl, mutable, is_var)?; + let mut stmts = + lower_var_decl_with_destructuring(ctx, decl, mutable, is_var)?; + if super::ambient::declarator_is_compile_time_constant(var_decl, decl) { + super::ambient::restore_compile_time_constant_shape(&mut stmts); + } // `var` is function-scoped: mark defined locals so // `pop_block_scope` preserves them when leaving an inner block. if is_var { diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index fc6adf5041..15eba5b2b6 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1982,6 +1982,7 @@ fn hoisted_class_constructs_sibling_declared_inside_a_later_closure() { ); } +mod ambient_declare; mod unresolved_new_global; mod capture_stash; diff --git a/crates/perry-hir/src/lower/tests/ambient_declare.rs b/crates/perry-hir/src/lower/tests/ambient_declare.rs new file mode 100644 index 0000000000..c0129cede5 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/ambient_declare.rs @@ -0,0 +1,327 @@ +//! #10363: `declare const/let/var` is an ambient declaration. TypeScript erases +//! it, so it binds nothing and every reference resolves to the global object +//! exactly as if the line were absent. Each test pairs the ambient name with an +//! ordinary binding of the same shape, which must keep lowering as a local. + +use crate::ir::{Expr, Module, Stmt}; + +fn lower(source: &str) -> Module { + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + super::lower_module(&module, "t", "t.ts").expect("source lowers") +} + +fn function_debug(hir: &Module, name: &str) -> String { + let function = hir + .functions + .iter() + .find(|function| function.name == name) + .unwrap_or_else(|| panic!("function {name} is lowered")); + format!("{function:?}") +} + +fn binds(debug: &str, name: &str) -> bool { + debug.contains(&format!("name: \"{name}\"")) +} + +fn global_read(name: &str) -> String { + format!( + "ExternFuncRef {{ name: \"js_global_get_or_throw_unresolved\", param_types: [Any], \ + return_type: Any }}, args: [String(\"{name}\")]" + ) +} + +#[test] +fn ambient_const_let_var_bind_nothing_at_module_scope() { + let hir = lower( + r#" + declare const dConst: number; + declare let dLet: string; + declare var dVar: any; + const realConst = 1; + console.log(dConst, dLet, dVar, realConst); + "#, + ); + let debug = format!("{:?}", hir.init); + assert!( + binds(&debug, "realConst"), + "control binding is lowered: {debug}" + ); + for name in ["dConst", "dLet", "dVar"] { + assert!( + !binds(&debug, name), + "`declare` must not materialize a `{name}` binding: {debug}" + ); + assert!( + debug.contains(&global_read(name)), + "`{name}` must be read off the global object: {debug}" + ); + } +} + +/// The issue's decl4 shape: a function lowered BEFORE the declaration reads +/// the name. The forward pre-registration pass used to hand it a local. +#[test] +fn function_declared_before_the_ambient_line_reads_the_global() { + let hir = lower( + r#" + function ambient(): string { return typeof dInner; } + function real(): string { return typeof realLater; } + declare const dInner: string; + const realLater = "x"; + "#, + ); + let ambient = function_debug(&hir, "ambient"); + assert!( + ambient.contains("js_global_get_optional") && ambient.contains("String(\"dInner\")"), + "`typeof` of an ambient name is a non-throwing global lookup: {ambient}" + ); + let real = function_debug(&hir, "real"); + assert!( + !real.contains("String(\"realLater\")"), + "control: a real module binding still resolves lexically: {real}" + ); +} + +/// `declare var` in a Script entry used to be published as a non-configurable +/// global property before user code ran, so a later +/// `Object.defineProperty(globalThis, "dVar", …)` threw. +#[test] +fn ambient_var_is_not_a_script_global_var() { + let source = r#" + declare var dVar: any; + var realVar = 1; + (globalThis as any).probe = realVar; + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + // The driver installs the source; its `globalThis` mention is what arms + // the Script global-var reflection. + crate::ir::set_current_module_source(source.to_string()); + let lowered = super::lower_module_with_class_id_types_seed_and_entry( + &module, "t", "t.ts", 1, None, None, None, true, + ); + crate::ir::clear_current_module_source(); + let (hir, _) = lowered.expect("source lowers"); + let names = &hir.annexb_global_undefined_names; + assert!( + names.iter().any(|name| name == "realVar"), + "control: a real Script `var` is a global var: {names:?}" + ); + assert!( + !names.iter().any(|name| name == "dVar"), + "an ambient `declare var` must not create a global property: {names:?}" + ); +} + +/// `export declare const` is erased exactly like `export declare function`: +/// no binding and no export. +#[test] +fn export_declare_const_exports_nothing() { + let hir = lower( + r#" + export declare const dExported: number; + export const alias = dExported; + "#, + ); + let exports = format!("{:?}", hir.exports); + assert!(exports.contains("\"alias\""), "control export: {exports}"); + assert!( + !exports.contains("\"dExported\""), + "an ambient declaration has no runtime export: {exports}" + ); + let init = format!("{:?}", hir.init); + assert!( + init.contains(&global_read("dExported")), + "a reference to an erased export reads the global: {init}" + ); +} + +#[test] +fn ambient_declaration_in_a_namespace_binds_nothing() { + let hir = lower( + r#" + namespace N { + declare const nsAmbient: number; + const nsReal = 2; + export const viaAmbient = nsAmbient; + export const viaReal = nsReal; + } + "#, + ); + let init = format!("{:?}", hir.init); + assert!(binds(&init, "nsReal"), "control namespace binding: {init}"); + assert!( + !binds(&init, "nsAmbient"), + "namespace ambient binding: {init}" + ); + assert!(init.contains(&global_read("nsAmbient")), "{init}"); +} + +/// tsc rejects `declare` inside a function body, but swc parses it and type +/// stripping erases it, so it must bind nothing there either, including in +/// the body's `var` hoisting and closure forward-capture passes. +#[test] +fn ambient_declarations_in_function_bodies_bind_nothing() { + let hir = lower( + r#" + function body(): number { + const early = () => bodyConst + realLater; + declare const bodyConst: number; + declare var bodyVar: number; + const realLater = 3; + return early() + bodyVar; + } + const expr = function (): number { + function inner(): number { return exprConst + exprReal; } + declare const exprConst: number; + declare var exprVar: number; + const exprReal = 4; + return inner() + exprVar; + }; + "#, + ); + let debug = format!("{hir:?}"); + for real in ["realLater", "exprReal"] { + assert!( + binds(&debug, real), + "control function binding `{real}`: {debug}" + ); + } + for name in ["bodyConst", "bodyVar", "exprConst", "exprVar"] { + assert!( + !binds(&debug, name), + "function-body ambient `{name}`: {debug}" + ); + assert!( + debug.contains(&global_read(name)), + "`{name}` reads the global: {debug}" + ); + } +} + +/// With the declaration erased, a closure in the body reads the enclosing +/// binding. The body's forward-capture pass must not box that outer binding +/// as if the body had declared its own `var`. +#[test] +fn closure_behind_an_ambient_var_reads_the_enclosing_binding() { + let hir = lower( + r#" + const outerShared = 1; + function ambient(): number { + const read = () => outerShared; + declare var outerShared: number; + return read(); + } + function control(): number { + const read = () => realShared; + var realShared = 2; + return read(); + } + "#, + ); + let control = function_debug(&hir, "control"); + assert!( + control.contains("PreallocateBoxes"), + "control: a forward-captured real `var` is boxed at entry: {control}" + ); + let ambient = function_debug(&hir, "ambient"); + assert!( + !ambient.contains("PreallocateBoxes"), + "the enclosing binding must not be re-boxed by the function: {ambient}" + ); +} + +/// Annex B.3.3: a block-level function declaration also creates a function +/// `var`, unless a lexical declaration of that name forbids it. An ambient +/// `declare let` declares nothing, so it must not forbid it. +#[test] +fn ambient_let_does_not_block_the_annex_b_function_var() { + let hir = lower( + r#" + function ambient(): string { + declare let ambientFn: any; + { function ambientFn() {} } + return typeof ambientFn; + } + function control(): string { + { function plainFn() {} } + return typeof plainFn; + } + "#, + ); + let control = function_debug(&hir, "control"); + assert!( + !control.contains("String(\"plainFn\")"), + "control: the Annex B `var` resolves `typeof plainFn` locally: {control}" + ); + let ambient = function_debug(&hir, "ambient"); + assert!( + !ambient.contains("String(\"ambientFn\")"), + "`typeof ambientFn` must read the Annex B `var`, not the global: {ambient}" + ); +} + +/// A block-scoped `let` is in its TDZ before its declarator, so `typeof` +/// throws. An ambient `declare let` has no TDZ: it is a global. +#[test] +fn typeof_before_an_ambient_let_does_not_throw() { + let hir = lower( + r#" + function probe(): string { + { + const ambient = typeof dBlock; + const real = typeof realBlock; + declare let dBlock: number; + let realBlock = 1; + return ambient + real + realBlock; + } + } + "#, + ); + let probe = function_debug(&hir, "probe"); + assert!( + probe.contains(&global_read("realBlock")), + "control: `typeof` of a lexical binding in its TDZ throws: {probe}" + ); + assert!( + !probe.contains(&global_read("dBlock")), + "`typeof` of an ambient name must not throw: {probe}" + ); + assert!( + probe.contains("js_global_get_optional") && probe.contains("String(\"dBlock\")"), + "{probe}" + ); +} + +/// `declare const __platform__: number` is Perry's compile-time platform +/// constant. The backends fold it from a `Stmt::Let` with NO initializer; since +/// #6871 materialized `undefined` for every uninitialized lexical binding, the +/// constant read `undefined` on every target. +#[test] +fn compile_time_constants_keep_their_uninitialized_binding() { + let hir = lower( + r#" + declare const __platform__: number; + declare const __plugins__: number; + const realUninitialized = undefined; + console.log(__platform__, __plugins__, realUninitialized); + "#, + ); + for name in ["__platform__", "__plugins__"] { + let init = hir.init.iter().find_map(|stmt| match stmt { + Stmt::Let { name: n, init, .. } if n == name => Some(init), + _ => None, + }); + assert!( + matches!(init, Some(None)), + "`{name}` must lower to `Let {{ init: None }}`, got {init:?}" + ); + } + let control = hir.init.iter().find_map(|stmt| match stmt { + Stmt::Let { name, init, .. } if name == "realUninitialized" => Some(init), + _ => None, + }); + assert!( + matches!(control, Some(Some(Expr::Undefined))), + "control: other bindings keep their initializer: {control:?}" + ); +} diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index e9403f5abd..34e0d2370e 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -222,6 +222,10 @@ pub(crate) fn pre_register_forward_captured_lets( if let Some(init) = &decl.init { cic_expr(init, false, &mut seen_closure_refs); } + // #10363: an ambient declarator binds nothing. + if crate::lower::ambient::declarator_binds_nothing(var_decl, decl) { + continue; + } let mut binding_idents: Vec<(String, u32)> = Vec::new(); collect_pat_forward_idents(&decl.name, &mut binding_idents); for (name, span_lo) in binding_idents { @@ -270,6 +274,9 @@ pub(crate) fn pre_register_forward_captured_lets( // hoist, so the capturing closure can live in a DIFFERENT // (e.g. enclosing) scope than the `var` statement itself. for decl in &var_decl.decls { + if crate::lower::ambient::declarator_binds_nothing(var_decl, decl) { + continue; + } let mut binding_idents: Vec<(String, u32)> = Vec::new(); collect_pat_forward_idents(&decl.name, &mut binding_idents); for (name, _span_lo) in binding_idents { @@ -1191,6 +1198,9 @@ fn register_block_forward_lexicals(ctx: &mut LoweringContext, stmts: &[ast::Stmt ) => { for decl in &var_decl.decls { + if crate::lower::ambient::declarator_binds_nothing(var_decl, decl) { + continue; + } let mut idents: Vec<(String, u32)> = Vec::new(); collect_pat_forward_idents(&decl.name, &mut idents); names.extend(idents.into_iter().map(|(n, _)| n)); diff --git a/crates/perry-hir/src/lower_decl/block/var_names.rs b/crates/perry-hir/src/lower_decl/block/var_names.rs index af257d1469..975c8904e9 100644 --- a/crates/perry-hir/src/lower_decl/block/var_names.rs +++ b/crates/perry-hir/src/lower_decl/block/var_names.rs @@ -38,7 +38,9 @@ fn collect_var_binding_names_from_var_decl(var_decl: &ast::VarDecl, out: &mut Ve return; } for decl in &var_decl.decls { - collect_var_binding_names_from_pat(&decl.name, out); + if !crate::lower::ambient::declarator_binds_nothing(var_decl, decl) { + collect_var_binding_names_from_pat(&decl.name, out); + } } } @@ -122,6 +124,9 @@ pub(crate) fn collect_lexical_decl_names( match stmt { ast::Stmt::Decl(ast::Decl::Var(var_decl)) if var_decl.kind != ast::VarDeclKind::Var => { for decl in &var_decl.decls { + if crate::lower::ambient::declarator_binds_nothing(var_decl, decl) { + continue; + } let mut names = Vec::new(); collect_var_binding_names_from_pat(&decl.name, &mut names); out.extend(names); diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index d339267582..60c448d03a 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -224,6 +224,11 @@ fn lower_body_stmt_impl(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result perry_hir::Module { + let mut cache = SourceCache::new(); + let parsed = parse_typescript_with_cache(src, "builder_fold_gap.ts", &mut cache) + .expect("parse should succeed"); + lower_module(&parsed.module, "test", "builder_fold_gap.ts").expect("lower should succeed") +} + +/// Number of constructor args on the `__AnonShape_…` allocation bound to +/// `name`, i.e. how many properties the literal carries after folding. +fn anon_shape_arity(stmts: &[Stmt], name: &str) -> Option { + stmts.iter().find_map(|stmt| match stmt { + Stmt::Let { + name: binding, + init: Some(Expr::New { + class_name, args, .. + }), + .. + } if binding == name && class_name.starts_with("__AnonShape_") => Some(args.len()), + _ => None, + }) +} + +fn runtime_set_keys(stmts: &[Stmt]) -> Vec { + stmts + .iter() + .filter_map(|stmt| match stmt { + Stmt::Expr(Expr::PutValueSet { key, .. }) => match key.as_ref() { + Expr::String(k) => Some(k.clone()), + _ => None, + }, + _ => None, + }) + .collect() +} + +fn fn_body<'a>(module: &'a perry_hir::Module, name: &str) -> &'a [Stmt] { + module + .functions + .iter() + .find(|f| f.name == name) + .map(|f| f.body.as_slice()) + .unwrap_or_else(|| panic!("function `{name}` not found")) +} + +/// Index of the `Let` binding `name` within a statement list. +fn let_position(stmts: &[Stmt], name: &str) -> Option { + stmts + .iter() + .position(|stmt| matches!(stmt, Stmt::Let { name: binding, .. } if binding == name)) +} + +#[test] +fn a_constant_binding_between_the_literal_and_its_stores_still_folds() { + // The issue's `vA.ts`, at module scope. + let module = lower_src( + r#" + const o: any = {}; + const x = 1; + o.p1 = x; o.p2 = x; o.p3 = x; o.p4 = x; o.p5 = x; o.p6 = x; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(6), + "all six stores should have folded into the allocation: {:?}", + module.init + ); + assert!( + runtime_set_keys(&module.init).is_empty(), + "no store should survive as a dynamic [[Set]]: {:?}", + runtime_set_keys(&module.init) + ); +} + +#[test] +fn the_skipped_binding_is_still_initialized_before_the_literal() { + // Sinking the allocation below `const x` is the whole point: the folded + // values read `x`, so `x` must still be initialized first or the literal + // would hit `x`'s TDZ. + let module = lower_src( + r#" + const o: any = {}; + const x = 1; + o.p1 = x; + "#, + ); + + let x = let_position(&module.init, "x").expect("`x` binding not found"); + let o = let_position(&module.init, "o").expect("`o` binding not found"); + assert!( + x < o, + "`x` must be initialized before the folded literal: {:?}", + module.init + ); +} + +#[test] +fn a_gap_inside_a_function_body_folds_too() { + let module = lower_src( + r#" + export function build(): any { + const o: any = {}; + const x = 1; + o.p1 = x; o.p2 = x; + return o; + } + "#, + ); + + let body = fn_body(&module, "build"); + assert_eq!( + anon_shape_arity(body, "o"), + Some(2), + "both stores should have folded inside the function body: {body:?}" + ); + assert!( + runtime_set_keys(body).is_empty(), + "no store should survive as a dynamic [[Set]]: {:?}", + runtime_set_keys(body) + ); +} + +#[test] +fn several_skipped_bindings_are_all_kept_in_order() { + let module = lower_src( + r#" + const o: any = {}; + const a = 1; + const b = a + 1; + o.p1 = a; + o.p2 = b; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(2), + "both stores should have folded: {:?}", + module.init + ); + let a = let_position(&module.init, "a").expect("`a` binding not found"); + let b = let_position(&module.init, "b").expect("`b` binding not found"); + let o = let_position(&module.init, "o").expect("`o` binding not found"); + assert!( + a < b && b < o, + "the skipped bindings must keep their order and precede the literal: {:?}", + module.init + ); +} + +#[test] +fn a_second_builder_in_the_gap_folds_as_well() { + let module = lower_src( + r#" + const a: any = {}; + const b: any = {}; + a.x = 1; + b.y = 2; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "a"), + Some(1), + "the outer builder should fold: {:?}", + module.init + ); + assert_eq!( + anon_shape_arity(&module.init, "b"), + Some(1), + "the builder found in the gap should fold too: {:?}", + module.init + ); +} + +#[test] +fn a_gap_statement_that_names_the_binding_blocks_the_fold() { + // `alias` reads `o` before the allocation would happen — sinking the + // declaration past it would be a TDZ ReferenceError. + let module = lower_src( + r#" + const o: any = {}; + const alias = o; + o.p1 = 1; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(0), + "the allocation must stay empty: {:?}", + module.init + ); + assert_eq!( + runtime_set_keys(&module.init), + vec!["p1".to_string()], + "the store must survive as a dynamic [[Set]]: {:?}", + module.init + ); +} + +#[test] +fn a_gap_statement_that_can_run_user_code_blocks_the_fold() { + // The gap statement does not name `o`, but `peek()` reads it — sinking + // the allocation below the call would turn that read into a TDZ + // ReferenceError. + let module = lower_src( + r#" + function peek(): any { return o; } + const o: any = {}; + const seen = peek(); + o.p1 = 1; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(0), + "a call in the gap must block the fold: {:?}", + module.init + ); + assert_eq!( + runtime_set_keys(&module.init), + vec!["p1".to_string()], + "the store must survive as a dynamic [[Set]]: {:?}", + module.init + ); +} + +#[test] +fn a_destructuring_gap_blocks_the_fold() { + // The pattern itself performs property reads, which can run a getter. + let module = lower_src( + r#" + const src: any = { a: 1 }; + const o: any = {}; + const { a } = src; + o.p1 = a; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(0), + "a destructuring gap must block the fold: {:?}", + module.init + ); + assert_eq!( + runtime_set_keys(&module.init), + vec!["p1".to_string()], + "the store must survive as a dynamic [[Set]]: {:?}", + module.init + ); +} + +#[test] +fn a_member_read_in_the_gap_blocks_the_fold() { + // A getter on `src` could reach the binding. + let module = lower_src( + r#" + const src: any = { a: 1 }; + const o: any = {}; + const a = src.a; + o.p1 = a; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(0), + "a member read in the gap must block the fold: {:?}", + module.init + ); + assert_eq!( + runtime_set_keys(&module.init), + vec!["p1".to_string()], + "the store must survive as a dynamic [[Set]]: {:?}", + module.init + ); +} + +#[test] +fn a_populated_literal_does_not_skip_a_gap() { + // Sinking `{ a: y }` below `const y` would turn a TDZ ReferenceError into + // a successful build, so only empty literals may skip statements. + let module = lower_src( + r#" + const o: any = { a: 1 }; + const y = 2; + o.b = y; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(1), + "the populated literal must keep its single property: {:?}", + module.init + ); + assert_eq!( + runtime_set_keys(&module.init), + vec!["b".to_string()], + "the store must survive as a dynamic [[Set]]: {:?}", + module.init + ); +} + +#[test] +fn an_adjacent_builder_still_folds_into_a_populated_literal() { + // The pre-#10353 behaviour is unchanged when there is no gap. + let module = lower_src( + r#" + const o: any = { a: 1 }; + o.b = 2; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(2), + "an adjacent store should still fold: {:?}", + module.init + ); + assert!( + runtime_set_keys(&module.init).is_empty(), + "no store should survive as a dynamic [[Set]]: {:?}", + runtime_set_keys(&module.init) + ); +} + +#[test] +fn a_type_only_declaration_in_the_gap_is_skipped() { + let module = lower_src( + r#" + const o: any = {}; + type Width = number; + interface Shape { w: Width } + const x: Width = 1; + o.p1 = x; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(1), + "erased declarations should not block the fold: {:?}", + module.init + ); +} + +#[test] +fn an_enum_in_the_gap_blocks_the_fold() { + // Unlike `type`/`interface`, an enum emits an initializer at run time. + let module = lower_src( + r#" + const o: any = {}; + enum Color { Red } + o.p1 = 1; + "#, + ); + + assert_eq!( + anon_shape_arity(&module.init, "o"), + Some(0), + "an enum in the gap must block the fold: {:?}", + module.init + ); +} diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index f5fbd30a8b..72a5a7c0d2 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -203,22 +203,22 @@ unsafe fn with_shape_shared_descriptor( // ONE shape-table probe (#8122). This used to be two — one for the keys // edge (the retired `object_keys_array_ptr`) and one here for the live // bound — on every field store that reaches it and on every traced object. - let descriptor = crate::object::shapes::object_shape_descriptor(object); - with_shape_shared_descriptor_from(user_ptr, descriptor, f) + let shape = crate::object::shapes::object_shape_record(object); + with_shape_shared_descriptor_from(user_ptr, shape, f) } -/// [`with_shape_shared_descriptor`] against a receiver `ShapeDescriptor` the +/// [`with_shape_shared_descriptor`] against a receiver shape record the /// caller has already resolved (or found absent). The receiver MUST be an /// ObjectFields object — this skips the kind screen the probing form applies. /// -/// #8122: the collector's per-object path resolves the descriptor once in +/// #8122: the collector's per-object path resolves the record once in /// `gc_child_slots` and hands it down here through /// [`HeapChildSlotIterator::new_object`], instead of re-probing the shape /// table for the keys edge and again for the live bound. #[inline] unsafe fn with_shape_shared_descriptor_from( user_ptr: usize, - descriptor: Option, + shape: Option, f: impl Fn(&TypedLayoutDescriptor) -> R, ) -> Option { let object = user_ptr as *const crate::object::ObjectHeader; @@ -228,10 +228,8 @@ unsafe fn with_shape_shared_descriptor_from( } // Defense-in-depth: both descriptor families must agree on the exact live // bound. #8113: an unstamped receiver has no bound anywhere, so 0 — not a - // second probe (`unwrap_or` is eager). - let field_count = descriptor - .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or(0); + // second probe (`map_or`'s default is eager). + let field_count = shape.map_or(0, |shape| shape.live_inline_slot_count() as usize); if shape_layout_keyed_enabled() { let map = hot_shape_layouts().borrow(); if let Some(desc) = map.get(&shape_id) { @@ -320,19 +318,18 @@ unsafe fn shape_shared_pointer_mask( with_shape_shared_descriptor(user_ptr, |d| d.pointer_mask.clone()) } -/// [`shape_shared_pointer_mask`] for an ObjectFields receiver whose -/// `ShapeDescriptor` the caller already resolved (#8122, see -/// [`with_shape_shared_descriptor_from`]). +/// [`shape_shared_pointer_mask`] for an ObjectFields receiver whose shape record +/// the caller already resolved (#8122, see [`with_shape_shared_descriptor_from`]). #[inline] unsafe fn shape_shared_pointer_mask_from( user_ptr: usize, header: *const GcHeader, - descriptor: Option, + shape: Option, ) -> Option { if (*header)._reserved & GC_OBJ_TYPED_LAYOUT_INTACT == 0 { return None; } - with_shape_shared_descriptor_from(user_ptr, descriptor, |d| d.pointer_mask.clone()) + with_shape_shared_descriptor_from(user_ptr, shape, |d| d.pointer_mask.clone()) } /// Install `descriptor` as the canonical layout for `shape_id` and set the @@ -1525,12 +1522,12 @@ pub(crate) struct HeapChildSlotIterator { pub(super) meta_slot2: Option<*mut u64>, pub(super) payload: HeapSlotRange, pub(super) selection: HeapPayloadSlotSelection, - /// #8122: the receiver's `ShapeDescriptor`, resolved ONCE by - /// [`gc_child_slots`] for an ObjectFields object and carried here so - /// `visit_gc_layout_slot_descriptors` reads the same facts instead of + /// #8122: the receiver's shape record, resolved ONCE by [`gc_child_slots`] + /// for an ObjectFields object and borrowed here in place (#10362), so + /// `visit_gc_layout_slot_descriptors` reads the same record instead of /// probing the shape table again. `None` for every other kind, and for /// an unstamped object. - pub(super) object_shape: Option, + pub(super) object_shape: Option, } impl HeapChildSlotIterator { @@ -1561,7 +1558,7 @@ impl HeapChildSlotIterator { } } - /// [`Self::new`] for an ObjectFields receiver whose `ShapeDescriptor` the + /// [`Self::new`] for an ObjectFields receiver whose shape record the /// caller already resolved (#8122). The payload-mask selection reuses it /// instead of probing the shape table, and it is retained on the iterator /// for the slot visitor. @@ -1569,7 +1566,7 @@ impl HeapChildSlotIterator { header: *mut GcHeader, prefix_slot: Option<*mut u64>, payload: HeapSlotRange, - object_shape: Option, + object_shape: Option, ) -> Self { let selection = unsafe { heap_payload_slot_selection_from(header, payload, object_shape) }; Self { @@ -1718,16 +1715,16 @@ pub(super) unsafe fn heap_payload_slot_selection( }) } -/// [`heap_payload_slot_selection`] for an ObjectFields receiver whose -/// `ShapeDescriptor` the caller already resolved (#8122): the shared-shape +/// [`heap_payload_slot_selection`] for an ObjectFields receiver whose shape +/// record the caller already resolved (#8122): the shared-shape /// pointer-mask lookup reuses it instead of probing the shape table twice. pub(super) unsafe fn heap_payload_slot_selection_from( header: *mut GcHeader, payload: HeapSlotRange, - descriptor: Option, + shape: Option, ) -> HeapPayloadSlotSelection { heap_payload_slot_selection_impl(header, payload, |user_ptr, header| { - shape_shared_pointer_mask_from(user_ptr, header, descriptor) + shape_shared_pointer_mask_from(user_ptr, header, shape) }) } @@ -1791,6 +1788,8 @@ unsafe fn heap_payload_slot_selection_impl( } } +/// #10362: every arm returns the iterator it builds, never through an `Option` +/// combinator whose temporary is copied out — a per-object memmove per GC walk. pub(super) unsafe fn gc_child_slots(header: *mut GcHeader) -> HeapChildSlotIterator { if header.is_null() || (*header).gc_flags & GC_FLAG_FORWARDED != 0 { return HeapChildSlotIterator::empty(); @@ -1799,20 +1798,21 @@ pub(super) unsafe fn gc_child_slots(header: *mut GcHeader) -> HeapChildSlotItera match gc_type_layout_slot_kind((*header).obj_type) { GcLayoutSlotKind::ArrayElements => { let arr = user_ptr as *mut crate::array::ArrayHeader; - crate::array::gc_element_slot_range(arr) - .map(|range| HeapChildSlotIterator::new(header, None, range)) - .unwrap_or_else(HeapChildSlotIterator::empty) + let Some(range) = crate::array::gc_element_slot_range(arr) else { + return HeapChildSlotIterator::empty(); + }; + HeapChildSlotIterator::new(header, None, range) } GcLayoutSlotKind::ObjectFields => { let obj = user_ptr as *mut crate::object::ObjectHeader; - // #8122: resolve the receiver's ShapeDescriptor ONCE and thread it + // #8122: resolve the receiver's shape record ONCE and thread it // through every step that needs a shape fact — the field range, // the keys edge, the shared pointer mask (`new_object`) and the // slot visitor (`object_shape` on the iterator). These used to be // five independent `shape_descriptor_by_id` probes per traced // object, the top leaf of a traced in-place-promotion cycle. - let descriptor = crate::object::shapes::object_shape_descriptor(obj); - let Some(range) = crate::object::gc_field_slot_range(obj, descriptor) else { + let shape = crate::object::shapes::object_shape_record(obj); + let Some(range) = crate::object::gc_field_slot_range(obj, shape) else { return HeapChildSlotIterator::empty(); }; // #6812: the meta record is a raw-pointer child edge; before the @@ -1821,7 +1821,7 @@ pub(super) unsafe fn gc_child_slots(header: *mut GcHeader) -> HeapChildSlotItera // which are usually rooted elsewhere; fatal for the spill // buffer, reachable through meta alone). A second prefix slot // keeps payload slot indices aligned with the layout masks. - HeapChildSlotIterator::new_object(header, None, range, descriptor) + HeapChildSlotIterator::new_object(header, None, range, shape) .with_meta_slot(crate::object::gc_object_meta_slot(user_ptr as usize)) } GcLayoutSlotKind::RegExpFields => { @@ -1858,9 +1858,10 @@ pub(super) unsafe fn gc_child_slots(header: *mut GcHeader) -> HeapChildSlotItera } GcLayoutSlotKind::ClosureCaptures => { let closure = user_ptr as *mut crate::closure::ClosureHeader; - crate::closure::gc_capture_slot_range(closure) - .map(|range| HeapChildSlotIterator::new(header, None, range)) - .unwrap_or_else(HeapChildSlotIterator::empty) + let Some(range) = crate::closure::gc_capture_slot_range(closure) else { + return HeapChildSlotIterator::empty(); + }; + HeapChildSlotIterator::new(header, None, range) } GcLayoutSlotKind::None => HeapChildSlotIterator::empty(), } diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index 02d73fc689..68ef1bccb0 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -30,10 +30,10 @@ pub(super) unsafe fn visit_gc_layout_slot_descriptors( visit(fixed_slot(slot)); }); } - // #8112: the authoritative ordered-keys edge, taken from the descriptor + // #8112: the authoritative ordered-keys edge, taken from the shape record // `gc_child_slots` already resolved for this receiver. It is the boxed // record's OWN `keys` word, so the collector marks through it and rewrites - // it in place — the descriptor is the root and the rewritable location. + // it in place — the record is the root and the rewritable location. // // Never enumerate the HashMap BUCKET as a GC slot: dirty-page work may // retain enumerated slot addresses across budgeted resumptions, during @@ -117,7 +117,9 @@ pub(super) unsafe fn visit_gc_layout_slot_descriptors( }); } HeapPayloadSlotScan::Masked => { - for child_slot in child_slots { + // Iterate by reference: `for .. in child_slots` moves the iterator + // into the loop, a copy per traced object (#10362). + for child_slot in &mut child_slots { if let HeapChildSlot::Child(slot, layout_kind) = child_slot { visit(GcMutableSlotDescriptor::Slot(GcMutableSlot::new( slot, diff --git a/crates/perry-runtime/src/object/field_set_by_name/tail.rs b/crates/perry-runtime/src/object/field_set_by_name/tail.rs index d58f513025..a929895ab1 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/tail.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/tail.rs @@ -270,11 +270,18 @@ pub(crate) fn set_field_by_name_object_tail( // diverges from its class chain (per-instance `setPrototypeOf` // override, null-proto) never records or honors a plan. // Flags that make an object ineligible for class-keyed plans: a - // diverging chain (per-instance proto override / null proto) or own - // descriptors (an own accessor must dispatch through the short-circuit - // below, which a plan hit skips). - const PLAN_BLOCKING_FLAGS: u16 = - crate::gc::OBJ_FLAG_NULL_PROTO | crate::gc::OBJ_FLAG_HAS_DESCRIPTORS; + // diverging chain (per-instance proto override / null proto). + // + // Own descriptors used to be a wholesale disqualifier here for a real + // reason — an own accessor must dispatch through the short-circuit + // below, which a plan hit skips. But that is a per-KEY fact, not a + // per-receiver one (#10287). zod puts `_zod` on every schema object, + // so the object-level flag denied a plan to every one of them and made + // each store re-run the whole interception vet: the chain walk, the + // class-registry lookups and the `Object.prototype` probe. The plan is + // now denied only for the keys an own descriptor can actually cover, + // which `own_descriptors_skip_key` decides exactly. + const PLAN_BLOCKING_FLAGS: u16 = crate::gc::OBJ_FLAG_NULL_PROTO; let obj_class_id = (*obj).class_id; // #6595: class objects are excluded by their authoritative ShapeId // kind — their @@ -286,6 +293,14 @@ pub(crate) fn set_field_by_name_object_tail( && obj_class_id != NATIVE_MODULE_CLASS_ID && crate::object::object_is_regular(obj) && (*gc_header)._reserved & PLAN_BLOCKING_FLAGS == 0 + // Per-key, not per-receiver: a descriptor on some OTHER key cannot + // intercept this one, and a plan hit skips the own-accessor + // short-circuit below, so the key must be provably uncovered. + && ((*gc_header)._reserved & crate::gc::OBJ_FLAG_HAS_DESCRIPTORS == 0 + || crate::object::own_descriptors_skip_key( + obj as usize, + f64::from_bits(JSValue::string_ptr(key as *mut _).bits()), + )) && !super::prototype_chain::object_has_prototype_divergence(obj as usize); let plan_fast = plan_eligible && super::prop_plan::store_plan_check(obj_class_id, interned_key as usize); @@ -525,6 +540,9 @@ pub(crate) fn set_field_by_name_object_tail( && obj_class_id != NATIVE_MODULE_CLASS_ID && crate::object::object_is_regular(obj) && obj_flags & PLAN_BLOCKING_FLAGS == 0 + // `desc_gate_ok` above already proved this key is uncovered on + // this receiver, which is the per-key half of the old flag. + && desc_gate_ok && !super::prototype_chain::object_has_prototype_divergence(obj as usize); if !plan_fast && record_plan_eligible { super::prop_plan::store_plan_record(obj_class_id, interned_key as usize); diff --git a/crates/perry-runtime/src/object/gc_slots.rs b/crates/perry-runtime/src/object/gc_slots.rs index 4dd728aeef..a47cbc5c87 100644 --- a/crates/perry-runtime/src/object/gc_slots.rs +++ b/crates/perry-runtime/src/object/gc_slots.rs @@ -3,10 +3,10 @@ use crate::ArrayHeader; /// The AUTHORITATIVE ordered-keys edge of a traced receiver (#8112). /// -/// This is the descriptor's own `keys` word, not a copy of it: the record is -/// boxed (`object::shapes::ShapeDescriptor`), so its address is fixed for the +/// This is the shape record's own `keys` word, not a copy of it: the record is +/// boxed (`object::shapes::ShapeRecordRef`), so its address is fixed for the /// record's lifetime and the collector can mark through it and rewrite it in -/// place like any other child slot. The address rides along on the descriptor +/// place like any other child slot. The address is the record handle /// `gc::layout::gc_child_slots` already resolved for this receiver, so the /// edge costs no extra shape-table probe (#8122's one-probe rule) and needs no /// post-visit write-back callback. @@ -20,9 +20,7 @@ use crate::ArrayHeader; /// deliberate — the alternative loses a shape whose only carrier is promoted /// during the very drain that would have emitted it. #[inline] -pub(crate) fn gc_shape_keys_edge_slot( - descriptor: Option, -) -> Option<*mut u64> { +pub(crate) fn gc_shape_keys_edge_slot(record: Option) -> Option<*mut u64> { #[cfg(test)] if shapes::test_keys_edge_suppressed() { // Sabotage arm: without this edge a keys array has no root and no @@ -31,18 +29,18 @@ pub(crate) fn gc_shape_keys_edge_slot( // the detector works, not that nothing was tried. return None; } - let descriptor = descriptor?; - if descriptor.keys == 0 { + let record = record?; + if record.keys() == 0 { return None; } - descriptor.keys_slot() + Some(record.keys_slot()) } -/// The object's inline field-slot range, given the receiver's `ShapeDescriptor` +/// The object's inline field-slot range, given the receiver's shape record /// resolved once by the collector. pub(crate) unsafe fn gc_field_slot_range( obj: *mut ObjectHeader, - descriptor: Option, + record: Option, ) -> Option { if obj.is_null() { return None; @@ -56,8 +54,8 @@ pub(crate) unsafe fn gc_field_slot_range( // escapes (`object/alloc.rs`), and every bound change is mint-then-stamp // (`shapes::publish_object_live_slot_count`), so a live object is never // observed here without one. - let field_count = descriptor - .map(|descriptor| descriptor.live_inline_slot_count as usize) + let field_count = record + .map(|record| record.live_inline_slot_count() as usize) .unwrap_or(0); if field_count > 1_000_000 { return None; diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index 8d7d435b0a..de30fc8f15 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -138,16 +138,61 @@ pub(crate) struct ShapeDescriptor { /// lifted out of the table compares equal to the record it came from. impl ShapeDescriptor { /// The one `keys` word the collector rewrites for this shape, or `None` - /// for a descriptor value that was never lifted out of the table. - /// - /// `keys` is the first field of the `#[repr(C)]` slab record, so the - /// record address IS the slot address. + /// for a descriptor value that was never lifted out of the table. The + /// collector itself asks [`ShapeRecordRef::keys_slot`] (#10362). + #[cfg(test)] #[inline] pub(crate) fn keys_slot(&self) -> Option<*mut u64> { - if self.record == 0 { - return None; - } - Some(self.record as *mut u64) + self.record_ref().map(ShapeRecordRef::keys_slot) + } + + /// The slab record this value was lifted from, or `None` for a descriptor + /// built outside the table. + #[inline] + pub(crate) fn record_ref(&self) -> Option { + std::ptr::NonNull::new(self.record as *mut ShapeRecord).map(ShapeRecordRef) + } +} + +/// One live slab record, borrowed in place rather than lifted (#10362). +/// +/// The collector asks three things of a traced receiver's shape: the live +/// inline-slot bound, the record's own `keys` word (the rewritable edge, +/// #8112), and the record's liveness bits. All three live in the record, so it +/// resolves this handle ONCE per receiver (#8122's one-probe rule) and threads +/// it through every step instead of a lifted [`ShapeDescriptor`]. The lifted +/// copy is 40 bytes and rode on the per-object `HeapChildSlotIterator`, which +/// made that iterator too large to move without an out-of-line `memmove`. +/// +/// Validity is exactly `ShapeDescriptor::record`'s, which the carrier notes +/// already write through: record addresses never move (#9706), and a record's +/// chunk is released only by `shrink_shape_tables` at the end of a major +/// collection, after every enumeration of the cycle that resolved it. +#[derive(Clone, Copy)] +pub(crate) struct ShapeRecordRef(std::ptr::NonNull); + +impl ShapeRecordRef { + /// The record's live inline-slot bound — the same fact a lifted + /// descriptor's `live_inline_slot_count` copies. + #[inline] + pub(crate) fn live_inline_slot_count(self) -> u32 { + // SAFETY: a live slab record (type docs). + unsafe { (*self.0.as_ptr()).live_inline_slot_count } + } + + /// The record's current `keys` word (0 for a keyless shape). + #[inline] + pub(crate) fn keys(self) -> u64 { + // SAFETY: a live slab record (type docs). + unsafe { (*self.0.as_ptr()).keys } + } + + /// The `keys` word's address: the slot the collector marks through and + /// rewrites in place. `keys` is the first field of the `#[repr(C)]` slab + /// record, so the record address IS the slot address. + #[inline] + pub(crate) fn keys_slot(self) -> *mut u64 { + self.0.as_ptr() as *mut u64 } } @@ -684,6 +729,14 @@ pub(crate) fn shape_descriptor_by_id(shape_id: u32) -> Option { crate::state::state().shapes.slab().lift(shape_id) } +/// The record named by `shape_id`, borrowed in place: the same slab probe as +/// [`shape_descriptor_by_id`], without lifting a copy (#10362). +#[inline] +pub(crate) fn shape_record_by_id(shape_id: u32) -> Option { + let record = crate::state::state().shapes.slab().record_ptr(shape_id)?; + std::ptr::NonNull::new(record).map(ShapeRecordRef) +} + /// Immutable ordinary-vs-class fact with a pointer-free, per-agent direct /// cache. The first observation remains the authoritative descriptor lookup_ways; /// subsequent observations avoid the hot ShapeId HashMap borrow. @@ -699,7 +752,7 @@ pub(crate) fn shape_object_kind_by_id(shape_id: u32) -> Option /// Record that a shape is carried by an OLD-generation receiver. /// -/// Called from the collector's slot visitor, which resolved the descriptor for +/// Called from the collector's slot visitor, which resolved the record for /// this receiver already, so the note costs a generation range check and a /// byte store — no second shape-table probe (#8122's one-probe rule). The /// store goes straight through the boxed record's address rather than @@ -708,25 +761,23 @@ pub(crate) fn shape_object_kind_by_id(shape_id: u32) -> Option /// /// # Safety /// -/// `descriptor.record`, when non-zero, is the address of a live slab record -/// owned by this agent's shape table. Records are retired only by the table's +/// `record` is a live slab record owned by this agent's shape table (the +/// contract on [`ShapeRecordRef`]). Records are retired only by the table's /// own retirement paths, and their chunk is released by /// `shrink_shape_tables` at the end of a major collection — after every -/// enumeration of the cycle that produced this descriptor. +/// enumeration of the cycle that resolved this record. #[inline] -pub(crate) unsafe fn note_old_generation_carrier(descriptor: Option) { - let Some(descriptor) = descriptor else { +pub(crate) unsafe fn note_old_generation_carrier(record: Option) { + let Some(record) = record else { return; }; - if descriptor.record == 0 { - return; - } - let record = descriptor.record as *mut ShapeRecord; + let keys = record.keys(); + let record = record.0.as_ptr(); let first_note_this_epoch = !(*record).has(RECORD_FLAG_OLD_CARRIER_SEEN); // GC_STORE_AUDIT(POINTER_FREE): liveness bookkeeping bits, never a heap reference. (*record).set(RECORD_FLAG_OLD_CARRIER | RECORD_FLAG_OLD_CARRIER_SEEN, true); if first_note_this_epoch { - note_shape_carrier_candidate(descriptor.keys); + note_shape_carrier_candidate(keys); } } @@ -734,13 +785,11 @@ pub(crate) unsafe fn note_old_generation_carrier(descriptor: Option) { - let Some(descriptor) = descriptor else { +pub(crate) unsafe fn note_full_trace_carrier(record: Option) { + let Some(record) = record else { return; }; - if descriptor.record != 0 { - (*(descriptor.record as *mut ShapeRecord)).set(RECORD_FLAG_CARRIED_SEEN, true); - } + (*record.0.as_ptr()).set(RECORD_FLAG_CARRIED_SEEN, true); } #[inline] @@ -808,14 +857,14 @@ pub(crate) unsafe fn stamp_object_shape_id_with_carrier_note( ) { (*obj).parent_class_id = id; if !crate::arena::pointer_in_nursery(obj as usize) { - let descriptor = shape_descriptor_by_id(id); - note_old_generation_carrier(descriptor); + let record = shape_record_by_id(id); + note_old_generation_carrier(record); // This stamp is the structural-mutation publication funnel. Re-arm // even when the descriptor was already an old carrier: an owned // Longlived keys array may have just gained a nursery key at the same // address, and its carrier flag alone cannot express that transition. - if let Some(descriptor) = descriptor { - note_shape_carrier_candidate(descriptor.keys); + if let Some(record) = record { + note_shape_carrier_candidate(record.keys()); } } } @@ -1310,7 +1359,7 @@ pub(crate) unsafe fn try_birth_stamp_preinstalled_shape( } (*obj).parent_class_id = runtime_shape_id; if !crate::arena::pointer_in_nursery(obj as usize) { - note_old_generation_carrier(Some(descriptor)); + note_old_generation_carrier(descriptor.record_ref()); } debug_assert_object_shape_parity(obj); true @@ -1747,6 +1796,14 @@ pub(crate) unsafe fn object_shape_descriptor( shape_descriptor_by_id(object_shape_stamp(obj)) } +/// [`object_shape_descriptor`]'s record, borrowed in place (#10362). +#[inline] +pub(crate) unsafe fn object_shape_record( + obj: *const crate::object::ObjectHeader, +) -> Option { + shape_record_by_id(object_shape_stamp(obj)) +} + #[inline] pub(crate) unsafe fn object_shape_id(obj: *const crate::object::ObjectHeader) -> u32 { object_shape_descriptor(obj) diff --git a/crates/perry/tests/builder_fold_gap_semantics.rs b/crates/perry/tests/builder_fold_gap_semantics.rs new file mode 100644 index 0000000000..1909593d75 --- /dev/null +++ b/crates/perry/tests/builder_fold_gap_semantics.rs @@ -0,0 +1,137 @@ +//! #10353: the builder fold may skip statements between a `{}` binding and +//! its stores, which sinks the ALLOCATION below them. These are the observable +//! consequences — the HIR tests in `perry-hir/tests/builder_fold_gap.rs` pin +//! which shapes fold, this file pins that folding them changes nothing a +//! program can see. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(source: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir.path()) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed (exit {:?})\nstdout:\n{}\nstderr:\n{}", + run.status.code(), + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +#[test] +fn a_skipped_constant_binding_leaves_the_object_unchanged() { + // The issue's repro plus the shape observations a fold could disturb: + // key order, own-property-ness and `in` must all match the unfolded form. + let stdout = compile_and_run( + r#" +function build(): any { + const o: any = {}; + const x = 1; + o.p1 = x; o.p2 = x + 1; o.p3 = x + 2; + return o; +} +const o = build(); +console.log(JSON.stringify(o)); +console.log(Object.keys(o).join(",")); +console.log("p1" in o, "p9" in o, Object.prototype.hasOwnProperty.call(o, "p3")); +"#, + ); + assert_eq!( + stdout, + "{\"p1\":1,\"p2\":2,\"p3\":3}\np1,p2,p3\ntrue false true\n" + ); +} + +#[test] +fn skipped_bindings_still_run_before_the_literal() { + let stdout = compile_and_run( + r#" +const o: any = {}; +const a = 2; +const b = a * 3; +o.sum = a + b; +o.b = b; +console.log(JSON.stringify(o)); +"#, + ); + assert_eq!(stdout, "{\"sum\":8,\"b\":6}\n"); +} + +#[test] +fn a_gap_statement_that_reads_the_binding_still_sees_the_object() { + // `peek()` does not name `o` at the call site, but it reads it. Sinking + // the allocation below the call would make this a TDZ ReferenceError. + let stdout = compile_and_run( + r#" +function peek(): any { return o; } +const o: any = {}; +const seen = peek(); +o.p1 = 1; +console.log(seen === o, seen.p1, JSON.stringify(o)); +"#, + ); + assert_eq!(stdout, "true 1 {\"p1\":1}\n"); +} + +#[test] +fn an_alias_taken_in_the_gap_still_observes_the_stores() { + let stdout = compile_and_run( + r#" +const o: any = {}; +const alias = o; +o.p1 = 1; +o.p2 = 2; +console.log(alias === o, JSON.stringify(alias)); +"#, + ); + assert_eq!(stdout, "true {\"p1\":1,\"p2\":2}\n"); +} + +#[test] +fn a_populated_literal_keeps_its_own_evaluation_order() { + let stdout = compile_and_run( + r#" +function run(): string { + try { + const o: any = { a: (y as any) }; + const y = 1; + o.b = 2; + return JSON.stringify(o); + } catch (e: any) { + return "threw " + e.name; + } +} +console.log(run()); +"#, + ); + assert_eq!(stdout, "threw ReferenceError\n"); +} diff --git a/crates/perry/tests/descriptor_store_fast_paths.rs b/crates/perry/tests/descriptor_store_fast_paths.rs index b1823277e0..067dcfe75d 100644 --- a/crates/perry/tests/descriptor_store_fast_paths.rs +++ b/crates/perry/tests/descriptor_store_fast_paths.rs @@ -217,3 +217,46 @@ console.log(`${b.k2} ${JSON.stringify(Object.keys(b))}`); undefined 1\n42 [\"k1\",\"k2\",\"k3\"]\n" ); } + +/// The store-plan cache is vetted per KEY rather than per receiver (#10287), +/// so a receiver carrying a descriptor can hold a plan for its other keys. +/// A plan hit skips the own-accessor short-circuit, which is exactly what must +/// NOT happen for a key the receiver does own an accessor on — so warm the +/// plan for this class on many receivers first, then prove the accessor still +/// dispatches and a non-writable data descriptor is still respected. +/// Expectations verified against Node 26 first. +#[test] +fn a_warmed_store_plan_still_dispatches_an_own_accessor() { + let dir = tempfile::tempdir().unwrap(); + let out = run( + dir.path(), + r#" +class C {} +const make = (tag) => { + const o = new C(); + Object.defineProperty(o, "_zod", { value: tag, enumerable: false }); + const seen = []; + Object.defineProperty(o, "acc", { + set(v) { seen.push(v); }, get() { return seen.length; }, configurable: true, + }); + o.__seen = seen; + return o; +}; +for (let i = 0; i < 300; i++) { const w = make(i); w.plain = i; } +const a = make("a"); +a.plain = 1; +a.acc = "x"; a.acc = "y"; +console.log(JSON.stringify(a.__seen) + " " + a.acc + " " + + typeof Object.getOwnPropertyDescriptor(a, "acc").set); +console.log(a.plain + " " + a._zod + " " + JSON.stringify(Object.keys(a))); +const b = make("b"); +Object.defineProperty(b, "ro", { value: 1, writable: false, configurable: true }); +b.ro = 99; +console.log(String(b.ro)); +"#, + ); + assert_eq!( + out, + "[\"x\",\"y\"] 2 function\n1 a [\"__seen\",\"plain\"]\n1\n" + ); +}