From 40edb278ecb23186a359666c42d5c5aafc7f1093 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:34:57 +0000 Subject: [PATCH 1/9] ci(deps): bump taiki-e/install-action from 2.87.3 to 2.87.8 Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.87.3 to 2.87.8. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/0758d235715de2f3551eacc980d9ae8fce9342c3...d438492cf8a250514fa2d34b30bc3c0dc37c65ff) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.87.8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 30144428585023079e9071155a8f7c0998650201 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:10:12 +0000 Subject: [PATCH 2/9] deps(deps): bump the cargo-minor-and-patch group across 1 directory with 12 updates Bumps the cargo-minor-and-patch group with 12 updates in the / directory: | Package | From | To | | --- | --- | --- | | [aho-corasick](https://github.com/BurntSushi/aho-corasick) | `1.1.4` | `1.1.5` | | [toml](https://github.com/toml-rs/toml) | `1.1.4+spec-1.1.0` | `1.1.5+spec-1.1.0` | | [tokio-rustls](https://github.com/rustls/tokio-rustls) | `0.26.4` | `0.26.5` | | [rustls](https://github.com/rustls/rustls) | `0.23.43` | `0.23.44` | | [hickory-proto](https://github.com/hickory-dns/hickory-dns) | `0.26.1` | `0.26.2` | | [cc](https://github.com/rust-lang/cc-rs) | `1.4.4` | `1.4.5` | | [encoding_rs](https://github.com/hsivonen/encoding_rs) | `0.8.35` | `0.8.41` | | [lru](https://github.com/jeromefroe/lru-rs) | `0.18.3` | `0.18.4` | | [redis](https://github.com/redis-rs/redis-rs) | `1.6.0` | `1.7.0` | | [mongodb](https://github.com/mongodb/mongo-rust-driver) | `3.8.2` | `3.9.0` | | [der](https://github.com/RustCrypto/formats) | `0.8.1` | `0.8.2` | | [indexmap](https://github.com/indexmap-rs/indexmap) | `2.14.1` | `2.14.2` | Updates `aho-corasick` from 1.1.4 to 1.1.5 - [Commits](https://github.com/BurntSushi/aho-corasick/compare/1.1.4...1.1.5) Updates `toml` from 1.1.4+spec-1.1.0 to 1.1.5+spec-1.1.0 - [Commits](https://github.com/toml-rs/toml/compare/toml-v1.1.4...toml-v1.1.5) Updates `tokio-rustls` from 0.26.4 to 0.26.5 - [Release notes](https://github.com/rustls/tokio-rustls/releases) - [Commits](https://github.com/rustls/tokio-rustls/compare/v/0.26.4...v/0.26.5) Updates `rustls` from 0.23.43 to 0.23.44 - [Release notes](https://github.com/rustls/rustls/releases) - [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md) - [Commits](https://github.com/rustls/rustls/compare/v/0.23.43...v/0.23.44) Updates `hickory-proto` from 0.26.1 to 0.26.2 - [Release notes](https://github.com/hickory-dns/hickory-dns/releases) - [Changelog](https://github.com/hickory-dns/hickory-dns/blob/main/CHANGELOG.md) - [Commits](https://github.com/hickory-dns/hickory-dns/compare/v0.26.1...v0.26.2) Updates `cc` from 1.4.4 to 1.4.5 - [Release notes](https://github.com/rust-lang/cc-rs/releases) - [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md) - [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.4.4...cc-v1.4.5) Updates `encoding_rs` from 0.8.35 to 0.8.41 - [Commits](https://github.com/hsivonen/encoding_rs/compare/v0.8.35...v0.8.41) Updates `lru` from 0.18.3 to 0.18.4 - [Changelog](https://github.com/jeromefroe/lru-rs/blob/master/CHANGELOG.md) - [Commits](https://github.com/jeromefroe/lru-rs/compare/0.18.3...0.18.4) Updates `redis` from 1.6.0 to 1.7.0 - [Release notes](https://github.com/redis-rs/redis-rs/releases) - [Commits](https://github.com/redis-rs/redis-rs/compare/redis-1.6.0...redis-1.7.0) Updates `mongodb` from 3.8.2 to 3.9.0 - [Release notes](https://github.com/mongodb/mongo-rust-driver/releases) - [Commits](https://github.com/mongodb/mongo-rust-driver/compare/v3.8.2...v3.9.0) Updates `der` from 0.8.1 to 0.8.2 - [Commits](https://github.com/RustCrypto/formats/compare/der/v0.8.1...der/v0.8.2) Updates `indexmap` from 2.14.1 to 2.14.2 - [Changelog](https://github.com/indexmap-rs/indexmap/blob/main/RELEASES.md) - [Commits](https://github.com/indexmap-rs/indexmap/compare/2.14.1...2.14.2) --- updated-dependencies: - dependency-name: aho-corasick dependency-version: 1.1.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch - dependency-name: cc dependency-version: 1.4.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch - dependency-name: der dependency-version: 0.8.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch - dependency-name: encoding_rs dependency-version: 0.8.40 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch - dependency-name: hickory-proto dependency-version: 0.26.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch - dependency-name: indexmap dependency-version: 2.14.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch - dependency-name: lru dependency-version: 0.18.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch - dependency-name: mongodb dependency-version: 3.9.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-minor-and-patch - dependency-name: redis dependency-version: 1.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-minor-and-patch - dependency-name: rustls dependency-version: 0.23.44 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch - dependency-name: tokio-rustls dependency-version: 0.26.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch - dependency-name: toml dependency-version: 1.1.5+spec-1.1.0 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 124 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 82 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e9311b096..3867714c39 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" @@ -6067,7 +6107,7 @@ name = "perry-ext-node-forge" version = "0.5.1581" dependencies = [ "const-oid 0.10.2", - "der 0.8.1", + "der 0.8.2", "pem 4.0.0", "perry-ffi", "perry-runtime", @@ -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", From 1a33c3ce19f4d0410e25729cf87c8cab5340ada7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 05:27:34 +0200 Subject: [PATCH 3/9] perf(object): vet the store-plan cache per key, not per receiver The store-plan cache refused any receiver carrying OBJ_FLAG_HAS_DESCRIPTORS. Every zod schema object carries `_zod`, so none ever held a plan and each of its stores re-ran the whole interception vet. The flag was there for a real reason - a plan hit skips the own-accessor short-circuit - but that is a per-KEY fact, and `desc_gate_ok` on the same path already proves the key uncovered. Vetting per key is worth -7.3% on a 300-schema zod workload and -19.7% on the 2,000-receiver fixture. The new native test warms the plan for a class across 300 receivers, then proves an own accessor on a different key still dispatches its setter and a non-writable data descriptor is still respected; it fails if a plan hit ever skips that dispatch. --- changelog.d/10287-store-plan-per-key.md | 16 +++++++ .../src/object/field_set_by_name/tail.rs | 28 +++++++++--- .../tests/descriptor_store_fast_paths.rs | 43 +++++++++++++++++++ 3 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 changelog.d/10287-store-plan-per-key.md 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/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/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" + ); +} From 27eff709ff169268aeaf56ed39f224259fd0224b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 08:42:08 +0200 Subject: [PATCH 4/9] fix(hir): fold a builder whose stores are separated from its `{}` (#10353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fold_builder_sequences` (#6812) only matched when the `o.k = v` assignments followed the `const o = {}` binding immediately, so a single ordinary declaration in between — the usual way initialisation code names its constants — dropped the whole sequence. The unfolded `{}` lowers to a 0-field `__AnonShape_…`, which denies `Ptr` containment (every key really is undeclared on a shape that declares nothing) and sends every store down `js_put_value_set`, re-interning and re-coercing the key per execution: 108,444,840 instructions against 1,400,471 for the same program with the constants written inline. The scan now skips up to 64 statements between an EMPTY literal and its first assignment, sinking the allocation below them. A statement qualifies only when moving the allocation past it is unobservable, which is the pair of conditions the value side already carries: 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 naming it in the statement, turning a successful read into a TDZ ReferenceError). Destructuring patterns, `enum`/`namespace` and populated literals are excluded; `type`/`interface` are erased and are skipped. Skipped statements keep their relative order and still run before every folded value. --- changelog.d/10355-builder-fold-gap.md | 37 ++ crates/perry-hir/src/lower/builder_fold.rs | 178 +++++++- crates/perry-hir/tests/builder_fold_gap.rs | 384 ++++++++++++++++++ .../perry/tests/builder_fold_gap_semantics.rs | 137 +++++++ 4 files changed, 716 insertions(+), 20 deletions(-) create mode 100644 changelog.d/10355-builder-fold-gap.md create mode 100644 crates/perry-hir/tests/builder_fold_gap.rs create mode 100644 crates/perry/tests/builder_fold_gap_semantics.rs 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/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/tests/builder_fold_gap.rs b/crates/perry-hir/tests/builder_fold_gap.rs new file mode 100644 index 0000000000..13ac6ea4af --- /dev/null +++ b/crates/perry-hir/tests/builder_fold_gap.rs @@ -0,0 +1,384 @@ +//! #10353: a builder whose assignments do not IMMEDIATELY follow the `{}` +//! binding must still fold into the literal — `const o = {}; const X = 1; +//! o.a = X;` is the ordinary shape of initialisation code, and leaving it +//! unfolded costs 75× (a 0-field anon shape that every store then transitions +//! dynamically through `js_put_value_set`). +//! +//! The gap is only skippable while moving the ALLOCATION below it stays +//! unobservable, so these tests pin both directions: the fold happens for +//! inert statements, and it does NOT happen for a statement that names the +//! binding, can run user code, or destructures. + +use perry_diagnostics::SourceCache; +use perry_hir::{lower_module, Expr, Stmt}; +use perry_parser::parse_typescript_with_cache; + +fn lower_src(src: &str) -> 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/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"); +} From bcb9b7588fc30c7bb1f17b38322cfbb528d4b41c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 10:37:41 +0000 Subject: [PATCH 5/9] perf(gc): borrow the shape record in the slot visitor, stop moving the iterator (#10362) Base: 33690c563 (main, includes the #10348 fix from #10352). gc_child_slots built a 152-byte HeapChildSlotIterator for every traced object, 40 bytes of it a lifted ShapeDescriptor (#8122). Two by-value moves of that iterator compiled to out-of-line 152-byte memcpy calls inside visit_gc_layout_slot_descriptors: * the Array and Closure arms' `Option::map(..).unwrap_or_else(..)` temporary, copied into the caller's slot (62% of the calls); * `for .. in child_slots` in the Masked arm, which moved the iterator into the loop (35%). On the #10362 retained-graph fixture that was 6,181,945 memcpy calls of exactly 152 bytes (counted with an LD_PRELOAD shim). After this change: 6. * object/shapes.rs: ShapeRecordRef, a Copy handle to one live slab record (live bound, keys word, keys slot). shape_record_by_id and object_shape_record make the same slab probe as the descriptor lookups, without lifting a copy. note_old_generation_carrier and note_full_trace_carrier take the handle. ShapeDescriptor::record_ref() adapts the one caller that holds a descriptor. keys_slot() is now used only by tests, so it is cfg(test). * object/gc_slots.rs: gc_shape_keys_edge_slot and gc_field_slot_range take the handle. * gc/layout.rs: the iterator carries Option (8 bytes; the iterator is now 120). The `_from` mask helpers take the handle. gc_child_slots' Array and Closure arms build the iterator directly in the return slot with let-else, as the ObjectFields arm already did. * gc/layout_slot_visit.rs: the Masked arm iterates `&mut child_slots`. 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 visit. It relies on the same record-address validity the carrier notes already write through (#9706). The #8112 old_carrier/ephemeron gate and the #9726 full-trace note are unchanged. instructions:u, min of 5, same host, base (main 33690c563) vs this: gc3 12,572,092,967 -> 12,275,653,370 -2.36% w1000 1,090,107,336 -> 1,081,645,123 -0.78% w5000 2,049,860,743 -> 2,018,013,960 -1.55% w20000 5,094,775,666 -> 4,994,573,033 -1.97% oldyoung 1,525,484,973 -> 1,503,383,712 -1.45% alloc-only 320,266,130 -> 320,266,017 0.00% All outputs match node byte for byte. PERRY_GC_FROMSPACE_SCAN_ABORT=1 is clean on all six fixtures, and it aborts on the same gc3 built at fcd108bfb. PERRY_GC_VERIFY_EVACUATION=1 on gc3 exits 0. --- crates/perry-runtime/src/gc/layout.rs | 69 +++++------ .../perry-runtime/src/gc/layout_slot_visit.rs | 8 +- crates/perry-runtime/src/object/gc_slots.rs | 24 ++-- crates/perry-runtime/src/object/shapes.rs | 115 +++++++++++++----- 4 files changed, 137 insertions(+), 79 deletions(-) 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/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) From d90ac7962ce296ea0847680f60baec56bee77555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 13:11:59 +0200 Subject: [PATCH 6/9] changelog: fragment for #10371 --- changelog.d/10371-gc-slot-iterator-memcpy.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/10371-gc-slot-iterator-memcpy.md 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. From c0c01f75131833fa0df8db4a123946f8f0d7b837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 13:05:46 +0200 Subject: [PATCH 7/9] fix(hir): an ambient `declare const/let/var` binds nothing (#10363) `declare const x: T` describes a binding the host supplies. TypeScript erases it, but perry lowered it as a real binding initialized to `undefined`. That shadowed the global it describes: reads never reached a globalThis data property or accessor, `typeof` said "undefined", and a `declare var` was reflected as a non-configurable global property, so a later `Object.defineProperty(globalThis, "x", ...)` threw. Every pass that models bindings now asks one predicate, `lower::ambient::declarator_binds_nothing`: source-position lowering (module, namespace, function body), `export declare const` (erased like `export declare function`), the module forward pre-registration and Script-var reflection, function/closure `var` hoisting and forward-capture boxing, block TDZ names, the Annex B forbidden set, and the `new Function` constant-argument scan (which recorded `declare var src` as a never-written `var` and folded `new Function(src)` to `new Function("undefined")`). Ambient names also suppress the unknown-identifier warning, like platform globals. `declare const __platform__` / `__plugins__` keep their binding: they are the documented compile-time constants the backends fold from a `Stmt::Let` with no initializer. #6871 gave every uninitialized lexical binding an explicit `undefined`, which hid them from that fold, so `__platform__` read `undefined` on every target. The ambient path restores the `init: None` shape. An `export declare const` inside a namespace is left as it was: TypeScript reads it as `N.x`, not as the global. --- crates/perry-hir/src/lower/ambient.rs | 68 ++++ crates/perry-hir/src/lower/expr_function.rs | 7 + crates/perry-hir/src/lower/fn_ctor_env.rs | 59 +++- crates/perry-hir/src/lower/lower_module_fn.rs | 8 + .../perry-hir/src/lower/lowering_context.rs | 6 +- crates/perry-hir/src/lower/mod.rs | 1 + crates/perry-hir/src/lower/module_decl.rs | 5 + .../src/lower/module_decl/namespace.rs | 3 + crates/perry-hir/src/lower/stmt.rs | 12 +- crates/perry-hir/src/lower/tests.rs | 1 + .../src/lower/tests/ambient_declare.rs | 327 ++++++++++++++++++ crates/perry-hir/src/lower_decl/block.rs | 10 + .../src/lower_decl/block/var_names.rs | 7 +- crates/perry-hir/src/lower_decl/body_stmt.rs | 5 + 14 files changed, 514 insertions(+), 5 deletions(-) create mode 100644 crates/perry-hir/src/lower/ambient.rs create mode 100644 crates/perry-hir/src/lower/tests/ambient_declare.rs 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/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 Date: Wed, 16 Sep 2026 13:24:57 +0200 Subject: [PATCH 8/9] changelog: fragment for #10374 --- changelog.d/10374-ambient-declare-binds-nothing.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/10374-ambient-declare-binds-nothing.md 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). From 06c5249840519d30e8485dac56e8ab1315d81363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 18:10:04 +0200 Subject: [PATCH 9/9] chore: release merge train 204 as v0.5.1582 --- CLAUDE.md | 2 +- Cargo.lock | 162 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 83 insertions(+), 83 deletions(-) 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 3867714c39..f9c816f59c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "1542e48011813fbdf3c075da4a4ed53ee93c816eef62e36eb5064a6fd2be10a5" [[package]] name = "perry" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "async-trait", "clap", @@ -5798,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", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1581" +version = "0.5.1582" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,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", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6039,7 +6039,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "lru", "perry-ffi", @@ -6048,7 +6048,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "chrono", "perry-ffi", @@ -6056,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "bson", "futures-util", @@ -6068,7 +6068,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "chrono", "perry-ffi", @@ -6080,7 +6080,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "nanoid", "perry-ffi", @@ -6089,7 +6089,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "bytes", "perry-ffi", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6123,7 +6123,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "lettre", "perry-ffi", @@ -6133,7 +6133,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "notify", "perry-ffi", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "printpdf", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "sqlx", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "perry-runtime", @@ -6171,7 +6171,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "governor", "perry-ffi", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "fast_image_resize", "image", @@ -6190,7 +6190,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "lazy_static", "perry-ffi", @@ -6199,7 +6199,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "perry-ffi", @@ -6219,7 +6219,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "perry-runtime", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "uuid", @@ -6236,7 +6236,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perry-ffi", "perry-validation", @@ -6245,7 +6245,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "futures-util", "lazy_static", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "brotli", "flate2", @@ -6268,7 +6268,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6278,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "perry-api-manifest", @@ -6298,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", @@ -6315,7 +6315,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "perex", "regex", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "ahash", "base64 0.22.1", @@ -6381,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", @@ -6477,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", @@ -6501,7 +6501,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "jni", @@ -6516,7 +6516,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "rand 0.10.2", "serde", @@ -6526,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", @@ -6549,7 +6549,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "block2", @@ -6566,7 +6566,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "block2", @@ -6583,7 +6583,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1581" +version = "0.5.1582" [[package]] name = "perry-ui-test" @@ -6594,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", @@ -6615,7 +6615,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "block2", @@ -6632,7 +6632,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "block2", "libc", @@ -6646,7 +6646,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "libc", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "base64 0.22.1", "libc", @@ -6678,7 +6678,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "anyhow", "base64 0.22.1", @@ -6693,7 +6693,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "idna", "regex", @@ -6703,7 +6703,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1581" +version = "0.5.1582" dependencies = [ "wasmi", ] 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"