diff --git a/CLAUDE.md b/CLAUDE.md index 13ee060916..940ea4d729 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.1594 +**Current Version:** 0.5.1595 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 387c3891aa..617abc8390 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5617,13 +5617,13 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perex" -version = "0.1.7" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc61f41aef38c94e922057977bcb33bf185ab42242188719991ecfdc0fa1fe6b" +checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1594" +version = "0.5.1595" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6039,7 +6039,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "lru", "perry-ffi", @@ -6048,7 +6048,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "chrono", "perry-ffi", @@ -6056,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "bson", "futures-util", @@ -6068,7 +6068,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "chrono", "perry-ffi", @@ -6080,7 +6080,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "nanoid", "perry-ffi", @@ -6089,7 +6089,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "bytes", "perry-ffi", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6123,7 +6123,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "lettre", "perry-ffi", @@ -6133,7 +6133,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "notify", "perry-ffi", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-ffi", "printpdf", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-ffi", "sqlx", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-ffi", "perry-runtime", @@ -6171,7 +6171,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "governor", "perry-ffi", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "fast_image_resize", "image", @@ -6190,7 +6190,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "lazy_static", "perry-ffi", @@ -6199,7 +6199,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "anyhow", "perry-ffi", @@ -6219,7 +6219,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-ffi", "perry-runtime", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-ffi", "uuid", @@ -6236,7 +6236,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-ffi", "perry-validation", @@ -6245,7 +6245,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "futures-util", "lazy_static", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "brotli", "flate2", @@ -6268,7 +6268,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6278,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "anyhow", "perry-api-manifest", @@ -6298,11 +6298,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1594" +version = "0.5.1595" [[package]] name = "perry-parser" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "anyhow", "perry-diagnostics", @@ -6315,7 +6315,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perex", "regex", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "ahash", "base64 0.22.1", @@ -6381,14 +6381,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6477,21 +6477,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "dirs", "perry-ffi", @@ -6501,7 +6501,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "base64 0.22.1", "jni", @@ -6516,7 +6516,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "rand 0.10.2", "serde", @@ -6526,7 +6526,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6549,7 +6549,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "base64 0.22.1", "block2", @@ -6566,7 +6566,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "base64 0.22.1", "block2", @@ -6583,7 +6583,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1594" +version = "0.5.1595" [[package]] name = "perry-ui-test" @@ -6594,11 +6594,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1594" +version = "0.5.1595" [[package]] name = "perry-ui-tvos" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "base64 0.22.1", "block2", @@ -6615,7 +6615,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "base64 0.22.1", "block2", @@ -6632,7 +6632,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "block2", "libc", @@ -6646,7 +6646,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "base64 0.22.1", "libc", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "base64 0.22.1", "libc", @@ -6678,7 +6678,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "anyhow", "base64 0.22.1", @@ -6693,7 +6693,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "idna", "regex", @@ -6703,7 +6703,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1594" +version = "0.5.1595" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 36f6330f74..7eed35eb9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1594" +version = "0.5.1595" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" @@ -399,7 +399,7 @@ chrono = "0.4" regex = "1.12" aho-corasick = "1.1" # The single regular-expression engine, through crates/perry-perex. -perex = "0.1.7" +perex = "0.1.9" hex = "0.4" tempfile = "3" itoa = "1.0" diff --git a/build.log b/build.log new file mode 100644 index 0000000000..525d84e940 --- /dev/null +++ b/build.log @@ -0,0 +1,682 @@ + Compiling proc-macro2 v1.0.106 + Compiling unicode-ident v1.0.24 + Compiling quote v1.0.45 + Compiling libc v0.2.189 + Compiling cfg-if v1.0.4 + Compiling version_check v0.9.5 + Compiling typenum v1.20.1 + Compiling subtle v2.6.1 + Compiling smallvec v1.15.1 + Compiling stable_deref_trait v1.2.1 + Compiling libm v0.2.16 + Compiling serde_core v1.0.229 + Compiling autocfg v1.5.1 + Compiling shlex v2.0.1 + Compiling find-msvc-tools v0.1.12 + Compiling either v1.16.0 + Compiling generic-array v0.14.7 + Compiling num-traits v0.2.19 + Compiling portable-atomic v1.13.1 + Compiling syn v2.0.117 + Compiling syn v3.0.3 + Compiling memchr v2.8.1 + Compiling getrandom v0.2.17 + Compiling jobserver v0.1.34 + Compiling cc v1.4.5 + Compiling rand_core v0.6.4 + Compiling serde v1.0.229 + Compiling critical-section v1.2.0 + Compiling pin-project-lite v0.2.17 + Compiling once_cell v1.21.4 + Compiling log v0.4.34 + Compiling writeable v0.6.4 + Compiling rand_core v0.10.1 + Compiling getrandom v0.4.2 + Compiling bytes v1.12.1 + Compiling litemap v0.8.2 + Compiling zerocopy v0.8.50 + Compiling cmov v0.5.4 + Compiling allocator-api2 v0.2.21 + Compiling const-oid v0.9.6 + Compiling ctutils v0.4.2 + Compiling scopeguard v1.2.0 + Compiling parking_lot_core v0.9.12 + Compiling lock_api v0.4.14 + Compiling siphasher v1.0.3 + Compiling utf8_iter v1.0.4 + Compiling bitflags v2.12.1 + Compiling icu_properties_data v2.2.0 + Compiling icu_normalizer_data v2.2.0 + Compiling percent-encoding v2.3.2 + Compiling getrandom v0.3.4 + Compiling parking_lot v0.12.5 + Compiling equivalent v1.0.2 + Compiling form_urlencoded v1.2.2 + Compiling anyhow v1.0.104 + Compiling num-integer v0.1.46 + Compiling tracing-core v0.1.36 + Compiling itoa v1.0.18 + Compiling cpufeatures v0.2.17 + Compiling foldhash v0.2.0 + Compiling base64ct v1.8.3 + Compiling crossbeam-utils v0.8.21 + Compiling new_debug_unreachable v1.0.6 + Compiling const-oid v0.10.2 + Compiling futures-core v0.3.34 + Compiling ahash v0.8.12 + Compiling hashbrown v0.17.1 + Compiling errno v0.3.14 + Compiling pkg-config v0.3.33 + Compiling signal-hook-registry v1.4.8 + Compiling socket2 v0.6.5 + Compiling mio v1.2.1 + Compiling futures-sink v0.3.34 + Compiling synstructure v0.13.2 + Compiling indexmap v2.14.2 + Compiling futures-io v0.3.34 + Compiling slab v0.4.12 + Compiling futures-task v0.3.34 + Compiling unicode-width v0.2.2 + Compiling rustc-hash v2.1.2 + Compiling thiserror v2.0.18 + Compiling rand v0.8.7 + Compiling swc_macros_common v1.0.1 + Compiling phf_shared v0.11.3 + Compiling object v0.37.3 + Compiling heck v0.5.0 + Compiling scoped-tls v1.0.1 + Compiling better_scoped_tls v1.0.1 + Compiling swc_visit v2.0.1 + Compiling siphasher v0.3.11 + Compiling cpufeatures v0.3.0 + Compiling zmij v1.0.21 + Compiling phf_generator v0.11.3 + Compiling unicode-id-start v1.4.0 + Compiling zeroize_derive v1.4.3 + Compiling zerofrom-derive v0.1.7 + Compiling yoke-derive v0.8.2 + Compiling serde_derive v1.0.229 + Compiling displaydoc v0.2.6 + Compiling zerovec-derive v0.11.6 + Compiling zerocopy-derive v0.8.50 + Compiling zeroize v1.8.2 + Compiling hybrid-array v0.4.12 + Compiling tracing-attributes v0.1.31 + Compiling zerofrom v0.1.8 + Compiling crypto-common v0.1.7 + Compiling block-buffer v0.10.4 + Compiling yoke v0.8.3 + Compiling tokio-macros v2.7.0 + Compiling digest v0.10.7 + Compiling zerovec v0.11.8 + Compiling zerotrie v0.2.5 + Compiling crypto-common v0.2.2 + Compiling tokio v1.53.1 + Compiling tracing v0.1.44 + Compiling tinystr v0.8.4 + Compiling potential_utf v0.1.5 + Compiling icu_locale_core v2.3.0 + Compiling icu_collections v2.2.0 + Compiling futures-macro v0.3.34 + Compiling block-padding v0.3.3 + Compiling inout v0.1.4 + Compiling cipher v0.4.4 + Compiling block-buffer v0.12.0 + Compiling thiserror-impl v2.0.18 + Compiling phf_macros v0.11.3 + Compiling futures-util v0.3.34 + Compiling digest v0.11.3 + Compiling sha2 v0.10.9 + Compiling icu_provider v2.3.1 + Compiling num-bigint v0.4.6 + Compiling icu_properties v2.2.0 + Compiling icu_normalizer v2.2.0 + Compiling triomphe v0.1.15 + Compiling phf v0.11.3 + Compiling bytes-str v0.2.7 + Compiling ast_node v5.0.0 + Compiling idna_adapter v1.2.2 + Compiling hashbrown v0.14.5 + Compiling idna v1.1.0 + Compiling swc_eq_ignore_macros v1.0.1 + Compiling url v2.5.8 + Compiling hstr v3.0.6 + Compiling from_variant v3.0.0 + Compiling hmac v0.12.1 + Compiling is-macro v0.3.7 + Compiling string_enum v1.0.2 + Compiling swc_atoms v9.0.3 + Compiling semver v1.0.28 + Compiling ar_archive_writer v0.5.1 + Compiling swc_common v18.0.1 + Compiling smartstring v1.0.1 + Compiling serde_json v1.0.151 + Compiling password-hash v0.5.0 + Compiling pem-rfc7468 v0.7.0 + Compiling static_assertions v1.1.0 + Compiling base64 v0.22.1 + Compiling psm v0.1.32 + Compiling der v0.7.10 + Compiling pbkdf2 v0.12.2 + Compiling salsa20 v0.10.2 + Compiling aes v0.8.4 + Compiling stacker v0.1.25 + Compiling seq-macro v0.3.6 + Compiling scrypt v0.11.0 + Compiling cbc v0.1.2 + Compiling cmake v0.1.58 + Compiling par-core v2.0.0 + Compiling num_cpus v1.17.0 + Compiling ryu-js v1.0.3 + Compiling dunce v1.0.5 + Compiling swc_ecma_ast v19.0.0 + Compiling spki v0.7.3 + Compiling fs_extra v1.3.0 + Compiling pkcs5 v0.7.1 + Compiling tokio-util v0.7.18 + Compiling aws-lc-sys v0.45.0 + Compiling phf_shared v0.13.1 + Compiling crossbeam-epoch v0.9.20 + Compiling rustversion v1.0.22 + Compiling fastrand v2.4.1 + Compiling pkcs8 v0.10.2 + Compiling phf_generator v0.13.1 + Compiling perry-diagnostics v0.5.1593 (/root/claude-fix-10476/crates/perry-diagnostics) + Compiling ppv-lite86 v0.2.21 + Compiling rustls-pki-types v1.14.1 + Compiling der_derive v0.8.0 + Compiling pem-rfc7468 v1.0.0 + Compiling flagset v0.4.7 + Compiling ring v0.17.14 + Compiling tinyvec_macros v0.1.1 + Compiling der v0.8.2 + Compiling aws-lc-rs v1.18.1 + Compiling icu_locale_fallback_data v2.3.0 + Compiling perry-dispatch v0.5.1593 (/root/claude-fix-10476/crates/perry-dispatch) + Compiling tinyvec v1.11.0 + Compiling rustc_version v0.4.1 + Compiling ipnet v2.12.0 + Compiling simd-adler32 v0.3.9 + Compiling arrayvec v0.7.6 + Compiling chacha20 v0.10.0 + Compiling futures-channel v0.3.32 + Compiling swc_ecma_visit v19.0.0 + Compiling swc_ecma_parser v29.0.2 + Compiling swc_ecma_parser v32.0.0 + Compiling spki v0.8.0 + Compiling http v1.4.1 + Compiling untrusted v0.9.0 + Compiling crunchy v0.2.4 + Compiling cpubits v0.1.1 + Compiling rand v0.10.2 + Compiling icu_locale_fallback v2.3.0 + Compiling block-padding v0.4.2 + Compiling signature v2.2.0 + Compiling zstd-sys v2.0.16+zstd.1.5.7 + Compiling tiny-keccak v2.0.2 + Compiling data-encoding v2.11.0 + Compiling rayon-core v1.13.0 + Compiling icu_calendar_data v2.3.0 + Compiling rustls v0.23.44 + Compiling inout v0.2.2 + Compiling swc_ecma_utils v25.0.0 + Compiling crossbeam-deque v0.8.6 + Compiling phf_codegen v0.13.1 + Compiling rand_core v0.9.5 + Compiling ff v0.13.1 + Compiling core_maths v0.1.1 + Compiling httparse v1.10.1 + Compiling swc_ecma_transforms_base v32.0.0 + Compiling glob v0.3.3 + Compiling adler2 v2.0.1 + Compiling crc32fast v1.5.0 + Compiling base16ct v0.2.0 + Compiling sec1 v0.7.3 + Compiling clang-sys v1.8.1 + Compiling perry-parser v0.5.1593 (/root/claude-fix-10476/crates/perry-parser) + Compiling calendrical_calculations v0.2.4 + Compiling group v0.13.0 + Compiling rand_chacha v0.9.0 + Compiling cipher v0.5.2 + Compiling unicode-normalization v0.1.25 + Compiling prefix-trie v0.8.4 + Compiling encoding_rs v0.8.41 + Compiling multiversion-macros v0.9.0 + Compiling combine v4.6.8 + Compiling hkdf v0.12.4 + Compiling perry-api-manifest v0.5.1593 (/root/claude-fix-10476/crates/perry-api-manifest) + Compiling sha2 v0.11.0 + Compiling crypto-bigint v0.5.5 + Compiling equator-macro v0.4.2 + Compiling concurrent-queue v2.5.0 + Compiling crossbeam-channel v0.5.15 + Compiling libmimalloc-sys v0.1.49 + Compiling zstd-safe v7.2.4 + Compiling icu_plurals_data v2.3.0 + Compiling bytemuck v1.25.0 + Compiling icu_time_data v2.3.0 + Compiling parking v2.2.1 + Compiling spin v0.9.8 + Compiling num-conv v0.2.2 + Compiling time-core v0.1.9 + Compiling perry-ui-model v0.5.1593 (/root/claude-fix-10476/crates/perry-ui-model) + Compiling icu_decimal_data v2.3.0 + Compiling lazy_static v1.5.0 + Compiling perry-hir v0.5.1593 (/root/claude-fix-10476/crates/perry-hir) + Compiling time-macros v0.2.32 + Compiling event-listener v5.4.1 + Compiling const-random-macro v0.1.16 + Compiling equator v0.4.2 + Compiling elliptic-curve v0.13.8 + Compiling multiversion v0.9.0 + Compiling hickory-proto v0.26.2 + Compiling icu_calendar v2.3.0 + Compiling rand v0.9.4 + Compiling http-body v1.0.1 + Compiling curve25519-dalek v4.1.3 + Compiling webpki-roots v1.0.9 + Compiling phf_macros v0.13.1 + Compiling hmac v0.13.0 + Compiling fixed_decimal v0.7.2 + Compiling hashbrown v0.16.1 + Compiling slotmap v1.1.1 + Compiling core_detect v1.0.0 + Compiling icu_datetime_data v2.3.0 + Compiling multiversion_no_op v1.0.0 + Compiling ryu v1.0.23 + Compiling iana-time-zone v0.1.65 + Compiling minimal-lexical v0.2.1 + Compiling deranged v0.5.8 + Compiling regex-syntax v0.8.11 + Compiling simdutf8 v0.1.5 + Compiling powerfmt v0.2.0 + Compiling icu_locale_data v2.2.0 + Compiling thiserror v1.0.69 + Compiling time v0.3.55 + Compiling regex-automata v0.4.18 + Compiling nom v7.1.3 + Compiling chrono v0.4.45 + Compiling phf v0.13.1 + Compiling tzif v0.4.1 + Compiling aligned-vec v0.6.4 + Compiling const-random v0.1.18 + Compiling rayon v1.12.0 + Compiling miniz_oxide v0.9.1 + Compiling string_cache_codegen v0.6.1 + Compiling rfc6979 v0.4.0 + Compiling derive-syn-parse v0.2.0 + Compiling thiserror-impl v1.0.69 + Compiling curve25519-dalek-derive v0.1.1 + Compiling macro_magic_core_macros v0.5.1 + Compiling miette-derive v7.6.0 + Compiling libloading v0.8.9 + Compiling inotify-sys v0.1.5 + Compiling filetime v0.2.29 + Compiling keccak v0.2.0 + Compiling ident_case v1.0.1 + Compiling jiff-tzdb v0.1.6 + Compiling resolv-conf v0.7.6 + Compiling atomic-waker v1.1.2 + Compiling bitflags v1.3.2 + Compiling precomputed-hash v0.1.1 + Compiling fnv v1.0.7 + Compiling cty v0.2.2 + Compiling regex-lite v0.1.9 + Compiling try-lock v0.2.5 + Compiling tower-service v0.3.3 + Compiling unicode-width v0.1.14 + Compiling unicode-segmentation v1.13.3 + Compiling option-ext v0.2.0 + Compiling bindgen v0.72.1 + Compiling same-file v1.0.6 + Compiling httpdate v1.0.3 + Compiling strsim v0.11.1 + Compiling zlib-rs v0.6.4 + Compiling sha1_smol v1.0.1 + Compiling winnow v1.0.3 + Compiling hex v0.4.3 + Compiling llvm-sys v221.0.1 + Compiling darling_core v0.23.0 + Compiling toml_parser v1.1.3+spec-1.1.0 + Compiling perry-runtime v0.5.1593 (/root/claude-fix-10476/crates/perry-runtime) + Compiling convert_case v0.10.0 + Compiling walkdir v2.5.0 + Compiling flate2 v1.1.10 + Compiling miette v7.6.0 + Compiling dirs-sys v0.5.0 + Compiling itertools v0.12.1 + Compiling want v0.3.1 + Compiling h2 v0.4.19 + Compiling inotify v0.9.6 + Compiling timezone_provider v0.2.6 + Compiling zstd v0.13.3 + Compiling macro_magic_core v0.5.1 + Compiling ecdsa v0.16.9 + Compiling web_atoms v0.2.4 + Compiling icu_time v2.3.0 + Compiling icu_decimal v2.3.0 + Compiling v_frame v0.3.9 + Compiling icu_plurals v2.3.0 + Compiling cexpr v0.6.0 + Compiling regex v1.13.1 + Compiling hashlink v0.11.1 + Compiling primeorder v0.13.6 + Compiling atomic v0.6.1 + Compiling pkcs8 v0.11.0 + Compiling rand_chacha v0.3.1 + Compiling num-rational v0.4.2 + Compiling sha1 v0.11.0 + Compiling icu_pattern v0.5.0 + Compiling md-5 v0.10.6 + Compiling universal-hash v0.5.1 + Compiling serde_spanned v1.1.1 + Compiling toml_datetime v1.1.1+spec-1.1.0 + Compiling ff v0.14.0 + Compiling mio v0.8.11 + Compiling nom v8.0.0 + Compiling hostname v0.4.2 + Compiling as-slice v0.2.1 + Compiling unicode-xid v0.2.6 + Compiling num-bigint-dig v0.8.6 + Compiling rustix v1.1.4 + Compiling radium v0.7.0 + Compiling unicase v2.9.0 + Compiling ixdtf v0.6.5 + Compiling xxhash-rust v0.8.18 + Compiling toml_writer v1.1.2+spec-1.1.0 + Compiling built v0.8.1 + Compiling opaque-debug v0.3.1 + Compiling base16ct v1.0.0 + Compiling shlex v1.3.0 + Compiling av-scenechange v0.14.1 + Compiling crc-catalog v2.5.0 + Compiling paste v1.0.15 + Compiling byteorder v1.5.0 + Compiling perex v0.1.7 + Compiling bytecount v0.6.9 + Compiling node-semver v2.2.0 + Compiling rav1e v0.8.1 + Compiling crc v3.4.0 + Compiling sec1 v0.8.1 + Compiling toml v1.1.5+spec-1.1.0 + Compiling temporal_rs v0.2.6 + Compiling mime_guess v2.0.5 + Compiling derive_more-impl v2.1.1 + Compiling aligned v0.4.3 + Compiling notify v6.1.1 + Compiling group v0.14.0 + Compiling icu_datetime v2.3.0 + Compiling uuid v1.26.0 + Compiling hyper v1.11.1 + Compiling icu_locale v2.2.0 + Compiling taffy v0.13.0 + Compiling macro_magic_macros v0.5.1 + Compiling mimalloc v0.1.52 + Compiling dirs v6.0.0 + Compiling darling_macro v0.23.0 + Compiling string_cache v0.9.0 + Compiling ed25519 v2.2.3 + Compiling crypto-bigint v0.7.5 + Compiling x509-cert v0.3.0 + Compiling tokio-stream v0.1.18 + Compiling async-trait v0.1.92 + Compiling profiling-procmacros v1.0.18 + Compiling arg_enum_proc_macro v0.3.4 + Compiling crossbeam-queue v0.3.12 + Compiling futures-intrusive v0.5.0 + Compiling sync_wrapper v1.0.2 + Compiling num-iter v0.1.45 + Compiling no_std_io2 v0.9.4 + Compiling unicode-bidi v0.3.18 + Compiling mime v0.3.17 + Compiling dtoa v1.0.11 + Compiling utf-8 v0.7.6 + Compiling pastey v0.1.1 + Compiling tower-layer v0.3.3 + Compiling rustc-demangle v0.1.28 + Compiling tap v1.0.1 + Compiling y4m v0.8.0 + Compiling linux-raw-sys v0.12.1 + Compiling sponge-cursor v0.1.0 + Compiling unicode-properties v0.1.4 + Compiling vcpkg v0.2.15 + Compiling unsafe-libyaml v0.2.11 + Compiling quick-error v2.0.1 + Compiling gimli v0.34.0 + Compiling dotenvy v0.15.7 + Compiling libsqlite3-sys v0.37.0 + Compiling stringprep v0.1.5 + Compiling wyz v0.5.1 + Compiling tower v0.5.3 + Compiling tendril v0.5.0 + Compiling elliptic-curve v0.14.0 + Compiling dtoa-short v0.3.5 + Compiling sqlx-core v0.9.0 + Compiling bitstream-io v4.10.0 + Compiling profiling v1.0.18 + Compiling ed25519-dalek v2.2.0 + Compiling darling v0.23.0 + Compiling hyper-util v0.1.20 + Compiling derive_more v2.1.1 + Compiling av1-grain v0.2.5 + Compiling maybe-rayon v0.1.1 + Compiling pbkdf2 v0.13.0 + Compiling aes v0.9.1 + Compiling miniz_oxide v0.8.9 + Compiling selectors v0.38.0 + Compiling rustc_version_runtime v0.3.0 + Compiling pkcs1 v0.7.5 + Compiling half v2.7.1 + Compiling md-5 v0.11.0 + Compiling signature v3.0.0 + Compiling sha1 v0.10.6 + Compiling num-derive v0.4.2 + Compiling cssparser-macros v0.7.0 + Compiling aho-corasick v1.1.5 + Compiling simd_helpers v0.1.0 + Compiling itertools v0.14.0 + Compiling funty v2.0.0 + Compiling noop_proc_macro v0.3.0 + Compiling alloc-no-stdlib v2.0.4 + Compiling weezl v0.1.12 + Compiling zune-core v0.5.1 + Compiling utf8parse v0.2.2 + Compiling imgref v1.12.3 + Compiling tagptr v0.2.0 + Compiling loop9 v0.1.5 + Compiling moka v0.12.15 + Compiling anstyle-parse v1.0.0 + Compiling zune-jpeg v0.5.15 + Compiling alloc-stdlib v0.2.2 + Compiling bitvec v1.0.1 + Compiling cssparser v0.37.0 + Compiling ed448 v0.5.0 + Compiling rsa v0.9.10 + Compiling markup5ever v0.39.0 + Compiling serde_with_macros v3.21.0 + Compiling hash2curve v0.14.0 + Compiling tower-http v0.6.11 + Compiling shake v0.1.0 + Compiling hickory-net v0.26.1 + Compiling macro_magic v0.5.1 + Compiling polyval v0.6.2 + Compiling p256 v0.13.2 + Compiling p384 v0.13.1 + Compiling simple_asn1 v0.6.4 + Compiling serde_urlencoded v0.7.1 + Compiling hkdf v0.13.0 + Compiling webpki-roots v0.26.11 + Compiling http-body-util v0.1.5 + Compiling event-listener-strategy v0.5.4 + Compiling avif-serialize v0.8.9 + Compiling zune-inflate v0.2.54 + Compiling fdeflate v0.3.7 + Compiling pem v3.0.6 + Compiling dashmap v6.2.1 + Compiling universal-hash v0.6.1 + Compiling aead v0.5.2 + Compiling typed-builder-macro v0.22.0 + Compiling raw-cpuid v11.6.0 + Compiling serde_bytes v0.11.19 + Compiling atoi v2.0.0 + Compiling servo_arc v0.4.3 + Compiling color_quant v1.1.0 + Compiling pxfm v0.1.29 + Compiling is_terminal_polyfill v1.70.2 + Compiling base64 v0.23.1 + Compiling bit_field v0.10.3 + Compiling byteorder-lite v0.1.0 + Compiling anstyle v1.0.14 + Compiling inkwell v0.9.0 + Compiling lebe v0.5.3 + Compiling rust_decimal v1.43.0 + Compiling colorchoice v1.0.5 + Compiling rgb v0.8.53 + Compiling whoami v2.1.2 + Compiling anstyle-query v1.1.5 + Compiling fax v0.2.7 + Compiling anstream v1.0.0 + Compiling tiff v0.11.3 + Compiling sqlx-postgres v0.9.0 + Compiling moxcms v0.8.1 + Compiling ravif v0.13.0 + Compiling quanta v0.12.6 + Compiling exr v1.74.0 + Compiling image-webp v0.2.4 + Compiling typed-builder v0.22.0 + Compiling gif v0.14.2 + Compiling bson v3.1.0 + Compiling polyval v0.7.3 + Compiling png v0.18.1 + Compiling jsonwebtoken v11.0.0 + Compiling async-lock v3.4.2 + Compiling hickory-resolver v0.26.1 + Compiling ghash v0.5.1 + Compiling mongodb-internal-macros v3.9.0 + Compiling ed448-goldilocks v0.14.0-pre.15 + Compiling serde_with v3.21.0 + Compiling winresource v0.1.31 + Compiling html5ever v0.39.0 + Compiling brotli-decompressor v5.0.1 + Compiling sqlx-mysql v0.9.0 + Compiling blowfish v0.10.0 + Compiling poly1305 v0.8.0 + Compiling backon v1.6.0 + Compiling sha3 v0.11.0 + Compiling qoi v0.4.1 +warning: function `relevant_box_roots` is never used + --> crates/perry-runtime/src/box.rs:1020:4 + | +1020 | fn relevant_box_roots() -> Vec { + | ^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + + Compiling arc-swap v1.9.1 + Compiling email-encoding v0.4.1 + Compiling ctr v0.9.2 + Compiling chacha20 v0.9.1 + Compiling kem v0.3.0 + Compiling module-lattice v0.2.3 + Compiling blake2 v0.10.6 + Compiling inkwell_internals v0.14.0 + Compiling derive-where v1.6.1 + Compiling console v0.16.4 + Compiling getopts v0.2.24 + Compiling num-bigint v0.5.1 + Compiling spinning_top v0.3.0 + Compiling perry-codegen v0.5.1593 (/root/claude-fix-10476/crates/perry-codegen) + Compiling web-time v1.1.0 + Compiling nonzero_ext v0.3.0 + Compiling futures-timer v3.0.4 + Compiling winnow v0.7.15 + Compiling arcstr v1.2.0 + Compiling fallible-streaming-iterator v0.1.9 + Compiling email_address v0.2.9 + Compiling quoted_printable v0.5.2 + Compiling keccak v0.1.6 + Compiling bumpalo v3.20.3 + Compiling leb128fmt v0.1.0 + Compiling fallible-iterator v0.3.0 + Compiling take_mut v0.2.2 + Compiling openssl-probe v0.2.1 + Compiling perry-native-registration v0.5.1593 (/root/claude-fix-10476/crates/perry-native-registration) + Compiling libbz2-rs-sys v0.2.5 + Compiling clap_lex v1.1.0 + Compiling ego-tree v0.11.0 + Compiling clap_builder v4.6.6 + Compiling scraper v0.27.0 + Compiling cron v0.17.0 + Compiling bzip2 v0.6.1 + Compiling perry-ffi v0.5.1593 (/root/claude-fix-10476/crates/perry-ffi) + Compiling rustls-native-certs v0.8.4 + Compiling rusqlite v0.39.0 + Compiling zopfli v0.8.3 + Compiling wasm-encoder v0.258.0 + Compiling sha3 v0.10.9 + Compiling redis v1.7.0 + Compiling governor v0.10.4 + Compiling argon2 v0.5.3 + Compiling ml-kem v0.3.2 + Compiling sqlx v0.9.0 + Compiling chacha20poly1305 v0.10.1 + Compiling aes-gcm v0.10.3 + Compiling brotli v8.0.4 + Compiling image v0.25.10 + Compiling bcrypt v0.19.3 + Compiling perry v0.5.1593 (/root/claude-fix-10476/crates/perry) + Compiling x448 v0.14.0-pre.12 + Compiling ghash v0.6.0 + Compiling aes-kw v0.3.1 + Compiling xattr v1.6.1 + Compiling tempfile v3.27.0 + Compiling sha3 v0.12.0 + Compiling p521 v0.13.3 + Compiling x25519-dalek v2.0.1 + Compiling perry-codegen-js v0.5.1593 (/root/claude-fix-10476/crates/perry-codegen-js) + Compiling nanoid v0.5.0 + Compiling lzma-rust2 v0.16.4 + Compiling cbc v0.2.1 + Compiling ecb v0.2.1 + Compiling rustls-pemfile v2.2.0 + Compiling perry-validation v0.5.1593 (/root/claude-fix-10476/crates/perry-validation) + Compiling sha3-utils v0.5.0 + Compiling clap_derive v4.6.4 + Compiling lru v0.18.4 + Compiling is-terminal v0.4.17 + Compiling deflate64 v0.1.12 + Compiling ppmd-rust v1.4.0 + Compiling rustls-webpki v0.103.15 + Compiling constant_time_eq v0.4.2 + Compiling termcolor v1.4.1 + Compiling unit-prefix v0.5.2 + Compiling typed-path v0.12.3 + Compiling humantime v2.3.0 + Compiling shell-words v1.1.1 + Compiling env_logger v0.10.2 + Compiling dialoguer v0.12.0 + Compiling indicatif v0.18.6 + Compiling zip v8.6.0 + Compiling clap v4.6.6 + Compiling perry-codegen-wasm v0.5.1593 (/root/claude-fix-10476/crates/perry-codegen-wasm) + Compiling tar v0.4.46 + Compiling perry-perex v0.5.1593 (/root/claude-fix-10476/crates/perry-perex) + Compiling tokio-rustls v0.26.5 + Compiling hyper-rustls v0.27.9 + Compiling tungstenite v0.29.0 + Compiling reqwest v0.12.28 + Compiling mongodb v3.9.0 + Compiling tokio-tungstenite v0.29.0 + Compiling lettre v0.11.23 + Compiling perry-codegen-glance v0.5.1593 (/root/claude-fix-10476/crates/perry-codegen-glance) + Compiling perry-codegen-arkts v0.5.1593 (/root/claude-fix-10476/crates/perry-codegen-arkts) + Compiling perry-codegen-wear-tiles v0.5.1593 (/root/claude-fix-10476/crates/perry-codegen-wear-tiles) + Compiling perry-transform v0.5.1593 (/root/claude-fix-10476/crates/perry-transform) + Compiling perry-codegen-swiftui v0.5.1593 (/root/claude-fix-10476/crates/perry-codegen-swiftui) + Compiling fslock v0.2.1 +warning: `perry-runtime` (lib) generated 1 warning + Compiling perry-updater v0.5.1593 (/root/claude-fix-10476/crates/perry-updater) + Compiling perry-runtime-static v0.5.1593 (/root/claude-fix-10476/crates/perry-runtime-static) + Compiling perry-stdlib v0.5.1593 (/root/claude-fix-10476/crates/perry-stdlib) + Compiling perry-stdlib-static v0.5.1593 (/root/claude-fix-10476/crates/perry-stdlib-static) + Finished `perry-dev` profile [optimized] target(s) in 3m 47s diff --git a/changelog.d/10564-implicit-this-scope-rooting.md b/changelog.d/10564-implicit-this-scope-rooting.md index ac5d670d7b..ef42c7ea66 100644 --- a/changelog.d/10564-implicit-this-scope-rooting.md +++ b/changelog.d/10564-implicit-this-scope-rooting.md @@ -47,3 +47,70 @@ five), and a scaled copy matches node with `PERRY_GC_SCHEDULE_ALLOC_KB=0` over 6 snapshot entries as the baseline, no new failures. Cost on the changed dispatch path is one handle slot: +0.71 % instructions on a 5M-call swapped-receiver microbenchmark, +0.36 % on 5M array-callback engine calls. + +Follow-up review finding (same PR): the rooting fix above makes every displaced +value survive a moving collection, but four sites still save/restore +IMPLICIT_THIS and `new.target` with a bare statement pair, not a guard -- +`fetch_globals.rs`'s Temporal/Intl subclass `super()` bridges, the +prototype-walk accessor dispatch in `handle_methods.rs`, and the stdlib +listener/getter dispatchers (`streams.rs` and its `net`/`tls`/`worker_threads` +siblings, `domain.rs`, `events.rs`, `events/warnings.rs`). When the bracketed +call throws, the restore statement textually follows it, so neither `longjmp` +nor a system unwind ever runs it -- both cells stay pinned at whatever the +failed call set them to for every later read. + +`exception.rs` already keeps a `catch_savepoints!` family of exactly this +shape (shadow stack, runtime handles, call-method depth, ...): one member per +piece of state a transport-skipped cleanup would otherwise leak, captured at +every `try` and replayed by `js_throw` before it transports the exception -- +uniformly for both `js_try_push`/`HandlerKind::Setjmp` and the generated-code +`js_eh_try_push`/`HandlerKind::Unwind`, which already funnel into one +`try_push_with_kind` -> `CatchSavepoint::capture()`, with `js_throw` calling +`.restore()` unconditionally before it branches on transport. `implicit_this` +and `new_target` now join that family. The captured bits are a second root for +whatever heap value they hold while a `try` is open, invisible to the live +cell's own scanner, so `scan_exception_roots_mut` now also walks the live +prefix of the per-thread savepoints slab and rewrites both fields across a +moving collection -- registered in the perex GC test harness too, which +clears the production scanner registry and had only restored the live-cell +scanner. + +Proven: a unit test that reproduces the bare save/call/restore shape using +only pre-existing public entry points (`js_implicit_this_set`/ +`js_new_target_set`/`catch_js_throw`/`js_throw`, none of them touched by this +fix, so the same test body runs unmodified on both trees) -- an inner +`js_throw` crossing the bare site leaves both cells stuck at the inner value +instead of the enclosing `try`'s baseline. Fails on the pre-fix tree with +`assertion left == right failed: the try open around the bare site must have +restored IMPLICIT_THIS` (left the inner sentinel, right the outer baseline); +passes after, along with the macro's own auto-generated nested-throw witness +for both new members. + +NOT proven: that any of the four named sites is reachable the way the finding +assumes. Targeting the most tractable one -- `handle_methods.rs`'s +prototype-walk accessor dispatch -- a temporary `eprintln!` placed directly on +that path never fired for a getter-throws test case built to exercise it, so +something else resolves that call first. The other three sites were not +probed at all. This is recorded, not fixed, in a follow-up issue. + +Validation: `cargo test --release -p perry-runtime --tests` +(`RUST_TEST_THREADS=1`): 3975 passed, 1 pre-existing failure +(`a_free_or_move_outside_every_scope_is_caught_in_debug_builds`, a +`debug_assert!` funnel that cannot fire under `--release`, already documented +in this PR's own validation table), 4 ignored. `cargo test --release +-p perry-stdlib --tests`: 138 passed, 1 pre-existing failure unrelated to this +change (`readline::stdin_data_listener_flows_without_raw_mode`; this fix +touches no file in `perry-stdlib`). `scripts/run_lint_gates.sh` +(`SKIP_COMPILE_GATES=1`, compile tier known-red on this host): 76 of 77 pass, +the one red (`Public benchmark evidence freshness`) pre-existing and +unrelated. 10 of 11 targeted exception/try-catch gap tests pass; the one +failure (`test_issue_7302_thread_throws`) reproduces identically on the +unmodified baseline (a Node-side environment artifact in this sandbox, not a +Perry regression). Instruction-count A/B (`perf stat`, 3 runs/arm, spread +<0.1%) on a 5,000,000-iteration try/catch loop that never throws: 895.28M +(baseline) vs 965.34M (fixed) instructions, +7.83% (~14 instructions per +`try`-push) -- the cost of two more TLS reads folded into the one savepoint +write every `try` already performs. Measured on a deliberately adversarial +microbenchmark (nothing but the `try`/`catch` itself); the original PR's own +dispatch-call microbenchmarks, which mix in real work, show proportionally +smaller deltas for comparable per-call additions. diff --git a/changelog.d/10576-template-literal-part-elision.md b/changelog.d/10576-template-literal-part-elision.md new file mode 100644 index 0000000000..f4984fd8c2 --- /dev/null +++ b/changelog.d/10576-template-literal-part-elision.md @@ -0,0 +1,33 @@ +### Performance + +- Cut the instruction count of a template literal that opens on a + substitution (`` `${x}...` ``, the common shape — no literal text before + the first `${`) by eliding two sources of wasted work in its desugared + `js_string_concat_chain` call: + - The leading quasi is skipped when empty instead of unconditionally + seeding the chain with a literal `Expr::String("")`. Every *interior* + quasi already had this guard; the leading one never did, so a template + opening on `${` always carried one extra, always-empty part through + classification, and a single-substitution template (`` `${x}` ``) missed + the concat-chain fold's 3-part minimum entirely, falling back to the + pairwise path to concatenate an empty string for nothing. + - A `number`-typed parameter's substitution now drops its redundant + `StringCoerce` wrapper even when codegen has no dataflow *proof* it is + numeric, only the declared annotation — mirroring how + `is_declared_string_expr` already trusts a declared `string` a few call + sites up the stack. This is sound because `js_string_concat_chain`'s own + part classifier tag-dispatches every part itself, and for any shape that + isn't a plain number it falls back to the exact `js_jsvalue_to_string` / + `js_string_materialize_to_heap` calls `js_string_coerce` forwards to for + those same shapes — so a lying `number` annotation still produces + byte-identical output, and only a genuine number additionally skips a + throwaway intermediate heap string. + + Measured on `` `${s}:${n}` `` (`s` a short string, `n` a non-integer + double), differencing two probes to cancel fixed per-process cost (median + of 7, N=20000; `loop16`/`loop80` control read ~0 in both arms): **1173 → + 955 instructions per evaluation (−18.6%)**. An integer-interpolation + variant (`` `${s}:${i}` ``, `i` a proven loop counter that already had the + numeric fast path) isolates the leading-quasi fix alone: 713 → 675 + (−5.4%), confirming the larger non-integer win comes from the + `StringCoerce` elision. diff --git a/changelog.d/10577-array-map-resolved-fill-past-64.md b/changelog.d/10577-array-map-resolved-fill-past-64.md new file mode 100644 index 0000000000..bccbc47c9d --- /dev/null +++ b/changelog.d/10577-array-map-resolved-fill-past-64.md @@ -0,0 +1,30 @@ +`Array.prototype.map` filling a plain result array only took the once-resolved +header fast path (`fill_resolved_array_slot`, from the earlier map fill +change) for a source of at most 64 elements; a longer source fell back to +`note_array_slot`, which re-classifies the result's ownership/forwarding +through `clean_arr_ptr` (`array_numeric_layout`) and unconditionally pays +`layout_note_slot`, on every element. `result` is re-derived from the result's +own GC root immediately after the callback returns and before either helper +runs, so the "no intervening allocation or safepoint" contract +`fill_resolved_array_slot` needs holds regardless of length — the 64-element +split was scope, not a correctness boundary. It now applies unconditionally. + +Also dropped a redundant raw `ptr::write` of the mapped value that ran +immediately before both branches — both `fill_resolved_array_slot` and +`note_array_slot` perform their own (possibly canonicalized) store of the same +slot, so the first write was always immediately overwritten. + +`a.map(x => x + v)` over a 16-vs-80-element `number[]` (measured as the +marginal per-call cost difference of two probes differing only in element +count, per element, N=20000, median of 7): 450.8 -> 206.1 instructions per +element (-54.3%), control `(loop80-loop16)/64` reads -0.39 and +0.08 in the +before/after arms respectively. + +New fixture `test-files/test_gap_array_map_resolved_fill_scale.ts` exercises +sources both under and over the old 64-element boundary and the ~2048-element +born-old allocation threshold: a callback returning non-numeric values +(retiring the raw-f64 numeric claim mid-fill), one that allocates heavily to +force collections between the header resolve and the store, one that pushes +to the source mid-fill, one that truncates it mid-fill, a sparse/holey source, +and a plain numeric control. Matches node 26.5.1 and passes under seeded +moving-GC stress (`PERRY_GC_SCHEDULE_SEED`/`PERRY_GC_PROTECT_FROMSPACE`). diff --git a/changelog.d/10579-function-source-intern.md b/changelog.d/10579-function-source-intern.md new file mode 100644 index 0000000000..9502ded431 --- /dev/null +++ b/changelog.d/10579-function-source-intern.md @@ -0,0 +1,15 @@ +### Performance + +- **Nested `Function.prototype.toString` source is interned at tsc scale + (#10574).** Codegen already shared overlapping function bodies via + `SourcePool`, but intern turned *off* when unique-string lengths summed + past 8 MiB. A CJS bundle like `typescript/lib/_tsc.js` is ~24 MB of + nested slices of a ~6 MB module, so `__cstring` kept one copy per + function (24.3 MB, 28% of an 86 MB tsc binary). Over-budget modules now + still share into the longest parent (the CJS factory / module wrapper). + `fn.toString()` is byte-identical. The remaining unique source (~6 MB) + can be dropped with `--function-source=header` / + `PERRY_FUNCTION_SOURCE=header`, which stores + `function () { /* source elided */ }` instead of the body + — enough for name extraction and parameter-name DI, not enough to + reconstruct bodies. Full interned source stays the default. diff --git a/changelog.d/10580-search-run-one-entry.md b/changelog.d/10580-search-run-one-entry.md new file mode 100644 index 0000000000..426a6db087 --- /dev/null +++ b/changelog.d/10580-search-run-one-entry.md @@ -0,0 +1,3 @@ +### Faster + +- Regular expression calls spend 305 to 332 fewer instructions each, 4.3% to 12.5% depending on the pattern. A `test` or `exec` call used to acquire the engine's program and subject views twice — once to learn the shape of the search and again to run it — and move the search between them; it now does both in one entry, and a search that its first quantum decides is never built as a resumable one at all (#10166). diff --git a/changelog.d/10582-define-property-accessor-attrs.md b/changelog.d/10582-define-property-accessor-attrs.md new file mode 100644 index 0000000000..342b1b3f23 --- /dev/null +++ b/changelog.d/10582-define-property-accessor-attrs.md @@ -0,0 +1,19 @@ +### Fixed + +- **A generic `Object.defineProperty`/`defineProperties` descriptor (no + `get`/`set`/`value`/`writable`, e.g. `{ enumerable: true }`) against an + existing **class-declared** get/set accessor no longer breaks it.** (#10480) + A ClassBody accessor lives in the class vtable, not the address-keyed + descriptor tables `defineProperty` normally writes, so the generic-descriptor + branch could not see the class key: it appended a shadowing data property + with `writable: false`, which silenced the setter (assignment threw in + strict code, silently dropped in sloppy code) and never actually applied the + requested `enumerable`/`configurable` change. Every WebIDL-generated class + (whatwg-url, node-fetch, undici-style polyfills) marks its prototype + accessors enumerable exactly this way at module load — node-fetch's + `Object.defineProperties(Request.prototype, { method: { enumerable: true }, + … })` broke every later write to those accessors. A new per-accessor + side table (`class_registry/accessor_attrs.rs`) now records the overridden + attributes instead, so `getOwnPropertyDescriptor`, `Object.keys`/`values`/ + `entries`, `hasOwnProperty`/`propertyIsEnumerable`, and `delete` all see the + update while the getter/setter stay exactly where they already lived. diff --git a/changelog.d/10584-inline-mask-walk.md b/changelog.d/10584-inline-mask-walk.md new file mode 100644 index 0000000000..e90f191b7c --- /dev/null +++ b/changelog.d/10584-inline-mask-walk.md @@ -0,0 +1,44 @@ +Walk an inline slot mask directly instead of re-entering the slot iterator once +per slot. `visit_gc_layout_slot_descriptors` called +`HeapChildSlotIterator::next` for every payload slot; for the common case — a +`Masked` selection whose mask is `LayoutSlotMask::Inline` — each of those calls +re-dispatched the selection, re-decoded the mask's niche and rebuilt the limit +and cursor masks, for about eight instructions of work. + +The mask's set bits are the slot indices, in ascending order, so the arm takes +the word once and walks it with `trailing_zeros` and `word &= word - 1`. Every +other selection, including a `Heap` mask (more than 64 payload slots), keeps the +iterator. The helper carries the iterator's two side conditions with it: the +one-shot raw-numeric accounting that `next`'s first call performs, and the +cursor, left at the end so a later `next` yields nothing. The prefix and meta +edges belong to the caller, which takes them before the payload; the helper +asserts they are gone rather than arguing it. + +Measured on a control whose pointer fields target DISTINCT objects, because the +older shared-child control let the collector's one-entry address memo answer +83.3% of its classifications against 0.0% on the real fixtures, and so hid the +cost of everything downstream of that memo. On it, `next` costs 75.8 of the +417.1 instructions a pointer-slot visit costs, and the walk removes 69.7 of +them. On the same control the shared-child version reports 75.9 — the iterator's +own cost is what the blind control did NOT distort. + +The descriptor walk serves the copying minor, the full mark and the +remembered-set rebuild. Inclusive instructions for the walk on gc3, exact, by +caller: copying minor -7.95%, full mark -16.62%, remembered-set rebuild -17.59%, +dirty scan unchanged. On `oldyoung`, whose masked population is mostly one +`Heap` mask, the remembered-set rebuild and the dirty-coverage restore each pay +one failed `take_inline_mask_word` dispatch per visit: +0.25% and +0.33%, about +two instructions per object visit, against -4.49% on that fixture's copying +minor and -1.07% on the program. + +Whole program, instructions:u, min of 5: gc3 -6.75%, w20000 -5.86%, w5000 +-4.90%, w1000 -2.36%, oldyoung -1.06%, and an allocation-only fixture flat to +298 instructions in 320 million. No fixture regresses in instructions, peak RSS +or max GC pause. + +The equivalence between the walk and the iterator is a property, and is tested +as one: identical index sequences for every mask word (empty, one bit at each +end, full width, both alternations, and 64 pseudo-random words) crossed with +every live slot count from 0 to 128, with a sabotaged twin that drops the mask's +top bit and must be caught, plus a real collection whose only young child hangs +off the highest masked slot and its own sabotaged twin. diff --git a/changelog.d/10587-argument-list-roots.md b/changelog.d/10587-argument-list-roots.md new file mode 100644 index 0000000000..6e42628f45 --- /dev/null +++ b/changelog.d/10587-argument-list-roots.md @@ -0,0 +1,14 @@ +Fixed three GC rooting gaps in #10532's dynamic-call argument-list handling: +`Reflect.apply` held the callee, receiver and arguments in plain Rust locals +across the one closure-rebind shape that allocates (a concise/object-literal +method's `this` clone); `CreateListFromArrayLike`'s array-like path reused a +raw source-object pointer read once before its per-index allocating loop +instead of re-deriving it after each allocation; and a `(...fixed, ...rest)` +body that also synthesizes `arguments` could hand its callee the rest +array's pre-move address once the `arguments` array's allocation moved it. A +new `rebind_explicit_this_allocates` predicate lets `Reflect.apply`'s common +(non-cloning) path stay allocation-free and unrooted; only the one shape that +actually allocates pays for rooting, so the fix measures ~0% instead of the ++38.8% an earlier, unconditionally-rooted attempt cost. New regression tests +arm a named collection point at each fixed allocation and assert the callee +observes post-collection addresses. diff --git a/changelog.d/10588-mock-timer-dispatch-pin.md b/changelog.d/10588-mock-timer-dispatch-pin.md new file mode 100644 index 0000000000..9dadce0dd3 --- /dev/null +++ b/changelog.d/10588-mock-timer-dispatch-pin.md @@ -0,0 +1,11 @@ +Fixed a follow-on to #10447/#10538: a one-shot `node:test` mock timer +(`setTimeout` under `mock.timers`) dropped its own registry pin +(`ScheduledTimerId`) the moment it left the mock queue for dispatch, rather +than after its callback returned. A callback that then scheduled and cleared +more than the registry's 65,536-entry eviction cap's worth of other timers +before finishing evicted its own handle mid-dispatch — the same symptom +#10447 fixed for long-lived real timers, reopened narrowly for the mock +dispatch path. The pin now rides along in the dispatch action tuple and +drops only after `call_timer_callback` returns. Real (non-mock) timers and +mock intervals were never affected. Reproduced against clean `main` with a +new regression test before the fix, confirmed passing after. diff --git a/changelog.d/10591-builtin-named-user-methods.md b/changelog.d/10591-builtin-named-user-methods.md new file mode 100644 index 0000000000..8be23081da --- /dev/null +++ b/changelog.d/10591-builtin-named-user-methods.md @@ -0,0 +1,25 @@ +Fix a call whose method name matched a `Date`/`Number`/`Array` builtin +(`getTime`, `toFixed`, `toISOString`, `toSorted`, `endsWith`, ...) being +lowered straight to that builtin regardless of the receiver. A class, +function-constructor prototype, or object literal defining a same-named +method — dayjs's `toISOString`/`toJSON`, decimal.js/bignumber.js's +`toFixed`, a plain `Clock.getTime()` — had its own method silently skipped +in favor of the builtin, producing `NaN`, `"[object Object]"`, `Invalid +Date`, or an uncaught `RangeError`. A zero-arg call of a user method sharing +a name with a required-arg String builtin (`endsWith`/`includes`/ +`startsWith`) didn't even compile (#10476). + +Add `builtin_kind_guard.rs`: a receiver the compiler has proven to be a +Date/number/array keeps the direct builtin call; any other receiver is +evaluated once, rooted, and branches at runtime on its actual kind to +either the builtin or the universal method dispatcher, which still reaches +the builtin via the prototype chain for a real Date/number/array. + +Known cost: a receiver whose kind is not statically provable now pays a +real runtime dispatch check to call a builtin-named method. On a synthetic +probe this puts two `any`-typed shapes (`dayjs`-like `toISOString`/`toJSON`, +a `Money`-like `toFixed`) at roughly 3-4x Node's wall time — well outside +the usual 20%-of-Node floor. The prior fast numbers for those two shapes +were never valid: the old code crashed on one and silently computed the +wrong answer on the other, so the comparison this fix is measured against +is fix-vs-Node, not fix-vs-old-Perry. diff --git a/changelog.d/10592-instanceof-value-kinds.md b/changelog.d/10592-instanceof-value-kinds.md new file mode 100644 index 0000000000..6a4e018fb1 --- /dev/null +++ b/changelog.d/10592-instanceof-value-kinds.md @@ -0,0 +1,24 @@ +`instanceof` no longer segfaults on short inline strings, and `Object.create(proto).constructor` +returns the real constructor. The receiver is now resolved per value kind rather than assumed to be a +heap pointer, with the prototype and class-registry paths updated to match. + +The string crash took down ajv, and with it every fastify schema route; the `constructor` defect +crashed lodash's `isEqual`. `new EventEmitter() instanceof EventEmitter` is fixed as a direct +consequence. + +A related shape, `class Sub extends EventEmitter {}` followed by `new Sub() instanceof +EventEmitter`, took a separate fix: that call compiles through the dynamic-dispatch instanceof path +(the RHS resolves via a native-module lookup), which never registered or consulted the class-chain +parent edge that `extends Array`/`Map`/`Set`/`Error` subclassing already uses. A subclass instance is +a real object carrying its own class id, not a handle and not prototype-linked to +`EventEmitter.prototype`, so it was invisible to the handle/prototype probes on that path and always +answered `false`. EventEmitter's reserved class id is now a valid `extends` parent, and the +dynamic-dispatch branch delegates to the class-chain walk first, falling back to the prototype walk +for `util.inherits`-style shapes. + +A CodeRabbit review pass on this PR also found that `instanceof`'s dynamic-RHS classification (and +`value_is_callable`) trusted the INT32-class-ref tag band alone, without checking the class id was +actually registered. A JS program can construct a `number` sharing that same tag band directly (via +`DataView`), which was then misread as a class reference instead of correctly reaching the +unresolved-RHS `TypeError`. Both sites now go through the same `class_ref_id` helper (which also +checks `is_class_id_registered`) that the rest of the crate already uses for this. diff --git a/changelog.d/10596-instanceof-imported-fn-ctor.md b/changelog.d/10596-instanceof-imported-fn-ctor.md new file mode 100644 index 0000000000..ba8005f9da --- /dev/null +++ b/changelog.d/10596-instanceof-imported-fn-ctor.md @@ -0,0 +1,11 @@ +### Fixed + +- **`x instanceof F` no longer folds to `false` for an imported non-class constructor.** Lowering + (`crates/perry-hir/src/lower/lower_expr/arm_bin.rs`) only attached a runtime value to an identifier + `instanceof` RHS for a local, a module function, or a native module — an imported binding was never + consulted, so codegen resolved the bare name to no class id and folded the check to + `js_instanceof(v, 0)` (always false). Affected every import form (named, default, CJS + `module.exports`/`exports.F`) for a plain ES5-style or factory-built constructor; `ns.F`, a local + alias, and the check written inside the defining module all worked already. Codegen + (`crates/perry-codegen/src/expr/instance_misc1.rs`) keeps the static class-id fast path for + imported classes and every non-compiled-source import, so those emit unchanged LLVM IR. diff --git a/changelog.d/10597-inherited-symbol-getter-receiver.md b/changelog.d/10597-inherited-symbol-getter-receiver.md new file mode 100644 index 0000000000..8161f2bfc3 --- /dev/null +++ b/changelog.d/10597-inherited-symbol-getter-receiver.md @@ -0,0 +1,15 @@ +### Fixed + +- **An inherited Symbol-keyed accessor now runs with the original receiver, not `undefined`.** + `obj[sym]` where the getter/setter lives on a prototype (`Object.defineProperty(Fn.prototype, sym, + ...)`, an object-literal `get [sym]()` reached through `Object.create`, or a declared class + prototype) used to invoke the accessor with no receiver at all, so it observed whatever `this` + happened to be ambient — `undefined` at module top level. fastify 5.10.0's + `Reply.prototype[kRouteContext]` getter crashed every HTTP request with `TypeError: Cannot read + properties of undefined (reading 'request')`. `[[Get]]`/`[[Set]]` now thread the read/write's + actual receiver through every prototype-chain walk (`crates/perry-runtime/src/symbol/get.rs`, + `object/class_registry/prototype_objects.rs`); `Reflect.get`/`Reflect.set` for a Symbol key reach + the receiver-aware entry points directly. An inherited *setter* is now consulted too — + `obj[sym] = v` used to silently shadow it with a new own data property instead of running it — + gated by a symbol-id-keyed accessor filter (`symbol_may_have_accessor`) so the common no-accessor + write path stays cheap. diff --git a/crates/perry-codegen/src/codegen/artifact_source_text.rs b/crates/perry-codegen/src/codegen/artifact_source_text.rs index f9c451c667..4126144511 100644 --- a/crates/perry-codegen/src/codegen/artifact_source_text.rs +++ b/crates/perry-codegen/src/codegen/artifact_source_text.rs @@ -16,6 +16,7 @@ use super::helpers::{scoped_method_name, scoped_static_method_name}; pub(super) fn extend_class_method_source_text( hir: &HirModule, + closures: &super::function_source_header::ClosureHeaders<'_>, module_prefix: &str, llmod: &LlModule, user_fn_source: &mut Vec<(String, String, bool)>, @@ -35,7 +36,16 @@ pub(super) fn extend_class_method_source_text( if symbol.is_empty() || !llmod.has_function(&symbol) || !seen.insert(symbol.clone()) { return; } - user_fn_source.push((symbol, source.text.clone(), source.is_non_strict_ordinary)); + user_fn_source.push(( + symbol, + super::function_source_header::retained_function_text( + hir, + closures, + func_id, + &source.text, + ), + source.is_non_strict_ordinary, + )); }; for class in &hir.classes { @@ -115,3 +125,91 @@ pub(super) fn extend_class_method_source_text( } } } + +/// Collect retained `Function.prototype.toString` source text for every user +/// function this module emits, keyed by the same wrapper/closure symbol the +/// name registration uses. +/// +/// Split out of `artifacts.rs::emit_module_artifacts` for the 2000-line cap +/// (#10579 took that file to 2006). Body verbatim; it returns the vector the +/// caller used to build in place. +pub(super) fn collect_user_fn_source( + hir: &HirModule, + func_names: &std::collections::HashMap, + closures: &[(FuncId, perry_hir::Expr)], + registered_fn_ids: &HashSet, + materialized_closure_ids: &HashSet, + module_prefix: &str, + llmod: &LlModule, +) -> Vec<(String, String, bool)> { + // #4101: collect retained function source text, keyed by the same + // wrapper/closure symbol the name registration uses. Top-level functions + // always have a `__perry_wrap_` global (emitted unconditionally + // above); inline closures only have a `perry_closure_*` global when + // materialized, so gate those on `materialized_closure_ids` to avoid + // referencing an undefined global (the #318/#343 clang-failure class). + // #10574: resolve closure params/kind for functions that are not + // `hir.functions` entries, so header mode keeps their names and parameters. + let closure_headers = super::function_source_header::ClosureHeaders::new(closures); + let mut user_fn_source: Vec<(String, String, bool)> = Vec::new(); + for f in &hir.functions { + if let Some(src) = hir.closure_source_text.get(&f.id) { + if let Some(sym) = func_names.get(&f.id) { + user_fn_source.push(( + format!("__perry_wrap_{}", sym), + super::function_source_header::retained_function_text( + hir, + &closure_headers, + f.id, + &src.text, + ), + src.is_non_strict_ordinary, + )); + } + } + } + // Sorted, NOT raw `HashMap` iteration (#7038). The loop above walks + // `hir.functions` (a `Vec`) and is already deterministic; this one keyed off + // the map's iteration order, so the `@.str.N` numbering of the emitted + // string constants was a per-process permutation. Same input, different + // `.ll` on every run — which silently invalidates any A/B that compares raw + // IR, a technique several representation and GC investigations relied on. + // Emission order is the only thing that changes; sorting by `FuncId` makes + // it stable without altering what is emitted. + let mut materialized_closure_sources: Vec<( + &perry_hir::types::FuncId, + &perry_hir::FunctionSourceMetadata, + )> = hir + .closure_source_text + .iter() + .filter(|(func_id, _)| { + !registered_fn_ids.contains(*func_id) && materialized_closure_ids.contains(*func_id) + }) + .collect(); + materialized_closure_sources.sort_by_key(|(func_id, _)| **func_id); + for (func_id, src) in materialized_closure_sources { + let sym = format!("perry_closure_{}__{}", module_prefix, func_id); + user_fn_source.push(( + sym, + super::function_source_header::retained_function_text( + hir, + &closure_headers, + *func_id, + &src.text, + ), + src.is_non_strict_ordinary, + )); + } + + // #9468: method/accessor bodies are raw symbols rather than closure + // wrappers. Pair retained MethodDefinition text only with symbols this + // module actually emitted; the helper also preserves the file-size gate. + extend_class_method_source_text( + hir, + &closure_headers, + module_prefix, + llmod, + &mut user_fn_source, + ); + user_fn_source +} diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index 695ecc65a0..dc26a7fe2b 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -1868,56 +1868,16 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { user_fn_display_names.push((sym, display.clone())); } - // #4101: collect retained function source text, keyed by the same - // wrapper/closure symbol the name registration uses. Top-level functions - // always have a `__perry_wrap_` global (emitted unconditionally - // above); inline closures only have a `perry_closure_*` global when - // materialized, so gate those on `materialized_closure_ids` to avoid - // referencing an undefined global (the #318/#343 clang-failure class). - let mut user_fn_source: Vec<(String, String, bool)> = Vec::new(); - for f in &hir.functions { - if let Some(src) = hir.closure_source_text.get(&f.id) { - if let Some(sym) = func_names.get(&f.id) { - user_fn_source.push(( - format!("__perry_wrap_{}", sym), - src.text.clone(), - src.is_non_strict_ordinary, - )); - } - } - } - // Sorted, NOT raw `HashMap` iteration (#7038). The loop above walks - // `hir.functions` (a `Vec`) and is already deterministic; this one keyed off - // the map's iteration order, so the `@.str.N` numbering of the emitted - // string constants was a per-process permutation. Same input, different - // `.ll` on every run — which silently invalidates any A/B that compares raw - // IR, a technique several representation and GC investigations relied on. - // Emission order is the only thing that changes; sorting by `FuncId` makes - // it stable without altering what is emitted. - let mut materialized_closure_sources: Vec<( - &perry_hir::types::FuncId, - &perry_hir::FunctionSourceMetadata, - )> = hir - .closure_source_text - .iter() - .filter(|(func_id, _)| { - !registered_fn_ids.contains(*func_id) && materialized_closure_ids.contains(*func_id) - }) - .collect(); - materialized_closure_sources.sort_by_key(|(func_id, _)| **func_id); - for (func_id, src) in materialized_closure_sources { - let sym = format!("perry_closure_{}__{}", module_prefix, func_id); - user_fn_source.push((sym, src.text.clone(), src.is_non_strict_ordinary)); - } - - // #9468: method/accessor bodies are raw symbols rather than closure - // wrappers. Pair retained MethodDefinition text only with symbols this - // module actually emitted; the helper also preserves the file-size gate. - super::artifact_source_text::extend_class_method_source_text( + // #4101 + #9468: collecting retained function source text lives in + // `artifact_source_text::collect_user_fn_source` (split out for the file cap). + let user_fn_source = super::artifact_source_text::collect_user_fn_source( hir, + &func_names, + closures, + ®istered_fn_ids, + &materialized_closure_ids, module_prefix, llmod, - &mut user_fn_source, ); // Wall 51: the standalone-ctor arity registered into CLASS_CONSTRUCTORS must @@ -1943,6 +1903,10 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { progress.checkpoint("runtime registration metadata"); + let class_source_elided = super::function_source_header::elide_class_sources(hir); + let class_source_text = class_source_elided + .as_ref() + .unwrap_or(&hir.class_source_text); emit_string_pool( llmod, strings, @@ -1954,7 +1918,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { class_table, imported_class_stubs, &hir.class_display_names, - &hir.class_source_text, + &class_source_text, &ctor_arity_overrides, closure_rest_params, closure_arities, diff --git a/crates/perry-codegen/src/codegen/emission_order_tests.rs b/crates/perry-codegen/src/codegen/emission_order_tests.rs index 84111b9326..0652dddc94 100644 --- a/crates/perry-codegen/src/codegen/emission_order_tests.rs +++ b/crates/perry-codegen/src/codegen/emission_order_tests.rs @@ -611,3 +611,75 @@ fn retained_source_ranges_preserve_registrations_and_ownership() { ); } } + +/// #10574 Part 2: `--function-source=header` must drop the body and keep the +/// name plus parameter names, which is what name-extraction and DI consume. +#[test] +fn header_mode_replaces_bodies_with_a_di_header() { + let _guard = super::function_source_header::override_function_source_header_mode(true); + let mut module = empty_module("function_source_header.ts"); + let mut foo = method_fn(100, "foo"); + foo.params = vec![ + Param { + id: 1, + name: "a".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }, + Param { + id: 2, + name: "b".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }, + ]; + module.functions.push(foo); + module.closure_source_text.insert( + 100, + perry_hir::FunctionSourceMetadata { + text: "function foo(a, b) {\n return 'DISTINCTIVE_BODY_10574';\n}".to_string(), + is_non_strict_ordinary: true, + }, + ); + module + .classes + .push(plain_class(3, "Envelope", method_fn(200, "m"))); + module.closure_source_text.insert( + 200, + perry_hir::FunctionSourceMetadata { + text: "m() { return 'METHOD_BODY_10574'; }".to_string(), + is_non_strict_ordinary: false, + }, + ); + module.class_source_text.insert( + 3, + "class Envelope { m() { return 'METHOD_BODY_10574'; } }".to_string(), + ); + let emitted = ir(&module); + assert!( + !emitted.contains("DISTINCTIVE_BODY_10574"), + "header mode must not retain the function body" + ); + assert!( + !emitted.contains("METHOD_BODY_10574"), + "header mode must not retain method or class bodies" + ); + assert!( + emitted.contains("source elided"), + "header mode must emit the elided-source stand-in" + ); + assert!( + emitted.contains("function foo(a, b)"), + "header must keep the name and parameter names" + ); + assert!( + emitted.contains("class Envelope"), + "class toString header must keep the class name" + ); +} diff --git a/crates/perry-codegen/src/codegen/function_source_header.rs b/crates/perry-codegen/src/codegen/function_source_header.rs new file mode 100644 index 0000000000..b004e1ddf0 --- /dev/null +++ b/crates/perry-codegen/src/codegen/function_source_header.rs @@ -0,0 +1,367 @@ +//! #10574 Part 2: retain a synthesized `Function.prototype.toString` header +//! instead of the function body. +//! +//! Default remains the interned original source (Part 1). `header` mode stores +//! `function () { /* source elided */ }`, which is enough for +//! name extraction, Angular/Vue-style parameter-name DI, and +//! `toString().includes("[native code]")` probes, and drops the remaining +//! ~6 MB of unique function source on a tsc-sized bundle. +//! +//! Opt in with `--function-source=header` or `PERRY_FUNCTION_SOURCE=header`. +//! Full source is the default so `fn.toString()` stays spec-identical and +//! first-party worker serialization (`perry-threads`) keeps working. + +use std::cell::Cell; +use std::collections::HashMap; + +use perry_hir::types::FuncId; +use perry_hir::{Expr, Function, Module as HirModule, Param}; + +thread_local! { + static HEADER_MODE_OVERRIDE: Cell> = const { Cell::new(None) }; +} + +/// True when codegen should emit the synthesized header instead of the body. +pub(super) fn function_source_header_mode() -> bool { + if let Some(overridden) = HEADER_MODE_OVERRIDE.with(Cell::get) { + return overridden; + } + matches!( + std::env::var("PERRY_FUNCTION_SOURCE").as_deref(), + Ok("header") | Ok("elide") + ) +} + +/// RAII override for unit tests. Restores the previous override on drop so +/// parallel tests on this thread cannot leak the mode into a later case. +#[cfg(test)] +pub(super) struct FunctionSourceHeaderGuard(Option); + +#[cfg(test)] +impl Drop for FunctionSourceHeaderGuard { + fn drop(&mut self) { + HEADER_MODE_OVERRIDE.with(|cell| cell.set(self.0)); + } +} + +#[cfg(test)] +pub(super) fn override_function_source_header_mode(on: bool) -> FunctionSourceHeaderGuard { + FunctionSourceHeaderGuard(HEADER_MODE_OVERRIDE.with(|cell| cell.replace(Some(on)))) +} + +/// Parameter/kind lookup for functions that are **not** `hir.functions` +/// entries. Arrow functions, function expressions and nested function +/// declarations all lower to an `Expr::Closure` nested inside an expression +/// tree, so `function_by_id` cannot see them — it searches only `hir.functions` +/// and class members. Before this existed, every one of them fell back to +/// `function () { ... }`, dropping the name *and* the parameters that the +/// documented Angular/Vue-style DI contract depends on. On `typescript@5.9.3` +/// that was ~9,600 of 9,644 functions, all interned onto one shared string. +/// +/// Built from the same `closures` slice `emit_module_artifacts` already holds, +/// so this adds a map build, not a traversal. +pub(super) struct ClosureHeaders<'a> { + by_id: HashMap, +} + +impl<'a> ClosureHeaders<'a> { + pub(super) fn new(closures: &'a [(FuncId, Expr)]) -> Self { + let mut by_id = HashMap::new(); + for (func_id, expr) in closures { + if let Expr::Closure { + params, is_arrow, .. + } = expr + { + by_id.insert(*func_id, (params.as_slice(), *is_arrow)); + } + } + Self { by_id } + } + + #[cfg(test)] + pub(super) fn empty() -> Self { + Self { + by_id: HashMap::new(), + } + } + + fn get(&self, id: FuncId) -> Option<(&'a [Param], bool)> { + self.by_id.get(&id).copied() + } +} + +/// Original source, or the synthesized header when header mode is on. +pub(super) fn retained_function_text( + hir: &HirModule, + closures: &ClosureHeaders<'_>, + func_id: FuncId, + original: &str, +) -> String { + if !function_source_header_mode() { + return original.to_string(); + } + if let Some(func) = function_by_id(hir, func_id) { + return synthesize_function_header(&header_name(hir, func), func.params.as_slice()); + } + if let Some((params, is_arrow)) = closures.get(func_id) { + // An arrow has no name in source and `toString()` must not claim one, + // nor call itself `function` - that misreports the function kind on + // top of eliding the body. + if is_arrow { + return synthesize_arrow_header(params); + } + return synthesize_function_header(&closure_header_name(hir, func_id), params); + } + synthesize_function_header("", &[]) +} + +/// Display name for a closure that has no `hir.functions` entry — a function +/// expression or nested declaration keeps its source name here. +fn closure_header_name(hir: &HirModule, func_id: FuncId) -> String { + match hir.closure_display_names.get(&func_id) { + Some(display) if is_user_visible_name(display) => display.clone(), + _ => String::new(), + } +} + +/// Header-mode class source map. `None` on the default path so the caller +/// can pass `hir.class_source_text` without cloning it. +pub(super) fn elide_class_sources(hir: &HirModule) -> Option> { + if !function_source_header_mode() { + return None; + } + Some( + hir.class_source_text + .keys() + .map(|&cid| (cid, synthesize_class_header(hir, cid))) + .collect(), + ) +} + +fn header_name(hir: &HirModule, func: &Function) -> String { + if let Some(display) = hir.closure_display_names.get(&func.id) { + if is_user_visible_name(display) { + return display.clone(); + } + } + if is_user_visible_name(&func.name) { + func.name.clone() + } else { + String::new() + } +} + +fn is_user_visible_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with("__perry") + && !name.starts_with("perry_") + && !name.starts_with("__Anon") + && !name.starts_with("__anon") +} + +fn synthesize_function_header(name: &str, params: &[Param]) -> String { + let params_src = params + .iter() + .filter_map(header_param) + .collect::>() + .join(", "); + if name.is_empty() { + format!("function ({params_src}) {{ /* source elided */ }}") + } else { + format!("function {name}({params_src}) {{ /* source elided */ }}") + } +} + +fn synthesize_arrow_header(params: &[Param]) -> String { + let params_src = params + .iter() + .filter_map(header_param) + .collect::>() + .join(", "); + format!("({params_src}) => {{ /* source elided */ }}") +} + +fn header_param(param: &Param) -> Option { + if param.arguments_object.is_some() { + return None; + } + if !is_user_visible_name(¶m.name) { + return None; + } + if param.is_rest { + Some(format!("...{}", param.name)) + } else { + Some(param.name.clone()) + } +} + +fn synthesize_class_header(hir: &HirModule, cid: u32) -> String { + let name = hir + .class_display_names + .get(&cid) + .cloned() + .or_else(|| { + hir.classes + .iter() + .find(|class| class.id == cid) + .map(|class| class.name.clone()) + }) + .filter(|name| is_user_visible_name(name)); + match name { + Some(name) => format!("class {name} {{ /* source elided */ }}"), + None => "class { /* source elided */ }".to_string(), + } +} + +fn function_by_id(hir: &HirModule, id: FuncId) -> Option<&Function> { + if let Some(func) = hir.functions.iter().find(|func| func.id == id) { + return Some(func); + } + for class in &hir.classes { + if let Some(ctor) = &class.constructor { + if ctor.id == id { + return Some(ctor); + } + } + for func in class + .methods + .iter() + .chain(class.static_methods.iter()) + .chain(class.getters.iter().map(|(_, func)| func)) + .chain(class.setters.iter().map(|(_, func)| func)) + { + if func.id == id { + return Some(func); + } + } + if let Some(member) = class + .computed_members + .iter() + .find(|member| member.function.id == id) + { + return Some(&member.function); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use perry_hir::types::Type; + use perry_hir::Param; + + fn param(name: &str, rest: bool) -> Param { + Param { + id: 1, + name: name.to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: rest, + arguments_object: None, + } + } + + #[test] + fn named_function_keeps_parameter_names_and_drops_the_body() { + let text = synthesize_function_header("foo", &[param("a", false), param("b", false)]); + assert_eq!(text, "function foo(a, b) { /* source elided */ }"); + assert!(text.starts_with("function foo(")); + assert!(!text.contains("return")); + } + + #[test] + fn anonymous_and_rest_params_round_trip_the_di_header() { + assert_eq!( + synthesize_function_header("", &[param("x", false), param("rest", true)]), + "function (x, ...rest) { /* source elided */ }" + ); + } + + fn closure_expr(func_id: u32, params: Vec, is_arrow: bool) -> (FuncId, Expr) { + ( + func_id, + Expr::Closure { + func_id, + params, + return_type: Type::Any, + body: Vec::new(), + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow, + is_async: false, + is_generator: false, + is_strict: false, + }, + ) + } + + /// #10574: the regression that shipped in the first cut of header mode. + /// A closure is not a `hir.functions` entry, so `function_by_id` misses it + /// and the fallback produced `function () { ... }` for ~9,600 of tsc's + /// 9,644 functions — losing the names and parameters the DI contract + /// promises. Without `ClosureHeaders` these two assertions fail. + #[test] + fn closures_keep_their_names_and_parameters() { + let hir = HirModule::new("t"); + let closures = vec![closure_expr(7, vec![param("epsilon", false)], false)]; + let headers = ClosureHeaders::new(&closures); + let _guard = override_function_source_header_mode(true); + assert_eq!( + retained_function_text(&hir, &headers, 7, "function named2(epsilon) { return 1; }"), + "function (epsilon) { /* source elided */ }" + ); + assert!(!retained_function_text(&hir, &headers, 7, "x").contains("return")); + } + + /// An arrow must not be reported as `function (...)`: that misstates the + /// function *kind* on top of eliding the body. + #[test] + fn arrow_closures_keep_arrow_syntax() { + let hir = HirModule::new("t"); + let closures = vec![closure_expr( + 9, + vec![param("g", false), param("d", false)], + true, + )]; + let headers = ClosureHeaders::new(&closures); + let _guard = override_function_source_header_mode(true); + assert_eq!( + retained_function_text(&hir, &headers, 9, "(g, d) => g + d"), + "(g, d) => { /* source elided */ }" + ); + } + + /// An unknown id still degrades safely rather than panicking. + #[test] + fn unknown_ids_fall_back_to_an_anonymous_header() { + let hir = HirModule::new("t"); + let headers = ClosureHeaders::empty(); + let _guard = override_function_source_header_mode(true); + assert_eq!( + retained_function_text(&hir, &headers, 404, "whatever"), + "function () { /* source elided */ }" + ); + } + + /// Default mode must stay byte-identical to the original source. + #[test] + fn full_mode_is_byte_identical() { + let hir = HirModule::new("t"); + let headers = ClosureHeaders::empty(); + let _guard = override_function_source_header_mode(false); + let src = "function keepMe(a, b) { return a + b; }"; + assert_eq!(retained_function_text(&hir, &headers, 1, src), src); + } + + #[test] + fn compiler_params_are_omitted() { + let text = + synthesize_function_header("foo", &[param("__perry_cap_0", false), param("a", false)]); + assert_eq!(text, "function foo(a) { /* source elided */ }"); + } +} diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 592d956d69..925c42f4cd 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -199,6 +199,7 @@ mod entry; pub mod entry_outline; pub(crate) mod func_registry; mod function; +mod function_source_header; #[cfg(test)] mod guarded_falsy_default_method_tests; #[cfg(test)] diff --git a/crates/perry-codegen/src/codegen/retained_source_pool.rs b/crates/perry-codegen/src/codegen/retained_source_pool.rs index dad12102c9..de5763001e 100644 --- a/crates/perry-codegen/src/codegen/retained_source_pool.rs +++ b/crates/perry-codegen/src/codegen/retained_source_pool.rs @@ -11,6 +11,12 @@ use crate::{ }; const MIN_PATTERN_BYTES: usize = 4096; +/// Byte-sum cap on the all-parents Aho-Corasick intern. Nested function +/// source on a real bundle is *unique strings* whose lengths still sum to +/// the duplicated total (tsc: ~24 MB of overlapping slices of a ~6 MB +/// module). Exceeding this used to disable intern entirely (`plan` returned +/// one blob per function), which is how #10574 measured 24.3 MB of +/// `__cstring`. Over-budget modules now still share into the longest parent. const MAX_PATTERN_BYTES: usize = 8 * 1024 * 1024; const MAX_MATCHES: usize = 1_000_000; @@ -81,8 +87,10 @@ impl<'a> SourcePool<'a> { } } -/// Each result names an input parent and an exact byte offset. Budget/build -/// failure keeps independent byte ranges, never a guessed match or missing text. +/// Each result names an input parent and an exact byte offset. Empty sources +/// and below-minimum modules keep independent byte ranges. Over-budget or +/// automaton-build failure still intern into the longest parent (#10574) +/// rather than emitting one copy per function. fn plan( input: &[&[u8]], minimum_bytes: usize, @@ -93,21 +101,19 @@ fn plan( let total = input .iter() .fold(0usize, |sum, bytes| sum.saturating_add(bytes.len())); - if input.len() < 2 - || total < minimum_bytes - || total > maximum_bytes - || input.iter().any(|bytes| bytes.is_empty()) - || maximum_matches == 0 - { + if input.len() < 2 || total < minimum_bytes || input.iter().any(|bytes| bytes.is_empty()) { return raw(); } + if total > maximum_bytes || maximum_matches == 0 { + return share_into_longest(input); + } // A contiguous NFA avoids the potentially much larger dense DFA. Pattern // bytes and reported matches are bounded independently of source syntax. let Ok(automaton) = AhoCorasickBuilder::new() .kind(Some(AhoCorasickKind::ContiguousNFA)) .build(input) else { - return raw(); + return share_into_longest(input); }; let mut order: Vec = (0..input.len()).collect(); order.sort_by_key(|&idx| (std::cmp::Reverse(input[idx].len()), idx)); @@ -138,6 +144,46 @@ fn plan( .collect() } +/// Nested `Function.prototype.toString` text is almost always a slice of the +/// longest function (the CJS factory / module wrapper). Searching each +/// remaining unique string in that one haystack recovers the 4× duplication +/// without building an automaton over every overlapping copy. +fn share_into_longest(input: &[&[u8]]) -> Vec<(usize, usize)> { + let n = input.len(); + let mut result: Vec<(usize, usize)> = (0..n).map(|idx| (idx, 0)).collect(); + if n < 2 { + return result; + } + let parent = (0..n) + .min_by_key(|&idx| (std::cmp::Reverse(input[idx].len()), idx)) + .expect("n >= 2"); + let haystack = input[parent]; + for (idx, needle) in input.iter().copied().enumerate() { + if idx == parent || needle.len() > haystack.len() { + continue; + } + if let Some(offset) = find_bytes(haystack, needle) { + result[idx] = (parent, offset); + } + } + result +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() { + return Some(0); + } + if needle.len() > haystack.len() { + return None; + } + AhoCorasickBuilder::new() + .kind(Some(AhoCorasickKind::ContiguousNFA)) + .build(std::iter::once(needle)) + .ok()? + .find(haystack) + .map(|found| found.start()) +} + #[cfg(test)] mod tests { use super::*; @@ -188,11 +234,34 @@ mod tests { } let raw: Vec<_> = (0..input.len()).map(|idx| (idx, 0)).collect(); assert_eq!(verify(input, 1024, 2048, 100), raw); - assert_eq!(verify(input, 0, 1, 100), raw); + // Over the automaton byte budget: still share into the longest parent + // (`aaaa`) instead of disabling intern. `b` is disjoint and stays a + // blob. #10574: this is the tsc-sized path (24 MB nested / 8 MB cap). + let over = verify(input, 0, 1, 100); + assert_eq!(over[0], (3, 0)); + assert_eq!(over[1], (3, 0)); + assert_eq!(over[2], (3, 0)); + assert_eq!(over[3], (3, 0)); + assert_eq!(over[4], (4, 0)); verify(&[b"", b"hello"], 0, 1024, 100); verify(&[], 0, 1024, 100); } + #[test] + fn over_budget_nested_function_source_shares_into_the_longest_parent() { + let inner = b"function inner() { return 1; }"; + let mut outer = b"function outer() { ".to_vec(); + outer.extend_from_slice(inner); + outer.extend_from_slice(b" }"); + let input: &[&[u8]] = &[&outer, inner]; + // `maximum_bytes` below `outer.len() + inner.len()` forces the + // longest-parent fallback the 8 MiB production cap takes on tsc. + let result = verify(input, 0, 8, 100); + assert_eq!(result[0], (0, 0)); + assert_eq!(result[1].0, 0); + assert_eq!(&outer[result[1].1..result[1].1 + inner.len()], inner); + } + #[test] fn emitted_pool_deduplicates_small_sources_without_a_matcher() { let mut module = LlModule::new("aarch64-apple-darwin"); diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index 4c2690b505..b09695285d 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -122,10 +122,51 @@ pub(crate) fn builtin_parent_reserved_class_id(name: &str) -> Option { "BigInt64Array" => 0xFFFF0039, "BigUint64Array" => 0xFFFF003A, "Function" => 0xFFFF00F0, + // #10556: `class Sub extends EventEmitter {}` — same shape as the + // Array/Map/Set/Error builtins above. Without this edge, + // `new Sub() instanceof EventEmitter` never reaches the class-chain + // walk in `js_instanceof` and falls back to the dynamic-dispatch + // handle/prototype probes in perry-runtime/src/object/instanceof.rs, + // which don't recognize a genuine subclass ObjectHeader. Keep in + // sync with `CLASS_ID_EVENT_EMITTER` there. + "EventEmitter" => 0xFFFF0076, _ => return None, }) } +/// #10477: HIR hands `x instanceof F` with an IMPORTED `F` to codegen as a +/// dynamic RHS (the binding's `ExternFuncRef` value), because only codegen +/// knows what the import resolved to. Returns `true` when the static +/// `js_instanceof(v, )` path below still answers the question, so the +/// value is never materialized: +/// +/// - an imported CLASS: its value is the INT32 class-ref immediate that +/// `js_instanceof_dynamic` would only unpack back to the same id. The filter +/// mirrors `ExternFuncRef`'s value lowering (`dyn_extern_i18n.rs`), so class +/// metadata that is not this lexical binding never claims it; +/// - a binding that is not a compiled source-module import (a V8-fallback or +/// node-submodule import, an FFI `declare function`, an unresolved name): its +/// value form is a placeholder, and these keep their reserved-id mapping. +/// +/// Every other import (a function constructor, an exported `const` holding +/// one, a CJS `module.exports = F`) has no class id, so the static path folded +/// it to id 0 and the check was always `false`. `name == ty` confines this to +/// the bare-identifier RHS; a parenthesized or cast RHS was dynamic before. +fn imported_instanceof_rhs_is_static(ctx: &FnCtx<'_>, ty: &str, ty_expr: &Expr) -> bool { + let Expr::ExternFuncRef { name, .. } = ty_expr else { + return false; + }; + if name != ty { + return false; + } + let imported_class = ctx.class_ids.contains_key(name) + && !ctx.imported_vars.contains(name) + && !ctx.namespace_imports.contains(name); + imported_class + || !ctx.import_function_prefixes.contains_key(name) + || ctx.import_function_v8_specifiers.contains_key(name) +} + fn emit_with_key(ctx: &mut FnCtx<'_>, property: &str) -> (String, String) { let key_idx = ctx.strings.intern(property); let key_entry = ctx.strings.entry(key_idx); @@ -334,7 +375,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // #7615 slice 2: `v` is live across the RHS's lowering, so the pair // is rooted as a group. The static-RHS path below lowers nothing // after `v` and keeps its plain `lower_expr`. - if let Some(ty_e) = ty_expr { + if let Some(ty_e) = ty_expr + .as_deref() + .filter(|ty_e| !imported_instanceof_rhs_is_static(ctx, ty, ty_e)) + { return rooting::with_operands_rooted(ctx, &[e, ty_e], |ctx, vals| { Ok(ctx.block().call( DOUBLE, diff --git a/crates/perry-codegen/src/expr/instanceof_imported_rhs_tests.rs b/crates/perry-codegen/src/expr/instanceof_imported_rhs_tests.rs new file mode 100644 index 0000000000..a58793068b --- /dev/null +++ b/crates/perry-codegen/src/expr/instanceof_imported_rhs_tests.rs @@ -0,0 +1,152 @@ +//! #10477: `x instanceof F` where `F` is an IMPORTED binding. +//! +//! HIR cannot tell an imported function constructor from an imported class — +//! both are `ExternFuncRef` bindings — so it hands every imported RHS to +//! codegen as a dynamic `ty_expr` and codegen decides. Both directions are +//! asserted here, because each one silently degrades in a way no other test +//! sees: +//! +//! * a compiled-source import with no class id must reach +//! `js_instanceof_dynamic`, which resolves the constructor value and walks +//! the prototype chain. Before #10477 it folded to `js_instanceof(v, 0)` — +//! a well-formed call that always answers `false`; +//! * an imported CLASS must keep the static `js_instanceof(v, )` +//! check. Routing it through the dynamic helper would still be correct, so +//! only an IR census catches the regression: the class fast path would just +//! get slower. + +use crate::{compile_module, CompileOptions, ImportedClass}; +use perry_hir::types::Type; +use perry_hir::{Expr, Module, Stmt}; + +const DYNAMIC_CALL: &str = "call double @js_instanceof_dynamic("; +const STATIC_CALL: &str = "call double @js_instanceof("; + +fn imported_ref(name: &str) -> Expr { + Expr::ExternFuncRef { + name: name.to_string(), + param_types: Vec::new(), + return_type: Type::Any, + } +} + +/// `{} instanceof ` with the imported binding's value attached, exactly +/// as `lower_expr/arm_bin.rs` lowers a bare imported identifier RHS. +fn instanceof_imported(name: &str) -> Module { + let mut module = Module::new("instanceof_imported.ts"); + module.init = vec![Stmt::Expr(Expr::InstanceOf { + expr: Box::new(Expr::Object(Vec::new())), + ty: name.to_string(), + ty_expr: Some(Box::new(imported_ref(name))), + })]; + module +} + +fn imported_class(name: &str, class_id: u32) -> ImportedClass { + ImportedClass { + name: name.to_string(), + local_alias: None, + namespace: None, + source_prefix: "lib_ts".to_string(), + constructor_param_count: 0, + has_own_constructor: true, + constructor_has_rest: false, + has_instance_fields: false, + method_names: Vec::new(), + proven_this_method_names: Vec::new(), + proven_this_tower_method_names: Vec::new(), + method_return_types: Vec::new(), + method_param_counts: Vec::new(), + method_has_rest: Vec::new(), + method_has_synthetic_arguments: Vec::new(), + method_arguments_length_only: Vec::new(), + static_field_names: Vec::new(), + static_method_names: Vec::new(), + static_method_return_types: Vec::new(), + static_method_param_counts: Vec::new(), + static_method_has_rest: Vec::new(), + static_method_has_user_rest: Vec::new(), + static_method_has_synthetic_arguments: Vec::new(), + getter_names: Vec::new(), + getter_return_types: Vec::new(), + setter_names: Vec::new(), + parent_name: None, + field_names: Vec::new(), + field_types: Vec::new(), + source_class_id: Some(class_id), + return_shape_imports: Vec::new(), + object_literal: None, + } +} + +fn compile(module: &Module, opts: CompileOptions) -> String { + String::from_utf8(compile_module(module, opts).expect("instanceof module compiles")) + .expect("LLVM IR is UTF-8") +} + +#[test] +fn imported_function_constructor_rhs_resolves_the_constructor_value() { + let mut opts = CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + opts.import_function_prefixes + .insert("Plain".to_string(), "lib_ts".to_string()); + let ir = compile(&instanceof_imported("Plain"), opts); + + assert!( + ir.contains(DYNAMIC_CALL), + "an imported function constructor must resolve its value and walk the \ + prototype chain:\n{ir}" + ); + assert!( + !ir.contains(STATIC_CALL), + "the class-id check has no id for a function import — it folds to \ + `false` (#10477):\n{ir}" + ); +} + +#[test] +fn imported_class_rhs_keeps_the_static_class_id_check() { + let mut opts = CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + opts.import_function_prefixes + .insert("Klass".to_string(), "lib_ts".to_string()); + opts.imported_classes.push(imported_class("Klass", 7701)); + let ir = compile(&instanceof_imported("Klass"), opts); + + assert!( + ir.contains(STATIC_CALL), + "an imported class must keep the static class-id check:\n{ir}" + ); + assert!( + !ir.contains(DYNAMIC_CALL), + "the dynamic helper would only unpack the same class id back out:\n{ir}" + ); +} + +#[test] +fn unresolved_import_rhs_keeps_its_reserved_builtin_id() { + // Not a compiled source module (nothing in `import_function_prefixes`): + // the binding's value form is a placeholder, so the reserved-id mapping + // stays — `js_instanceof_dynamic` on a placeholder would throw instead of + // answering. + let ir = compile( + &instanceof_imported("Error"), + CompileOptions { + emit_ir_only: true, + ..Default::default() + }, + ); + + assert!( + ir.contains(STATIC_CALL), + "an unresolved import must keep the static reserved-id check:\n{ir}" + ); + assert!( + !ir.contains(DYNAMIC_CALL), + "nothing resolves this binding to a constructor value:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index fae77a1dba..f6c47ec5aa 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -170,6 +170,8 @@ mod entry_block_alloca_tests; mod hit_path_access_tests; #[cfg(test)] mod index_set_barrier_tests; +#[cfg(test)] +mod instanceof_imported_rhs_tests; mod record_value; mod repsel_gates; mod scalar_slot_root; @@ -3048,7 +3050,7 @@ mod math_simple; mod misc_methods; mod new_dynamic; mod objects_arrays_lit; -mod os_uri_dates; +pub(crate) mod os_uri_dates; pub(crate) mod property_get; pub(crate) mod property_set; pub(crate) mod proxy_reflect; diff --git a/crates/perry-codegen/src/lower_array_method.rs b/crates/perry-codegen/src/lower_array_method.rs index 3b66f4cf99..9516b7e0fa 100644 --- a/crates/perry-codegen/src/lower_array_method.rs +++ b/crates/perry-codegen/src/lower_array_method.rs @@ -363,67 +363,13 @@ pub(crate) fn lower_array_method( if args.is_empty() { bail!("perry-codegen: Array.copyWithin expects 1-3 args, got 0",); } - let target_d = arg_vals[0].clone(); - let start_d = if args.len() >= 2 { - arg_vals[1].clone() - } else { - double_literal(0.0) - }; - let (has_end_str, end_d) = if args.len() >= 3 { - ("1".to_string(), arg_vals[2].clone()) - } else { - ("0".to_string(), "0.0".to_string()) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, recv_box); - let result = blk.call( - I64, - "js_array_copy_within", - &[ - (I64, &recv_handle), - (DOUBLE, &target_d), - (DOUBLE, &start_d), - (I32, &has_end_str), - (DOUBLE, &end_d), - ], - ); - Ok(nanbox_pointer_inline(blk, &result)) - } - "flat" => { - // ECMA-262 §23.1.3.10 `arr.flat(depth?)`. Default depth = 1. - // The depth-aware path routes to `js_array_flat_depth` (handles - // 0 = shallow copy, Infinity = full recursion); 0-arg keeps - // the legacy `js_array_flat` fast path. - if args.is_empty() { - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, recv_box); - let result = blk.call(I64, "js_array_flat", &[(I64, &recv_handle)]); - Ok(nanbox_pointer_inline(blk, &result)) - } else { - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, recv_box); - let result = blk.call( - I64, - "js_array_flat_depth", - &[(I64, &recv_handle), (DOUBLE, &arg_vals[0])], - ); - Ok(nanbox_pointer_inline(blk, &result)) - } - } - "flatMap" => { - // 0-arg → runtime TypeError (pad undefined), not compile-fail. - let cb_box = arg_or_undefined(arg_vals, 0); - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, recv_box); - // #4091: throw TypeError for a non-callable callback before iterating. - let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); - let result = blk.call( - I64, - "js_array_flatMap", - &[(I64, &recv_handle), (I64, &cb_handle)], - ); - Ok(nanbox_pointer_inline(blk, &result)) + Ok(emit_array_method_on_values( + ctx, property, recv_box, arg_vals, + )) } + "flat" | "flatMap" => Ok(emit_array_method_on_values( + ctx, property, recv_box, arg_vals, + )), // -------- Safety-net handlers for methods that normally arrive -------- // as HIR variants but may reach here as generic MethodCall when // the HIR lowering doesn't recognize the pattern. @@ -553,28 +499,8 @@ pub(crate) fn lower_array_method( args.len() ); } - // 0-arg → runtime TypeError (callback validation on undefined), - // not compile-fail. - let cb_box = arg_or_undefined(arg_vals, 0); - let (has_initial, initial_box) = if args.len() == 2 { - (1i32, arg_vals[1].clone()) - } else { - (0i32, "0.0".to_string()) - }; - let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, recv_box); - // #4091: throw TypeError for a non-callable callback before iterating. - let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); - let has_init_str = format!("{}", has_initial); - Ok(blk.call( - DOUBLE, - "js_array_reduce_right", - &[ - (I64, &recv_handle), - (I64, &cb_handle), - (I32, &has_init_str), - (DOUBLE, &initial_box), - ], + Ok(emit_array_method_on_values( + ctx, property, recv_box, arg_vals, )) } "map" => { @@ -1051,14 +977,17 @@ pub(crate) fn lower_array_method( // the array, not the joined locale string). Route through the runtime // dispatch tower, which walks elements and calls each element's own // `toLocaleString(locales, options)`. - // - // #2803 defensive: `toReversed` / `toSorted` / `toSpliced` normally fold - // to dedicated `Expr::ArrayTo*` nodes upstream, but if that fold ever - // bails for an `any`-typed receiver they would otherwise hit the - // receiver-returning catch-all below. Dispatching them dynamically here - // keeps the immutable-copy semantics (the runtime arms added in #2803). - "next" | "return" | "throw" | "toLocaleString" | "toReversed" | "toSorted" - | "toSpliced" => emit_native_method_dispatch(ctx, recv_box, property, arg_vals), + "next" | "return" | "throw" | "toLocaleString" => { + emit_native_method_dispatch(ctx, recv_box, property, arg_vals) + } + // #2803 / #10476: `toReversed` / `toSorted` / `toSpliced` fold to + // `Expr::ArrayTo*` upstream only for a receiver HIR proves is an + // Array (a method NAME is no proof). One this pass proves — e.g. an + // `any`-annotated binding of an array literal — arrives here and + // keeps the same direct helpers rather than paying for dispatch. + "toReversed" | "toSorted" | "toSpliced" => Ok(emit_array_method_on_values( + ctx, property, recv_box, arg_vals, + )), // #3148: TypedArray.prototype.set(source, offset?). Copies elements // from an Array/TypedArray source into this typed array. The runtime // helper no-ops for non-typed-array receivers, so it is safe under the @@ -1119,6 +1048,154 @@ pub(crate) fn lower_array_method( }) } +/// Whether [`emit_array_method_on_values`] lowers `property` called with +/// `argc` arguments without a compile-time arity error. +pub(crate) fn is_array_method_on_values(property: &str, argc: usize) -> bool { + match property { + "flat" | "flatMap" | "toReversed" | "toSorted" | "toSpliced" => true, + "reduceRight" => argc <= 2, + "copyWithin" => argc >= 1, + _ => false, + } +} + +/// The dense-Array lowering of the ES2019+ flatten, right-fold and copy +/// methods on an already-evaluated receiver and arguments — the runtime calls +/// the `Expr::Array*` folds make. Shared by the arms above and by the +/// plain-array arm of the receiver-kind guard (#10476). The receiver may be +/// a typed array or Buffer here too; each helper re-dispatches on that before +/// reading an ArrayHeader. Surplus arguments were evaluated and are ignored. +/// +/// Only names [`is_array_method_on_values`] accepts reach this. +pub(crate) fn emit_array_method_on_values( + ctx: &mut FnCtx<'_>, + property: &str, + recv_box: &str, + arg_vals: &[String], +) -> String { + let blk = ctx.block(); + let recv_handle = unbox_to_i64(blk, recv_box); + let result = match property { + // ECMA-262 §23.1.3.10 `arr.flat(depth?)`. Default depth = 1. The + // depth-aware path routes to `js_array_flat_depth` (handles 0 = shallow + // copy, Infinity = full recursion); 0-arg keeps the `js_array_flat` fast + // path. + "flat" => match arg_vals.first() { + None => blk.call(I64, "js_array_flat", &[(I64, &recv_handle)]), + Some(depth) => blk.call( + I64, + "js_array_flat_depth", + &[(I64, &recv_handle), (DOUBLE, depth)], + ), + }, + "flatMap" => { + // 0-arg → runtime TypeError (pad undefined), not compile-fail. + let cb_box = arg_or_undefined(arg_vals, 0); + // #4091: throw TypeError for a non-callable callback before iterating. + let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); + blk.call( + I64, + "js_array_flatMap", + &[(I64, &recv_handle), (I64, &cb_handle)], + ) + } + "reduceRight" => { + // 0-arg → runtime TypeError (callback validation on undefined), + // not compile-fail. + let cb_box = arg_or_undefined(arg_vals, 0); + let (has_initial, initial_box) = match arg_vals.get(1) { + Some(initial) => ("1", initial.clone()), + None => ("0", "0.0".to_string()), + }; + // #4091: throw TypeError for a non-callable callback before iterating. + let cb_handle = blk.call(I64, "js_validate_array_callback", &[(DOUBLE, &cb_box)]); + return blk.call( + DOUBLE, + "js_array_reduce_right", + &[ + (I64, &recv_handle), + (I64, &cb_handle), + (I32, has_initial), + (DOUBLE, &initial_box), + ], + ); + } + "copyWithin" => { + let target = arg_or_undefined(arg_vals, 0); + let start = arg_vals + .get(1) + .cloned() + .unwrap_or_else(|| double_literal(0.0)); + let (has_end, end) = match arg_vals.get(2) { + Some(end) => ("1", end.clone()), + None => ("0", "0.0".to_string()), + }; + blk.call( + I64, + "js_array_copy_within", + &[ + (I64, &recv_handle), + (DOUBLE, &target), + (DOUBLE, &start), + (I32, has_end), + (DOUBLE, &end), + ], + ) + } + "toSorted" => match arg_vals.first() { + None => blk.call(I64, "js_array_to_sorted_default", &[(I64, &recv_handle)]), + Some(comparator) => { + // #2796: the comparator must be a function or undefined. + let cmp = blk.call(I64, "js_validate_array_comparator", &[(DOUBLE, comparator)]); + blk.call( + I64, + "js_array_to_sorted_with_comparator", + &[(I64, &recv_handle), (I64, &cmp)], + ) + } + }, + "toSpliced" => { + // #2794: 0 args → shallow copy (start 0, deleteCount 0); 1 arg → + // delete through the end (deleteCount +Infinity, clamped by the + // runtime); 2+ → explicit count, the rest are inserted items. + let start = arg_vals + .first() + .cloned() + .unwrap_or_else(|| double_literal(0.0)); + let delete_count = match (arg_vals.len(), arg_vals.get(1)) { + (_, Some(count)) => count.clone(), + (0, None) => double_literal(0.0), + (_, None) => double_literal(f64::INFINITY), + }; + let items = arg_vals.get(2..).unwrap_or(&[]); + let (items_ptr, items_len) = if items.is_empty() { + ("null".to_string(), "0".to_string()) + } else { + let buf = ctx.func.alloca_entry_array(DOUBLE, items.len()); + let blk = ctx.block(); + for (i, item) in items.iter().enumerate() { + let slot = blk.gep(DOUBLE, &buf, &[(I64, &i.to_string())]); + blk.store(DOUBLE, item, &slot); + } + (buf, items.len().to_string()) + }; + ctx.block().call( + I64, + "js_array_to_spliced", + &[ + (I64, &recv_handle), + (DOUBLE, &start), + (DOUBLE, &delete_count), + (PTR, &items_ptr), + (I32, &items_len), + ], + ) + } + _ => blk.call(I64, "js_array_to_reversed", &[(I64, &recv_handle)]), + }; + nanbox_pointer_inline(ctx.block(), &result) +} + /// `js_native_call_method(recv, name, name_len, argv, argc)` over already-lowered /// argument values. /// diff --git a/crates/perry-codegen/src/lower_call/property_get.rs b/crates/perry-codegen/src/lower_call/property_get.rs index 7fc22aa15f..dda1cccd8d 100644 --- a/crates/perry-codegen/src/lower_call/property_get.rs +++ b/crates/perry-codegen/src/lower_call/property_get.rs @@ -21,6 +21,7 @@ use crate::type_analysis::{ }; use crate::types::{DOUBLE, I1, I64}; +mod builtin_kind_guard; mod dynamic_dispatch; mod fetch_chain; mod helpers; @@ -219,6 +220,19 @@ pub fn try_lower_property_get_method_call( return Ok(Some(value)); } + // #10476: the same guard for Date / Number method names. HIR folds a + // Date intrinsic only for a proven Date and number_string.rs claims only + // a proven number; any other receiver checks its runtime kind here. + if let Some(value) = builtin_kind_guard::try_lower_kind_guarded_builtin_method( + ctx, + object, + property, + args, + call_byte_offset, + )? { + return Ok(Some(value)); + } + // Class instance method call (interface/dynamic dispatch tower + // static-fallback / virtual-override tower). if let Some(value) = dynamic_dispatch::try_lower_instance_method_call( diff --git a/crates/perry-codegen/src/lower_call/property_get/builtin_kind_guard.rs b/crates/perry-codegen/src/lower_call/property_get/builtin_kind_guard.rs new file mode 100644 index 0000000000..759dc7f952 --- /dev/null +++ b/crates/perry-codegen/src/lower_call/property_get/builtin_kind_guard.rs @@ -0,0 +1,484 @@ +//! Receiver-kind guards for Date / Number / Array builtin method names (#10476). +//! +//! A method NAME is not proof of its receiver's kind. dayjs and moment own +//! `toISOString`/`getTime`, decimal.js and bignumber.js own `toFixed`, and any +//! class may define `setHours`, `toPrecision` or `toSorted`. Lowering such a +//! call straight to `js_date_*` / `js_number_to_*` / `js_array_*` ran the +//! builtin on the user's object (`"NaN"`, `Invalid time value`, +//! `[object Object]`, `[]`) and never called the user's method. +//! +//! The direct builtin call is kept for a receiver the compiler has proven to be +//! a Date / number. Any other receiver without a known class is evaluated once +//! with its arguments, then its runtime kind selects the builtin or the +//! universal method dispatcher, which finds an own or inherited user method and +//! still reaches the builtin through the prototype for a Date, a number or an +//! array. Known class receivers are left to the class dispatch tower. + +use anyhow::Result; +use perry_hir::Expr; + +use crate::expr::{lower_expr, nanbox_string_inline, unbox_to_i64, FnCtx}; +use crate::rooting::{any_operand_may_collect, open_rooted_group, Repr}; +use crate::type_analysis::{ + is_array_expr, is_native_module_dynamic_index, is_numeric_expr, is_string_expr, + receiver_class_name, +}; +use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; + +use super::is_date_receiver; + +/// A Date method with a direct runtime entry point. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DateBuiltin { + /// `double fn(double time)`. Accepts a Date or its time value. + Getter(&'static str), + /// `StringHeader* fn(double time)`. Accepts a Date or its time value. + Formatter(&'static str), + /// `double js_value_to_locale_string(date)`, the proven-Date + /// `Expr::DateToLocaleString` entry point. It dispatches on the value's + /// tag, so it needs the Date itself, never its time value. + LocaleString, + /// `double js_date_apply_setter(date, is_utc, field, args, argc)`. + Setter { is_utc: bool, field: i32 }, +} + +/// The receiver kind a guarded builtin requires. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ReceiverKind { + Date, + /// `toLocaleString()`: any primitive, Date or Symbol (see + /// [`emit_receiver_kind_branch`]). + LocaleValue, + Number, + Array, +} + +/// The runtime entry point the HIR `Expr::Date*` arms lower each name to. +/// `toLocale*String` only has a fast path without locale/options arguments; +/// with arguments they belong to the generic dispatcher's Intl thunks. +fn date_builtin(property: &str, argc: usize) -> Option { + use crate::expr::os_uri_dates::{ + DATE_FIELD_DATE, DATE_FIELD_FULL_YEAR, DATE_FIELD_HOURS, DATE_FIELD_MILLISECONDS, + DATE_FIELD_MINUTES, DATE_FIELD_MONTH, DATE_FIELD_SECONDS, DATE_FIELD_TIME, + }; + let getter = DateBuiltin::Getter; + let formatter = DateBuiltin::Formatter; + let local = |field| DateBuiltin::Setter { + is_utc: false, + field, + }; + let utc = |field| DateBuiltin::Setter { + is_utc: true, + field, + }; + Some(match property { + "getTime" => getter("js_date_get_time"), + "getTimezoneOffset" => getter("js_date_get_timezone_offset"), + "getFullYear" => getter("js_date_get_full_year"), + "getMonth" => getter("js_date_get_month"), + "getDate" => getter("js_date_get_date"), + "getDay" => getter("js_date_get_day"), + "getHours" => getter("js_date_get_hours"), + "getMinutes" => getter("js_date_get_minutes"), + "getSeconds" => getter("js_date_get_seconds"), + "getMilliseconds" => getter("js_date_get_milliseconds"), + "getUTCFullYear" => getter("js_date_get_utc_full_year"), + "getUTCMonth" => getter("js_date_get_utc_month"), + "getUTCDate" => getter("js_date_get_utc_date"), + "getUTCDay" => getter("js_date_get_utc_day"), + "getUTCHours" => getter("js_date_get_utc_hours"), + "getUTCMinutes" => getter("js_date_get_utc_minutes"), + "getUTCSeconds" => getter("js_date_get_utc_seconds"), + "getUTCMilliseconds" => getter("js_date_get_utc_milliseconds"), + "toISOString" => formatter("js_date_to_iso_string_or_throw"), + "toDateString" => formatter("js_date_to_date_string"), + "toTimeString" => formatter("js_date_to_time_string"), + "toUTCString" | "toGMTString" => formatter("js_date_to_utc_string"), + "toLocaleDateString" if argc == 0 => formatter("js_date_to_locale_date_string"), + "toLocaleTimeString" if argc == 0 => formatter("js_date_to_locale_time_string"), + "toLocaleString" if argc == 0 => DateBuiltin::LocaleString, + "setFullYear" => local(DATE_FIELD_FULL_YEAR), + "setMonth" => local(DATE_FIELD_MONTH), + "setDate" => local(DATE_FIELD_DATE), + "setHours" => local(DATE_FIELD_HOURS), + "setMinutes" => local(DATE_FIELD_MINUTES), + "setSeconds" => local(DATE_FIELD_SECONDS), + "setMilliseconds" => local(DATE_FIELD_MILLISECONDS), + "setTime" => local(DATE_FIELD_TIME), + "setUTCFullYear" => utc(DATE_FIELD_FULL_YEAR), + "setUTCMonth" => utc(DATE_FIELD_MONTH), + "setUTCDate" => utc(DATE_FIELD_DATE), + "setUTCHours" => utc(DATE_FIELD_HOURS), + "setUTCMinutes" => utc(DATE_FIELD_MINUTES), + "setUTCSeconds" => utc(DATE_FIELD_SECONDS), + "setUTCMilliseconds" => utc(DATE_FIELD_MILLISECONDS), + _ => return None, + }) +} + +/// `StringHeader* fn(double number, double arg)` for each Number method. +fn number_builtin(property: &str) -> Option<&'static str> { + match property { + "toFixed" => Some("js_number_to_fixed"), + "toPrecision" => Some("js_number_to_precision"), + "toExponential" => Some("js_number_to_exponential"), + _ => None, + } +} + +/// An unproven receiver that neither the String/Array lowering nor the class +/// dispatch tower owns. Module objects keep their own member dispatch. +/// +/// When any compiled class defines `property`, the receiver may be one of its +/// instances — including a `class X extends Date` override, whose instance is a +/// Date at runtime and would pass the kind check — so the class dispatch tower +/// keeps the call; its fallback is the same universal method dispatch. +fn receiver_is_unproven(ctx: &FnCtx<'_>, object: &Expr, property: &str) -> bool { + !is_string_expr(ctx, object) + && !is_array_expr(ctx, object) + && receiver_class_name(ctx, object).is_none() + && !matches!(object, Expr::GlobalGet(_) | Expr::NativeModuleRef(_)) + && !is_native_module_dynamic_index(object) + && !ctx.methods.keys().any(|(_, method)| method == property) +} + +/// Lower a Date, Number or Array builtin-named method call whose receiver was +/// not claimed by a proven fast path. Returns `Ok(None)` for other names and +/// for receivers the class dispatch tower owns. +pub(super) fn try_lower_kind_guarded_builtin_method( + ctx: &mut FnCtx<'_>, + object: &Expr, + property: &str, + args: &[Expr], + call_byte_offset: u32, +) -> Result> { + if let Some(date) = date_builtin(property, args.len()) { + let unproven_kind = if date == DateBuiltin::LocaleString { + ReceiverKind::LocaleValue + } else { + ReceiverKind::Date + }; + let guard = if is_date_receiver(ctx, object) { + None + } else if receiver_is_unproven(ctx, object, property) { + Some(unproven_kind) + } else { + return Ok(None); + }; + return guarded_call( + ctx, + object, + property, + args, + call_byte_offset, + guard, + |ctx, recv, time, arg_vals| emit_date_builtin(ctx, date, recv, time, arg_vals), + ) + .map(Some); + } + if let Some(runtime_fn) = number_builtin(property) { + // A proven number keeps number_string.rs's direct lowering. + if receiver_is_unproven(ctx, object, property) && !is_numeric_expr(ctx, object) { + return guarded_call( + ctx, + object, + property, + args, + call_byte_offset, + Some(ReceiverKind::Number), + |ctx, recv, _, arg_vals| emit_number_builtin(ctx, runtime_fn, recv, arg_vals), + ) + .map(Some); + } + } + // A proven array keeps `lower_array_method`, and HIR folds most. + if crate::lower_array_method::is_array_method_on_values(property, args.len()) + && receiver_is_unproven(ctx, object, property) + { + return guarded_call( + ctx, + object, + property, + args, + call_byte_offset, + Some(ReceiverKind::Array), + |ctx, recv, _, arg_vals| { + crate::lower_array_method::emit_array_method_on_values( + ctx, property, recv, arg_vals, + ) + }, + ) + .map(Some); + } + Ok(None) +} + +fn emit_number_builtin( + ctx: &mut FnCtx<'_>, + runtime_fn: &'static str, + recv: &str, + arg_vals: &[String], +) -> String { + let arg = arg_vals.first().cloned().unwrap_or_else(|| { + crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + }); + let blk = ctx.block(); + let handle = blk.call(I64, runtime_fn, &[(DOUBLE, recv), (DOUBLE, &arg)]); + nanbox_string_inline(blk, &handle) +} + +/// `time` is the Date's time value when the kind check already read it. +/// Getters and formatters take it in place of the Date, so the runtime does +/// not classify the receiver a second time; `getTime` is that value. +fn emit_date_builtin( + ctx: &mut FnCtx<'_>, + date: DateBuiltin, + recv: &str, + time: Option<&str>, + arg_vals: &[String], +) -> String { + match date { + DateBuiltin::Getter("js_date_get_time") if time.is_some() => { + time.unwrap_or(recv).to_string() + } + DateBuiltin::Getter(runtime_fn) => { + let date = time.unwrap_or(recv); + ctx.block().call(DOUBLE, runtime_fn, &[(DOUBLE, date)]) + } + DateBuiltin::Formatter(runtime_fn) => { + let date = time.unwrap_or(recv); + let blk = ctx.block(); + let handle = blk.call(I64, runtime_fn, &[(DOUBLE, date)]); + nanbox_string_inline(blk, &handle) + } + DateBuiltin::LocaleString => { + ctx.block() + .call(DOUBLE, "js_value_to_locale_string", &[(DOUBLE, recv)]) + } + DateBuiltin::Setter { is_utc, field } => { + // Entry-block buffer: an alloca in a loop body is a stack + // adjustment that is never restored (#167). + let (args_ptr, argc) = if arg_vals.is_empty() { + ("null".to_string(), "0".to_string()) + } else { + let buf = ctx.func.alloca_entry_array(DOUBLE, arg_vals.len()); + let blk = ctx.block(); + for (i, value) in arg_vals.iter().enumerate() { + let slot = blk.gep(DOUBLE, &buf, &[(I64, &i.to_string())]); + blk.store(DOUBLE, value, &slot); + } + (buf, arg_vals.len().to_string()) + }; + let is_utc = if is_utc { "1" } else { "0" }; + ctx.block().call( + DOUBLE, + "js_date_apply_setter", + &[ + (DOUBLE, recv), + (I32, is_utc), + (I32, &field.to_string()), + (PTR, &args_ptr), + (I32, &argc), + ], + ) + } + } +} + +/// Evaluate the receiver, then the arguments, once; then either call the +/// builtin directly (`guard == None`, a proven receiver) or branch on the +/// receiver's runtime kind between the builtin and universal method dispatch. +/// +/// The receiver is rooted across argument evaluation, and both are re-read +/// below it. The group is released in the merge block, below both consuming +/// calls (`open_rooted_group`'s diamond case). +fn guarded_call( + ctx: &mut FnCtx<'_>, + object: &Expr, + property: &str, + args: &[Expr], + call_byte_offset: u32, + guard: Option, + builtin: impl FnOnce(&mut FnCtx<'_>, &str, Option<&str>, &[String]) -> String, +) -> Result { + let mut group = open_rooted_group(args.len() + 1); + let recv_box = lower_expr(ctx, object)?; + let recv_collects = any_operand_may_collect(ctx, args.iter()); + let rooted_recv = group.adopt_emitted(ctx, Repr::Boxed, &recv_box, recv_collects); + for (i, arg) in args.iter().enumerate() { + let collects = any_operand_may_collect(ctx, args[i + 1..].iter()); + group.lower(ctx, arg, collects)?; + } + let recv = group.reread_emitted(ctx, rooted_recv); + let arg_vals = group.reread_all(ctx)?; + + let Some(kind) = guard else { + let value = builtin(ctx, &recv, None, &arg_vals); + group.release(ctx); + return Ok(value); + }; + + let builtin_idx = ctx.new_block("kindguard.builtin"); + let generic_idx = ctx.new_block("kindguard.generic"); + let merge_idx = ctx.new_block("kindguard.merge"); + let builtin_label = ctx.block_label(builtin_idx); + let generic_label = ctx.block_label(generic_idx); + let merge_label = ctx.block_label(merge_idx); + let time = emit_receiver_kind_branch(ctx, kind, &recv, &builtin_label, &generic_label); + + ctx.current_block = builtin_idx; + let builtin_value = builtin(ctx, &recv, time.as_deref(), &arg_vals); + let builtin_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = generic_idx; + let generic_value = super::super::console_promise::emit_native_method_str_dispatch( + ctx, + property, + call_byte_offset, + &recv, + &arg_vals, + ); + let generic_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + let value = ctx.block().phi( + DOUBLE, + &[ + (builtin_value.as_str(), builtin_end.as_str()), + (generic_value.as_str(), generic_end.as_str()), + ], + ); + group.release(ctx); + Ok(value) +} + +/// Branch to `builtin_label` when the NaN-boxed `recv` has the kind the +/// builtin requires, else to `generic_label`. No check allocates or runs user +/// code. A Date check returns the Date's time value. +fn emit_receiver_kind_branch( + ctx: &mut FnCtx<'_>, + kind: ReceiverKind, + recv: &str, + builtin_label: &str, + generic_label: &str, +) -> Option { + match kind { + // A Date is a `DateCell` pointer, and only the runtime can tell one + // from any other pointer safely (off-heap buffers carry no GC header). + // `js_date_get_time` returns a Date's time value — a Number, which can + // never equal the POINTER-tagged bits of the cell holding it — and + // every other value bit for bit, so one call both classifies the + // receiver and reads the time value the builtin arm needs. + ReceiverKind::Date => { + let blk = ctx.block(); + let time = blk.call(DOUBLE, "js_date_get_time", &[(DOUBLE, recv)]); + let time_bits = blk.bitcast_double_to_i64(&time); + let recv_bits = blk.bitcast_double_to_i64(recv); + let is_date = blk.icmp_ne(I64, &time_bits, &recv_bits); + blk.cond_br(&is_date, builtin_label, generic_label); + Some(time) + } + // `toLocaleString()` without arguments is `Object.prototype`'s on every + // primitive, a Date's own, and a Symbol's inherited one: + // `js_value_to_locale_string` — the proven path's entry point — formats + // all of them. A heap object may own the method (a user class, dayjs), + // and a nullish receiver must throw the property read's TypeError, so + // both take method dispatch. + ReceiverKind::LocaleValue => { + let heap_or_nullish_idx = ctx.new_block("kindguard.locale_heap_or_nullish"); + let heap_idx = ctx.new_block("kindguard.locale_heap"); + let symbol_idx = ctx.new_block("kindguard.locale_symbol"); + let heap_or_nullish_label = ctx.block_label(heap_or_nullish_idx); + let heap_label = ctx.block_label(heap_idx); + let symbol_label = ctx.block_label(symbol_idx); + + let blk = ctx.block(); + let bits = blk.bitcast_double_to_i64(recv); + let tag = blk.lshr(I64, &bits, "48"); + let is_pointer = blk.icmp_eq(I64, &tag, crate::nanbox::POINTER_TAG_TOP16_I64); + let is_undefined = blk.icmp_eq(I64, &bits, crate::nanbox::TAG_UNDEFINED_I64); + let is_null = blk.icmp_eq(I64, &bits, crate::nanbox::TAG_NULL_I64); + let is_nullish = blk.or(I1, &is_undefined, &is_null); + let not_primitive = blk.or(I1, &is_pointer, &is_nullish); + blk.cond_br(¬_primitive, &heap_or_nullish_label, builtin_label); + + ctx.current_block = heap_or_nullish_idx; + ctx.block().cond_br(&is_pointer, &heap_label, generic_label); + + ctx.current_block = heap_idx; + let blk = ctx.block(); + let time = blk.call(DOUBLE, "js_date_get_time", &[(DOUBLE, recv)]); + let time_bits = blk.bitcast_double_to_i64(&time); + let is_date = blk.icmp_ne(I64, &time_bits, &bits); + blk.cond_br(&is_date, builtin_label, &symbol_label); + + ctx.current_block = symbol_idx; + let blk = ctx.block(); + let is_symbol = blk.call(I32, "js_is_symbol", &[(DOUBLE, recv)]); + let is_symbol = blk.icmp_ne(I32, &is_symbol, "0"); + blk.cond_br(&is_symbol, builtin_label, generic_label); + None + } + // A number is any IEEE double outside Perry's positive tag band + // 0x7FF9..=0x7FFF (`JSValue::is_number`), or an INT32-tagged value. + ReceiverKind::Number => { + let blk = ctx.block(); + let bits = blk.bitcast_double_to_i64(recv); + let tag = blk.lshr(I64, &bits, "48"); + let band_offset = blk.sub(I64, &tag, crate::nanbox::SHORT_STRING_TAG_TOP16_I64); + let in_tag_band = blk.icmp_ult(I64, &band_offset, "7"); + let is_double = blk.xor(I1, &in_tag_band, "true"); + let is_int32 = blk.icmp_eq(I64, &tag, crate::nanbox::INT32_TAG_TOP16_I64); + let is_number = blk.or(I1, &is_double, &is_int32); + blk.cond_br(&is_number, builtin_label, generic_label); + None + } + // A plain array: a POINTER-tagged heap address above the small-handle + // band whose GcHeader is `GC_TYPE_ARRAY` and not forwarded — the header + // test `expr/array_pop.rs` inlines. Lazy JSON arrays, Array subclass + // instances, typed arrays and proxies take method dispatch. (A + // header-less Buffer whose preceding byte happens to match reaches + // helpers that dispatch on Buffer / typed-array receivers first, the + // same helpers the unguarded pre-#10476 fold called on every receiver.) + ReceiverKind::Array => { + const POINTER_TAG_TOP16: &str = "32765"; // 0x7FFD + const HANDLE_BAND_TOP: &str = "1048575"; // 0x0FFFFF + const HEAP_LIMIT: &str = "140737488355328"; // 2^47 + const GC_TYPE_ARRAY_I8: &str = "1"; + const GC_FLAG_FORWARDED_I8: &str = "-128"; // 0x80 as i8 + let header_idx = ctx.new_block("kindguard.array_header"); + let header_label = ctx.block_label(header_idx); + let blk = ctx.block(); + let bits = blk.bitcast_double_to_i64(recv); + let tag = blk.lshr(I64, &bits, "48"); + let is_pointer = blk.icmp_eq(I64, &tag, POINTER_TAG_TOP16); + let handle = unbox_to_i64(blk, recv); + let above_band = blk.icmp_ugt(I64, &handle, HANDLE_BAND_TOP); + let below_limit = blk.icmp_ult(I64, &handle, HEAP_LIMIT); + let in_heap = blk.and(I1, &above_band, &below_limit); + let candidate = blk.and(I1, &is_pointer, &in_heap); + blk.cond_br(&candidate, &header_label, generic_label); + + ctx.current_block = header_idx; + let blk = ctx.block(); + let type_addr = blk.sub(I64, &handle, "8"); + let type_ptr = blk.inttoptr(I64, &type_addr); + let gc_type = blk.load(I8, &type_ptr); + let is_array = blk.icmp_eq(I8, &gc_type, GC_TYPE_ARRAY_I8); + let flags_addr = blk.sub(I64, &handle, "7"); + let flags_ptr = blk.inttoptr(I64, &flags_addr); + let flags = blk.load(I8, &flags_ptr); + let forwarded = blk.and(I8, &flags, GC_FLAG_FORWARDED_I8); + let not_forwarded = blk.icmp_eq(I8, &forwarded, "0"); + let plain_array = blk.and(I1, &is_array, ¬_forwarded); + blk.cond_br(&plain_array, builtin_label, generic_label); + None + } + } +} + +#[cfg(test)] +#[path = "builtin_kind_guard_tests.rs"] +mod tests; diff --git a/crates/perry-codegen/src/lower_call/property_get/builtin_kind_guard_tests.rs b/crates/perry-codegen/src/lower_call/property_get/builtin_kind_guard_tests.rs new file mode 100644 index 0000000000..9825f68ec2 --- /dev/null +++ b/crates/perry-codegen/src/lower_call/property_get/builtin_kind_guard_tests.rs @@ -0,0 +1,283 @@ +//! #10476: IR census for builtin-named Date / Number / Array method calls. +//! +//! The defect was a lowering choice, so the assertions are on the emitted +//! call, not on a predicate: a `getTime` / `setUTCHours` / `toFixed` / +//! `toSorted` call on an unproven receiver must reach universal method dispatch +//! on the non-builtin arm (where a user method lives), and a proven receiver +//! must keep the direct builtin call with no guard. + +use perry_hir::types::Type; +use perry_hir::{Expr, Stmt}; + +use crate::compile_module; +use crate::temp_root_coverage::{entry_opts, module_with_init}; + +const DISPATCH: &str = "call double @js_typed_feedback_native_call_method_by_id("; +const GET_TIME: &str = "call double @js_date_get_time("; + +/// The `main` body for `init`, with `make()` importable as an `any`-returning +/// function so its result is an unproven receiver. +fn main_ir(name: &str, init: Vec) -> String { + let mut opts = entry_opts(); + opts.import_function_prefixes + .insert("make".to_string(), "kind_guard_ts".to_string()); + let bytes = compile_module(&module_with_init(name, init), opts) + .unwrap_or_else(|e| panic!("codegen failed for {name}: {e}")); + let ir = String::from_utf8(bytes).expect("LLVM IR should be UTF-8"); + crate::testing::root_slots::function_slice(&ir, "main").to_string() +} + +fn any_value() -> Expr { + Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: "make".to_string(), + param_types: Vec::new(), + return_type: Type::Any, + }), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + } +} + +fn method_call(object: Expr, property: &str, args: Vec) -> Stmt { + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::PropertyGet { + byte_offset: 0, + object: Box::new(object), + property: property.to_string(), + }), + args, + type_args: Vec::new(), + byte_offset: 0, + }) +} + +/// The register a `= call double @callee(` line assigns. +fn call_result(ir: &str, callee: &str) -> String { + let marker = format!(" = call double @{callee}("); + let line = ir + .lines() + .find(|line| line.contains(&marker)) + .unwrap_or_else(|| panic!("no call to {callee}:\n{ir}")); + line.trim_start() + .split(" = ") + .next() + .expect("assignment") + .to_string() +} + +#[test] +fn unproven_date_getter_checks_the_receiver_kind() { + let ir = main_ir( + "kind_guard_get_time.ts", + vec![method_call(any_value(), "getTime", Vec::new())], + ); + assert_eq!( + ir.matches(GET_TIME).count(), + 1, + "one js_date_get_time call both classifies the receiver and is getTime's value:\n{ir}" + ); + assert!( + ir.contains("icmp ne i64"), + "a Date is recognized by its time value differing from the receiver bits:\n{ir}" + ); + assert!( + ir.contains(DISPATCH), + "a non-Date receiver must reach its own getTime through method dispatch:\n{ir}" + ); + assert_eq!( + ir.matches("= call double @perry_fn_kind_guard_ts__make(") + .count(), + 1, + "the receiver must be evaluated exactly once:\n{ir}" + ); +} + +#[test] +fn unproven_date_getter_reuses_the_time_value_from_the_check() { + let ir = main_ir( + "kind_guard_utc_full_year.ts", + vec![method_call(any_value(), "getUTCFullYear", Vec::new())], + ); + let time = call_result(&ir, "js_date_get_time"); + assert!( + ir.contains(&format!( + "call double @js_date_get_utc_full_year(double {time})" + )), + "the getter must read the time value the check produced, not re-classify \ + the receiver:\n{ir}" + ); + assert!( + ir.contains(DISPATCH), + "missing the method-dispatch arm:\n{ir}" + ); +} + +#[test] +fn unproven_date_setter_forwards_every_argument_on_both_arms() { + let ir = main_ir( + "kind_guard_set_utc_hours.ts", + vec![method_call( + any_value(), + "setUTCHours", + vec![Expr::Number(1.0), Expr::Number(2.0)], + )], + ); + assert!( + ir.contains(GET_TIME) && ir.contains("call double @js_date_apply_setter("), + "a Date receiver must keep the setter behind a runtime Date check:\n{ir}" + ); + assert!( + ir.contains(DISPATCH), + "missing the method-dispatch arm:\n{ir}" + ); +} + +#[test] +fn unproven_to_locale_string_formats_primitives_dates_and_symbols_directly() { + let ir = main_ir( + "kind_guard_to_locale_string.ts", + vec![method_call(any_value(), "toLocaleString", Vec::new())], + ); + assert!( + ir.contains("call double @js_value_to_locale_string(") + && ir.contains(GET_TIME) + && ir.contains("call i32 @js_is_symbol(") + && ir.contains(DISPATCH), + "primitives, Dates and Symbols format directly; heap objects dispatch:\n{ir}" + ); +} + +#[test] +fn unproven_number_method_uses_an_inline_tag_check() { + let ir = main_ir( + "kind_guard_to_fixed.ts", + vec![method_call(any_value(), "toFixed", vec![Expr::Number(2.0)])], + ); + assert!( + ir.contains("call i64 @js_number_to_fixed(") && ir.contains(DISPATCH), + "toFixed on an unproven receiver needs both the Number and dispatch arms:\n{ir}" + ); + assert!( + !ir.contains(GET_TIME), + "the Number check is an inline tag test, not a runtime call:\n{ir}" + ); +} + +#[test] +fn unproven_to_sorted_checks_for_a_plain_array_header() { + let ir = main_ir( + "kind_guard_to_sorted.ts", + vec![method_call(any_value(), "toSorted", vec![Expr::Undefined])], + ); + assert!( + ir.contains("call i64 @js_validate_array_comparator(") + && ir.contains("call i64 @js_array_to_sorted_with_comparator(") + && ir.contains(DISPATCH), + "toSorted on an unproven receiver needs both the Array and dispatch arms:\n{ir}" + ); + assert!( + ir.contains("load i8"), + "the Array check reads the GcHeader type inline:\n{ir}" + ); +} + +#[test] +fn unproven_flatten_and_splice_methods_keep_the_dense_helpers_behind_the_guard() { + let ir = main_ir( + "kind_guard_flatten.ts", + vec![ + method_call(any_value(), "flat", Vec::new()), + method_call(any_value(), "flatMap", vec![Expr::Undefined]), + method_call(any_value(), "toSpliced", vec![Expr::Number(1.0)]), + ], + ); + for helper in [ + "call i64 @js_array_flat(", + "call i64 @js_array_flatMap(", + "call i64 @js_array_to_spliced(", + ] { + assert!( + ir.contains(helper), + "missing the plain-array arm {helper}:\n{ir}" + ); + } + assert_eq!( + ir.matches(DISPATCH).count(), + 3, + "every call keeps a method-dispatch arm for a user method:\n{ir}" + ); +} + +#[test] +fn unproven_reduce_right_with_three_arguments_is_plain_dispatch() { + // Array.prototype.reduceRight's static lowering rejects three arguments, + // but a user method may accept them: no guard, no compile error. + let ir = main_ir( + "kind_guard_reduce_right_arity.ts", + vec![method_call( + any_value(), + "reduceRight", + vec![Expr::Undefined, Expr::Undefined, Expr::Undefined], + )], + ); + assert!( + ir.contains(DISPATCH) && !ir.contains("call double @js_array_reduce_right("), + "an out-of-arity reduceRight must stay a method call:\n{ir}" + ); +} + +#[test] +fn proven_receivers_keep_the_direct_builtin_call() { + let ir = main_ir( + "kind_guard_proven.ts", + vec![ + Stmt::Let { + id: 1, + name: "n".to_string(), + ty: Type::Number, + mutable: false, + init: Some(Expr::Number(1.25)), + }, + method_call(Expr::LocalGet(1), "toFixed", vec![Expr::Number(1.0)]), + method_call(Expr::DateNew(Vec::new()), "getTime", Vec::new()), + ], + ); + assert!( + ir.contains("call i64 @js_number_to_fixed(") + && ir.contains("call double @js_date_get_time("), + "proven receivers must use the builtin directly:\n{ir}" + ); + assert_eq!( + ir.matches(GET_TIME).count(), + 1, + "a proven Date calls the getter once, with no kind check:\n{ir}" + ); + assert!( + !ir.contains(DISPATCH), + "a proven receiver must not pay for method dispatch:\n{ir}" + ); +} + +#[test] +fn zero_argument_search_methods_compile_on_any_receiver_and_on_a_string() { + let ir = main_ir( + "kind_guard_zero_arg_search.ts", + vec![ + method_call(any_value(), "endsWith", Vec::new()), + method_call(any_value(), "includes", Vec::new()), + method_call( + Expr::String("xundefined".to_string()), + "startsWith", + Vec::new(), + ), + ], + ); + assert!( + ir.contains("call i32 @js_string_ends_with(") + && ir.contains("call i32 @js_string_starts_with(") + && ir.contains(DISPATCH), + "an omitted searchString is `undefined`, not a compile error:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/lower_call/property_get/number_string.rs b/crates/perry-codegen/src/lower_call/property_get/number_string.rs index 93335ef115..c3ce1ef55f 100644 --- a/crates/perry-codegen/src/lower_call/property_get/number_string.rs +++ b/crates/perry-codegen/src/lower_call/property_get/number_string.rs @@ -9,8 +9,8 @@ use perry_hir::Expr; use crate::expr::{lower_expr, nanbox_string_inline, FnCtx}; use crate::nanbox::double_literal; use crate::type_analysis::{ - is_array_expr, is_native_module_dynamic_index, is_string_expr, - is_url_search_params_subclass_expr, receiver_class_name, + is_array_expr, is_numeric_expr, is_string_expr, is_url_search_params_subclass_expr, + receiver_class_name, }; use crate::types::{DOUBLE, I32, I64}; @@ -25,13 +25,10 @@ pub(crate) fn try_lower_number_string_methods( args: &[Expr], ) -> Result> { // Number.prototype.toFixed(decimals) — call js_number_to_fixed. - // Receiver is any number-typed value; we don't gate on - // is_numeric_expr because tests often call it on Any locals. - if property == "toFixed" - && !is_string_expr(ctx, object) - && !is_array_expr(ctx, object) - && !is_native_module_dynamic_index(object) - { + // #10476: only for a proven number. decimal.js / bignumber.js and any + // class own `toFixed` / `toPrecision` / `toExponential`; an unproven + // receiver checks its runtime kind in `builtin_kind_guard.rs`. + if property == "toFixed" && is_numeric_expr(ctx, object) { let v = lower_expr(ctx, object)?; let dec = if let Some(arg) = args.first() { lower_expr(ctx, arg)? @@ -43,11 +40,7 @@ pub(crate) fn try_lower_number_string_methods( return Ok(Some(nanbox_string_inline(blk, &handle))); } // Number.prototype.toPrecision(digits) - if property == "toPrecision" - && !is_string_expr(ctx, object) - && !is_array_expr(ctx, object) - && !is_native_module_dynamic_index(object) - { + if property == "toPrecision" && is_numeric_expr(ctx, object) { let v = lower_expr(ctx, object)?; let prec = if let Some(arg) = args.first() { lower_expr(ctx, arg)? @@ -63,11 +56,7 @@ pub(crate) fn try_lower_number_string_methods( return Ok(Some(nanbox_string_inline(blk, &handle))); } // Number.prototype.toExponential(decimals) - if property == "toExponential" - && !is_string_expr(ctx, object) - && !is_array_expr(ctx, object) - && !is_native_module_dynamic_index(object) - { + if property == "toExponential" && is_numeric_expr(ctx, object) { let v = lower_expr(ctx, object)?; let dec = if let Some(arg) = args.first() { lower_expr(ctx, arg)? diff --git a/crates/perry-codegen/src/lower_string_concat.rs b/crates/perry-codegen/src/lower_string_concat.rs index 4f0fa92e10..b475f24208 100644 --- a/crates/perry-codegen/src/lower_string_concat.rs +++ b/crates/perry-codegen/src/lower_string_concat.rs @@ -774,17 +774,42 @@ pub(crate) fn flatten_string_add_chain<'a>( /// call in `js_number_to_string` -> `js_string_from_bytes_with_capacity` -> /// `string_storage_alloc` doing exactly that. /// -/// The non-pointer proof is what keeps an object out: `String(obj)` and the -/// helper's slow path can disagree on a value with both `valueOf` and -/// `toString`, so an object-valued part — including one a lying annotation -/// claims is a number — keeps its wrapper. +/// The non-pointer-by-construction arm covers a value the codegen dataflow +/// itself proved numeric (an integer-range local, a raw i32 counter slot). +/// [`crate::type_analysis::is_declared_number_expr`] widens this to a +/// DECLARED-only `number` local too — a plain `n: number` parameter that +/// codegen has no runtime or dataflow proof for, only the erased annotation +/// (#8105-shaped: the overwhelmingly common template-substitution shape, and +/// the one `stable_local_type_proof` never covers for an ordinary +/// unspecialized function body, since that map starts empty precisely so a +/// lying annotation can't be mistaken for a proof). Trusting it here is sound +/// for the SAME reason `is_declared_string_expr` already trusts a declared +/// `string` a few call sites up the stack (`expr/binary.rs`): the receiving +/// helper does its own tag dispatch, not `js_string_concat_chain`'s directly. +/// Every one of `js_string_coerce`'s non-plain-number arms — pointer, +/// short-string, BigInt, int32 class-ref — either returns a literal +/// ("undefined"/"null"/"true"/"false") that `js_string_concat_chain`'s +/// classify loop's fallback ALSO returns, or forwards to the exact same +/// `js_jsvalue_to_string`/`js_string_materialize_to_heap` that classify loop +/// fallback calls too. So for a lying `number` annotation whose runtime value +/// is anything else, `js_string_concat_chain` reproduces `js_string_coerce`'s +/// output byte-for-byte via that shared fallback — only a genuine number (the +/// overwhelmingly common case) additionally gets the fast, allocation-free +/// `format_number_into` path instead of a throwaway heap string. Only an +/// object with `valueOf`/`toString` needs its own live-dataflow proof rather +/// than the declared check, because `String(obj)` and `+`'s ToPrimitive can +/// disagree — but `js_string_coerce`'s object arm ITSELF forwards to +/// `js_jsvalue_to_string`, matching classify loop's fallback exactly, so even +/// that case stays correct; declared-number trust only ever changes which +/// code path produces the (identical) answer, never the answer. fn chain_part_without_redundant_coerce<'a>(ctx: &FnCtx<'_>, part: &'a Expr) -> &'a Expr { let Expr::StringCoerce(inner) = part else { return part; }; let is_string = crate::type_analysis::string_value_is_runtime_guaranteed(ctx, inner); - let is_plain_number = crate::type_analysis::is_numeric_expr(ctx, inner) - && crate::expr::expr_produces_non_pointer_bits_by_construction(ctx, inner); + let is_plain_number = (crate::type_analysis::is_numeric_expr(ctx, inner) + && crate::expr::expr_produces_non_pointer_bits_by_construction(ctx, inner)) + || crate::type_analysis::is_declared_number_expr(ctx, inner); if is_string || is_plain_number { inner } else { diff --git a/crates/perry-codegen/src/lower_string_method.rs b/crates/perry-codegen/src/lower_string_method.rs index 7925272030..9c143a1df9 100644 --- a/crates/perry-codegen/src/lower_string_method.rs +++ b/crates/perry-codegen/src/lower_string_method.rs @@ -1054,14 +1054,16 @@ fn lower_string_method_dispatch( "startsWith" | "endsWith" => { // Spec allows the 2-arg form: startsWith(searchString, position) // and endsWith(searchString, endPosition). Closes #315. - if args.is_empty() { - bail!( - "perry-codegen: String.{} expects 1 or 2 args, got {}", - property, - args.len() - ); - } - let other_box = lower_expr(ctx, &args[0])?; + // #10476: an omitted searchString is `undefined`, which ToString + // turns into "undefined" (`"xundefined".endsWith()` is true). A + // compile-time arity error here also rejected the string arm of + // the tag guard for a user `endsWith()` on an `any` receiver. + let other_box = if args.is_empty() { + ctx.block() + .bitcast_i64_to_double(crate::nanbox::TAG_UNDEFINED_I64) + } else { + lower_expr(ctx, &args[0])? + }; let pos_d = if args.len() >= 2 { Some(lower_expr(ctx, &args[1])?) } else { @@ -1117,13 +1119,13 @@ fn lower_string_method_dispatch( // honored (search starts there), matching the dynamic dispatch // path. Negative/NaN clamp to 0 and Infinity saturates past the // end inside js_string_index_of_from. - if args.is_empty() { - bail!( - "perry-codegen: String.includes expects 1 or 2 args, got {}", - args.len() - ); - } - let needle_box = lower_expr(ctx, &args[0])?; + // #10476: an omitted searchString is `undefined` (see above). + let needle_box = if args.is_empty() { + ctx.block() + .bitcast_i64_to_double(crate::nanbox::TAG_UNDEFINED_I64) + } else { + lower_expr(ctx, &args[0])? + }; // Preserve evaluation of the second argument for side effects and // use it as the start index when present. let pos_d = if args.len() >= 2 { diff --git a/crates/perry-codegen/src/temp_root_coverage/mod.rs b/crates/perry-codegen/src/temp_root_coverage/mod.rs index fb7d55edf1..504c712101 100644 --- a/crates/perry-codegen/src/temp_root_coverage/mod.rs +++ b/crates/perry-codegen/src/temp_root_coverage/mod.rs @@ -106,7 +106,7 @@ pub(crate) fn entry_opts() -> CompileOptions { } } -fn module_with_init(name: &str, init: Vec) -> Module { +pub(crate) fn module_with_init(name: &str, init: Vec) -> Module { Module { name: name.to_string(), imports: Vec::new(), diff --git a/crates/perry-codegen/src/type_analysis.rs b/crates/perry-codegen/src/type_analysis.rs index fc0e7a4c50..a8c4a050f0 100644 --- a/crates/perry-codegen/src/type_analysis.rs +++ b/crates/perry-codegen/src/type_analysis.rs @@ -32,8 +32,8 @@ mod refine; mod strings; pub(crate) use numeric::{ - expr_produces_canonical_raw_f64, is_bigint_expr, is_bool_expr, is_integer_valued_expr, - is_numeric_expr, is_provably_not_bigint, + expr_produces_canonical_raw_f64, is_bigint_expr, is_bool_expr, is_declared_number_expr, + is_integer_valued_expr, is_numeric_expr, is_provably_not_bigint, }; pub(crate) use pod::{ add_operands_have_pod_materialization_hazard, diff --git a/crates/perry-codegen/src/type_analysis/numeric.rs b/crates/perry-codegen/src/type_analysis/numeric.rs index 338442e398..b0a983ef5f 100644 --- a/crates/perry-codegen/src/type_analysis/numeric.rs +++ b/crates/perry-codegen/src/type_analysis/numeric.rs @@ -582,6 +582,32 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { } } +/// A DECLARED-only `number` local — the erased TypeScript annotation, not a +/// runtime or dataflow proof. Mirrors `is_declared_string_expr` +/// (`type_analysis/strings.rs`) exactly, one call site up the stack: that +/// predicate lets `expr/binary.rs` trust `s: string` for the pairwise concat +/// fast path because the receiving helper (`js_string_concat_box`) tag- +/// dispatches both operands itself, so a lying annotation degrades to the +/// helper's own dynamic fallback rather than misreading bits. This is the +/// number-typed twin, for `lower_string_concat.rs`'s n-way concat-chain fold: +/// see `chain_part_without_redundant_coerce` for why the same trust is sound +/// there (`js_string_concat_chain`'s own classify loop is what actually +/// dispatches on the runtime tag; a lying declaration only changes which of +/// two call sites produces the identical answer). +/// +/// Deliberately narrower than [`is_numeric_expr`]: only a direct `LocalGet` +/// of a `number`-declared binding. `Int32` is excluded on purpose — an +/// integer-range local already has a stronger *proof* available +/// (`ctx.integer_locals` / `is_numeric_expr` + +/// `expr_produces_non_pointer_bits_by_construction`), so it never needs this +/// weaker, declaration-only fallback. +pub(crate) fn is_declared_number_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { + let Expr::LocalGet(id) = e else { + return false; + }; + matches!(ctx.local_type_hint(id), Some(HirType::Number)) +} + /// Repsel Phase 4a.0 (#6904): statically prove that an expression's LOWERED /// value is a **canonical raw f64** — a real machine double whose bit pattern /// is never a NaN-box tag (`0x7FF9..=0x7FFF` upper 16 with a set quiet-NaN diff --git a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs index 32b179ea71..09e4ee8597 100644 --- a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs +++ b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs @@ -698,17 +698,24 @@ pub(super) fn try_array_only_methods( // with positive Array evidence. This mirrors the bare-local // gate in `local_array_methods.rs` instead of reviving the old // any-receiver fallback for property/call receivers. - let recv_is_proven_array = matches!(method_name, "entries" | "keys" | "values") - && { - let recv_ty = crate::lower_types::infer_type_from_expr(&member.obj, ctx); - matches!(recv_ty, Type::Array(_) | Type::Tuple(_)) - || matches!( - &recv_ty, - Type::Generic { base, .. } - if base == "Array" || base == "ReadonlyArray" - ) - || chain_roots_at_array(ctx, &member.obj) - }; + // + // #10476: `reduceRight` / `toReversed` / `toSorted` are the same + // case — a user class, prototype or object literal owns them + // (a vector's `toSorted()`), and the dense fold read that + // object as an empty ArrayHeader, so the user method never ran. + let recv_is_proven_array = matches!( + method_name, + "entries" | "keys" | "values" | "reduceRight" | "toReversed" | "toSorted" + ) && { + let recv_ty = crate::lower_types::infer_type_from_expr(&member.obj, ctx); + matches!(recv_ty, Type::Array(_) | Type::Tuple(_)) + || matches!( + &recv_ty, + Type::Generic { base, .. } + if base == "Array" || base == "ReadonlyArray" + ) + || chain_roots_at_array(ctx, &member.obj) + }; // thisArg routing: the dense `Expr::Array` fast paths // carry only the callback and silently drop a 2nd positional // `thisArg` argument, so `[x].every(cb, thisArg)` ran the @@ -1174,7 +1181,7 @@ pub(super) fn try_array_only_methods( array: Box::new(array_expr), })); } - "reduceRight" if !args.is_empty() => { + "reduceRight" if !args.is_empty() && recv_is_proven_array => { let array_expr = lower_expr(ctx, &member.obj)?; let mut args_iter = args.into_iter(); let callback = args_iter.next().unwrap(); @@ -1185,13 +1192,13 @@ pub(super) fn try_array_only_methods( initial, })); } - "toReversed" => { + "toReversed" if recv_is_proven_array => { let array_expr = lower_expr(ctx, &member.obj)?; return Ok(Ok(Expr::ArrayToReversed { array: Box::new(array_expr), })); } - "toSorted" => { + "toSorted" if recv_is_proven_array => { let array_expr = lower_expr(ctx, &member.obj)?; let comparator = args.into_iter().next().map(Box::new); return Ok(Ok(Expr::ArrayToSorted { diff --git a/crates/perry-hir/src/lower/expr_call/builtin_named_user_methods_tests.rs b/crates/perry-hir/src/lower/expr_call/builtin_named_user_methods_tests.rs new file mode 100644 index 0000000000..5d803c1df9 --- /dev/null +++ b/crates/perry-hir/src/lower/expr_call/builtin_named_user_methods_tests.rs @@ -0,0 +1,154 @@ +//! #10476: verdict tests for builtin-named method calls in HIR lowering. +//! +//! A Date / Array method NAME is not proof of the receiver's kind: dayjs's +//! `Dayjs.prototype.toISOString`, a class's `getTime()` or `toSorted()`, an +//! object literal's `setHours`. These assert which lowering a call got — the +//! builtin intrinsic only for a statically proven receiver, a generic method +//! call otherwise (codegen then checks the runtime kind). + +#![cfg(test)] + +use crate::Module; +use perry_diagnostics::SourceCache; + +fn lower(src: &str) -> String { + let src = src.to_string(); + let module: Module = std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || { + let mut cache = SourceCache::new(); + let parsed = perry_parser::parse_typescript_with_cache( + &src, + "builtin_named_user_methods.ts", + &mut cache, + ) + .expect("parse should succeed"); + crate::lower_module(&parsed.module, "test", "builtin_named_user_methods.ts") + .expect("lower should succeed") + }) + .expect("spawn lower thread") + .join() + .expect("lower thread panicked"); + format!("{module:?}") +} + +#[test] +fn date_names_on_unproven_receivers_stay_method_calls() { + let hir = lower( + r#" + function Dayjs(this: any, ms: number) { this.$ms = ms; } + Dayjs.prototype.toISOString = function () { return "x"; }; + Dayjs.prototype.toJSON = function (this: any) { return this.toISOString(); }; + class Clock { getTime() { return 1; } setHours(h: number) { return h; } } + const c = new Clock(); + const lit = { getFullYear() { return 2; }, toLocaleString() { return "l"; } }; + function viaAny(x: any) { + return [x.getTime(), x.setUTCHours(1), x.toDateString(), x.toLocaleTimeString()]; + } + console.log(c.getTime(), c.setHours(3), lit.getFullYear(), lit.toLocaleString(), viaAny(c)); + "#, + ); + for intrinsic in [ + "DateToISOString", + "DateGetTime", + "DateSetHours", + "DateGetFullYear", + "DateToLocaleString", + "DateSetUtcHours", + "DateToDateString", + "DateToLocaleTimeString", + ] { + assert!( + !hir.contains(intrinsic), + "an unproven receiver was lowered to the Date intrinsic {intrinsic}: {hir}" + ); + } +} + +#[test] +fn proven_date_receivers_keep_the_date_intrinsics() { + let hir = lower( + r#" + const d = new Date(0); + function takesDate(p: Date) { return p.getUTCHours(); } + console.log( + d.getTime(), d.toISOString(), d.setHours(1), new Date(5).getFullYear(), + takesDate(d), d.toLocaleString(), (12345).toLocaleString(), + ); + "#, + ); + for intrinsic in [ + "DateGetTime", + "DateToISOString", + "DateSetHours", + "DateGetFullYear", + "DateGetUtcHours", + "DateToLocaleString", + ] { + assert!( + hir.contains(intrinsic), + "a proven Date receiver lost the {intrinsic} intrinsic: {hir}" + ); + } +} + +#[test] +fn array_copy_names_on_unproven_receivers_stay_method_calls() { + let hir = lower( + r#" + const add = (a: number, b: number) => a + b; + class Vec { toSorted() { return this; } toReversed() { return this; } reduceRight(f: any) { return f; } } + const v = new Vec(); + const lit: any = { flat() { return 1; }, flatMap(f: any) { return f; }, toSpliced(i: number) { return i; } }; + const proto: any = Object.create({ toSorted() { return 0; }, copyWithin(a: number, b: number) { return a + b; } }); + function viaAny(x: any) { return [x.toSorted(), x.toReversed(), x.reduceRight(add)]; } + console.log( + v.toSorted(), v.toReversed(), v.reduceRight(add), lit.flat(), lit.flatMap(add), + lit.toSpliced(1), proto.toSorted(), proto.copyWithin(0, 1), viaAny(v), + ); + "#, + ); + for intrinsic in [ + "ArrayToSorted", + "ArrayToReversed", + "ArrayReduceRight", + "ArrayFlat {", + "ArrayFlatMap", + "ArrayToSpliced", + "ArrayCopyWithin", + ] { + assert!( + !hir.contains(intrinsic), + "an unproven receiver was lowered to the Array intrinsic {intrinsic}: {hir}" + ); + } +} + +#[test] +fn proven_array_receivers_keep_the_array_intrinsics() { + let hir = lower( + r#" + const add = (a: number, b: number) => a + b; + const arr: number[] = [3, 1, 2]; + const nested = [1, [2]]; + console.log( + arr.toSorted(), arr.toReversed(), arr.reduceRight(add), nested.flat(), + arr.flatMap((x) => [x]), arr.toSpliced(1), [4, 5].toSorted(), + arr.map((x) => x).toReversed(), + ); + "#, + ); + for intrinsic in [ + "ArrayToSorted", + "ArrayToReversed", + "ArrayReduceRight", + "ArrayFlat {", + "ArrayFlatMap", + "ArrayToSpliced", + ] { + assert!( + hir.contains(intrinsic), + "a proven Array receiver lost the {intrinsic} intrinsic: {hir}" + ); + } +} diff --git a/crates/perry-hir/src/lower/expr_call/local_array_methods.rs b/crates/perry-hir/src/lower/expr_call/local_array_methods.rs index 99a7807ca3..529c2e8a1d 100644 --- a/crates/perry-hir/src/lower/expr_call/local_array_methods.rs +++ b/crates/perry-hir/src/lower/expr_call/local_array_methods.rs @@ -216,6 +216,17 @@ pub(super) fn try_local_array_methods( | "pop" | "shift" | "unshift" + // #10476: the ES2019+/ES2023 copy and flatten names are + // just as often user methods (a vector type's + // `toSorted`, a tree's `flat`). An unproven receiver + // reaches them through dynamic dispatch, which still + // runs the Array helper for a real array. + | "flat" + | "flatMap" + | "toReversed" + | "toSorted" + | "toSpliced" + | "copyWithin" ); let is_unknown_recv = matches!(type_info, None | Some(Type::Any) | Some(Type::Unknown)); diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index 8af6aff797..8c9108ad0b 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -55,6 +55,8 @@ fn typed_array_lacks_array_method(name: &str) -> bool { mod array_only_methods; #[cfg(test)] mod array_only_methods_tests; +#[cfg(test)] +mod builtin_named_user_methods_tests; mod crypto; mod globals; mod imported_array_methods; diff --git a/crates/perry-hir/src/lower/expr_call/url_date_instance.rs b/crates/perry-hir/src/lower/expr_call/url_date_instance.rs index fd842b7a68..3893cca7a9 100644 --- a/crates/perry-hir/src/lower/expr_call/url_date_instance.rs +++ b/crates/perry-hir/src/lower/expr_call/url_date_instance.rs @@ -132,36 +132,20 @@ pub(super) fn try_url_date_weakref_instance( } } - // Issue #650: gate the AMBIGUOUS Date instance method arms - // on a receiver-type check. Methods like `toJSON` / - // `toString` / `toLocaleString` / `valueOf` exist on every - // JS object — pre-fix the arms below fired unconditionally, - // so calling any of them on a URL / class instance / array - // got silently rewritten as a Date method, returning a Date - // string for the URL.toJSON() case the issue tracks. + // Issue #650 / #10476: a Date method NAME is not proof that the + // receiver is a Date. Userland objects own `getTime`, `setHours`, + // `toISOString`, … (dayjs, moment and every timer/clock type), so + // the Date intrinsics below require a statically proven Date + // receiver. Every other receiver stays a generic method call; codegen + // keeps a fast path for one that is a Date at runtime by checking the + // evaluated receiver's kind before taking it + // (`property_get/builtin_kind_guard.rs`). let recv_class = if let ast::MemberProp::Ident(_) = &member.prop { static_receiver_class(ctx, member.obj.as_ref()) } else { None }; - // #809: `Some("Object")` (object literal / `Object.create`) - // joins URL as a "definitely not a Date" receiver. - let receiver_may_be_date = !matches!( - recv_class, - Some("URL") - | Some("Object") - | Some("Buffer") - | Some("BlockList") - | Some("SocketAddress") - | Some("Uint8Array") - | Some("Uint8ClampedArray") - | Some("Array") - ); - // Most ambiguous Date methods retain the historical "unknown may be - // Date" behavior. Direct `.toJSON()` is different: userland classes - // commonly expose it as a plain method, and bracket/computed forms - // already dispatch generically, so only statically-known Date - // receivers should use the Date intrinsic. + let receiver_is_date = recv_class == Some("Date"); // Check for Date instance method calls (date.getTime(), etc.) if let ast::MemberProp::Ident(method_ident) = &member.prop { @@ -196,36 +180,18 @@ pub(super) fn try_url_date_weakref_instance( } cached_recv = Some(recv_expr); } - let ambiguous = matches!( - method_name, - "toJSON" - | "toString" - | "toLocaleString" - | "toDateString" - | "toTimeString" - | "toUTCString" - | "toGMTString" - | "toLocaleDateString" - | "toLocaleTimeString" - | "toISOString" - | "valueOf" - ); - let allow_date_method = if method_name == "toJSON" { - recv_class == Some("Date") - } else { - receiver_may_be_date - }; - if method_name == "setTime" - && is_node_test_mock_timers_receiver(ctx, member.obj.as_ref()) - { - // `node:test` exposes `mock.timers.setTime(ms)`. The broad - // Date setter fallback below also matches `.setTime(...)` on - // unknown receivers, so keep this known non-Date receiver on - // the generic method-call path. - } else if ambiguous && !allow_date_method { - // Receiver is statically a non-Date class (e.g. URL), or this - // is `.toJSON()` on an unknown/userland receiver. - // Skip the Date arms below — fall through to generic. + // `(12345).toLocaleString()` shares the misnamed + // `DateToLocaleString` node: its codegen arm routes a numeric + // receiver to the Number formatter and anything else through the + // runtime's tag dispatch. + let number_to_locale_string = method_name == "toLocaleString" + && matches!( + crate::lower_types::infer_type_from_expr(&member.obj, ctx), + Type::Number | Type::Int32 + ); + if !receiver_is_date && !number_to_locale_string { + // Not a proven Date (including `node:test`'s + // `mock.timers.setTime(ms)`): generic method call. } else { match method_name { "getTime" => { @@ -454,7 +420,7 @@ pub(super) fn try_url_date_weakref_instance( } _ => {} // Fall through to other handling } - } // close `else` of `if ambiguous && !allow_ambiguous_date` + } // close `else` of `if !receiver_is_date && !number_to_locale_string` } // Check for WeakRef.deref() / FinalizationRegistry.register() / .unregister() @@ -579,16 +545,3 @@ pub(super) fn try_url_date_weakref_instance( } Ok(Err(args)) } - -fn is_node_test_mock_timers_receiver(ctx: &LoweringContext, expr: &ast::Expr) -> bool { - let ast::Expr::Member(inner) = expr else { - return false; - }; - if !matches!(&inner.prop, ast::MemberProp::Ident(prop) if prop.sym.as_ref() == "timers") { - return false; - } - let ast::Expr::Ident(root) = inner.obj.as_ref() else { - return false; - }; - ctx.lookup_imported_func(root.sym.as_ref()) == Some("mock") -} diff --git a/crates/perry-hir/src/lower/expr_misc.rs b/crates/perry-hir/src/lower/expr_misc.rs index c97f798cd6..7eecff8ebf 100644 --- a/crates/perry-hir/src/lower/expr_misc.rs +++ b/crates/perry-hir/src/lower/expr_misc.rs @@ -306,9 +306,26 @@ pub(super) fn lower_tpl(ctx: &mut LoweringContext, tpl: &ast::Tpl) -> Result = if first_raw.is_empty() { + None + } else { + Some(Expr::String(unescape_template(first_raw))) + }; // Interleave expressions and remaining quasis for (i, expr) in tpl.exprs.iter().enumerate() { @@ -320,26 +337,30 @@ pub(super) fn lower_tpl(ctx: &mut LoweringContext, tpl: &ast::Tpl) -> Result coerced, + Some(prev) => Expr::Binary { + op: BinaryOp::Add, + left: Box::new(prev), + right: Box::new(coerced), + }, + }); // Add the next quasi (if it's non-empty) if let Some(quasi) = tpl.quasis.get(i + 1) { let quasi_str: &str = quasi.raw.as_ref(); if !quasi_str.is_empty() { - result = Expr::Binary { + result = Some(Expr::Binary { op: BinaryOp::Add, - left: Box::new(result), + left: Box::new(result.take().expect("substitution just set result")), right: Box::new(Expr::String(unescape_template(quasi_str))), - }; + }); } } } - Ok(result) + Ok(result.unwrap_or_else(|| Expr::String(String::new()))) } pub(super) fn lower_seq(ctx: &mut LoweringContext, seq: &ast::SeqExpr) -> Result { diff --git a/crates/perry-hir/src/lower/lower_expr/arm_bin.rs b/crates/perry-hir/src/lower/lower_expr/arm_bin.rs index 738e4a9918..0642f86da4 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_bin.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_bin.rs @@ -148,9 +148,18 @@ pub(crate) fn lower_bin_expr(ctx: &mut LoweringContext, bin: &ast::BinExpr) -> R // route through `js_instanceof_dynamic`, which derives the same // `synthetic_class_id_for_function` that `new Foo()` stamps onto // the instance (see js_new_function_construct). + // + // #10477: an IMPORTED binding is a runtime value too. It has + // no class entry in this module unless it is an imported + // class, so without its value an imported function + // constructor (`import F from "./f.js"`, CJS + // `module.exports = F`, decimal.js's `Decimal`) folded to + // class_id 0 and `x instanceof F` was always false. Codegen + // keeps the static class-id check for imported classes. if ctx.lookup_local(name).is_some() || ctx.lookup_func(name).is_some() || ctx.lookup_native_module(name).is_some() + || ctx.lookup_imported_func(name).is_some() { match lower_expr(ctx, &bin.right) { Ok(e) => Some(Box::new(e)), diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index f127f3eff9..afa26dba29 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -15,6 +15,7 @@ fn make_ctx() -> LoweringContext { LoweringContext::new("test.ts") } +mod instanceof_rhs; mod literal_shape; #[test] diff --git a/crates/perry-hir/src/lower/tests/instanceof_rhs.rs b/crates/perry-hir/src/lower/tests/instanceof_rhs.rs new file mode 100644 index 0000000000..421f5035a9 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/instanceof_rhs.rs @@ -0,0 +1,79 @@ +//! #10477: which `instanceof` right-hand sides carry a runtime value. +//! +//! An identifier RHS only reaches `js_instanceof_dynamic` when lowering +//! attaches the binding's value as `ty_expr`; otherwise codegen resolves the +//! bare NAME to a class id, and a name with no class entry folds to +//! `js_instanceof(v, 0)` — always `false`. An imported binding was in exactly +//! that position: `import { Plain } from "./lib.js"; x instanceof Plain` was +//! false for every non-class constructor, in every import form, while +//! `ns.Plain`, `const A = Plain` and the same check inside `lib.js` all worked. +//! +//! The builtin case is the other half: `x instanceof Date` must NOT grow a +//! value, because the reserved class id is what brand-checks a native Date. + +use super::*; + +fn instanceof_rhs_value(source: &str, function: &str) -> Option { + let module = + perry_parser::parse_typescript(source, "instanceof-rhs.ts").expect("source parses"); + let hir = crate::lower::lower_module(&module, "instanceof-rhs", "instanceof-rhs.ts") + .expect("source lowers"); + let body = hir + .functions + .iter() + .find(|f| f.name == function) + .unwrap_or_else(|| panic!("`{function}` must be lowered: {hir:?}")) + .body + .clone(); + match body.first() { + Some(Stmt::Return(Some(Expr::InstanceOf { ty_expr, .. }))) => { + ty_expr.as_ref().map(|value| (**value).clone()) + } + other => panic!("`{function}` must lower to a returned instanceof: {other:?}"), + } +} + +#[test] +fn imported_binding_instanceof_rhs_carries_its_value() { + let rhs = instanceof_rhs_value( + r#" + import { Plain } from "./lib.js"; + export function check(x: unknown) { return x instanceof Plain; } + "#, + "check", + ); + assert!( + matches!(&rhs, Some(Expr::ExternFuncRef { name, .. }) if name == "Plain"), + "an imported RHS must be lowered to its value so the dynamic path can \ + resolve the constructor (#10477): {rhs:?}" + ); +} + +#[test] +fn builtin_instanceof_rhs_stays_a_static_name() { + let rhs = instanceof_rhs_value( + r#" + export function check(x: unknown) { return x instanceof Date; } + "#, + "check", + ); + assert!( + rhs.is_none(), + "an unshadowed builtin RHS must keep the reserved-class-id check: {rhs:?}" + ); +} + +#[test] +fn local_function_constructor_instanceof_rhs_carries_its_value() { + let rhs = instanceof_rhs_value( + r#" + function Plain(this: any) {} + export function check(x: unknown) { return x instanceof Plain; } + "#, + "check", + ); + assert!( + matches!(&rhs, Some(Expr::FuncRef(_))), + "a module-local function constructor keeps its pre-#10477 value RHS: {rhs:?}" + ); +} diff --git a/crates/perry-runtime/src/array/iter_methods.rs b/crates/perry-runtime/src/array/iter_methods.rs index 93372fe32f..e59c1b4709 100644 --- a/crates/perry-runtime/src/array/iter_methods.rs +++ b/crates/perry-runtime/src/array/iter_methods.rs @@ -1,7 +1,6 @@ //! Higher-order array methods. use super::*; use crate::closure::ClosureHeader; -use std::ptr; /// NaN-box an array header pointer as the JS `array` receiver value passed as /// the 3rd/4th callback argument (`(element, index, array)` / @@ -138,7 +137,7 @@ mod rooted_iter_array_tests { let rooted = RootedIterArray::new(&scope, arr); let mut live_arr = js_array_grow(arr, (*arr).capacity + 1); assert_ne!(live_arr, arr); - let _removed = js_array_splice(live_arr, 1, 1, ptr::null(), 0, &mut live_arr); + let _removed = js_array_splice(live_arr, 1, 1, std::ptr::null(), 0, &mut live_arr); assert_eq!((*live_arr).length, 2); assert_eq!(rooted.arr(), clean_arr_ptr(live_arr)); @@ -397,19 +396,15 @@ pub extern "C" fn js_array_map( let mapped = cb_site.call(callback, element, i as f64, rooted.receiver()); if is_plain { let result = result_arr(&result_rooted); - let result_elements = - crate::array::array_elements_ptr(result as *const ArrayHeader) as *mut f64; - // GC_STORE_AUDIT(INIT): plain result is unpublished; slot layout noted below. - ptr::write(result_elements.add(i), mapped); - let mapped_bits = mapped.to_bits(); - if length <= 64 { - // The head was just re-derived from `result_rooted`, so the - // per-element helpers' repeated ownership/forwarding proofs - // are redundant: resolve the header once. - super::header_gc_slots::fill_resolved_array_slot(result, i, mapped_bits); - } else { - note_array_slot(result, i, mapped_bits); - } + // The head was just re-derived from `result_rooted` (a GC + // root), with no intervening allocation or safepoint since — + // `fill_resolved_array_slot` satisfies exactly this contract + // regardless of the result's length, so it applies to every + // plain result, not only ones at or under some fixed size. + // It performs the element's ONLY store itself (canonicalizing + // under the array's already-known layout first), so no + // separate publish write is needed here. + super::header_gc_slots::fill_resolved_array_slot(result, i, mapped.to_bits()); } else { // Custom species container: CreateDataPropertyOrThrow via [[Set]]. crate::array::species::species_result_set( diff --git a/crates/perry-runtime/src/closure/dispatch.rs b/crates/perry-runtime/src/closure/dispatch.rs index b9c829849a..34581b3eee 100644 --- a/crates/perry-runtime/src/closure/dispatch.rs +++ b/crates/perry-runtime/src/closure/dispatch.rs @@ -22,7 +22,7 @@ mod value_call; pub(crate) use bound::{ bound_function_lazy_name, bound_method_source_func_ptr, coerce_call_this, rebind_explicit_this, - reify_function_method_value, + rebind_explicit_this_allocates, reify_function_method_value, }; pub use bound::{dispatch_bound_function, dispatch_bound_method, js_function_bind}; diff --git a/crates/perry-runtime/src/closure/dispatch/bound.rs b/crates/perry-runtime/src/closure/dispatch/bound.rs index 66ebb000ca..c73f17a5bb 100644 --- a/crates/perry-runtime/src/closure/dispatch/bound.rs +++ b/crates/perry-runtime/src/closure/dispatch/bound.rs @@ -412,6 +412,37 @@ pub(crate) fn coerce_call_this(target: f64, this_arg: f64) -> f64 { /// - bound functions and non-closure values /// (`clone_closure_rebind_this` no-ops on these — it only rewrites a /// `CAPTURES_THIS` slot). +/// Does [`rebind_explicit_this`] ALLOCATE for this target? +/// +/// Only the clone does, and it happens for one shape: a non-arrow closure that +/// captures `this` in a reserved slot and may be re-bound. Everything else — +/// a plain function, an arrow, a bound function, a non-closure value — comes +/// back unchanged, allocation-free. A caller that has GC values in Rust locals +/// can therefore skip rooting them entirely for the common callee, and pay for +/// handles only on the shape that can collect underneath it. +/// +/// Kept in step with `clone_closure_rebind_this`'s early-outs by +/// `rebind_predicate_tests` below, which asserts the two agree on every shape. +#[inline] +pub(crate) fn rebind_explicit_this_allocates(target: f64) -> bool { + let bits = target.to_bits(); + if bits & 0xFFFF_0000_0000_0000 != 0x7FFD_0000_0000_0000 { + return false; + } + let ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; + if ptr < 0x100000 || !crate::closure::is_closure_ptr(ptr) { + return false; + } + let header = ptr as *const ClosureHeader; + if crate::closure::closure_is_arrow(header) { + return false; + } + let raw_count = unsafe { (*header).capture_count }; + raw_count & CAPTURES_THIS_FLAG != 0 + && raw_count & NO_THIS_REBIND_FLAG == 0 + && crate::closure::real_capture_count(raw_count) > 0 +} + #[inline] pub(crate) fn rebind_explicit_this(target: f64, this_arg: f64) -> f64 { let bits = target.to_bits(); @@ -761,3 +792,82 @@ pub(crate) unsafe fn reify_function_method_value(receiver: f64, method: &'static crate::gc::runtime_write_barrier_root_heap_word(closure as u64); f64::from_bits(crate::value::JSValue::pointer(closure as *mut u8).bits()) } + +#[cfg(test)] +mod rebind_predicate_tests { + use super::*; + + // Distinct bodies on purpose: arrow-ness is registered per func_ptr, and + // two `extern "C"` bodies with identical machine code get folded to one + // address by the linker — which silently makes every case in this test the + // same closure body. + extern "C" fn arrow_probe(_closure: *const ClosureHeader) -> f64 { + 1.0 + } + + extern "C" fn method_probe(_closure: *const ClosureHeader) -> f64 { + 2.0 + } + + fn closure_value(body: *const u8, capture_count: u32) -> f64 { + let closure = crate::closure::js_closure_alloc(body, capture_count); + f64::from_bits(crate::value::JSValue::pointer(closure as *mut u8).bits()) + } + + /// The predicate exists to let `Reflect.apply` skip rooting when nothing + /// can collect, so it has to answer exactly the question + /// `rebind_explicit_this` answers with its clone: saying "no" where the + /// rebind allocates is a rooting hole, and saying "yes" everywhere is the + /// per-call cost it was written to avoid. + #[test] + fn the_predicate_agrees_with_what_the_rebind_actually_does() { + let receiver = f64::from_bits(crate::value::TAG_UNDEFINED); + let method_body = method_probe as *const u8; + let arrow_body = arrow_probe as *const u8; + crate::closure::js_register_closure_arrow_function(arrow_body); + + // The one shape that clones, and the shapes that look like it but + // return the target untouched. + let method = closure_value(method_body, CAPTURES_THIS_FLAG | 1); + let cases = [ + ("concise method with a reserved `this`", method, true), + ( + "arrow with a captured this", + closure_value(arrow_body, CAPTURES_THIS_FLAG | 1), + false, + ), + ( + "generator step closure (no rebind)", + closure_value(method_body, CAPTURES_THIS_FLAG | NO_THIS_REBIND_FLAG | 1), + false, + ), + ( + "captures `this` but has no capture slots", + closure_value(method_body, CAPTURES_THIS_FLAG), + false, + ), + ("plain closure", closure_value(method_body, 0), false), + ("a plain number", 42.0, false), + ( + "undefined", + f64::from_bits(crate::value::TAG_UNDEFINED), + false, + ), + ]; + for (what, value, clones) in cases { + let rebound_differs = + rebind_explicit_this(value, receiver).to_bits() != value.to_bits(); + assert_eq!( + rebound_differs, + clones, + "premise: {what} must {} clone", + if clones { "" } else { "not" } + ); + assert_eq!( + rebind_explicit_this_allocates(value), + rebound_differs, + "{what}: the predicate and the rebind must agree" + ); + } + } +} diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index a7e4210220..57d7404170 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -40,23 +40,23 @@ pub(crate) fn closure_side_table_census() -> Vec Option { #[inline(always)] pub unsafe fn build_rest_array(values: &[f64], arguments_object: bool) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); - let value_handles: Vec<_> = values - .iter() - .map(|value| scope.root_nanbox_f64(*value)) - .collect(); + let value_handles = scope.root_nanbox_f64_slice(values); + build_rest_array_rooted(&value_handles, arguments_object) +} + +/// [`build_rest_array`] for a caller that already holds its values in handles. +/// The array allocation and every push can collect, so the values have to be +/// read out of the handles anyway — a caller that has them keeps one rooting +/// pass instead of two. +pub unsafe fn build_rest_array_rooted( + values: &[crate::gc::RuntimeHandle<'_>], + arguments_object: bool, +) -> f64 { let arr = crate::array::js_array_alloc(values.len() as u32); let mut cur = arr; - for handle in value_handles.iter() { + for handle in values.iter() { cur = crate::array::js_array_push_f64(cur, handle.get_nanbox_f64()); } if arguments_object { @@ -1021,19 +1029,30 @@ pub unsafe fn dispatch_rest_bundled( .map(|value| arg_scope.root_nanbox_f64(*value)) .collect(); - let rest_slice: &[f64] = if kind == RestDispatchKind::SyntheticArguments { - args - } else if provided > k { - &args[k..] - } else { - &[] - }; - let rest_double = build_rest_array(rest_slice, kind == RestDispatchKind::SyntheticArguments); - let all_arguments_double = if kind == RestDispatchKind::UserRestAndArguments { - Some(build_rest_array(args, true)) + // Both arrays are built from the arguments this scope already roots, so + // they read current values however many times the builder collects — and + // the second array's allocation can move the first, which the body is + // about to receive, so the first takes a handle too (#10532 review). + let rest_handles: &[crate::gc::RuntimeHandle<'_>] = + if kind == RestDispatchKind::SyntheticArguments { + &arg_handles + } else if provided > k { + &arg_handles[k..] + } else { + &[] + }; + let rest_handle = arg_scope.root_nanbox_f64(build_rest_array_rooted( + rest_handles, + kind == RestDispatchKind::SyntheticArguments, + )); + crate::gc::collection_point("closure.rest_bundle.between_arrays"); + let all_arguments_handle = if kind == RestDispatchKind::UserRestAndArguments { + Some(arg_scope.root_nanbox_f64(build_rest_array_rooted(&arg_handles, true))) } else { None }; + let rest_double = rest_handle.get_nanbox_f64(); + let all_arguments_double = all_arguments_handle.map(|handle| handle.get_nanbox_f64()); // Read fixed args, padding with undefined when caller under-supplied. macro_rules! a { diff --git a/crates/perry-runtime/src/cluster.rs b/crates/perry-runtime/src/cluster.rs index 2798e8a4cc..48e4c0fb90 100644 --- a/crates/perry-runtime/src/cluster.rs +++ b/crates/perry-runtime/src/cluster.rs @@ -1526,15 +1526,15 @@ fn object_ptr(value: f64) -> Option<*mut ObjectHeader> { return None; } let raw = (bits & crate::value::POINTER_MASK) as usize; - if raw < 0x10000 || crate::buffer::is_registered_buffer(raw) { + if crate::buffer::is_registered_buffer(raw) { return None; } - unsafe { - let header = - (raw as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; - if (*header).obj_type != crate::gc::GC_TYPE_OBJECT { - return None; - } + // #10556: `x instanceof EventEmitter` asks `is_worker_instance_value`, and a + // native `new EventEmitter()` is a POINTER_TAG registry handle (`0x38000`) + // that sailed over the old `0x10000` floor into a header read. + let header = unsafe { crate::value::addr_class::try_read_gc_header(raw) }?; + if header.obj_type != crate::gc::GC_TYPE_OBJECT { + return None; } Some(raw as *mut ObjectHeader) } @@ -1545,18 +1545,10 @@ fn array_ptr(value: f64) -> Option<*mut ArrayHeader> { return None; } let raw = (bits & crate::value::POINTER_MASK) as usize; - if raw < 0x10000 { - return None; - } - unsafe { - let header = - (raw as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; - match (*header).obj_type { - crate::gc::GC_TYPE_ARRAY | crate::gc::GC_TYPE_LAZY_ARRAY => { - Some(raw as *mut ArrayHeader) - } - _ => None, - } + let header = unsafe { crate::value::addr_class::try_read_gc_header(raw) }?; + match header.obj_type { + crate::gc::GC_TYPE_ARRAY | crate::gc::GC_TYPE_LAZY_ARRAY => Some(raw as *mut ArrayHeader), + _ => None, } } diff --git a/crates/perry-runtime/src/collection_iter_object.rs b/crates/perry-runtime/src/collection_iter_object.rs index d69b652d05..e3a45afc69 100644 --- a/crates/perry-runtime/src/collection_iter_object.rs +++ b/crates/perry-runtime/src/collection_iter_object.rs @@ -55,12 +55,13 @@ pub fn is_set_iterator_addr(addr: usize) -> bool { } fn iterator_class_id(addr: usize) -> Option { - if addr < crate::gc::GC_HEADER_SIZE + 0x1000 { - return None; - } + // `util.types.isMapIterator(v)` / `isSetIterator(v)` hand any value's + // candidate address here. The canonical header read rejects the handle + // band and out-of-window bits before touching memory (#10479: the old + // `addr < GC_HEADER_SIZE + 0x1000` floor let a proxy/fetch id through). unsafe { - let gc_header = (addr - crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; - if (*gc_header).obj_type != crate::gc::GC_TYPE_OBJECT { + let header = crate::value::addr_class::try_read_gc_header(addr)?; + if header.obj_type != crate::gc::GC_TYPE_OBJECT { return None; } Some((*(addr as *const ObjectHeader)).class_id) diff --git a/crates/perry-runtime/src/exception.rs b/crates/perry-runtime/src/exception.rs index 53459f49fc..4d93dd9667 100644 --- a/crates/perry-runtime/src/exception.rs +++ b/crates/perry-runtime/src/exception.rs @@ -692,6 +692,9 @@ pub fn scan_exception_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) if (*s).has_exception { visitor.visit_nanbox_f64_raw_slot(&raw mut (*s).current_exception); } + // #10564 review finding: every open `try`'s captured implicit-`this`/ + // `new.target` snapshot is a second root for whatever it holds. + savepoints::scan_pending_trap_roots_mut(&mut (*s).savepoints, (*s).try_depth, visitor); }); } @@ -787,6 +790,54 @@ mod tests { assert_eq!(RuntimeHandleScope::active_len_for_tests(), base_handles); } + /// PR #10564 review finding, reproduced without touching the new + /// `implicit_this`/`new_target` savepoints directly: a bare + /// save/call/restore pair — no RAII guard — around a call that throws. + /// This is exactly the shape at the 4 named sites (fetch_globals.rs's + /// Temporal/Intl subclass bridges, handle_methods.rs's prototype-walk + /// accessor dispatch, the stdlib listener/getter dispatchers) before this + /// fix: the restore statement textually follows the call, so neither + /// `longjmp` nor a system unwind ever reaches it once the call throws. + /// + /// Deliberately uses only pre-existing public entry points + /// (`js_implicit_this_set`/`js_new_target_set`/`catch_js_throw`/ + /// `js_throw`) that this fix does not change, so the same test body + /// fails on the unfixed tree (nothing restores either cell across the + /// inner `js_throw`) and passes once `implicit_this`/`new_target` join + /// `catch_savepoints!` — the fail-before/pass-after proof for the + /// mechanism every one of the 4 sites shares. + #[test] + fn a_bare_save_call_restore_site_is_made_exception_safe_by_the_savepoint() { + let base_this = crate::object::js_implicit_this_get().to_bits(); + let base_nt = crate::object::js_new_target_get().to_bits(); + + let outcome: Result<(), f64> = catch_js_throw(|| { + // The caller's open `try` around the guarded call, e.g. user code + // wrapping `obj.method()` in `try {}`. + let inner: Result<(), f64> = catch_js_throw(|| { + // The bare save/call/restore pair itself: set, call something + // that throws, restore — except the restore is unreachable. + let _prev_this = crate::object::js_implicit_this_set(11.0); + let _prev_nt = crate::object::js_new_target_set(22.0); + js_throw(99.0) + }); + assert_eq!(inner, Err(99.0)); + assert_eq!( + crate::object::js_implicit_this_get().to_bits(), + base_this, + "the try open around the bare site must have restored IMPLICIT_THIS" + ); + assert_eq!( + crate::object::js_new_target_get().to_bits(), + base_nt, + "the try open around the bare site must have restored new.target" + ); + }); + assert_eq!(outcome, Ok(())); + assert_eq!(crate::object::js_implicit_this_get().to_bits(), base_this); + assert_eq!(crate::object::js_new_target_get().to_bits(), base_nt); + } + #[test] fn try_push_pop_beyond_old_limit_does_not_panic() { // Regression for #5065: old fixed limit was 128 and js_try_push panicked diff --git a/crates/perry-runtime/src/exception/savepoints.rs b/crates/perry-runtime/src/exception/savepoints.rs index b8410821c5..a0d1cdee71 100644 --- a/crates/perry-runtime/src/exception/savepoints.rs +++ b/crates/perry-runtime/src/exception/savepoints.rs @@ -188,6 +188,24 @@ catch_savepoints! { capture: crate::object::call_method_depth_savepoint, restore: crate::object::call_method_depth_restore, latch: catch_subsystem::ALWAYS, idle: 0; + // PR #10564 review finding: the runtime guards that displace IMPLICIT_THIS + // around a `super()`/accessor/listener call they don't own (Temporal/Intl + // subclass bridges, the handle-method prototype-walk accessor dispatch, + // the stdlib listener/getter dispatchers) are a bare save/call/restore + // pair, not `ImplicitThisScope` — neither transport runs the restore + // statement that follows the call. The captured value is a second root + // for the object the live cell's own scanner already protects; see + // `scan_pending_trap_roots_mut` below. + implicit_this: u64, + capture: crate::object::implicit_this_trap_savepoint, + restore: crate::object::implicit_this_trap_restore, + latch: catch_subsystem::ALWAYS, idle: crate::value::TAG_UNDEFINED; + // Same shape as `implicit_this`, for `new.target` (the Temporal/Intl + // subclass `super()` bridges save/restore both together). + new_target: u64, + capture: crate::object::new_target_trap_savepoint, + restore: crate::object::new_target_trap_restore, + latch: catch_subsystem::ALWAYS, idle: crate::value::TAG_UNDEFINED; // Includes removal of the process-wide outer-pump contribution. pump: u32, capture: crate::stdlib_pump::pump_depth_savepoint, @@ -235,5 +253,32 @@ catch_savepoints! { latch: catch_subsystem::DYN_EVAL, idle: 0; } +/// Root + rewrite `implicit_this`/`new_target` in every OPEN `try`'s captured +/// savepoint (PR #10564 review finding). +/// +/// `capture()` copies the live cells' bits into this per-depth slab +/// precisely so `js_throw` can put them back after a bare save/call/restore +/// site (see `object::this_binding::implicit_this_trap_savepoint`) gets +/// longjmp'd or unwound past. That copy is a second root for the same value +/// the live cell's own scanner (`object::this_binding:: +/// scan_implicit_this_roots_mut`) already protects, and it is invisible to +/// that scanner. A moving minor that runs while a `try` is open — before any +/// throw crosses it — must rewrite this copy too, or a later throw restores a +/// from-space address. Bounded by `try_depth <= MAX_TRY_DEPTH`, same as every +/// other read of this slab. +pub(super) fn scan_pending_trap_roots_mut( + savepoints: &mut [std::mem::MaybeUninit], + try_depth: usize, + visitor: &mut crate::gc::RuntimeRootVisitor<'_>, +) { + for entry in &mut savepoints[..try_depth] { + // SAFETY: every slot below `try_depth` was written by `capture()` in + // `try_push_with_kind` before `try_depth` advanced past it. + let entry = unsafe { entry.assume_init_mut() }; + visitor.visit_nanbox_u64_slot(&mut entry.implicit_this); + visitor.visit_nanbox_u64_slot(&mut entry.new_target); + } +} + #[cfg(test)] mod tests; diff --git a/crates/perry-runtime/src/exception/savepoints/tests.rs b/crates/perry-runtime/src/exception/savepoints/tests.rs index 5b463d711f..e4fd866d76 100644 --- a/crates/perry-runtime/src/exception/savepoints/tests.rs +++ b/crates/perry-runtime/src/exception/savepoints/tests.rs @@ -89,6 +89,18 @@ pub(super) fn call_method(_: u32) { crate::object::test_enter_catch_method(); } +/// A plain finite double round-trips through the nanbox bit pattern +/// unchanged (no tag rewriting to worry about), so a distinct marker per call +/// is enough to make `capture()` observe a change — mirrors how +/// `static_private_owner`'s witness below reuses `marker as f64`. +pub(super) fn implicit_this(marker: u32) { + crate::object::js_implicit_this_set(marker as f64); +} + +pub(super) fn new_target(marker: u32) { + crate::object::js_new_target_set(marker as f64); +} + pub(super) fn pump(_: u32) { crate::stdlib_pump::test_enter_catch_pump(); } diff --git a/crates/perry-runtime/src/gc/collection_points.rs b/crates/perry-runtime/src/gc/collection_points.rs new file mode 100644 index 0000000000..a732113b57 --- /dev/null +++ b/crates/perry-runtime/src/gc/collection_points.rs @@ -0,0 +1,56 @@ +//! Named collection points for rooting regression tests. +//! +//! A runtime helper that holds a GC value in a Rust local across an allocation +//! only goes wrong when a moving collection lands in that exact window, which +//! an allocation-trigger test cannot aim at a specific allocation inside one +//! call. A test arms a site by name; the next time the helper passes that site +//! it runs a copying minor, so the test observes what the helper holds after a +//! collection at precisely that point. The same one-shot shape as +//! `set.rs`'s `test_force_next_set_helper_gc`, shared instead of repeated. +//! +//! Outside `cfg(test)` a collection point is an empty inline function. + +#[cfg(test)] +crate::perry_thread_local! { + static ARMED_SITE: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Run one copying minor the next time `collection_point(site)` is reached on +/// this thread. +#[cfg(test)] +pub(crate) fn arm_collection_point(site: &'static str) { + arm_collection_point_after(site, 0); +} + +/// Like [`arm_collection_point`], but skips the first `skip` hits on `site` +/// before firing on hit number `skip + 1`. A helper called once per loop +/// iteration only exposes ONE named site, so this is how a test puts the +/// forced collection on a LATER iteration than the first -- e.g. to check +/// that a value already read (and copied into a plain, unrooted local) on an +/// earlier iteration survives a collection triggered by a later one. +#[cfg(test)] +pub(crate) fn arm_collection_point_after(site: &'static str, skip: u32) { + ARMED_SITE.with(|armed| armed.set(Some((site, skip)))); +} + +#[cfg(test)] +pub(crate) fn collection_point(site: &'static str) { + let hit = ARMED_SITE.with(|armed| match armed.get() { + Some((armed_site, 0)) if armed_site == site => { + armed.set(None); + true + } + Some((armed_site, remaining)) if armed_site == site => { + armed.set(Some((armed_site, remaining - 1))); + false + } + _ => false, + }); + if hit { + let _ = super::gc_collect_minor(); + } +} + +#[cfg(not(test))] +#[inline(always)] +pub(crate) fn collection_point(_site: &'static str) {} diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index d76398e700..b14865203b 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -10,6 +10,71 @@ fn fixed_slot(slot: *mut u64) -> GcMutableSlotDescriptor { GcMutableSlotDescriptor::Slot(GcMutableSlot::new(slot, None)) } +impl HeapChildSlotIterator { + /// The payload mask WORD for a `Masked` selection whose mask is + /// [`LayoutSlotMask::Inline`]: exactly the slot indices [`Self::next`] + /// would yield, in the same ascending order, so the caller can walk them + /// with `trailing_zeros` and `word &= word - 1` instead of re-entering the + /// iterator once per slot. `None` for every other selection, which keeps + /// iterating through `next`. + /// + /// `next` re-dispatched the selection, re-decoded the mask's niche and + /// rebuilt the limit and cursor masks FOR EVERY SLOT, for about eight + /// instructions of work. + /// + /// Equivalence, since this replaces the whole iteration: + /// * `next` stops at `slot_count` and at 64 (an inline mask holds no bit + /// above 63), so the eligible set is the mask under both limits — which + /// is what this returns; + /// * it takes the ONE-SHOT raw-numeric accounting with it, exactly as + /// `next`'s first call performs it, so the counters see one record per + /// traced object either way; and + /// * it leaves the cursor at the end, so a later `next` yields nothing. + /// + /// The prefix and meta slots are NOT its business: every caller takes them + /// with `take_prefix_child_slot` / `take_meta_child_slot{,2}` before it + /// reaches the payload, so they are already `None` here. The debug + /// assertion below is what keeps that true. + pub(super) fn take_inline_mask_word(&mut self) -> Option { + // Silent loss of a prefix/meta edge is the one way this can go wrong + // without disagreeing with `next` on any payload index, so it is + // asserted rather than argued. + debug_assert!( + self.prefix_slot.is_none() && self.meta_slot.is_none() && self.meta_slot2.is_none(), + "the inline walk covers the PAYLOAD only; the caller takes the prefix and meta edges first" + ); + let slot_count = self.payload.slot_count(); + let HeapPayloadSlotSelection::Masked { + mask: LayoutSlotMask::Inline(bits), + cursor, + raw_numeric_object_slots, + raw_numeric_recorded, + } = &mut self.selection + else { + return None; + }; + debug_assert_eq!(*cursor, 0, "the inline walk replaces the whole iteration"); + if !*raw_numeric_recorded { + *raw_numeric_recorded = true; + if *raw_numeric_object_slots != 0 { + record_layout_raw_numeric_object_field_range_skipped(*raw_numeric_object_slots); + } + } + let bits = *bits; + *cursor = slot_count; + let limit = slot_count.min(64); + let limit_mask = if limit == 64 { + u64::MAX + } else { + (1u64 << limit) - 1 + }; + let word = bits & limit_mask; + #[cfg(test)] + let word = inline_mask_sabotage::perturb(word); + Some(word) + } +} + pub(super) unsafe fn visit_gc_layout_slot_descriptors( header: *mut GcHeader, visit: &mut dyn FnMut(GcMutableSlotDescriptor), @@ -117,15 +182,32 @@ pub(super) unsafe fn visit_gc_layout_slot_descriptors( }); } HeapPayloadSlotScan::Masked => { - // 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 { + // An inline mask's set bits ARE the slot indices, in ascending + // order: take the word once and walk it, instead of re-entering + // `next` per slot to re-dispatch the selection and rebuild the + // same two masks. Every other mask — `Heap`, i.e. more than 64 + // payload slots — keeps the iterator. + if let Some(mut word) = child_slots.take_inline_mask_word() { + let payload = child_slots.payload; + while word != 0 { + let index = word.trailing_zeros() as usize; + word &= word - 1; visit(GcMutableSlotDescriptor::Slot(GcMutableSlot::new( - slot, - Some(layout_kind), + payload.slot(index), + Some(HeapChildSlotReadKind::Masked), ))); } + } else { + // 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, + Some(layout_kind), + ))); + } + } } } HeapPayloadSlotScan::All(range) => visit(GcMutableSlotDescriptor::Range { @@ -402,3 +484,45 @@ pub(super) unsafe fn visit_gc_rewrite_slots( descriptor.visit_slots(&mut visit); }); } + +/// Test-only sabotage for the inline mask walk +/// ([`HeapChildSlotIterator::take_inline_mask_word`]): a fast path that +/// enumerates a DIFFERENT set than the iterator it replaces must be caught, so +/// the witnesses arm this and REQUIRE the failure. Its witnesses are +/// `gc::tests::layout_inline_mask`. +#[cfg(test)] +pub(crate) mod inline_mask_sabotage { + use std::cell::Cell; + + /// Forget the mask's highest slot — the one a cursor-or-limit mistake + /// loses, and the one no `0..slot_count` spot check would look at. + pub(crate) const DROP_TOP: u8 = 1; + + thread_local! { + static PERTURB: Cell = const { Cell::new(0) }; + } + + #[inline] + pub(crate) fn perturb(word: u64) -> u64 { + let armed = PERTURB.with(Cell::get); + if armed & DROP_TOP != 0 && word != 0 { + return word & !(1u64 << (63 - word.leading_zeros())); + } + word + } + + pub(crate) struct Guard(u8); + + impl Guard { + pub(crate) fn arm(what: u8) -> Self { + Self(PERTURB.with(|p| p.replace(p.get() | what))) + } + } + + impl Drop for Guard { + fn drop(&mut self) { + let prior = self.0; + PERTURB.with(|p| p.set(prior)); + } + } +} diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index f897b34fb4..c062e634f4 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -49,6 +49,10 @@ pub(crate) use policy::young_generation_holds_a_nursery; pub use policy::*; mod progress; pub use progress::*; +mod collection_points; +pub(crate) use collection_points::collection_point; +#[cfg(test)] +pub(crate) use collection_points::{arm_collection_point, arm_collection_point_after}; mod heap_budget; pub(crate) use heap_budget::*; mod pressure; diff --git a/crates/perry-runtime/src/gc/tests/layout_inline_mask.rs b/crates/perry-runtime/src/gc/tests/layout_inline_mask.rs new file mode 100644 index 0000000000..5512b0a7ec --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/layout_inline_mask.rs @@ -0,0 +1,220 @@ +//! The inline-mask walk that replaced the per-slot `HeapChildSlotIterator::next` +//! call in `visit_gc_layout_slot_descriptors` must enumerate EXACTLY what the +//! iterator enumerates, for every mask shape it claims. That is a property, so +//! it is tested as one — over every edge case of the mask word and of the live +//! slot count, and over a deterministic pseudo-random sample — and each +//! property has a sabotaged twin that must fail. + +use super::super::layout::{ + HeapChildSlot, HeapChildSlotIterator, HeapPayloadSlotSelection, HeapSlotRange, LayoutSlotMask, +}; +use super::super::layout_slot_visit::inline_mask_sabotage; +use super::super::*; +use super::support::*; + +/// Every mask word worth a case: empty, one bit at each end, full width, both +/// alternations, a few hand-picked sparse shapes, and 64 pseudo-random words +/// from a fixed LCG so the sample is the same on every run. +fn mask_words() -> Vec { + let mut words = vec![ + 0, + 1, + 0b10, + 0b1011, + 1 << 31, + 1 << 62, + 1 << 63, + (1 << 63) | 1, + u64::MAX, + u64::MAX >> 1, + 0xAAAA_AAAA_AAAA_AAAA, + 0x5555_5555_5555_5555, + 0xFFFF_FFFF, + 0xFFFF_FFFF_0000_0000, + ]; + let mut state: u64 = 0x2545_F491_4F6C_DD1D; + for _ in 0..64 { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + words.push(state); + } + words +} + +/// Live payload slot counts: zero, the small shapes, both sides of every word +/// boundary, the maximum inline width (64) and past it. +fn slot_counts() -> Vec { + vec![0, 1, 2, 3, 7, 8, 31, 32, 33, 63, 64, 65, 96, 128] +} + +fn iterator_for(bits: u64, slots: &mut [u64]) -> HeapChildSlotIterator { + HeapChildSlotIterator { + prefix_slot: None, + meta_slot: None, + meta_slot2: None, + payload: HeapSlotRange::new(slots.as_mut_ptr(), slots.len()), + selection: HeapPayloadSlotSelection::Masked { + mask: LayoutSlotMask::Inline(bits), + cursor: 0, + raw_numeric_object_slots: 0, + raw_numeric_recorded: true, + }, + object_shape: None, + } +} + +/// What the iterator yields: the slot INDEX of every `Child`, in order. +fn iterated_indices(bits: u64, slots: &mut [u64]) -> Vec { + let base = slots.as_mut_ptr(); + let mut out = Vec::new(); + for child in &mut iterator_for(bits, slots) { + match child { + HeapChildSlot::Child(slot, kind) => { + assert_eq!(kind, HeapChildSlotReadKind::Masked, "masked payload slot"); + out.push((slot as usize - base as usize) / std::mem::size_of::()); + } + other => panic!("a masked payload yields only children, got {other:?}"), + } + } + out +} + +/// What the walk yields: the set bits of the word it hands the visitor, in the +/// order `trailing_zeros` + `word &= word - 1` produces. +fn walked_indices(bits: u64, slots: &mut [u64]) -> Vec { + let mut word = iterator_for(bits, slots) + .take_inline_mask_word() + .expect("an inline mask must take the walk"); + let mut out = Vec::new(); + while word != 0 { + out.push(word.trailing_zeros() as usize); + word &= word - 1; + } + out +} + +fn disagreements() -> Vec<(u64, usize)> { + let mut bad = Vec::new(); + for bits in mask_words() { + for count in slot_counts() { + let mut slots = vec![0u64; count]; + if iterated_indices(bits, &mut slots) != walked_indices(bits, &mut slots) { + bad.push((bits, count)); + } + } + } + bad +} + +#[test] +fn the_inline_walk_enumerates_exactly_what_the_iterator_enumerates() { + let bad = disagreements(); + assert!( + bad.is_empty(), + "the inline walk and `next` must agree for every mask and live slot \ + count; they disagreed on {} of {} cases, first {:?}", + bad.len(), + mask_words().len() * slot_counts().len(), + bad.first() + ); +} + +#[test] +fn every_index_the_walk_yields_is_live_and_set_in_the_mask() { + for bits in mask_words() { + for count in slot_counts() { + let mut slots = vec![0u64; count]; + for index in walked_indices(bits, &mut slots) { + assert!( + index < count, + "walked slot {index} is past the live count {count}" + ); + assert!( + bits & (1u64 << index) != 0, + "walked slot {index} is not in the mask" + ); + } + } + } +} + +#[test] +fn a_sabotaged_walk_is_caught_by_the_property() { + let _sabotage = inline_mask_sabotage::Guard::arm(inline_mask_sabotage::DROP_TOP); + let bad = disagreements(); + assert!( + !bad.is_empty(), + "with the mask's top slot dropped the walk must disagree with `next`; \ + a property that cannot see that proves nothing" + ); +} + +/// The walk is the arm a real collection takes: a young string reachable only +/// through the HIGHEST masked slot of a rooted young array must survive a +/// copying minor, and must not when that slot is dropped. +fn top_masked_slot_child_survives(sabotaged: bool) -> bool { + std::thread::spawn(move || { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _scan = ConservativeScanDisabledGuard::new(); + let _roots = ShadowAndGlobalRootResetGuard; + const LEN: usize = 6; + let top = LEN - 1; + let arr = crate::array::js_array_alloc_with_length(LEN as u32); + let child = young_leaf(); + // Numbers everywhere else, so the mask holds exactly the top slot: a + // walk that loses its top bit loses this child and nothing else. + for index in 0..top { + crate::array::js_array_set_f64(arr, index as u32, index as f64); + } + crate::array::js_array_set_f64(arr, top as u32, f64::from_bits(string_bits(child))); + js_shadow_slot_set(0, ptr_bits(arr as usize)); + + // Premise: this array's payload really is an inline-masked selection + // whose only bit is the top slot. Without it the collection below takes + // the general arm and proves nothing about the walk. + let word = unsafe { + let header = header_from_user_ptr(arr as *const u8) as *mut GcHeader; + crate::gc::layout::gc_child_slots(header).take_inline_mask_word() + }; + assert_eq!( + word, + Some(1u64 << top), + "premise: the fixture must produce an inline mask holding only slot {top}" + ); + + { + let _sabotage = + sabotaged.then(|| inline_mask_sabotage::Guard::arm(inline_mask_sabotage::DROP_TOP)); + let _ = gc_collect_minor(); + } + let arr_after = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_ne!(arr_after, arr as usize, "premise: the rooted array moved"); + let slot = unsafe { + *crate::array::gc_element_slot_range(arr_after as *mut crate::array::ArrayHeader) + .expect("the array must still enumerate its elements") + .slot(top) + }; + (slot & POINTER_MASK) as usize != child + }) + .join() + .expect("inline-mask collection test thread must not panic") +} + +#[test] +fn the_top_masked_slots_child_is_evacuated_through_the_walk() { + assert!( + top_masked_slot_child_survives(false), + "the child in the highest masked slot must be evacuated and the slot rewritten" + ); +} + +#[test] +fn sabotaging_the_walks_top_slot_strands_its_child() { + assert!( + !top_masked_slot_child_survives(true), + "with the mask's top slot dropped the child is never visited, so the \ + slot still names from-space" + ); +} diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 5e8e3f86c0..7a7e8a70e3 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -48,6 +48,7 @@ mod inline_generation_gate_contract; mod inline_pointer_bearing_contract; mod json_parse_scalar; mod json_stringify_output; +mod layout_inline_mask; mod layout_pointer_free_hazard; mod layout_residue_histogram; mod layout_trace; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index 0eda3a2f4b..b78396beca 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -3,6 +3,7 @@ use super::support::*; use std::cell::Cell; mod arraylike_callbacks; mod bound_method_builder; +mod call_argument_lists; mod callback_scanners; mod fs_options_object; mod generator_attach_prototype; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/call_argument_lists.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/call_argument_lists.rs new file mode 100644 index 0000000000..13f79d52f9 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/call_argument_lists.rs @@ -0,0 +1,406 @@ +//! Argument lists a runtime dispatcher holds across an allocation. +//! +//! `Reflect.apply` and the rest/`arguments` bundler both read a call's +//! arguments into Rust locals and then allocate — a rebound closure, an index +//! key, the `arguments` array — before the callee ever sees them. A local is +//! not a GC root, so a moving collection in that window leaves the callee with +//! from-space addresses while the values themselves live on somewhere else. +//! +//! An allocation-trigger test cannot aim at one allocation inside one call, so +//! each test arms the named collection point that stands for it +//! (`gc::collection_points`) and then asserts the callee received each value's +//! POST-collection location. Every test also asserts its premise — a copying +//! minor ran and the value really moved — because "the callee saw the right +//! address" passes vacuously if nothing moved. + +use super::*; +use crate::ObjectHeader; +use std::cell::RefCell; + +crate::perry_thread_local! { + static SEEN: RefCell> = RefCell::new(Vec::new()); +} + +fn record(values: &[u64]) { + SEEN.with(|seen| seen.borrow_mut().extend_from_slice(values)); +} + +fn seen() -> Vec { + SEEN.with(|seen| std::mem::take(&mut *seen.borrow_mut())) +} + +extern "C" fn record_this_and_six_args( + _closure: *const crate::closure::ClosureHeader, + a0: f64, + a1: f64, + _a2: f64, + _a3: f64, + _a4: f64, + a5: f64, +) -> f64 { + record(&[ + crate::object::js_implicit_this_get().to_bits(), + a0.to_bits(), + a1.to_bits(), + a5.to_bits(), + ]); + 0.0 +} + +extern "C" fn record_two_args( + _closure: *const crate::closure::ClosureHeader, + a0: f64, + a1: f64, +) -> f64 { + record(&[a0.to_bits(), a1.to_bits()]); + 0.0 +} + +#[allow(clippy::too_many_arguments)] +extern "C" fn record_rest_and_arguments_after_16( + _closure: *const crate::closure::ClosureHeader, + _a0: f64, + _a1: f64, + _a2: f64, + _a3: f64, + _a4: f64, + _a5: f64, + _a6: f64, + _a7: f64, + _a8: f64, + _a9: f64, + _a10: f64, + _a11: f64, + _a12: f64, + _a13: f64, + _a14: f64, + _a15: f64, + rest: f64, + arguments: f64, +) -> f64 { + let rest_ptr = (rest.to_bits() & POINTER_MASK) as *const crate::array::ArrayHeader; + let arguments_ptr = (arguments.to_bits() & POINTER_MASK) as *const crate::array::ArrayHeader; + let rest_len = crate::array::js_array_length(rest_ptr); + record(&[u64::from(rest_len)]); + // A from-space read under `PoisonOnly` returns the poison word, so the + // length above is the discriminating observation; only read elements when + // it is the length this call really has. + if rest_len == 2 { + record(&[ + crate::array::js_array_get(rest_ptr, 0).bits(), + u64::from(crate::array::js_array_length(arguments_ptr)), + crate::array::js_array_get(arguments_ptr, 16).bits(), + ]); + } + 0.0 +} + +/// A closure the armed minor must NOT move: the callee identity is not what +/// these tests are about, and a from-space callee pointer would throw +/// "value is not a function" out of a unit test instead of failing an +/// assertion. Two minors under a pinned promotion age tenure it into the +/// non-moving old generation. +fn tenured_closure(body: *const u8, capture_count: u32) -> *mut crate::closure::ClosureHeader { + let closure = crate::closure::js_closure_alloc(body, capture_count); + let scope = RuntimeHandleScope::new(); + let handle = scope.root_raw_mut_ptr(closure); + { + let _tenuring = crate::gc::tenuring::set_survivals_for_test(1); + let _ = gc_collect_minor(); + let _ = gc_collect_minor(); + } + // #7341: nothing allocates after this read within the function (the two + // minors above already ran), so it is the "final read in a scope with + // nothing after it" the ratchet's own docstring carves out — but + // `with_mut_ptr` keeps it out of the raw-handle debt count without + // changing behavior: the closure runs immediately and hands the pointer + // straight back. + let tenured = handle.with_mut_ptr::(|ptr| ptr); + assert!( + !crate::arena::pointer_in_nursery(tenured as usize), + "premise: the callee must be out of the nursery so only the arguments move" + ); + tenured +} + +fn array_value(values: &[f64]) -> f64 { + let mut arr = crate::array::js_array_alloc(values.len() as u32); + for value in values { + arr = crate::array::js_array_push_f64(arr, *value); + } + f64::from_bits(ptr_bits(arr as usize)) +} + +fn array_element(value: f64, index: u32) -> u64 { + let arr = (value.to_bits() & POINTER_MASK) as *const crate::array::ArrayHeader; + crate::array::js_array_get(arr, index).bits() +} + +/// #10532 review finding: `Reflect.apply` held the callee, the receiver and +/// every argument in plain Rust locals while `rebind_explicit_this` allocated a +/// rebound closure. A collection there left the callee reading from-space +/// addresses for all three. +#[test] +fn reflect_apply_roots_receiver_and_arguments_across_the_rebind_allocation() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _evacuate = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); + register_runtime_handle_root_scanner_for_tests(); + + // A concise/object-literal method — a non-arrow closure with a reserved + // `this` capture — is the shape whose rebind CLONES, and that clone is the + // allocation this test collects inside. + let closure = tenured_closure( + record_this_and_six_args as *const u8, + crate::closure::CAPTURES_THIS_FLAG | 1, + ); + let scope = RuntimeHandleScope::new(); + let callee = scope.root_nanbox_f64(f64::from_bits(ptr_bits(closure as usize))); + let receiver = scope.root_nanbox_f64(f64::from_bits(ptr_bits(crate::object::js_object_alloc( + 0, 0, + ) as usize))); + let receiver_original = (receiver.get_nanbox_f64().to_bits() & POINTER_MASK) as usize; + let list = scope.root_nanbox_f64(array_value(&[ + test_string_value(b"first"), + test_string_value(b"second"), + 2.0, + 3.0, + 4.0, + test_string_value(b"sixth"), + ])); + let first_original = (array_element(list.get_nanbox_f64(), 0) & POINTER_MASK) as usize; + + crate::gc::arm_collection_point("reflect.apply.rebind"); + let before = crate::gc::copying_minor_cycles(); + crate::proxy::js_reflect_apply( + callee.get_nanbox_f64(), + receiver.get_nanbox_f64(), + list.get_nanbox_f64(), + ); + + assert!( + crate::gc::copying_minor_cycles() > before, + "premise: the armed collection point ran a copying minor" + ); + let receiver_now = receiver.get_nanbox_f64(); + let list_now = list.get_nanbox_f64(); + assert_ne!( + (receiver_now.to_bits() & POINTER_MASK) as usize, + receiver_original, + "premise: the receiver moved" + ); + assert_ne!( + (array_element(list_now, 0) & POINTER_MASK) as usize, + first_original, + "premise: the first argument moved" + ); + assert_eq!( + seen(), + vec![ + receiver_now.to_bits(), + array_element(list_now, 0), + array_element(list_now, 1), + array_element(list_now, 5), + ], + "Reflect.apply must hand the callee the post-collection receiver and \ + arguments, not the addresses they had before the rebind allocated" + ); +} + +/// #10532 review finding: `CreateListFromArrayLike`'s array-like path allocated +/// an index key per element while the source object and the elements collected +/// so far sat in Rust locals. +#[test] +fn array_like_argument_lists_root_the_source_and_the_collected_elements() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _evacuate = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); + register_runtime_handle_root_scanner_for_tests(); + + let closure = tenured_closure(record_two_args as *const u8, 0); + let scope = RuntimeHandleScope::new(); + let callee = scope.root_nanbox_f64(f64::from_bits(ptr_bits(closure as usize))); + let source = crate::object::js_object_alloc(0, 3); + let source_value = scope.root_nanbox_f64(f64::from_bits(ptr_bits(source as usize))); + for (name, value) in [ + (&b"length"[..], 2.0), + (&b"0"[..], test_string_value(b"zero")), + (&b"1"[..], test_string_value(b"one")), + ] { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let obj = (source_value.get_nanbox_f64().to_bits() & POINTER_MASK) as *mut ObjectHeader; + crate::object::js_object_set_field_by_name(obj, key, value); + } + let zero_original = (element_by_name(&source_value, b"0") & POINTER_MASK) as usize; + + crate::gc::arm_collection_point("reflect.list_from_array_like.index_key"); + let before = crate::gc::copying_minor_cycles(); + crate::proxy::js_reflect_apply( + callee.get_nanbox_f64(), + f64::from_bits(crate::value::TAG_UNDEFINED), + source_value.get_nanbox_f64(), + ); + + assert!( + crate::gc::copying_minor_cycles() > before, + "premise: the armed collection point ran a copying minor" + ); + assert_ne!( + (element_by_name(&source_value, b"0") & POINTER_MASK) as usize, + zero_original, + "premise: the element moved" + ); + assert_eq!( + seen(), + vec![ + element_by_name(&source_value, b"0"), + element_by_name(&source_value, b"1"), + ], + "an array-like argument list must be read out of the post-collection \ + source object" + ); +} + +fn element_by_name(source: &RuntimeHandle<'_>, name: &[u8]) -> u64 { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let obj = (source.get_nanbox_f64().to_bits() & POINTER_MASK) as *const ObjectHeader; + crate::object::js_object_get_field_by_name_f64(obj, key).to_bits() +} + +/// #10532 review finding: a `(…fixed, ...rest)` body that also takes a +/// synthesized `arguments` builds TWO arrays. The second allocation could move +/// the first, and the bundler passed the callee the address the first array had +/// before it moved. From-space is poisoned here so a stale read is visible as a +/// wrong `rest.length` instead of intact bytes that happen to still be there. +#[test] +fn rest_bundling_roots_the_rest_array_across_the_arguments_array() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _evacuate = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); + let _protection = + crate::arena::ProtectionModeGuard::set(crate::arena::FromSpaceProtection::PoisonOnly); + register_runtime_handle_root_scanner_for_tests(); + + let body = record_rest_and_arguments_after_16 as *const u8; + crate::closure::js_register_closure_rest_and_arguments(body, 16); + let closure = crate::closure::js_closure_alloc(body, 0); + let scope = RuntimeHandleScope::new(); + let closure_handle = scope.root_raw_mut_ptr(closure); + let tail = scope.root_nanbox_f64(test_string_value(b"rest-tail")); + let tail_original = (tail.get_nanbox_f64().to_bits() & POINTER_MASK) as usize; + + let mut args: Vec = (0..16).map(f64::from).collect(); + args.push(tail.get_nanbox_f64()); + args.push(99.0); + + crate::gc::arm_collection_point("closure.rest_bundle.between_arrays"); + let before = crate::gc::copying_minor_cycles(); + let retired_before = crate::arena::quarantine_stats().sets_retired; + // #7341: `js_closure_call_array` is itself the self-rooting entry point + // under test here, so the closure pointer is a scoped argument to it — + // `with_mut_ptr` is the blessed shape for that instead of a bare read. + closure_handle.with_mut_ptr::(|ptr| unsafe { + crate::closure::js_closure_call_array(ptr as i64, args.as_ptr(), args.len() as i64); + }); + + assert!( + crate::gc::copying_minor_cycles() > before, + "premise: the armed collection point ran a copying minor" + ); + assert!( + crate::arena::quarantine_stats().sets_retired > retired_before, + "premise: the minor retired from-space, so a stale read finds poison" + ); + assert_ne!( + (tail.get_nanbox_f64().to_bits() & POINTER_MASK) as usize, + tail_original, + "premise: the trailing argument moved" + ); + assert_eq!( + seen(), + vec![ + 2, + tail.get_nanbox_f64().to_bits(), + 18, + tail.get_nanbox_f64().to_bits() + ], + "the rest array and the arguments object must both be the post-collection \ + arrays, holding the post-collection argument values" + ); +} + +/// #10532 review (round 2): a raw, untagged heap-pointer bit pattern (the +/// Promise executor's resolve/reject shape, `top16 == 0`) stored as an +/// array-like element is exactly as movable as a NaN-boxed pointer, but +/// `JSValue::is_pointer()` does not recognize it, and `root_nanbox_f64`'s +/// `Nanbox` scanner only rewrites POINTER_TAG/STRING_TAG/BIGINT_TAG bit +/// patterns -- it would silently do nothing for a raw one. +#[test] +fn array_like_argument_lists_root_raw_untagged_heap_pointer_elements() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _evacuate = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); + register_runtime_handle_root_scanner_for_tests(); + + let callee = tenured_closure(record_two_args as *const u8, 0); + let scope = RuntimeHandleScope::new(); + let callee_handle = scope.root_nanbox_f64(f64::from_bits(ptr_bits(callee as usize))); + + // A closure left in the nursery, referenced ONLY by its raw (unboxed) + // address -- the exact shape `js_promise_new_with_executor` hands a + // user's executor for `resolve`/`reject` (see proxy.rs's + // `ValueMoveKind::RawHeapWord` doc comment). + let raw_closure = crate::closure::js_closure_alloc(record_two_args as *const u8, 0); + let observer = scope.root_raw_mut_ptr(raw_closure); + let raw_bits_before = raw_closure as usize as u64; + + // Exactly 2 elements to match `record_two_args`'s declared arity -- an + // under-applied raw extern "C" test body has no registered arity to pad + // against, so this keeps the call itself unremarkable and isolates the + // one thing under test: whether element 0 survives as a raw heap word. + let source = crate::object::js_object_alloc(0, 3); + let source_value = scope.root_nanbox_f64(f64::from_bits(ptr_bits(source as usize))); + for (name, value) in [ + (&b"length"[..], 2.0), + (&b"0"[..], f64::from_bits(raw_bits_before)), + (&b"1"[..], 7.0), + ] { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let obj = (source_value.get_nanbox_f64().to_bits() & POINTER_MASK) as *mut ObjectHeader; + crate::object::js_object_set_field_by_name(obj, key, value); + } + + // Fire the forced collection on the SECOND loop iteration (reading + // element "1"), not the first: element "0"'s raw pointer is read on the + // first iteration and, without this fix, copied bare into `out[0]` with + // nothing rooting it. Firing the collection a step later is what puts + // that already-read copy at risk, instead of the collection landing + // before element "0" is ever read (which every read would trivially + // survive, fix or no fix). + crate::gc::arm_collection_point_after("reflect.list_from_array_like.index_key", 1); + let before = crate::gc::copying_minor_cycles(); + crate::proxy::js_reflect_apply( + callee_handle.get_nanbox_f64(), + f64::from_bits(crate::value::TAG_UNDEFINED), + source_value.get_nanbox_f64(), + ); + + assert!( + crate::gc::copying_minor_cycles() > before, + "premise: the armed collection point ran a copying minor" + ); + // #7341: nothing allocates after this read; `with_mut_ptr` keeps it out + // of the raw-handle debt count (see `tenured_closure` above). + let raw_bits_after = + observer.with_mut_ptr::(|ptr| ptr as usize as u64); + assert_ne!( + raw_bits_after, raw_bits_before, + "premise: the raw-bit closure moved" + ); + assert_eq!( + seen(), + vec![raw_bits_after, 7.0_f64.to_bits()], + "element \"0\" must be the post-collection raw address, not the \ + pre-collection one read before the later collection at element \"1\"" + ); +} diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_public.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_public.rs index 26232afff6..b368f6c518 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_public.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_public.rs @@ -34,6 +34,11 @@ pub(super) fn register_host_roots() { crate::object::regex_proto_thunks::scan_canonical_test_site_roots_mut, ); gc_register_mutable_root_scanner(crate::object::scan_implicit_this_roots_mut); + // PR #10564 review finding: implicit_this/new_target savepoints in + // exception.rs are a second root for whatever these tests displace + // IMPLICIT_THIS to across a throw. gc_init registers this in + // production; the isolation guard clears that registry too. + gc_register_mutable_root_scanner(crate::exception::scan_exception_roots_mut); gc_register_mutable_root_scanner(crate::closure::scan_singleton_closure_roots_mut); gc_register_mutable_root_scanner(crate::closure::scan_closure_dynamic_props_roots_mut); gc_register_mutable_root_scanner(crate::string::scan_intern_table_roots_mut); diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 6afe9c9bf7..85557855b5 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -42,6 +42,7 @@ pub use super::class_handles::{ }; use super::*; +mod accessor_attrs; mod builtin_alias_construct; mod class_meta; mod construct; @@ -63,6 +64,14 @@ mod registration; mod state; mod vm_brand; +// ── accessor_attrs.rs ─────────────────────────────────────────────────────── +pub(crate) use accessor_attrs::{ + class_accessor_attrs, class_accessor_attrs_in_use, class_accessor_descriptor, + class_declared_accessor_ptrs, class_enumerable_accessor_names, + class_prototype_enumerable_accessor, class_set_accessor_attrs, + decl_prototype_enumerable_key_snapshot, decl_prototype_keys_with_enumerable_accessors, +}; + // ── state.rs ──────────────────────────────────────────────────────────────── #[cfg(test)] pub(crate) use state::class_decl_prototype_object_root_store; @@ -96,8 +105,9 @@ pub use state::{ // ── prototype_objects.rs ──────────────────────────────────────────────────── pub(crate) use prototype_objects::{ class_prototype_object, ensure_function_prototype_object, function_class_id, - function_value_for_class_id, resolve_proto_chain_field, + function_value_for_class_id, proto_chain_symbol_slot, resolve_proto_chain_field, resolve_proto_chain_field_with_receiver, resolve_proto_chain_symbol, + synthetic_class_prototype_object, }; pub use prototype_objects::{ js_set_function_prototype, js_set_prototype_property, NEXT_SYNTHETIC_CLASS_ID, diff --git a/crates/perry-runtime/src/object/class_registry/accessor_attrs.rs b/crates/perry-runtime/src/object/class_registry/accessor_attrs.rs new file mode 100644 index 0000000000..89c970d026 --- /dev/null +++ b/crates/perry-runtime/src/object/class_registry/accessor_attrs.rs @@ -0,0 +1,327 @@ +//! Reflective attributes of DECLARED class accessors (#10480). +//! +//! A ClassBody `get x() {}` / `set x(v) {}` lives in the class vtable +//! (`CLASS_VTABLE_REGISTRY`, or `CLASS_STATIC_ACCESSORS` for `static`), not in +//! the address-keyed descriptor tables `Object.defineProperty` writes. The +//! vtable records only the two function pointers, so the accessor's +//! `[[Enumerable]]` / `[[Configurable]]` were pinned to the ClassBody defaults +//! (`false` / `true`) and nothing could change them. +//! +//! `Object.defineProperties(C.prototype, { x: { enumerable: true } })` is how +//! every WebIDL-generated class (whatwg-url, node-fetch, undici-style +//! polyfills) marks its accessors at module load. That generic descriptor fell +//! through to the ordinary define path, which could not see the class key: it +//! appended a keys-array placeholder with a NEW property's `writable: false`, +//! and that data property on the prototype then rejected every instance write +//! before the class setter could run — while the requested enumerability was +//! never reported. +//! +//! This table holds what a generic descriptor applied, keyed by +//! `(class_id, is_static, name)`. Absence means the ClassBody defaults, so the +//! table stays empty in a program that never redefines a class accessor, and +//! [`class_accessor_attrs_in_use`] lets the enumeration paths skip the lookup +//! with one load. The values are booleans: nothing here is a GC root. + +use super::*; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; + +crate::perry_thread_local! { + static CLASS_ACCESSOR_ATTRS: std::cell::RefCell> = + std::cell::RefCell::new(HashMap::new()); +} + +/// Sticky: set by the first [`class_set_accessor_attrs`]. Only a hint that +/// the table may be non-empty — never cleared, so a stale `true` merely costs +/// a lookup. +static CLASS_ACCESSOR_ATTRS_IN_USE: AtomicBool = AtomicBool::new(false); + +/// ClassBody defaults for an accessor: `(enumerable, configurable)`. +const CLASS_ACCESSOR_DEFAULT_ATTRS: (bool, bool) = (false, true); + +#[inline] +pub(crate) fn class_accessor_attrs_in_use() -> bool { + CLASS_ACCESSOR_ATTRS_IN_USE.load(Ordering::Relaxed) +} + +/// `(enumerable, configurable)` of the declared accessor `name`. +pub(crate) fn class_accessor_attrs(class_id: u32, is_static: bool, name: &str) -> (bool, bool) { + if !class_accessor_attrs_in_use() { + return CLASS_ACCESSOR_DEFAULT_ATTRS; + } + CLASS_ACCESSOR_ATTRS.with(|table| { + table + .borrow() + .get(&(class_id, is_static, name.to_string())) + .copied() + .unwrap_or(CLASS_ACCESSOR_DEFAULT_ATTRS) + }) +} + +pub(crate) fn class_set_accessor_attrs( + class_id: u32, + is_static: bool, + name: &str, + enumerable: bool, + configurable: bool, +) { + CLASS_ACCESSOR_ATTRS_IN_USE.store(true, Ordering::Relaxed); + CLASS_ACCESSOR_ATTRS.with(|table| { + table.borrow_mut().insert( + (class_id, is_static, name.to_string()), + (enumerable, configurable), + ); + }); +} + +/// Raw `(getter, setter)` func_ptrs of a live own declared accessor — `None` +/// for a method, a field, an inherited accessor, or one `delete` removed. +pub(crate) fn class_declared_accessor_ptrs( + class_id: u32, + is_static: bool, + name: &str, +) -> Option<(usize, usize)> { + if class_is_key_deleted(class_id, name) { + return None; + } + if is_static { + class_own_static_accessor_ptrs(class_id, name) + } else { + class_own_accessor_ptrs(class_id, name) + } +} + +/// `Object.getOwnPropertyDescriptor` for a declared accessor. The getter value +/// is rooted across the setter value's allocation. +pub(crate) unsafe fn class_accessor_descriptor( + class_id: u32, + is_static: bool, + name: &str, + getter: usize, + setter: usize, +) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let get = scope.root_nanbox_f64(class_accessor_function_value(getter, false, name)); + let set = class_accessor_function_value(setter, true, name); + let (enumerable, configurable) = class_accessor_attrs(class_id, is_static, name); + crate::object::descriptors::build_accessor_descriptor( + get.get_nanbox_f64(), + set, + enumerable, + configurable, + ) +} + +/// The class's own declared accessors that are currently enumerable, in +/// ClassBody order. Empty (without walking the class) unless some accessor of +/// this class was made enumerable. +pub(crate) fn class_enumerable_accessor_names(class_id: u32, is_static: bool) -> Vec { + if !class_accessor_attrs_in_use() { + return Vec::new(); + } + let any = CLASS_ACCESSOR_ATTRS.with(|table| { + table + .borrow() + .iter() + .any(|(&(cid, st, _), &(enumerable, _))| { + cid == class_id && st == is_static && enumerable + }) + }); + if !any { + return Vec::new(); + } + class_own_string_member_names(class_id, is_static) + .into_iter() + .filter(|name| { + class_declared_accessor_ptrs(class_id, is_static, name).is_some() + && class_accessor_attrs(class_id, is_static, name).0 + }) + .collect() +} + +/// `Object.keys(C.prototype)` when `physical` is the enumerable key list of the +/// declared-class prototype object of `class_id`: splice in the enumerable +/// declared accessors (which have no physical slot) in [[OwnPropertyKeys]] +/// order — `constructor`, then ClassBody members, then keys added later. +/// Returns `physical` itself when the class has no enumerable accessor. +pub(crate) unsafe fn decl_prototype_keys_with_enumerable_accessors( + class_id: u32, + physical: *mut crate::array::ArrayHeader, +) -> *mut crate::array::ArrayHeader { + let accessors = class_enumerable_accessor_names(class_id, false); + if accessors.is_empty() || physical.is_null() { + return physical; + } + // Copy the physical names out before the first allocation below moves + // anything; `physical` is not read again. + let mut physical_names = Vec::new(); + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + for i in 0..crate::array::js_array_length(physical) { + let key = crate::array::js_array_get(physical, i); + if let Some(bytes) = crate::string::js_string_key_bytes(key, &mut scratch) { + if let Ok(name) = std::str::from_utf8(bytes) { + physical_names.push(name.to_string()); + } + } + } + let mut names: Vec = Vec::new(); + let mut push = |name: &str| { + if !names.iter().any(|existing| existing == name) { + names.push(name.to_string()); + } + }; + if physical_names.iter().any(|name| name == "constructor") { + push("constructor"); + } + for name in class_own_string_member_names(class_id, false) { + if accessors.contains(&name) || physical_names.contains(&name) { + push(&name); + } + } + for name in &physical_names { + push(name); + } + crate::object::descriptors::sort_property_names_ecma(&mut names); + let scope = crate::gc::RuntimeHandleScope::new(); + let out = scope.root_raw_mut_ptr(crate::array::js_array_alloc(names.len() as u32)); + for name in names { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let updated = out.with_mut_ptr(|out| { + crate::array::js_array_push(out, crate::value::JSValue::string_ptr(key)) + }); + out.set_raw_mut_ptr(updated); + } + out.with_mut_ptr(|out: *mut crate::array::ArrayHeader| out) +} + +/// True when `name` is an enumerable declared accessor of the declared-class +/// prototype at `obj_addr` — the enumeration paths' test for a key with no +/// physical slot. Cheap (one atomic load) until some accessor is redefined. +pub(crate) fn class_prototype_enumerable_accessor(obj_addr: usize, name: &str) -> bool { + if !class_accessor_attrs_in_use() { + return false; + } + let Some(class_id) = class_id_for_decl_prototype_object(obj_addr) else { + return false; + }; + class_declared_accessor_ptrs(class_id, false, name).is_some() + && class_accessor_attrs(class_id, false, name).0 +} + +/// `Object.values` / `Object.entries` own-key snapshot for a declared-class +/// prototype with enumerable ClassBody accessors — those keys have no slot in +/// the physical keys array the ordinary snapshot walks. `None` for every other +/// receiver, which keeps the existing walk. +/// +/// The list is `Object.keys`', so (unlike the ordinary snapshot) enumerability +/// is settled here rather than re-read per key: a getter that flips a SIBLING +/// accessor's enumerability mid-enumeration is not modelled. Nothing else +/// changes — the per-key `[[Get]]` and its side effects are unaffected. +pub(crate) unsafe fn decl_prototype_enumerable_key_snapshot( + obj: *const ObjectHeader, +) -> Option>> { + if !class_accessor_attrs_in_use() { + return None; + } + let class_id = class_id_for_decl_prototype_object(obj as usize)?; + if class_enumerable_accessor_names(class_id, false).is_empty() { + return None; + } + let keys = crate::object::field_get_set::enumeration::js_object_keys(obj); + if keys.is_null() { + return None; + } + let mut snapshot = Vec::new(); + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + for i in 0..crate::array::js_array_length(keys) { + let key = crate::array::js_array_get(keys, i); + if let Some(bytes) = crate::string::js_string_key_bytes(key, &mut scratch) { + snapshot.push(bytes.to_vec()); + } + } + Some(snapshot) +} + +#[cfg(test)] +mod tests { + use super::*; + + extern "C" fn getter(_this: f64) -> f64 { + 0.0 + } + + extern "C" fn setter(_this: f64, _value: f64) -> f64 { + 0.0 + } + + unsafe fn register(class_id: u32, name: &str, with_setter: bool, order: i64) { + js_register_class_getter( + class_id as i64, + name.as_ptr(), + name.len() as i64, + getter as *const () as usize as i64, + ); + if with_setter { + js_register_class_setter( + class_id as i64, + name.as_ptr(), + name.len() as i64, + setter as *const () as usize as i64, + ); + } + js_register_class_string_member_order( + class_id as i64, + name.as_ptr(), + name.len() as i64, + 0, + order, + ); + } + + #[test] + fn unrecorded_accessor_keeps_classbody_defaults() { + assert_eq!( + class_accessor_attrs(0x7c48_0001, false, "never"), + (false, true) + ); + } + + #[test] + fn attrs_are_keyed_by_static_side_and_name() { + let cid = 0x7c48_0002; + class_set_accessor_attrs(cid, false, "x", true, false); + assert!(class_accessor_attrs_in_use()); + assert_eq!(class_accessor_attrs(cid, false, "x"), (true, false)); + assert_eq!(class_accessor_attrs(cid, true, "x"), (false, true)); + assert_eq!(class_accessor_attrs(cid, false, "y"), (false, true)); + } + + /// Only live declared accessors qualify: a deleted one, a name the class + /// never declared, and a non-enumerable one are all excluded, and the + /// survivors come back in ClassBody order rather than insertion order. + #[test] + fn enumerable_accessor_names_follow_classbody_order() { + let cid = 0x7c48_0003; + unsafe { + register(cid, "b", true, 10); + register(cid, "a", false, 20); + register(cid, "c", true, 30); + register(cid, "gone", true, 40); + } + class_set_accessor_attrs(cid, false, "a", true, true); + class_set_accessor_attrs(cid, false, "b", true, true); + class_set_accessor_attrs(cid, false, "c", false, true); + class_set_accessor_attrs(cid, false, "gone", true, true); + class_set_accessor_attrs(cid, false, "undeclared", true, true); + class_mark_key_deleted(cid, "gone"); + assert_eq!( + class_enumerable_accessor_names(cid, false), + vec!["b".to_string(), "a".to_string()] + ); + assert!(class_enumerable_accessor_names(cid, true).is_empty()); + assert_eq!(class_declared_accessor_ptrs(cid, false, "gone"), None); + assert!( + class_declared_accessor_ptrs(cid, false, "a").is_some_and(|(g, s)| g != 0 && s == 0) + ); + } +} diff --git a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs index d5c41c34bb..9a689eba69 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs @@ -146,6 +146,9 @@ pub(crate) fn ensure_function_prototype_object( proto_handle.with_mut_ptr::(|proto| proto) } +/// Floor of the synthetic class-id range (see [`NEXT_SYNTHETIC_CLASS_ID`]). +pub(crate) const SYNTHETIC_CLASS_ID_BASE: u32 = 0x8000_0000; + per_test_global! { /// Synthetic class id allocator for prototype-object classes. High bit /// set (0x8000_0000+) to keep them separate from codegen-assigned ids @@ -153,7 +156,24 @@ per_test_global! { /// concern in practice — would require ~2 billion `Function.prototype = X` /// statements at module init. pub static NEXT_SYNTHETIC_CLASS_ID: std::sync::atomic::AtomicU32 = - std::sync::atomic::AtomicU32::new(0x8000_0000); + std::sync::atomic::AtomicU32::new(SYNTHETIC_CLASS_ID_BASE); +} + +/// The `[[Prototype]]` object recorded for a SYNTHETIC class id — one of the +/// ids `Object.create(proto)` (#809) and `F.prototype = obj` (#711) allocate +/// from [`NEXT_SYNTHETIC_CLASS_ID`]. That link is the authoritative prototype +/// of every instance stamped with the id. +/// +/// Unlike [`class_prototype_object`], this refuses a DECLARED class id, whose +/// entry in the same table is the parent CLASS OBJECT of a class-expression +/// subclass (#1788/#6552) rather than a prototype. +pub(crate) fn synthetic_class_prototype_object(class_id: u32) -> *mut ObjectHeader { + if class_id < SYNTHETIC_CLASS_ID_BASE + || class_id >= NEXT_SYNTHETIC_CLASS_ID.load(std::sync::atomic::Ordering::Relaxed) + { + return std::ptr::null_mut(); + } + class_prototype_object(class_id) } /// Perform ordinary `.prototype` assignment, then synchronize the synthetic @@ -657,7 +677,24 @@ unsafe fn resolve_proto_chain_field_inner( /// At each node we follow the proto object's own class id (the /// `Object.create` prototype link) first, then fall back to /// `parent_class_id` (the `extends` link); a `visited` set bounds cycles. -pub(crate) unsafe fn resolve_proto_chain_symbol(class_id: u32, sym_f64: f64) -> Option { +/// +/// #10481: an accessor found on a prototype object runs with `this === +/// receiver`, the object the read started from — never the prototype object +/// that holds it. +pub(crate) unsafe fn resolve_proto_chain_symbol( + class_id: u32, + sym_f64: f64, + receiver: f64, +) -> Option { + proto_chain_symbol_slot(class_id, sym_f64).map(|slot| slot.read(receiver)) +} + +/// The walk behind [`resolve_proto_chain_symbol`], stopping at the nearest +/// prototype object that owns `sym_f64` without invoking an accessor there. +pub(crate) unsafe fn proto_chain_symbol_slot( + class_id: u32, + sym_f64: f64, +) -> Option { let mut cid = class_id; let mut depth = 0usize; let mut visited: [u32; 32] = [0; 32]; @@ -672,8 +709,8 @@ pub(crate) unsafe fn resolve_proto_chain_symbol(class_id: u32, sym_f64: f64) -> let proto_f64 = f64::from_bits(JSValue::pointer(proto_obj as *const u8).bits()); // OWN lookup only — this fn IS the chain walk, so recursing into // the full chain-walking getter would re-walk per prototype. - if let Some(v) = crate::symbol::own_symbol_property(proto_f64, sym_f64) { - return Some(v); + if let Some(slot) = crate::symbol::own_symbol_slot(proto_f64, sym_f64) { + return Some(slot); } // Prefer the `Object.create` prototype link: the next chain node // is the proto object's own class id (which maps to ITS proto in diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index a9c0fb2e96..f22daa3667 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -70,6 +70,12 @@ pub extern "C" fn js_object_delete_field( if let Some(name) = super::has_own_helpers::str_from_string_header(key) { let class_id = obj as usize as u32; if super::class_registry::class_name_for_id(class_id).is_some() { + if super::class_registry::class_declared_accessor_ptrs(class_id, true, name) + .is_some() + && !super::class_registry::class_accessor_attrs(class_id, true, name).1 + { + return 0; + } super::class_registry::class_delete_own_dynamic_prop(class_id, name); super::class_registry::class_mark_key_deleted(class_id, name); super::class_registry::invalidate_class_string_member_order( @@ -294,6 +300,13 @@ pub extern "C" fn js_object_delete_field( super::class_registry::class_id_for_decl_prototype_object(obj as usize) { if let Some(name) = super::has_own_helpers::str_from_string_header(key) { + // #10480: a ClassBody accessor redefined non-configurable. + if super::class_registry::class_declared_accessor_ptrs(cid, false, name) + .is_some() + && !super::class_registry::class_accessor_attrs(cid, false, name).1 + { + return 0; + } if name != "constructor" && (super::class_registry::class_own_accessor_ptrs(cid, name).is_some() || super::native_module::class_has_own_method(cid, name) @@ -725,6 +738,11 @@ fn delete_receiver_is_pointer(obj_value: f64) -> bool { } fn delete_class_prototype_key(class_id: u32, name: &str) -> i32 { + if super::class_registry::class_declared_accessor_ptrs(class_id, false, name).is_some() + && !super::class_registry::class_accessor_attrs(class_id, false, name).1 + { + return 0; + } let has_own = name == "constructor" || super::native_module::class_has_own_method(class_id, name) || super::class_registry::class_own_accessor_ptrs(class_id, name).is_some() diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index 11b61e7f15..cef07e07f0 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -521,15 +521,13 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu super::class_registry::class_own_static_accessor_ptrs(class_id, &method_name) }; if let Some((g, s)) = accessor { - return build_accessor_descriptor( - super::class_registry::class_accessor_function_value( - g, - false, - &method_name, - ), - super::class_registry::class_accessor_function_value(s, true, &method_name), - false, - true, + let is_static = super::class_prototype_ref_id(obj_value).is_none(); + return super::class_registry::class_accessor_descriptor( + class_id, + is_static, + &method_name, + g, + s, ); } if super::class_prototype_ref_id(obj_value).is_some() @@ -946,11 +944,8 @@ pub extern "C" fn js_object_get_own_property_descriptor(obj_value: f64, key_valu } else if let Some((g, s)) = super::class_registry::class_own_accessor_ptrs(cid, name) { - return build_accessor_descriptor( - super::class_registry::class_accessor_function_value(g, false, name), - super::class_registry::class_accessor_function_value(s, true, name), - false, - true, + return super::class_registry::class_accessor_descriptor( + cid, false, name, g, s, ); } } diff --git a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs index 59d47729e2..be93e31988 100644 --- a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs +++ b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs @@ -246,6 +246,38 @@ pub(super) unsafe fn instance_constructor_value( if let Some(func_value) = super::super::class_registry::function_value_for_class_id(class_id) { return Some(JSValue::from_bits(func_value.to_bits())); } + // #10478: an `Object.create(proto)` result is stamped with a synthetic + // class id that only indexes its prototype object + // (`CLASS_PROTOTYPE_OBJECTS`); unlike the function ids above it names no + // class VALUE. Its `constructor` is the inherited `proto.constructor`, so + // read it off that chain. The INT32 synthesis below minted a ClassRef for + // the synthetic id itself (`0x7FFE_0000_8000_0000`): unequal to `Object` / + // `A`, printed as `[object Function]`, and `C instanceof C` segfaulted + // (lodash `isEqual(cloneDeep(x), x)`). + let synthetic_proto = super::super::class_registry::synthetic_class_prototype_object(class_id); + if !synthetic_proto.is_null() { + // The prototype's OWN `constructor` data field answers the common + // shapes directly — `Object.prototype`, a declared `C.prototype`, a + // materialized `F.prototype`, a `{ constructor: F }` literal — so take + // it without the general chain walk, whose implicit-`this` juggling, + // accessor-receiver override and registry probes cost ~3000 + // instructions per read. Skipped when an accessor owns the key, which + // must run through the walk to fire with the right receiver. + if get_accessor_descriptor(synthetic_proto as usize, "constructor").is_none() { + if let Some(value) = own_data_field_by_name(synthetic_proto, key) { + if !value.is_undefined() && !value.is_null() { + return Some(value); + } + } + } + let receiver = f64::from_bits(crate::value::js_nanbox_pointer(obj as i64).to_bits()); + return Some( + super::super::class_registry::resolve_proto_chain_field_with_receiver( + class_id, key, receiver, + ) + .unwrap_or_else(JSValue::undefined), + ); + } if class_id != 0 && is_class_id_registered(class_id) { let bits = 0x7FFE_0000_0000_0000u64 | (class_id as u64); return Some(JSValue::from_bits(bits)); diff --git a/crates/perry-runtime/src/object/field_get_set/entries_shape.rs b/crates/perry-runtime/src/object/field_get_set/entries_shape.rs index 2e373ef769..6ae7c6a79d 100644 --- a/crates/perry-runtime/src/object/field_get_set/entries_shape.rs +++ b/crates/perry-runtime/src/object/field_get_set/entries_shape.rs @@ -166,6 +166,15 @@ pub(super) fn js_object_entries_shape(obj: *const ObjectHeader) -> *mut ArrayHea // that. Enumerability is likewise re-evaluated per key in the read phase // (an earlier getter can create a descriptor or flip a future key's // enumerability), so we deliberately do NOT filter it during the snapshot. + // #10480: a declared-class prototype's enumerable ClassBody accessors + // have no physical key, so the keys-array walk cannot see them. The + // probe runs `Object.keys`, which allocates, so the receiver is rooted + // across it and re-read. + let accessor_scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = accessor_scope.root_raw_const_ptr(obj); + let (class_accessor_keys, obj) = obj_handle.across_const::(|| { + super::super::class_registry::decl_prototype_enumerable_key_snapshot(obj) + }); let mut snapshot_keys: Vec> = Vec::with_capacity(count); let mut key_buf = [0u8; crate::value::SHORT_STRING_MAX_LEN]; for j in 0..count { @@ -181,6 +190,9 @@ pub(super) fn js_object_entries_shape(obj: *const ObjectHeader) -> *mut ArrayHea snapshot_keys.push(bytes.to_vec()); } } + if let Some(merged) = class_accessor_keys { + snapshot_keys = merged; + } for key_bytes in snapshot_keys { let key_str = @@ -194,11 +206,19 @@ pub(super) fn js_object_entries_shape(obj: *const ObjectHeader) -> *mut ArrayHea // hidden a key that was in the initial snapshot (test262 // entries/getter-removing-future-key, getter-making-future-key- // nonenumerable). - if !super::super::own_key_present(obj as *mut ObjectHeader, key_str) { - continue; - } - if descriptor_marks_non_enumerable(obj, JSValue::string_ptr(key_str)) { - continue; + let class_accessor = std::str::from_utf8(&key_bytes).is_ok_and(|name| { + super::super::class_registry::class_prototype_enumerable_accessor( + obj as usize, + name, + ) + }); + if !class_accessor { + if !super::super::own_key_present(obj as *mut ObjectHeader, key_str) { + continue; + } + if descriptor_marks_non_enumerable(obj, JSValue::string_ptr(key_str)) { + continue; + } } // Create a pair array [key, value]. let pair = crate::array::js_array_alloc(2); diff --git a/crates/perry-runtime/src/object/field_get_set/enumeration.rs b/crates/perry-runtime/src/object/field_get_set/enumeration.rs index 610c165113..246fc28b84 100644 --- a/crates/perry-runtime/src/object/field_get_set/enumeration.rs +++ b/crates/perry-runtime/src/object/field_get_set/enumeration.rs @@ -169,8 +169,11 @@ pub extern "C" fn js_object_keys_value(value: f64) -> *mut ArrayHeader { // `Object.keys(C)` / `for (k in C)` (test262 class/elements static-field-*). if let Some(class_id) = super::super::class_ref_id(value) { if super::super::class_prototype_ref_id(value).is_none() { + // Static accessors are defined before static fields, so an + // enumerable one (#10480) precedes them. let mut names = - super::super::class_registry::class_own_enumerable_field_names(class_id); + super::super::class_registry::class_enumerable_accessor_names(class_id, true); + names.extend(super::super::class_registry::class_own_enumerable_field_names(class_id)); super::super::descriptors::sort_property_names_ecma(&mut names); let arr = crate::array::js_array_alloc(names.len().max(1) as u32); let mut out = arr; @@ -1286,7 +1289,22 @@ pub extern "C" fn js_object_keys(obj: *const ObjectHeader) -> *mut ArrayHeader { } return shape_keys; } - js_object_keys_shape(obj) + // #10480: a declared-class prototype's enumerable ClassBody accessors have + // no physical key. Resolve the class before the walk can move `obj`. + let decl_class = super::super::class_registry::class_accessor_attrs_in_use() + .then(|| { + super::super::class_registry::class_id_for_decl_prototype_object(strip_nanbox_addr(obj)) + }) + .flatten(); + let keys = js_object_keys_shape(obj); + match decl_class { + Some(class_id) => unsafe { + super::super::class_registry::decl_prototype_keys_with_enumerable_accessors( + class_id, keys, + ) + }, + None => keys, + } } /// [`js_object_keys`] over the shape alone. @@ -1841,6 +1859,15 @@ fn js_object_values_shape(obj: *const ObjectHeader) -> *mut ArrayHeader { // read time, not cached up front: an earlier getter can create a // descriptor or flip a future key's enumerability, so we defer the // `descriptor_marks_non_enumerable` check to the read phase. + // #10480: a declared-class prototype's enumerable ClassBody accessors + // have no physical key, so the keys-array walk cannot see them. The + // probe runs `Object.keys`, which allocates, so the receiver is rooted + // across it and re-read. + let accessor_scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = accessor_scope.root_raw_const_ptr(obj); + let (class_accessor_keys, obj) = obj_handle.across_const::(|| { + super::super::class_registry::decl_prototype_enumerable_key_snapshot(obj) + }); let mut snapshot_keys: Vec> = Vec::with_capacity(count); let mut key_buf = [0u8; crate::value::SHORT_STRING_MAX_LEN]; for j in 0..count { @@ -1856,6 +1883,9 @@ fn js_object_values_shape(obj: *const ObjectHeader) -> *mut ArrayHeader { snapshot_keys.push(bytes.to_vec()); } } + if let Some(merged) = class_accessor_keys { + snapshot_keys = merged; + } for key_bytes in snapshot_keys { let key_str = crate::string::js_string_from_bytes(key_bytes.as_ptr(), key_bytes.len() as u32); @@ -1865,11 +1895,19 @@ fn js_object_values_shape(obj: *const ObjectHeader) -> *mut ArrayHeader { // Re-check own + enumerable at read time (a prior getter may have // removed/hidden the key, or created a descriptor) — see // `js_object_entries`. - if !super::super::own_key_present(obj as *mut ObjectHeader, key_str) { - continue; - } - if descriptor_marks_non_enumerable(obj, JSValue::string_ptr(key_str)) { - continue; + let class_accessor = std::str::from_utf8(&key_bytes).is_ok_and(|name| { + super::super::class_registry::class_prototype_enumerable_accessor( + obj as usize, + name, + ) + }); + if !class_accessor { + if !super::super::own_key_present(obj as *mut ObjectHeader, key_str) { + continue; + } + if descriptor_marks_non_enumerable(obj, JSValue::string_ptr(key_str)) { + continue; + } } let value = js_object_get_field_by_name(obj as *const ObjectHeader, key_str); crate::array::js_array_push_f64(result, f64::from_bits(value.bits())); diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index 6030dd9601..da9b4ba13b 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -30,7 +30,11 @@ pub(crate) fn value_is_callable(value: f64) -> bool { // INT32-tagged class references (top 16 bits = 0x7FFE) are callable // constructors emitted by codegen. `is_pointer()` only checks 0x7FFD, // so they would fall through to `return false` without this guard. - if (value.to_bits() >> 48) == 0x7FFE { + // `class_ref_id` also requires `is_class_id_registered`, so a + // user-crafted NaN payload sharing this tag band (e.g. via + // `DataView.setFloat64` — a real JS number, not a class ref) is not + // misclassified as callable. + if class_ref_id(value).is_some() { return true; } let jv = crate::JSValue::from_bits(value.to_bits()); @@ -62,15 +66,13 @@ fn small_native_handle_id(value: f64) -> Option { None } +/// Candidate heap address of an `instanceof` operand; 0 for every primitive. +/// #10479: this used to treat every tag band `>= 0x7FF8` as a pointer, so a +/// 1-5 byte inline string (or an INT32 class ref) reached +/// `object_static_prototype` as a garbage address and segfaulted. +#[inline] fn value_addr(value: f64) -> usize { - let bits = value.to_bits(); - if (bits >> 48) >= 0x7FF8 { - (bits & crate::value::POINTER_MASK) as usize - } else if (bits >> 48) == 0 && bits >= 0x1000 { - bits as usize - } else { - 0 - } + crate::value::addr_class::object_ref_addr(value) } fn recorded_prototype_instanceof_builtin(value: f64, name: &str) -> Option { @@ -99,7 +101,12 @@ fn is_native_module_namespace_value(value: f64, expected: &str) -> bool { return false; } let obj = jv.as_pointer::(); - if obj.is_null() { + // #10556: a native `new EventEmitter()` is a POINTER_TAG registry handle + // (`0x38000`), and `x instanceof EventEmitter` asks this probe first — the + // null check alone let it read `class_id` out of unmapped low memory. + let is_object = unsafe { crate::value::addr_class::try_read_gc_header(obj as usize) } + .is_some_and(|header| header.obj_type == crate::gc::GC_TYPE_OBJECT); + if !is_object { return false; } unsafe { @@ -245,12 +252,13 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { } } let bits = type_ref.to_bits(); - let top16 = bits >> 48; - if top16 == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; - if class_id != 0 { - return js_instanceof(value, class_id); - } + // `class_ref_id` requires `is_class_id_registered`, not just the tag — + // a user-crafted NaN payload sharing the 0x7FFE band (a real JS number + // constructed via `DataView.setFloat64`, not a codegen-emitted class + // ref) must fall through to the unresolved-RHS `TypeError` below + // instead of being dispatched into `js_instanceof` as a bogus class id. + if let Some(class_id) = class_ref_id(type_ref) { + return js_instanceof(value, class_id); } // #9502: a heap class object's template id identifies its code, not its // evaluation. Compare the actual prototype objects so sibling evaluations @@ -328,9 +336,20 @@ pub extern "C" fn js_instanceof_dynamic(value: f64, type_ref: f64) -> f64 { return f64::from_bits(crate::value::TAG_TRUE); } if module == "events" && method == "EventEmitter" { + // #10556: a genuine subclass instance (`class Sub extends + // EventEmitter {}`) is a real ObjectHeader carrying Sub's own + // class id, not a handle and not prototype-linked to the real + // `EventEmitter.prototype` — so it is invisible to the + // handle/prototype probes below. Delegate to the static path + // first: `js_instanceof` walks the class-chain parent edge that + // codegen registers for `extends EventEmitter` + // (`builtin_parent_reserved_class_id` in + // perry-codegen/src/expr/instance_misc1.rs), and its own + // `CLASS_ID_EVENT_EMITTER` branch already covers the direct + // handle/`util.inherits` cases. Keep the general prototype walk + // as a fallback for shapes neither path reaches. return f64::from_bits( - if is_event_emitter_instance_value(value) - || super::tls_constructor_prototype_is_instance_of(value, method.as_str()) + if js_instanceof(value, CLASS_ID_EVENT_EMITTER).to_bits() == crate::value::TAG_TRUE || ordinary_has_instance_prototype_walk(value, type_ref) { crate::value::TAG_TRUE @@ -702,11 +721,12 @@ fn ordinary_has_instance_prototype_walk(value: f64, type_ref: f64) -> bool { // * a heap-allocated string/bigint/symbol gets ToObject-wrapped by // getPrototypeOf, so the walk climbs the wrapper chain and can spuriously // match (`Symbol() instanceof Object` wrongly returned `true`). - // Every tag below is checked without dereferencing. Real f64 numbers are - // already answered `false` by the primitive fast paths before this point and - // share tag-space with raw heap pointers (a bare `is_number()` would - // misclassify a module-level object var), so they are intentionally left to - // those paths rather than guarded here. + // Every tag below is checked without dereferencing. Real f64 numbers share + // tag-space with legacy raw heap pointers (a bare `is_number()` would + // misclassify a raw-bitcast object), so a number is only rejected when it + // does not decode as an object address. They are NOT all answered by + // earlier fast paths: a dynamic `1.5 instanceof Number` / `instanceof + // Object` reached this walk, ToObject-wrapped the number and matched. let scope = crate::gc::RuntimeHandleScope::new(); let value = scope.root_nanbox_f64(value); let type_ref = scope.root_nanbox_f64(type_ref); @@ -718,6 +738,7 @@ fn ordinary_has_instance_prototype_walk(value: f64, type_ref: f64) -> bool { || jv.is_int32() || jv.is_any_string() || jv.is_bigint() + || (jv.is_number() && value_addr(value.get_nanbox_f64()) == 0) || unsafe { crate::symbol::js_is_symbol(value.get_nanbox_f64()) != 0 } { return false; @@ -1599,27 +1620,15 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { // perspective — must return true without force-materializing. const CLASS_ID_ARRAY: u32 = 0xFFFF0024; if class_id == CLASS_ID_ARRAY { - let addr = if jsval.is_pointer() { - (bits & 0x0000_FFFF_FFFF_FFFF) as usize - } else { - let top16 = (bits >> 48) as u16; - if top16 == 0 && bits >= 0x1000 { - bits as usize - } else { - 0 - } - }; - if addr != 0 && addr >= crate::gc::GC_HEADER_SIZE { - let gc_header = (addr - crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; - unsafe { - let obj_type = (*gc_header).obj_type; - if obj_type == crate::gc::GC_TYPE_ARRAY || obj_type == crate::gc::GC_TYPE_LAZY_ARRAY - { - return true_val; - } - } - } - return false_val; + // A POINTER_TAG handle id (fetch/zlib/stdlib registries) is not a heap + // address; the canonical header read rejects it instead of probing the + // byte below it. + let is_array = unsafe { crate::value::addr_class::try_read_gc_header(value_addr(value)) } + .is_some_and(|header| { + header.obj_type == crate::gc::GC_TYPE_ARRAY + || header.obj_type == crate::gc::GC_TYPE_LAZY_ARRAY + }); + return if is_array { true_val } else { false_val }; } // Typed arrays — Int8Array..Float16Array reserved IDs (0xFFFF0030..3B). @@ -1783,6 +1792,18 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { if let Some(matches) = recorded_prototype_instanceof_builtin(value, "Error") { return if matches { true_val } else { false_val }; } + } + + // Everything below reads `ObjectHeader::class_id`, which only a + // genuine `GC_TYPE_OBJECT` has. Every other GC type keeps something + // else in that word — an array's `length`, a closure's function + // pointer, a Map's `size` — so `[1, 2] instanceof C` was true whenever + // the length equalled (or chained to) `C`'s class id. + if gc_type != crate::gc::GC_TYPE_OBJECT { + return false_val; + } + + if class_id == crate::error::CLASS_ID_ERROR { let obj_class_id = (*obj_ptr).class_id; if extends_builtin_error(obj_class_id) { return true_val; @@ -1834,6 +1855,15 @@ mod null_lhs_tests { f64::from_bits(crate::value::INT32_TAG | 5), // int32 5 f64::from_bits(crate::value::STRING_TAG | 0x1000), // string tag (addr never deref'd) f64::from_bits(crate::value::BIGINT_TAG | 0x1000), // bigint tag (addr never deref'd) + // #10479: inline SSO strings ("uri", "a") and a synthetic class ref. + f64::from_bits(crate::value::SHORT_STRING_TAG | 0x0300_0069_7275), + f64::from_bits(crate::value::SHORT_STRING_TAG | 0x0100_0000_0061), + f64::from_bits(crate::value::INT32_TAG | 0x8000_0000), + // Ordinary numbers: a dynamic `1.5 instanceof Number` reached the + // walk and matched through the ToObject wrapper. + 1.5, + -0.0, + f64::NAN, ]; for lhs in cases { // A dummy non-object RHS is never consulted for a non-object LHS. diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index f5644e6c80..d23eab11e6 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -302,8 +302,9 @@ pub(crate) use field_get_set::{ pub(crate) use this_binding::js_derived_super_scope_push; pub(crate) use this_binding::{ derived_super_binding_stack_restore, derived_super_binding_stack_savepoint, - scan_implicit_this_roots_mut, static_private_owner_current, static_private_owner_pop, - static_private_owner_push, static_private_owner_stack_restore, + implicit_this_trap_restore, implicit_this_trap_savepoint, new_target_trap_restore, + new_target_trap_savepoint, scan_implicit_this_roots_mut, static_private_owner_current, + static_private_owner_pop, static_private_owner_push, static_private_owner_stack_restore, static_private_owner_stack_savepoint, static_this_arm, static_this_arm_if_unarmed, static_this_disarm, IMPLICIT_THIS, }; diff --git a/crates/perry-runtime/src/object/object_ops.rs b/crates/perry-runtime/src/object/object_ops.rs index fd522d72da..f68017e7c2 100644 --- a/crates/perry-runtime/src/object/object_ops.rs +++ b/crates/perry-runtime/src/object/object_ops.rs @@ -10,6 +10,7 @@ use super::*; mod accessors; +mod define_class_accessor; mod define_get_accessor; mod define_properties; mod define_property; diff --git a/crates/perry-runtime/src/object/object_ops/define_class_accessor.rs b/crates/perry-runtime/src/object/object_ops/define_class_accessor.rs new file mode 100644 index 0000000000..d2ca5e16ef --- /dev/null +++ b/crates/perry-runtime/src/object/object_ops/define_class_accessor.rs @@ -0,0 +1,95 @@ +//! `Object.defineProperty` onto a DECLARED class accessor (#10480). +//! +//! A ClassBody accessor is an own property of `C.prototype` (or of `C` for +//! `static`) whose get/set live in the class vtable, so the ordinary define +//! path — which only consults the address-keyed descriptor tables — took it for +//! a brand-new key. See `class_registry/accessor_attrs.rs` for what that broke. +use super::*; + +/// ValidateAndApplyPropertyDescriptor for the declared accessor `name` of +/// `class_id` (`is_static` selects `C` over `C.prototype`). +/// +/// * Not a live declared accessor → `false`, the caller's path decides. +/// * Current accessor non-configurable → the spec's rejections throw +/// `Cannot redefine property: ` exactly as for any other property. +/// * Generic descriptor (none of `get`/`set`/`value`/`writable`) → only the +/// attributes change: an omitted field keeps its current value and the +/// getter/setter stay in place. Returns `true`. +/// * Anything else → `false`: replacing a vtable accessor half or converting it +/// to a data property is not modelled here (compiled receivers call the +/// declared get/set directly), so the caller's existing path is unchanged. +pub(super) unsafe fn define_declared_class_accessor( + class_id: u32, + is_static: bool, + name: &str, + descriptor_value: f64, + desc_view: Option<&super::descriptor_helpers::DescView<'_>>, +) -> bool { + let Some((getter, setter)) = + super::super::class_registry::class_declared_accessor_ptrs(class_id, is_static, name) + else { + return false; + }; + let (enumerable, configurable) = + super::super::class_registry::class_accessor_attrs(class_id, is_static, name); + // The per-field reads below allocate a field-name string (and may run a + // user getter on a non-plain descriptor), so the descriptor is re-read from + // its root at every use. + let scope = crate::gc::RuntimeHandleScope::new(); + let desc = scope.root_nanbox_f64(descriptor_value); + if !configurable { + // The validator compares accessor halves by closure `func_ptr`, which a + // reflected class accessor value carries. Root the getter value across + // the setter value's allocation; the validator roots both on entry. + let get = scope.root_nanbox_f64( + super::super::class_registry::class_accessor_function_value(getter, false, name), + ); + let set = super::super::class_registry::class_accessor_function_value(setter, true, name); + validate_nonconfigurable_redefine( + name, + PropertyAttrs::new(false, enumerable, false), + Some(AccessorDescriptor { + get: get.get_nanbox_u64(), + set: set.to_bits(), + }), + f64::from_bits(crate::value::TAG_UNDEFINED), + desc.get_nanbox_f64(), + desc_view, + ); + } + // `ToPropertyDescriptor` field presence is HasProperty (own or inherited). + let has = |index: usize, field: &[u8]| -> bool { + match desc_view { + Some(view) => view.has(index), + None => desc_has_field(desc.get_nanbox_f64(), field), + } + }; + if has(DESC_GET, b"get") + || has(DESC_SET, b"set") + || has(DESC_VALUE, b"value") + || has(DESC_WRITABLE, b"writable") + { + return false; + } + // A present field is `ToBoolean(value)` — `{ enumerable: undefined }` is + // an explicit `false`, not an omission. + let flag = |index: usize, field: &[u8]| -> Option { + has(index, field).then(|| { + let value = match desc_view { + Some(view) => view.read(index), + None => desc_read_field(desc.get_nanbox_f64(), field), + }; + crate::value::js_is_truthy(f64::from_bits(value.bits())) != 0 + }) + }; + let enumerable = flag(DESC_ENUMERABLE, b"enumerable").unwrap_or(enumerable); + let configurable = flag(DESC_CONFIGURABLE, b"configurable").unwrap_or(configurable); + super::super::class_registry::class_set_accessor_attrs( + class_id, + is_static, + name, + enumerable, + configurable, + ); + true +} diff --git a/crates/perry-runtime/src/object/object_ops/define_property.rs b/crates/perry-runtime/src/object/object_ops/define_property.rs index 9bf54caca2..c7ea7e6dde 100644 --- a/crates/perry-runtime/src/object/object_ops/define_property.rs +++ b/crates/perry-runtime/src/object/object_ops/define_property.rs @@ -824,6 +824,19 @@ pub extern "C" fn js_object_define_property( return obj_value; } if let Some(name) = super::super::metadata_key_to_string(key_value) { + // #10480: a declared accessor — instance on the prototype ref, + // static on the class ref — keeps its get/set under a generic + // descriptor; only its attributes change. + if super::define_class_accessor::define_declared_class_accessor( + target_cid, + super::super::class_prototype_ref_id(obj_value).is_none(), + &name, + desc_handle.get_nanbox_f64(), + desc_view.as_ref(), + ) { + return obj_value; + } + let descriptor_value = desc_handle.get_nanbox_f64(); let has_get = desc_has_field(descriptor_value, b"get"); let has_set = desc_has_field(descriptor_value, b"set"); if super::super::class_prototype_ref_id(obj_value).is_none() && (has_get || has_set) @@ -1402,6 +1415,23 @@ pub extern "C" fn js_object_define_property( super::super::class_registry::class_id_for_decl_prototype_object(obj as usize) { if let Some(ref name) = key_rust { + // #10480: the prototype's ClassBody accessors have no physical + // key, so the ordinary arm below would define a NEW property + // over them. A physical key (an expando that shadows the class + // member) keeps the ordinary arm. + if !own_key_present(obj, key_str) + && across!( + super::define_class_accessor::define_declared_class_accessor( + target_cid, + false, + name, + descriptor_value, + desc_view.as_ref(), + ) + ) + { + return obj_value; + } if across!(desc_has_field(descriptor_value, b"value")) { let value_bits = across!(desc_read_field(descriptor_value, b"value").bits()); if !crate::value::JSValue::from_bits(value_bits).is_undefined() { diff --git a/crates/perry-runtime/src/object/object_ops/has_own.rs b/crates/perry-runtime/src/object/object_ops/has_own.rs index eb07574f9f..7355eff40e 100644 --- a/crates/perry-runtime/src/object/object_ops/has_own.rs +++ b/crates/perry-runtime/src/object/object_ops/has_own.rs @@ -600,7 +600,24 @@ pub extern "C" fn js_object_property_is_enumerable(obj_value: f64, key_value: f6 class_id, key_name, ) .is_some(); - return f64::from_bits(if is_static_field { TAG_TRUE } else { TAG_FALSE }); + // #10480: a declared static accessor is non-enumerable by + // ClassBody default, but a generic descriptor can flip it + // (Object.defineProperty(C, "x", { enumerable: true })). + let is_enumerable_static_accessor = + super::super::class_registry::class_accessor_attrs_in_use() + && super::super::class_registry::class_declared_accessor_ptrs( + class_id, true, key_name, + ) + .is_some() + && super::super::class_registry::class_accessor_attrs( + class_id, true, key_name, + ) + .0; + return f64::from_bits(if is_static_field || is_enumerable_static_accessor { + TAG_TRUE + } else { + TAG_FALSE + }); } } } @@ -720,6 +737,12 @@ pub extern "C" fn js_object_property_is_enumerable(obj_value: f64, key_value: f6 if (*obj).class_id != 0 && super::super::field_get_set::is_internal_runtime_key(key_name) { return f64::from_bits(TAG_FALSE); } + // #10480: a ClassBody accessor is an own property of the prototype with + // no physical key; a generic `defineProperty` can make it enumerable. + if super::super::class_registry::class_prototype_enumerable_accessor(obj as usize, key_name) + { + return f64::from_bits(TAG_TRUE); + } if !own_key_present(obj, key_str) { return f64::from_bits(TAG_FALSE); } diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index a5d0a0bfd6..a4e0bcfb59 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -619,6 +619,29 @@ fn get_prototype_of_resolved(obj_value: f64) -> f64 { return proto; } } + // #10478: `Object.create(proto)` (and a plain-function `new + // F()`) records the exact `[[Prototype]]` object under the + // instance's SYNTHETIC class id. That link is authoritative, so + // prefer it over the `constructor`-derived guess below, which + // reads `obj.constructor` and answers `ctor.prototype`. The + // guess only happened to miss while an `Object.create` result's + // inherited `constructor` resolved to a bogus class ref; once it + // correctly answers `Object` / `A`, the guess returns + // `Object.prototype` / `A.prototype` and drops `proto` itself — + // with its inherited accessors, descriptors and non-writable + // slots. (The same lookup runs further down for receivers that + // reach it; this one only moves it ahead of the guess.) + if (*gc).obj_type == crate::gc::GC_TYPE_OBJECT { + let synth_proto = + super::super::class_registry::synthetic_class_prototype_object( + (*obj).class_id, + ); + if !synth_proto.is_null() && synth_proto as usize != raw_addr as usize { + return f64::from_bits( + crate::value::js_nanbox_pointer(synth_proto as i64).to_bits(), + ); + } + } if let Some(proto) = constructor_dynamic_prototype(obj) { return proto; } diff --git a/crates/perry-runtime/src/object/this_binding.rs b/crates/perry-runtime/src/object/this_binding.rs index 74e12cdb6d..2e48e72f92 100644 --- a/crates/perry-runtime/src/object/this_binding.rs +++ b/crates/perry-runtime/src/object/this_binding.rs @@ -301,6 +301,41 @@ pub extern "C" fn js_new_target_set(value: f64) -> f64 { NEW_TARGET.with(|c| f64::from_bits(c.replace(value.to_bits()))) } +/// `catch_savepoints!` capture/restore for `IMPLICIT_THIS` (PR #10564 review +/// finding). Several runtime guards displace `IMPLICIT_THIS` around a call +/// they don't control — a `super()` bridge, a prototype-walk accessor +/// dispatch, a stdlib listener/getter dispatcher — with a bare +/// save/call/restore statement sequence, not `ImplicitThisScope`. Neither a +/// `longjmp` nor a system unwind runs the restore statement that follows the +/// call, so a throw crossing one of those sites leaves the callee's receiver +/// installed for every later implicit-`this` read. This closes that gap the +/// same way `runtime_handles`/`call_method` already do: captured at every `try`, +/// replayed by `js_throw` before the exception transports, regardless of +/// transport. It is an unconditional `set`, so it composes safely with a +/// `ImplicitThisScope::drop` that also fires on the unwind path: whichever +/// runs last for a given frame reproduces the same locally-correct value. +#[inline] +pub(crate) fn implicit_this_trap_savepoint() -> u64 { + implicit_this_cell().get() +} + +pub(crate) fn implicit_this_trap_restore(bits: u64) { + implicit_this_cell().set(bits); +} + +/// `catch_savepoints!` capture/restore for `NEW_TARGET`. Same rationale as +/// [`implicit_this_trap_savepoint`]: the Temporal/Intl subclass `super()` +/// bridges (`fetch_globals.rs`, `intl/subclass.rs`) save/restore `new.target` +/// with a bare statement pair around the parent constructor call. +#[inline] +pub(crate) fn new_target_trap_savepoint() -> u64 { + NEW_TARGET.with(|c| c.get()) +} + +pub(crate) fn new_target_trap_restore(bits: u64) { + NEW_TARGET.with(|c| c.set(bits)); +} + /// GC mutable-root scanner for the implicit-`this` cell (issue #1813). /// /// `IMPLICIT_THIS` holds the NaN-boxed receiver for the duration of a diff --git a/crates/perry-runtime/src/object/util_types.rs b/crates/perry-runtime/src/object/util_types.rs index aa2237dbee..fbe82fa9ec 100644 --- a/crates/perry-runtime/src/object/util_types.rs +++ b/crates/perry-runtime/src/object/util_types.rs @@ -18,14 +18,12 @@ fn nanbox_bool(v: bool) -> f64 { ) } +/// Candidate object address of a predicate argument; 0 for every primitive. +/// #10479: the old `tag >= 0x7FF8 ⇒ payload` decode handed an inline SSO +/// string's packed bytes to `isMapIterator`/`isSetIterator` as an address. #[inline] fn jsvalue_addr(v: f64) -> usize { - let bits = v.to_bits(); - if (bits >> 48) >= 0x7FF8 { - (bits & 0x0000_FFFF_FFFF_FFFF) as usize - } else { - bits as usize - } + crate::value::addr_class::object_ref_addr(v) } fn jsvalue_extends_data_view(value: f64) -> bool { diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index f90c9d087c..f95d8286d6 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -884,17 +884,83 @@ fn create_list_from_array_like(value: f64) -> Vec { } else { 0 }; + // Every index key allocates, and a property read can run a getter: the + // source object and the elements read so far must survive both, so the + // list handed back holds their post-collection addresses (#10532 review). + // Only values a collection can relocate take a handle — an all-primitive + // argument list pays one scope and a tag test per element. + let scope = crate::gc::RuntimeHandleScope::new(); + let source = scope.root_nanbox_f64(value); let mut out = Vec::with_capacity(len); - let obj_ptr = extract_pointer(value.to_bits()) as *const crate::ObjectHeader; + let mut moved: Vec<(usize, MovedElement<'_>)> = Vec::new(); for i in 0..len { let idx_str = i.to_string(); + crate::gc::collection_point("reflect.list_from_array_like.index_key"); let key = crate::string::js_string_from_bytes(idx_str.as_ptr(), idx_str.len() as u32); + let obj_ptr = + extract_pointer(source.get_nanbox_f64().to_bits()) as *const crate::ObjectHeader; let v = crate::object::js_object_get_field_by_name_f64(obj_ptr, key); + match value_move_kind(v) { + ValueMoveKind::Tagged => { + moved.push((i, MovedElement::Tagged(scope.root_nanbox_f64(v)))) + } + ValueMoveKind::RawHeapWord => moved.push(( + i, + MovedElement::RawHeapWord(scope.root_heap_word_u64(v.to_bits())), + )), + ValueMoveKind::Immediate => {} + } out.push(v); } + for (index, handle) in moved { + out[index] = match handle { + MovedElement::Tagged(h) => h.get_nanbox_f64(), + MovedElement::RawHeapWord(h) => f64::from_bits(h.get_heap_word_u64()), + }; + } out } +/// Which rooting a `create_list_from_array_like` element needs, if any. +enum ValueMoveKind { + /// No handle needed: an immediate value no collection can touch. + Immediate, + /// A NaN-boxed pointer/string/BigInt -- `root_nanbox_f64`'s `Nanbox` slot + /// rewrites these. + Tagged, + /// A raw, untagged heap-pointer bit pattern (`top16 == 0`) -- e.g. the + /// Promise executor's resolve/reject closures from + /// `js_promise_new_with_executor`, or a TypedArray/Buffer pointer handed + /// through as `bitcast i64 → double` on some platforms (see + /// `object/native_call_method.rs` and `value/dynamic_object.rs` for the + /// same representation). `root_nanbox_f64`'s scanner only rewrites + /// POINTER_TAG/STRING_TAG/BIGINT_TAG bit patterns and would silently do + /// nothing for one of these, so it needs the raw-aware `HeapWord` slot + /// instead (#10532 review). + RawHeapWord, +} + +/// A value a moving collection can relocate, and therefore the only kind that +/// needs a handle when a runtime helper holds it across an allocation. Numbers, +/// booleans, `undefined`/`null`, int32s, short strings and class refs are +/// immediate values that no collection can touch. +#[inline] +fn value_move_kind(value: f64) -> ValueMoveKind { + let jsvalue = crate::value::JSValue::from_bits(value.to_bits()); + if jsvalue.is_pointer() || jsvalue.is_string() || jsvalue.is_bigint() { + return ValueMoveKind::Tagged; + } + if crate::value::addr_class::is_plausible_heap_addr(value.to_bits() as usize) { + return ValueMoveKind::RawHeapWord; + } + ValueMoveKind::Immediate +} + +enum MovedElement<'a> { + Tagged(crate::gc::RuntimeHandle<'a>), + RawHeapWord(crate::gc::RuntimeHandle<'a>), +} + /// Invoke a callable `f64` value with the supplied positional args and an /// explicit `thisArg` binding, throwing `TypeError` if `f` is not callable. /// Used by `Reflect.apply`. `thisArg` flows through `IMPLICIT_THIS` so free @@ -903,7 +969,40 @@ fn call_with_this_and_args(f: f64, this_arg: f64, args: &[f64]) -> f64 { // A concise/object-literal method reads `this` from a baked capture slot, // not IMPLICIT_THIS; rebind to the explicit `Reflect.apply` receiver so it // is honored (no-op for arrows / plain fns / bound fns). - let f = crate::closure::rebind_explicit_this(f, this_arg); + // + // That rebind is also the one thing on this path that ALLOCATES, and the + // callee, the receiver and the whole argument list are live across it in + // plain Rust locals — not GC roots (#10532 review). The clone happens for + // exactly one callee shape, so ask first and hand that shape to the rooted + // path below; every other callee keeps the allocation-free dispatch. + if crate::closure::rebind_explicit_this_allocates(f) { + return call_rooted_across_rebind(f, this_arg, args); + } + dispatch_with_explicit_this(f, this_arg, args) +} + +/// The `Reflect.apply` slow path: the rebind will clone, so root what the call +/// still needs and re-read it from the handles below the allocation. +#[cold] +#[inline(never)] +fn call_rooted_across_rebind(f: f64, this_arg: f64, args: &[f64]) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(this_arg); + let arg_handles: Vec<_> = args + .iter() + .map(|value| scope.root_nanbox_f64(*value)) + .collect(); + crate::gc::collection_point("reflect.apply.rebind"); + // `rebind_explicit_this` roots the callee and the receiver it is given + // (`clone_closure_rebind_this`), so its result is already current. + let rebound = crate::closure::rebind_explicit_this(f, receiver.get_nanbox_f64()); + let args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles); + dispatch_with_explicit_this(rebound, receiver.get_nanbox_f64(), &args) +} + +/// Invoke an already-rebound callable with an explicit `this`. Nothing here +/// allocates before the callee runs, so the arguments need no protection. +fn dispatch_with_explicit_this(f: f64, this_arg: f64, args: &[f64]) -> f64 { let closure = closure_from(f); if closure.is_null() { return throw_type_error("Reflect.apply target is not a function"); @@ -1363,6 +1462,19 @@ fn own_set_descriptor(target: f64, key: f64) -> Option { if !unsafe { crate::symbol::has_own_symbol_property(target, key) } { return None; } + // #10481: a symbol ACCESSOR is not a data slot — report its setter so + // the walk runs it with the receiver instead of shadowing it. + let (owner, sym_key) = unsafe { + ( + crate::symbol::obj_key_from_f64(target), + crate::symbol::sym_key_from_f64(key), + ) + }; + if let Some((_, setter_bits)) = + crate::symbol::symbol_accessor_descriptor_bits(owner, sym_key) + { + return Some(OwnSetDescriptor::Accessor { setter_bits }); + } // An existing symbol-keyed own data property is non-writable when the // receiver is frozen or its per-symbol attrs say so — so a strict // `obj[sym] = v` is rejected (throws) rather than silently no-op'd diff --git a/crates/perry-runtime/src/proxy/reflect.rs b/crates/perry-runtime/src/proxy/reflect.rs index 646e934456..c61a1724ec 100644 --- a/crates/perry-runtime/src/proxy/reflect.rs +++ b/crates/perry-runtime/src/proxy/reflect.rs @@ -97,6 +97,18 @@ pub extern "C" fn js_reflect_get(target: f64, key: f64, receiver: f64) -> f64 { } else { receiver_handle.get_nanbox_f64() }; + // #10481: the Symbol resolver invokes every accessor it finds with an + // explicit receiver, so hand it the one this call was given; an inherited + // getter must not see the target the lookup started from. + if unsafe { crate::symbol::js_is_symbol(property_key_handle.get_nanbox_f64()) } != 0 { + return unsafe { + crate::symbol::js_object_get_symbol_property_with_receiver( + target_handle.get_nanbox_f64(), + property_key_handle.get_nanbox_f64(), + recv, + ) + }; + } let prev = this_scope.root_nanbox_f64(crate::object::js_implicit_this_set(recv)); let result = target_get_property_key( target_handle.get_nanbox_f64(), diff --git a/crates/perry-runtime/src/proxy/reflect_misc.rs b/crates/perry-runtime/src/proxy/reflect_misc.rs index a79c6ec259..2ae7545d1f 100644 --- a/crates/perry-runtime/src/proxy/reflect_misc.rs +++ b/crates/perry-runtime/src/proxy/reflect_misc.rs @@ -106,8 +106,13 @@ pub extern "C" fn js_reflect_apply(f: f64, this_arg: f64, args_array: f64) -> f6 if !is_callable(f) { return throw_type_error("Reflect.apply target is not a function"); } + // Building the list allocates (index keys) and can run user getters, so the + // callee and the receiver are rooted across it (#10532 review). + let scope = crate::gc::RuntimeHandleScope::new(); + let callee = scope.root_nanbox_f64(f); + let receiver = scope.root_nanbox_f64(this_arg); let args = create_list_from_array_like(args_array); - call_with_this_and_args(f, this_arg, &args) + call_with_this_and_args(callee.get_nanbox_f64(), receiver.get_nanbox_f64(), &args) } /// `Reflect.defineProperty(obj, key, descriptor)` — returns `false` when the diff --git a/crates/perry-runtime/src/regex/perex_runtime.rs b/crates/perry-runtime/src/regex/perex_runtime.rs index a4853ba871..fccd00fe3d 100644 --- a/crates/perry-runtime/src/regex/perex_runtime.rs +++ b/crates/perry-runtime/src/regex/perex_runtime.rs @@ -11,8 +11,8 @@ use perex::binding::{ }; use perex::compiler::{self, CompileError, Node, Range}; use perex::executor::{ - ExecError, Frame, Progress, Scratch, ScratchOwner, ScratchRequirements, Search, SearchError, - Undo, + ExecError, Frame, Progress, Run, Scratch, ScratchOwner, ScratchRequirements, Search, + SearchError, Undo, }; use perex::input::Position; use perex::span::Span; @@ -382,11 +382,46 @@ fn find_near_lent<'mem, S: ImmutableSubject>( undo: &mut cell.undo[..], }; poll_on_stride(poll)?; - let mut search = match near { - Some(near) => Search::new_near(resources, start, near, scratch, *budget), - None => Search::new(resources, start, scratch, *budget), - } - .map_err(search_error)?; + // Both views are acquired once for the quantum that decides nearly + // every per-call search; a `Search` is built and moved only if this + // one pauses or asks for more scratch. + // On an error that is not a capacity request -- WorkLimit, + // InvalidProgram, ChangedResources, Cancelled -- `run` returns Err and + // drops the scratch and the remaining budget, where `new` + `advance` + // left a Search to read `remaining_work()` from. So `*budget` keeps its + // entry value and under-counts what the failed call spent. WORK is + // usize::MAX on every path here, so nothing observes it today; if a + // finite execution budget is ever reintroduced (see #10164/#10165), + // this stops being free and wants the engine's failure arm to report + // remaining work. + let mut search = match Search::run(resources, start, near, scratch, *budget, quantum) + .map_err(search_error)? + { + Run::Finished(mut finished) => { + *budget = Budget::new(finished.remaining_work()); + let position = finished.position(); + if !finished.matched() { + return Ok(Lent::Done(None, position)); + } + let full = finished + .capture(0) + .map_err(EngineError::Execution)? + .ok_or(EngineError::Execution(ExecError::InvalidProgram))?; + let captures = match mode { + CaptureMode::Full => None, + CaptureMode::All => { + poll()?; + let mut output = Slots::new(memory, finished.capture_count())?; + finished + .copy_captures(&mut output) + .map_err(EngineError::Execution)?; + Some(output) + } + }; + return Ok(Lent::Done(Some(Match { full, captures }), position)); + } + Run::Paused(search) => search, + }; loop { let result = search.advance(quantum); *budget = Budget::new(search.remaining_work()); @@ -482,11 +517,43 @@ pub(crate) fn find_near<'mem, S: ImmutableSubject>( poll()?; let buffers = MatchBuffers::new(memory, size)?; - let mut search = match near { - Some(near) => Search::new_near(&resources, start, near, buffers, *budget), - None => Search::new(&resources, start, buffers, *budget), - } - .map_err(search_error)?; + // On an error that is not a capacity request -- WorkLimit, + // InvalidProgram, ChangedResources, Cancelled -- `run` returns Err and + // drops the scratch and the remaining budget, where `new` + `advance` + // left a Search to read `remaining_work()` from. So `*budget` keeps its + // entry value and under-counts what the failed call spent. WORK is + // usize::MAX on every path here, so nothing observes it today; if a + // finite execution budget is ever reintroduced (see #10164/#10165), + // this stops being free and wants the engine's failure arm to report + // remaining work. + let mut search = match Search::run(&resources, start, near, buffers, *budget, quantum) + .map_err(search_error)? + { + Run::Finished(mut finished) => { + *budget = Budget::new(finished.remaining_work()); + let position = finished.position(); + if !finished.matched() { + return Ok((None, position)); + } + let full = finished + .capture(0) + .map_err(EngineError::Execution)? + .ok_or(EngineError::Execution(ExecError::InvalidProgram))?; + let captures = match mode { + CaptureMode::Full => None, + CaptureMode::All => { + poll()?; + let mut output = Slots::new(memory, finished.capture_count())?; + finished + .copy_captures(&mut output) + .map_err(EngineError::Execution)?; + Some(output) + } + }; + return Ok((Some(Match { full, captures }), position)); + } + Run::Paused(search) => search, + }; loop { let result = search.advance(quantum); // Preserve consumed work even when the following poll cancels/throws, diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 5bab20ae26..fb3375b629 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -27,6 +27,8 @@ pub(crate) use accessors::{ mod constructors; mod gc_roots; mod get; +#[cfg(test)] +mod inherited_accessor_tests; mod iterator; mod properties; @@ -56,7 +58,11 @@ pub use properties::{ // Symbol-keyed property reads. pub(crate) use get::has_declared_prototype_symbol_property; -pub(crate) use get::{has_own_symbol_property, inherited_symbol_property, own_symbol_property}; +pub(crate) use get::{ + has_own_symbol_property, inherited_symbol_property, + js_object_get_symbol_property_with_receiver, own_symbol_property, own_symbol_slot, + OwnSymbolSlot, +}; pub use get::{ js_object_get_symbol_property, js_object_get_symbol_property_ic_miss, js_object_get_symbol_then_field_ic_miss, SymbolPicCache, SymbolPicCacheSlot, SYMBOL_PIC_WORDS, diff --git a/crates/perry-runtime/src/symbol/accessors.rs b/crates/perry-runtime/src/symbol/accessors.rs index 9ceeddbe3c..f0cdcd9203 100644 --- a/crates/perry-runtime/src/symbol/accessors.rs +++ b/crates/perry-runtime/src/symbol/accessors.rs @@ -78,6 +78,66 @@ pub(crate) fn test_seed_symbol_accessor_property(obj_key: usize, sym_key: usize, ); } +/// One bit per symbol that has ever carried an accessor, hashed by the +/// symbol's ID (#10481). `obj[sym] = v` consults it before walking the +/// prototype chain for an inherited setter: a clear bit proves no accessor +/// exists under that symbol anywhere, so the walk cannot find one and the +/// write goes straight to the own-data store. A set bit is only a maybe, and +/// costs the walk that a correct answer needed anyway. +/// +/// Keyed by `SymbolHeader::id`, not by the symbol's ADDRESS: a moving +/// collection rewrites the accessor table's pointer keys (see +/// `scan_symbol_accessor_roots_mut`) but copies the id verbatim, so an +/// id-keyed filter needs no rescan, no rekey and no GC root. Monotonic — +/// removing an accessor leaves the bit set, which only costs a walk. +static SYMBOL_ACCESSOR_IDS: [std::sync::atomic::AtomicU64; SYMBOL_ACCESSOR_ID_WORDS] = + [const { std::sync::atomic::AtomicU64::new(0) }; SYMBOL_ACCESSOR_ID_WORDS]; +const SYMBOL_ACCESSOR_ID_WORDS: usize = 4; + +/// `(word, mask)` for a symbol's id. Ids are a monotonic counter, so the low +/// bits discriminate perfectly until the filter saturates at 256 distinct +/// accessor symbols. +/// +/// # Safety +/// `sym_key` must be a live `SymbolHeader` address (a non-zero +/// `sym_key_from_f64` result, which has already checked the magic). +#[inline] +unsafe fn symbol_accessor_id_bit(sym_key: usize) -> (usize, u64) { + let id = (*(sym_key as *const crate::symbol::SymbolHeader)).id; + let bit = (id % (SYMBOL_ACCESSOR_ID_WORDS as u64 * 64)) as usize; + (bit / 64, 1u64 << (bit % 64)) +} + +/// `false` ⟹ no accessor has ever been installed under this symbol, on any +/// object. Checked before the inherited-accessor walk on the write path. +/// +/// # Safety +/// Same contract as [`symbol_accessor_id_bit`]. +#[inline] +pub(super) unsafe fn symbol_may_have_accessor(sym_key: usize) -> bool { + let (word, mask) = symbol_accessor_id_bit(sym_key); + SYMBOL_ACCESSOR_IDS[word].load(std::sync::atomic::Ordering::Acquire) & mask != 0 +} + +/// Record that `sym_key` carries an accessor. Published BEFORE the table +/// insert, so a reader that sees a clear bit cannot miss the entry. +/// +/// # Safety +/// Same contract as [`symbol_accessor_id_bit`]. +#[inline] +unsafe fn note_symbol_accessor_key(sym_key: usize) { + let (word, mask) = symbol_accessor_id_bit(sym_key); + SYMBOL_ACCESSOR_IDS[word].fetch_or(mask, std::sync::atomic::Ordering::AcqRel); +} + +#[cfg(test)] +pub(crate) fn test_symbol_accessor_id_bits_set() -> u32 { + SYMBOL_ACCESSOR_IDS + .iter() + .map(|w| w.load(std::sync::atomic::Ordering::Acquire).count_ones()) + .sum() +} + pub(crate) unsafe fn set_symbol_accessor_property( obj_f64: f64, sym_f64: f64, @@ -90,6 +150,7 @@ pub(crate) unsafe fn set_symbol_accessor_property( return; } crate::symbol::note_symbol_key_installed(sym_key); + note_symbol_accessor_key(sym_key); { // `SYMBOL_PROPERTIES` is the only insertion-ordered record of symbol // property CREATION order, which `[[OwnPropertyKeys]]` must report @@ -161,6 +222,27 @@ pub(super) fn symbol_accessor_property_by_key( .and_then(|m| m.get(&(obj_key, sym_key)).copied()) } +/// Setter twin of [`invoke_symbol_accessor_getter`] (#10481): runs `set_bits` +/// with `this === receiver` and returns the assigned value. The value is +/// re-read from its root afterwards — the setter body is user code, so a +/// collection inside it can move whatever `value` points at. +pub(super) unsafe fn invoke_symbol_accessor_setter( + set_bits: u64, + receiver: f64, + value: f64, +) -> f64 { + let closure = (set_bits & crate::value::POINTER_MASK) as *const crate::closure::ClosureHeader; + if set_bits == 0 || closure.is_null() { + return value; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let value_h = scope.root_nanbox_f64(value); + let prev = scope.root_nanbox_f64(crate::object::js_implicit_this_set(receiver)); + crate::closure::js_closure_call1(closure, value_h.get_nanbox_f64()); + crate::object::js_implicit_this_set(prev.get_nanbox_f64()); + value_h.get_nanbox_f64() +} + pub(super) unsafe fn invoke_symbol_accessor_getter(get_bits: u64, receiver: f64) -> f64 { if get_bits == 0 { return f64::from_bits(TAG_UNDEFINED); diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index ed72157767..30bd25b0e6 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -95,15 +95,55 @@ pub(crate) unsafe fn has_own_symbol_property(obj_f64: f64, sym_f64: f64) -> bool /// `resolve_proto_chain_symbol`, which walks prototype objects itself and must /// therefore NOT recurse into the full chain-walking getter. pub(crate) unsafe fn own_symbol_property(obj_f64: f64, sym_f64: f64) -> Option { - if let Some(acc) = accessors::symbol_accessor_property(obj_f64, sym_f64) { - if acc.get != 0 { - let closure = - (acc.get & crate::value::POINTER_MASK) as *const crate::closure::ClosureHeader; - if !closure.is_null() { - return Some(crate::closure::js_closure_call0(closure)); + own_symbol_property_for_receiver(obj_f64, sym_f64, obj_f64) +} + +/// #10481: [`own_symbol_property`] on `obj_f64` for a `[[Get]]` whose +/// receiver is `receiver` — the object the read started from, which differs +/// from `obj_f64` whenever a prototype walk found the property on an ancestor. +/// An accessor's getter runs with `this === receiver` (spec `[[Get]](P, +/// Receiver)`). The getter used to be called with no receiver at all, so it +/// observed whatever `IMPLICIT_THIS` the caller happened to leave behind: +/// fastify's inherited `Reply.prototype[kRouteContext]` getter saw +/// `this === undefined` on every request. +pub(crate) unsafe fn own_symbol_property_for_receiver( + obj_f64: f64, + sym_f64: f64, + receiver: f64, +) -> Option { + own_symbol_slot(obj_f64, sym_f64).map(|slot| slot.read(receiver)) +} + +/// An own symbol-keyed property as stored, before any accessor runs (#10481). +/// Lets a prototype walk locate the holder once and leave the decision to its +/// caller: a `[[Get]]` reads it for the original receiver, a `[[Set]]` runs +/// an accessor's setter or stops at a data property. +#[derive(Clone, Copy)] +pub(crate) enum OwnSymbolSlot { + Accessor { get: u64, set: u64 }, + Data(u64), +} + +impl OwnSymbolSlot { + /// `[[Get]]` of this property with `this === receiver`. + pub(crate) unsafe fn read(self, receiver: f64) -> f64 { + match self { + OwnSymbolSlot::Accessor { get, .. } => { + accessors::invoke_symbol_accessor_getter(get, receiver) } + OwnSymbolSlot::Data(bits) => f64::from_bits(bits), } - return Some(f64::from_bits(TAG_UNDEFINED)); + } +} + +/// The two lookups [`has_own_symbol_property`] mirrors (accessor table, then +/// the raw `SYMBOL_PROPERTIES` data table), returning what they found. +pub(crate) unsafe fn own_symbol_slot(obj_f64: f64, sym_f64: f64) -> Option { + if let Some(acc) = accessors::symbol_accessor_property(obj_f64, sym_f64) { + return Some(OwnSymbolSlot::Accessor { + get: acc.get, + set: acc.set, + }); } let obj_key = obj_key_from_f64(obj_f64); let sym_key = sym_key_from_f64(sym_f64); @@ -115,7 +155,7 @@ pub(crate) unsafe fn own_symbol_property(obj_f64: f64, sym_f64: f64) -> Option Option<(usize, u8)> { Some((raw, (*gc_header).obj_type)) } -/// Walk the explicit static prototype chain to find an inherited symbol property. -/// Used by `Object.prototype.toString` to implement the spec's -/// `Get(O, @@toStringTag)` prototype-chain walk. +/// Walk the prototype chains to find an inherited symbol property. Used by +/// `Object.prototype.toString` to implement the spec's `Get(O, @@toStringTag)` +/// prototype-chain walk; an accessor runs with `this === obj_f64` (#10481). pub(crate) unsafe fn inherited_symbol_property(obj_f64: f64, sym_f64: f64) -> Option { - resolve_explicit_object_prototype_symbol(obj_f64, sym_f64) + inherited_symbol_slot(obj_f64, sym_f64).map(|slot| slot.read(obj_f64)) } -unsafe fn resolve_explicit_object_prototype_symbol(obj_f64: f64, sym_f64: f64) -> Option { +/// `receiver` is the `this` an inherited accessor runs with (#10481). +unsafe fn resolve_explicit_object_prototype_symbol( + obj_f64: f64, + sym_f64: f64, + receiver: f64, +) -> Option { + explicit_prototype_symbol_slot(obj_f64, sym_f64).map(|slot| slot.read(receiver)) +} + +/// The explicit-static-prototype walk behind +/// [`resolve_explicit_object_prototype_symbol`], stopping at the nearest +/// holder without invoking it. +unsafe fn explicit_prototype_symbol_slot(obj_f64: f64, sym_f64: f64) -> Option { const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; // #9192: the receiver may be a real ARRAY with a retargeted `[[Prototype]]` // (`Object.setPrototypeOf(arr, {[S]: v})`). Its address is only a lookup @@ -426,9 +478,8 @@ unsafe fn resolve_explicit_object_prototype_symbol(obj_f64: f64, sym_f64: f64) - if proto_bits == TAG_NULL { return None; } - let proto_f64 = f64::from_bits(proto_bits); - if let Some(v) = own_symbol_property(proto_f64, sym_f64) { - return Some(v); + if let Some(slot) = own_symbol_slot(f64::from_bits(proto_bits), sym_f64) { + return Some(slot); } let proto_ptr = object_header_ptr_from_value_bits(proto_bits)?; // Cycle detection. @@ -450,8 +501,8 @@ unsafe fn resolve_explicit_object_prototype_symbol(obj_f64: f64, sym_f64: f64) - let proto_obj = proto_ptr as *const crate::object::ObjectHeader; let cid = crate::object::js_object_get_class_id(proto_obj); if cid != 0 { - if let Some(v) = crate::object::resolve_proto_chain_symbol(cid, sym_f64) { - return Some(v); + if let Some(slot) = crate::object::proto_chain_symbol_slot(cid, sym_f64) { + return Some(slot); } } owner = proto_ptr; @@ -520,6 +571,19 @@ unsafe fn web_stream_symbol_property(obj_f64: f64, sym_f64: f64) -> Option #[no_mangle] pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f64) -> f64 { + js_object_get_symbol_property_with_receiver(obj_f64, sym_f64, obj_f64) +} + +/// `[[Get]](sym_f64, receiver_f64)` on `obj_f64`: the property is resolved on +/// `obj_f64` and its prototype chain, and every accessor found along the way +/// runs with `this === receiver_f64` (#10481). `Reflect.get(target, sym, +/// receiver)` is the caller whose receiver differs from the holder of the +/// lookup; every ordinary `obj[sym]` read passes `obj_f64` itself. +pub(crate) unsafe fn js_object_get_symbol_property_with_receiver( + obj_f64: f64, + sym_f64: f64, + receiver_f64: f64, +) -> f64 { #[cfg(feature = "regex-engine")] if crate::regex::is_registered_regex(crate::value::js_nanbox_get_pointer(obj_f64) as usize) { // RegExpHeader is not an ObjectHeader. Resolve its own symbols and @@ -527,13 +591,12 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 let scope = crate::gc::RuntimeHandleScope::new(); let receiver = scope.root_nanbox_f64(obj_f64); let symbol = scope.root_nanbox_f64(sym_f64); - if let Some(acc) = - accessors::symbol_accessor_property(receiver.get_nanbox_f64(), symbol.get_nanbox_f64()) - { - return accessors::invoke_symbol_accessor_getter(acc.get, receiver.get_nanbox_f64()); - } - if let Some(value) = own_symbol_property(receiver.get_nanbox_f64(), symbol.get_nanbox_f64()) - { + let this_h = scope.root_nanbox_f64(receiver_f64); + if let Some(value) = own_symbol_property_for_receiver( + receiver.get_nanbox_f64(), + symbol.get_nanbox_f64(), + this_h.get_nanbox_f64(), + ) { return value; } let proto = scope.root_nanbox_f64(crate::object::js_object_get_prototype_of( @@ -545,7 +608,7 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 return crate::proxy::js_reflect_get( proto.get_nanbox_f64(), symbol.get_nanbox_f64(), - receiver.get_nanbox_f64(), + this_h.get_nanbox_f64(), ); } // A Proxy is a small registered id (its band overlaps the small-handle @@ -566,12 +629,17 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 // installed `[Symbol.toPrimitive]`). This is the DateCell analogue of the // ordinary object's own-then-prototype symbol walk. if crate::date::is_date_value(obj_f64) { - if let Some(v) = own_symbol_property(obj_f64, sym_f64) { + if let Some(v) = own_symbol_property_for_receiver(obj_f64, sym_f64, receiver_f64) { return v; } + // Materializing `Date.prototype` can allocate; the receiver outlives it. + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_h = scope.root_nanbox_f64(receiver_f64); let proto = crate::object::builtin_prototype_value("Date"); if (proto.to_bits() >> 48) == 0x7FFD { - if let Some(v) = own_symbol_property(proto, sym_f64) { + if let Some(v) = + own_symbol_property_for_receiver(proto, sym_f64, receiver_h.get_nanbox_f64()) + { return v; } } @@ -585,7 +653,7 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 let sym_key = sym_key_from_f64(sym_f64); if sym_key != 0 { if let Some(v) = - crate::object::class_symbol_getter_value(class_id, sym_key, obj_f64, true) + crate::object::class_symbol_getter_value(class_id, sym_key, receiver_f64, true) { return v; } @@ -640,7 +708,8 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 // #1758: a class ref whose own static symbols miss may inherit the // symbol from a class-expression parent (`class Sub extends make(...) {}` // → `Sub[TypeId]`). Walk the CLASS_PROTOTYPE_OBJECTS chain. - if let Some(v) = crate::object::resolve_proto_chain_symbol(class_id, sym_f64) { + if let Some(v) = crate::object::resolve_proto_chain_symbol(class_id, sym_f64, receiver_f64) + { return v; } // #36 / #321: the subclass extends a FUNCTION value @@ -653,7 +722,11 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if let Some(closure_ptr) = crate::object::parent_closure_in_chain(class_id) { let closure_f64 = f64::from_bits(crate::value::js_nanbox_pointer(closure_ptr as i64).to_bits()); - let v = js_object_get_symbol_property(closure_f64, sym_f64); + // #10481: preserve the caller's receiver here too — without it, an + // accessor reached through the parent closure's own symbol walk + // would see the closure as `this` instead of the original + // receiver (e.g. `Reflect.get(Child, sym, other)`). + let v = js_object_get_symbol_property_with_receiver(closure_f64, sym_f64, receiver_f64); if v.to_bits() != TAG_UNDEFINED { return v; } @@ -822,7 +895,7 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 } } if let Some(acc) = accessors::symbol_accessor_property(obj_f64, sym_f64) { - return accessors::invoke_symbol_accessor_getter(acc.get, obj_f64); + return accessors::invoke_symbol_accessor_getter(acc.get, receiver_f64); } if let Some(v) = own_symbol_property(obj_f64, sym_f64) { return v; @@ -848,11 +921,13 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 let scope = crate::gc::RuntimeHandleScope::new(); let obj_h = scope.root_nanbox_f64(obj_f64); let sym_h = scope.root_nanbox_f64(sym_f64); + let receiver_h = scope.root_nanbox_f64(receiver_f64); if let Some(v) = req_handle_symbol_fallback(obj_h.get_nanbox_f64(), sym_h.get_nanbox_f64()) { return v; } let obj_f64 = obj_h.get_nanbox_f64(); let sym_f64 = sym_h.get_nanbox_f64(); + let receiver_f64 = receiver_h.get_nanbox_f64(); let bits = obj_f64.to_bits(); let sym_key = sym_key_from_f64(sym_f64); if sym_key != 0 { @@ -862,9 +937,12 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if !ptr.is_null() && crate::object::is_valid_obj_ptr(ptr as *const u8) { let class_id = crate::object::js_object_get_class_id(ptr); if class_id != 0 { - if let Some(v) = - crate::object::class_symbol_getter_value(class_id, sym_key, obj_f64, false) - { + if let Some(v) = crate::object::class_symbol_getter_value( + class_id, + sym_key, + receiver_f64, + false, + ) { return v; } // #5128: a symbol-keyed instance METHOD — `*[Symbol.iterator]()` @@ -879,9 +957,12 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if let Some(owner) = crate::object::method_owner_class_id(class_id, method_name) { - if let Some(value) = - class_iterator_prototype_override(obj_f64, sym_f64, class_id, owner) - { + if let Some(value) = class_iterator_prototype_override( + receiver_f64, + sym_f64, + class_id, + owner, + ) { return value; } return crate::object::js_class_method_bind( @@ -926,7 +1007,7 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 } } } - if let Some(v) = resolve_explicit_object_prototype_symbol(obj_f64, sym_f64) { + if let Some(v) = resolve_explicit_object_prototype_symbol(obj_f64, sym_f64, receiver_f64) { return v; } // `class X extends Map | Set` instance — its default `[Symbol.iterator]` @@ -1016,7 +1097,8 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if proto_ptr == 0 || proto_ptr == cur { break; } - if let Some(v) = own_symbol_property(proto_f64, sym_f64) { + if let Some(v) = own_symbol_property_for_receiver(proto_f64, sym_f64, receiver_f64) + { return v; } // A class-object proto may carry the symbol through ITS own @@ -1027,7 +1109,9 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if !proto_obj.is_null() { let cid = crate::object::js_object_get_class_id(proto_obj); if cid != 0 { - if let Some(v) = crate::object::resolve_proto_chain_symbol(cid, sym_f64) { + if let Some(v) = + crate::object::resolve_proto_chain_symbol(cid, sym_f64, receiver_f64) + { return v; } } @@ -1054,7 +1138,9 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if ptr != 0 && crate::closure::is_closure_ptr(ptr) { let func_proto = crate::object::builtin_prototype_value("Function"); if (func_proto.to_bits() >> 48) == 0x7FFD { - if let Some(v) = own_symbol_property(func_proto, sym_f64) { + // Re-read: materializing `Function.prototype` can allocate. + let receiver = receiver_h.get_nanbox_f64(); + if let Some(v) = own_symbol_property_for_receiver(func_proto, sym_f64, receiver) { return v; } } @@ -1196,7 +1282,9 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 if !obj_ptr.is_null() { let cid = crate::object::js_object_get_class_id(obj_ptr); if cid != 0 { - if let Some(v) = crate::object::resolve_proto_chain_symbol(cid, sym_f64) { + if let Some(v) = + crate::object::resolve_proto_chain_symbol(cid, sym_f64, receiver_f64) + { return v; } // A symbol-keyed property added to a DECLARED class's @@ -1210,7 +1298,9 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 // so drizzle-orm's `applyEffectWrapper` (effect's // `Effectable.Prototype` assigned onto query classes) left // `yield* query` with no iterator ("next is not a function"). - if let Some(v) = declared_prototype_chain_symbol(obj_f64, sym_f64, cid) { + if let Some(v) = + declared_prototype_chain_symbol(obj_f64, sym_f64, cid, receiver_f64) + { return v; } // #1838: a class can define a computed well-known-symbol METHOD @@ -1242,12 +1332,14 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 /// Accessors run with the original receiver; data properties are returned as /// stored. Nearest class first, so a subclass's prototype write shadows a /// base class's. -unsafe fn declared_prototype_chain_symbol(receiver: f64, sym: f64, class_id: u32) -> Option { - let holder = declared_prototype_symbol_holder(receiver, sym, class_id)?; - if let Some(acc) = accessors::symbol_accessor_property(holder, sym) { - return Some(accessors::invoke_symbol_accessor_getter(acc.get, receiver)); - } - own_symbol_property(holder, sym) +unsafe fn declared_prototype_chain_symbol( + obj: f64, + sym: f64, + class_id: u32, + receiver: f64, +) -> Option { + let holder = declared_prototype_symbol_holder(obj, sym, class_id)?; + own_symbol_property_for_receiver(holder, sym, receiver) } /// Locate a declared prototype property without invoking its getter. An @@ -1281,6 +1373,36 @@ unsafe fn declared_prototype_symbol_holder( None } +/// #10481: the accessor an ordinary `[[Set]]` of `sym` on `obj` must run when +/// `obj` has no own property under it — the nearest inherited holder along +/// the chains the getter reads (the recorded `[[Prototype]]` chain, the +/// synthetic class-id prototype chain, the declared class prototypes), as its +/// `(get, set)` bits. `None` when that holder is a data property or nothing on +/// those chains carries `sym`. Nothing is invoked. +pub(crate) unsafe fn inherited_symbol_accessor(obj: f64, sym: f64) -> Option<(u64, u64)> { + match inherited_symbol_slot(obj, sym)? { + OwnSymbolSlot::Accessor { get, set } => Some((get, set)), + OwnSymbolSlot::Data(_) => None, + } +} + +/// The nearest inherited holder of `sym` for an ordinary object `obj`, in the +/// order the getter consults these chains, without invoking it. +unsafe fn inherited_symbol_slot(obj: f64, sym: f64) -> Option { + if let Some(slot) = explicit_prototype_symbol_slot(obj, sym) { + return Some(slot); + } + let ptr = object_header_ptr_from_value_bits(obj.to_bits())?; + let class_id = crate::object::js_object_get_class_id(ptr as *const _); + if class_id == 0 { + return None; + } + if let Some(slot) = crate::object::proto_chain_symbol_slot(class_id, sym) { + return Some(slot); + } + own_symbol_slot(declared_prototype_symbol_holder(obj, sym, class_id)?, sym) +} + /// Presence of the declared-prototype properties handled above, including /// accessors and data properties whose value is undefined. pub(crate) unsafe fn has_declared_prototype_symbol_property(receiver: f64, sym: f64) -> bool { diff --git a/crates/perry-runtime/src/symbol/inherited_accessor_tests.rs b/crates/perry-runtime/src/symbol/inherited_accessor_tests.rs new file mode 100644 index 0000000000..94b504233f --- /dev/null +++ b/crates/perry-runtime/src/symbol/inherited_accessor_tests.rs @@ -0,0 +1,211 @@ +//! #10481: a Symbol-keyed accessor found on a PROTOTYPE runs with the original +//! receiver as `this`, for `[[Get]]` and `[[Set]]` alike. + +use super::*; +use crate::closure::{js_closure_alloc, js_register_closure_arity, ClosureHeader}; +use std::cell::Cell; + +thread_local! { + static SETTER_CALL: Cell> = const { Cell::new(None) }; +} + +/// Getter body: answers the `this` it was invoked with. +extern "C" fn this_getter(_closure: *const ClosureHeader) -> f64 { + crate::object::js_implicit_this_get() +} + +/// Setter body: records `(this, value)`. +extern "C" fn recording_setter(_closure: *const ClosureHeader, value: f64) -> f64 { + let this = crate::object::js_implicit_this_get(); + SETTER_CALL.with(|c| c.set(Some((this.to_bits(), value.to_bits())))); + f64::from_bits(TAG_UNDEFINED) +} + +unsafe fn closure_bits(f: *const u8, arity: u32) -> u64 { + js_register_closure_arity(f, arity); + crate::value::js_nanbox_pointer(js_closure_alloc(f, 0) as i64).to_bits() +} + +unsafe fn plain_object() -> f64 { + crate::value::js_nanbox_pointer(crate::object::js_object_alloc(0, 0) as i64) +} + +/// `proto` carrying a `[sym]` accessor, `child = Object.create(proto)` and +/// `grandchild = Object.create(child)`. +unsafe fn fixture() -> (f64, f64, f64, f64) { + let sym = super::constructors::js_symbol_new_empty(); + let proto = plain_object(); + set_symbol_accessor_property( + proto, + sym, + closure_bits(this_getter as *const u8, 0), + closure_bits(recording_setter as *const u8, 1), + ); + let child = crate::object::js_object_create(proto); + let grandchild = crate::object::js_object_create(child); + (sym, proto, child, grandchild) +} + +#[test] +fn inherited_symbol_getter_receives_the_original_receiver() { + let _global = crate::gc::global_side_table_test_lock(); + unsafe { + crate::gc::gc_suppress(); + let (sym, proto, child, grandchild) = fixture(); + let own = js_object_get_symbol_property(proto, sym); + let one_up = js_object_get_symbol_property(child, sym); + let two_up = js_object_get_symbol_property(grandchild, sym); + let other = plain_object(); + let reflected = js_object_get_symbol_property_with_receiver(grandchild, sym, other); + let tag = inherited_symbol_property(child, sym); + crate::gc::gc_unsuppress(); + + assert_eq!( + own.to_bits(), + proto.to_bits(), + "an own accessor read on the holder itself sees the holder as `this`" + ); + assert_eq!( + one_up.to_bits(), + child.to_bits(), + "a one-level-inherited getter must see the ORIGINAL receiver, not the prototype that holds it" + ); + assert_eq!( + two_up.to_bits(), + grandchild.to_bits(), + "a two-level-inherited getter must still see the original receiver" + ); + assert_eq!( + reflected.to_bits(), + other.to_bits(), + "an explicit receiver (Reflect.get-shaped call) must reach the getter unchanged" + ); + assert_eq!( + tag.unwrap().to_bits(), + child.to_bits(), + "inherited_symbol_property (the Symbol.toStringTag walk) runs the getter with `this === obj`" + ); + } +} + +#[test] +fn inherited_symbol_setter_receives_the_receiver_and_the_value() { + let _global = crate::gc::global_side_table_test_lock(); + unsafe { + crate::gc::gc_suppress(); + let (sym, proto, child, grandchild) = fixture(); + let value = 42.0_f64; + + SETTER_CALL.with(|c| c.set(None)); + let ret = js_object_set_symbol_property(child, sym, value); + let recorded_child = SETTER_CALL.with(|c| c.get()); + + SETTER_CALL.with(|c| c.set(None)); + let value2 = 43.0_f64; + js_object_set_symbol_property(grandchild, sym, value2); + let recorded_grandchild = SETTER_CALL.with(|c| c.get()); + + SETTER_CALL.with(|c| c.set(None)); + let value3 = 44.0_f64; + js_object_set_symbol_property(proto, sym, value3); + let recorded_own = SETTER_CALL.with(|c| c.get()); + crate::gc::gc_unsuppress(); + + assert_eq!( + ret.to_bits(), + value.to_bits(), + "the setter's return value is the assigned value" + ); + assert_eq!( + recorded_child, + Some((child.to_bits(), value.to_bits())), + "a one-level-inherited setter must run with the write's receiver, not the holder" + ); + assert_eq!( + recorded_grandchild, + Some((grandchild.to_bits(), value2.to_bits())), + "a two-level-inherited setter must still run with the original receiver" + ); + assert_eq!( + recorded_own, + Some((proto.to_bits(), value3.to_bits())), + "an own accessor write runs with the object written to as `this`" + ); + } +} + +#[test] +fn own_data_property_shadows_an_inherited_accessor_for_read_and_write() { + let _global = crate::gc::global_side_table_test_lock(); + unsafe { + crate::gc::gc_suppress(); + let (sym, _proto, child, _grandchild) = fixture(); + let data_value = 99.0_f64; + define_symbol_data_property(child, sym, data_value); + + SETTER_CALL.with(|c| c.set(None)); + let read = js_object_get_symbol_property(child, sym); + let read_did_not_invoke_getter = SETTER_CALL.with(|c| c.get()).is_none(); + + let new_value = 100.0_f64; + let written = js_object_set_symbol_property(child, sym, new_value); + let write_invoked_setter = SETTER_CALL.with(|c| c.get()).is_some(); + let read_after = js_object_get_symbol_property(child, sym); + crate::gc::gc_unsuppress(); + + assert_eq!( + read.to_bits(), + data_value.to_bits(), + "a nearer own DATA property must shadow the inherited accessor on read" + ); + assert!( + read_did_not_invoke_getter, + "reading a shadowing own data property must not run the inherited getter" + ); + assert_eq!( + written.to_bits(), + new_value.to_bits(), + "writing a shadowed key returns the assigned value" + ); + assert!( + !write_invoked_setter, + "writing a nearer own data property must not run the inherited setter" + ); + assert_eq!( + read_after.to_bits(), + new_value.to_bits(), + "the own data property must be updated in place, not routed to the inherited accessor" + ); + } +} + +#[test] +fn symbol_may_have_accessor_is_false_until_an_accessor_is_installed() { + let _global = crate::gc::global_side_table_test_lock(); + unsafe { + crate::gc::gc_suppress(); + let sym = super::constructors::js_symbol_new_empty(); + let sym_key = sym_key_from_f64(sym); + let before = super::accessors::symbol_may_have_accessor(sym_key); + let bits_before = super::accessors::test_symbol_accessor_id_bits_set(); + + let holder = plain_object(); + set_symbol_accessor_property(holder, sym, closure_bits(this_getter as *const u8, 0), 0); + let after = super::accessors::symbol_may_have_accessor(sym_key); + let bits_after = super::accessors::test_symbol_accessor_id_bits_set(); + crate::gc::gc_unsuppress(); + + assert!( + !before, + "a freshly minted symbol must not read as accessor-bearing before any accessor exists" + ); + assert!( + after, + "installing an accessor under a symbol must flip its filter bit" + ); + assert!( + bits_after >= bits_before, + "the filter's population count must never decrease" + ); + } +} diff --git a/crates/perry-runtime/src/symbol/properties.rs b/crates/perry-runtime/src/symbol/properties.rs index ddcbcf4f32..f4e5cc159c 100644 --- a/crates/perry-runtime/src/symbol/properties.rs +++ b/crates/perry-runtime/src/symbol/properties.rs @@ -323,14 +323,8 @@ fn next_request_meta_sym_key() -> usize { unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 { if let Some(acc) = accessors::symbol_accessor_property(obj_f64, sym_f64) { - if acc.set != 0 { - let closure = - (acc.set & crate::value::POINTER_MASK) as *const crate::closure::ClosureHeader; - if !closure.is_null() { - crate::closure::js_closure_call1(closure, value_f64); - } - } - return value_f64; + // #10481: the setter runs with the object written to as `this`. + return accessors::invoke_symbol_accessor_setter(acc.set, obj_f64, value_f64); } let obj_key = obj_key_from_f64(obj_f64); let sym_key = sym_key_from_f64(sym_f64); @@ -391,6 +385,39 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 crate::array::note_array_proto_iterator_write(obj_key, sym_key); crate::object::map_set_subclass::note_iterator_symbol_write(obj_key, sym_key); let has_own_data = object_symbol_data_property_exists(obj_key, sym_key); + // #10481: an INHERITED symbol accessor's setter runs even on a + // non-extensible receiver — [[Set]] through an inherited accessor never + // creates a new own property, so OBJ_FLAG_NO_EXTEND (checked below) must + // not block it. Checked first, ahead of the extensibility gate, using + // the same pointer-object condition the class-setter/accessor fallback + // below uses. + if !has_own_data && !native_async_resource { + let bits = obj_f64.to_bits(); + if (bits >> 48) != 0x7FFE { + let jsval = crate::value::JSValue::from_bits(bits); + if jsval.is_pointer() { + let ptr = jsval.as_pointer::(); + // `is_valid_obj_ptr` is only a floor check and deliberately does + // NOT reject the fetch/zlib/proxy handle bands (see its own doc). + // Handles are pointer-tagged with small addresses, so a handle + // receiver would reach the deref below and segfault on Linux while + // macOS hides it (#1843/#4004/#4665/#4800/#6271). `is_above_handle_band` + // is the sanctioned predicate for "may be treated as a heap address". + if !ptr.is_null() + && crate::value::addr_class::is_above_handle_band(ptr as usize) + && accessors::symbol_may_have_accessor(sym_key) + { + if let Some((_, set_bits)) = + super::get::inherited_symbol_accessor(obj_f64, sym_f64) + { + return accessors::invoke_symbol_accessor_setter( + set_bits, obj_f64, value_f64, + ); + } + } + } + } + } // Frozen / sealed / non-extensible receivers reject symbol-keyed writes // like string-keyed ones: an existing prop is non-writable when frozen // (or its per-symbol attrs say so), a new prop is forbidden when @@ -436,6 +463,11 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 { return value_f64; } + // #10481: an inherited accessor (installed by + // `Object.defineProperty(Fn.prototype, sym, …)`, an object + // literal `set [sym](v)` reached through `Object.create`, or a + // declared class prototype) is checked ABOVE, before the + // extensibility gate, so it is never reached from here. } } } diff --git a/crates/perry-runtime/src/timer.rs b/crates/perry-runtime/src/timer.rs index a52fc5a5db..95b1162978 100644 --- a/crates/perry-runtime/src/timer.rs +++ b/crates/perry-runtime/src/timer.rs @@ -815,6 +815,8 @@ fn mock_timers_advance_to(target_ms: f64) { }; state.current_ms = due_ms; if is_interval { + // The interval entry stays in the queue (it re-fires), so its + // `_scheduled` pin is untouched here — nothing to carry. let timer = &mut state.intervals[idx]; timer.next_ms = due_ms + timer.interval_ms.max(1) as f64; Some(( @@ -822,14 +824,32 @@ fn mock_timers_advance_to(target_ms: f64) { timer.callback, timer.args.clone(), timer.context.clone(), + None, )) } else { + // #10447 follow-up: `remove` takes the WHOLE entry, including + // its `_scheduled` pin. Move that pin into the action too and + // hand it back below, instead of leaving it behind on `timer` + // to drop (and retire the id) right here — before + // `call_timer_callback` has even run, let alone finished. A + // one-shot mock timer otherwise loses its own registry entry + // if its callback churns more than the eviction cap's worth of + // other timers while it is still dispatching. let timer = state.callbacks.remove(idx); - Some((timer.id, timer.callback, timer.args, timer.context)) + Some(( + timer.id, + timer.callback, + timer.args, + timer.context, + Some(timer._scheduled), + )) } }; - if let Some((id, callback, args, context)) = action { + if let Some((id, callback, args, context, _pin)) = action { call_timer_callback(id, callback, &args, &context); + // `_pin` (the one-shot case's `ScheduledTimerId`, moved out of the + // popped queue entry above) stays alive across that call and only + // retires the id here, after the callback has returned. } } } diff --git a/crates/perry-runtime/src/timer/tests_inline.rs b/crates/perry-runtime/src/timer/tests_inline.rs index f365981d55..96c6255cad 100644 --- a/crates/perry-runtime/src/timer/tests_inline.rs +++ b/crates/perry-runtime/src/timer/tests_inline.rs @@ -244,3 +244,73 @@ mod expired_batch_order_tests { ); } } + +#[cfg(test)] +mod mock_dispatch_own_pin_tests { + use super::*; + use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; + + static SELF_ID: AtomicI64 = AtomicI64::new(0); + static SAW_KNOWN: AtomicBool = AtomicBool::new(false); + static SAW_HAS_REF: AtomicBool = AtomicBool::new(false); + static RAN: AtomicBool = AtomicBool::new(false); + + /// A one-shot mock timer's own callback: churns more real one-shot timers + /// than the registry's eviction cap, then checks its OWN id. If this + /// timer's `_scheduled` pin already retired the moment it was popped off + /// the mock queue for dispatch (the bug), it is the OLDEST retired id in + /// the shared registry when the churn starts, so it is the very first one + /// evicted once the churn passes the cap — and this callback observes its + /// own eviction while it is still running. + extern "C" fn churn_then_check_self(_closure: *const crate::closure::ClosureHeader) -> f64 { + let id = SELF_ID.load(Ordering::SeqCst); + for _ in 0..(ref_states::TIMER_REF_STATES_CAP + 2_000) { + clearTimeout(js_set_timeout_callback(0, 1_000.0)); + } + SAW_KNOWN.store(is_known_timer_id(id), Ordering::SeqCst); + SAW_HAS_REF.store(js_timer_has_ref(id) != 0, Ordering::SeqCst); + RAN.store(true, Ordering::SeqCst); + 0.0 + } + + /// #10447 follow-up: `mock_timers_advance_to` used to pop a one-shot mock + /// timer off the queue with `state.callbacks.remove(idx)` and destructure + /// out `(id, callback, args, context)` — leaving the popped entry's + /// `_scheduled: ScheduledTimerId` behind to drop, and retire the id, right + /// there, before `call_timer_callback` had even run, let alone finished. + /// A callback that then churned more timers than the eviction cap evicted + /// its OWN handle mid-dispatch. The fix carries the pin into the dispatch + /// action and drops it only after the callback returns. + #[test] + fn a_one_shot_mock_timers_own_pin_survives_its_own_dispatch() { + let _serial = crate::gc::global_side_table_test_lock(); + SAW_KNOWN.store(false, Ordering::SeqCst); + SAW_HAS_REF.store(false, Ordering::SeqCst); + RAN.store(false, Ordering::SeqCst); + js_mock_timers_reset(); + js_mock_timers_enable(MOCK_TIMERS_API_SET_TIMEOUT, 0.0); + + let closure = crate::closure::js_closure_alloc(churn_then_check_self as *const u8, 0); + let id = schedule_mock_callback_timer( + closure as i64, + 10.0, + Vec::new(), + CallbackTimerKind::Timeout, + ) + .expect("mock setTimeout must be enabled for this API set"); + SELF_ID.store(id, Ordering::SeqCst); + + js_mock_timers_tick(10.0); + + assert!(RAN.load(Ordering::SeqCst), "the mock timer never fired"); + assert!( + SAW_KNOWN.load(Ordering::SeqCst), + "timer {id} was evicted from the registry by its own callback's churn" + ); + assert!( + SAW_HAS_REF.load(Ordering::SeqCst), + "timer {id}'s ref state was lost to its own callback's churn" + ); + js_mock_timers_reset(); + } +} diff --git a/crates/perry-runtime/src/value/addr_class.rs b/crates/perry-runtime/src/value/addr_class.rs index dd5796d312..4d37c9a7d9 100644 --- a/crates/perry-runtime/src/value/addr_class.rs +++ b/crates/perry-runtime/src/value/addr_class.rs @@ -249,6 +249,53 @@ pub(crate) unsafe fn try_read_gc_header(addr: usize) -> Option<&'static GcHeader Some(&*((addr - GC_HEADER_SIZE) as *const GcHeader)) } +/// Candidate object address carried by a NaN-boxed JS value, decided by the +/// value's TAG before any magnitude test (#10479). +/// +/// Only two representations name an address: a `POINTER_TAG` payload and a +/// legacy raw bitcast pointer (top 16 bits clear, above the null page). Every +/// other tag is a primitive whose low 48 bits are not an address, and several +/// of them land inside the heap window, so the old "tag band `>= 0x7FF8` ⇒ +/// payload" decode turned them into plausible-looking pointers: +/// +/// * an inline SSO string packs its bytes plus a length byte — `"uri"` decodes +/// to `0x0300_0069_7275`, and `meta_capable_object` read a GC header below it +/// and segfaulted (ajv's `arg instanceof _Code`); +/// * an INT32 value / class ref carries its id — `Object.create`'s first +/// synthetic class id decodes to `0x8000_0000` (#10478's `C instanceof C`); +/// * heap strings, bigints, JS handles and the undefined/null/boolean markers. +/// +/// All of those answer 0. So does an ordinary number: a bare top-16-clear +/// word is only the legacy raw-pointer shape when the allocator owns it +/// ([`try_read_tracked_gc_header`], or a registered buffer) — a denormal +/// double such as `1e-310` decodes into the heap window too. +/// +/// A non-zero `POINTER_TAG` answer is a CANDIDATE, not a validity proof — it +/// can still be a handle-band id — so pair it with a registry lookup or +/// [`try_read_gc_header`] before touching memory. +#[inline(always)] +pub(crate) fn object_ref_addr(value: f64) -> usize { + let bits = value.to_bits(); + if (bits & crate::value::TAG_MASK) == crate::value::POINTER_TAG { + (bits & crate::value::POINTER_MASK) as usize + } else if (bits >> 48) == 0 && bits != 0 { + raw_object_ref_addr(bits as usize) + } else { + 0 + } +} + +#[cold] +fn raw_object_ref_addr(addr: usize) -> usize { + let owned = crate::buffer::is_registered_buffer(addr) + || unsafe { try_read_tracked_gc_header(addr) }.is_some(); + if owned { + addr + } else { + 0 + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum TrackedGcStorage { Arena, @@ -381,6 +428,58 @@ mod tests { assert!(unsafe { try_read_gc_header(0x7FFD_0000_0000_0000) }.is_none()); } + /// #10479: only a `POINTER_TAG` payload or a raw pointer names an address. + /// Every primitive band answers 0 — in particular the SSO and INT32 bands, + /// whose payloads decode into the heap window. + #[test] + fn object_ref_addr_classifies_by_tag_before_magnitude() { + use crate::value::{ + BIGINT_TAG, INT32_TAG, JS_HANDLE_TAG, POINTER_TAG, SHORT_STRING_TAG, STRING_TAG, + TAG_FALSE, TAG_NULL, TAG_TRUE, TAG_UNDEFINED, + }; + let heap_addr = 0x7F12_3456_7890usize; + let primitives = [ + SHORT_STRING_TAG | 0x0300_0069_7275, // "uri" + SHORT_STRING_TAG | 0x0100_0000_0061, // "a" + SHORT_STRING_TAG, // "" + INT32_TAG | 0x8000_0000, // synthetic class ref + INT32_TAG | 5, + STRING_TAG | heap_addr as u64, + BIGINT_TAG | heap_addr as u64, + JS_HANDLE_TAG | 7, + TAG_UNDEFINED, + TAG_NULL, + TAG_TRUE, + TAG_FALSE, + 1.5f64.to_bits(), + f64::NAN.to_bits(), + (-0.0f64).to_bits(), + 0, + // Denormal doubles: top 16 bits clear, low bits in the heap + // window, but no allocator owns them. + 1e-310f64.to_bits(), + ]; + for bits in primitives { + assert_eq!( + object_ref_addr(f64::from_bits(bits)), + 0, + "{bits:#018x} is a primitive, not an address" + ); + } + assert_eq!( + object_ref_addr(f64::from_bits(POINTER_TAG | heap_addr as u64)), + heap_addr + ); + // A POINTER_TAG handle id is still a candidate; callers band-check it. + assert_eq!( + object_ref_addr(f64::from_bits(POINTER_TAG | 0x40001)), + 0x40001 + ); + // The legacy raw-bitcast shape is kept for an allocator-owned object. + let obj = crate::object::js_object_alloc(0, 0) as usize; + assert_eq!(object_ref_addr(f64::from_bits(obj as u64)), obj); + } + #[test] fn tracked_gc_classifier_accepts_injected_low_arena_membership() { use std::cell::Cell; diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 2aa0e7c1b6..116f75b6ff 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -32,6 +32,7 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "HARMONYOS_SDK_HOME", "PERRY_DEBUG_INIT", "PERRY_DEBUG_SYMBOLS", + "PERRY_FUNCTION_SOURCE", "PERRY_LLVM_CLANG", // exp/llvm-inprocess: selects the in-process LLVM backend for .ll -> .o; // a different backend means different object bytes. diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index b628b59f3e..01c0f0f466 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -241,7 +241,7 @@ fn stable_type_key(ty: &perry_hir::types::Type) -> String { /// /// We also mix in environment variables that `perry-codegen` reads /// at compile time but that aren't part of `CompileOptions`: -/// `PERRY_DEBUG_INIT`, `PERRY_DEBUG_SYMBOLS`, `PERRY_LLVM_CLANG`, +/// `PERRY_DEBUG_INIT`, `PERRY_DEBUG_SYMBOLS`, `PERRY_FUNCTION_SOURCE`, `PERRY_LLVM_CLANG`, /// `PERRY_WRITE_BARRIERS`, `PERRY_SHADOW_STACK`, /// `PERRY_DISABLE_BUFFER_FAST_PATH`, `PERRY_VERIFY_NATIVE_REGIONS`, /// and `PERRY_TARGET_CPU`. See the env-var @@ -1014,6 +1014,8 @@ fn compute_object_cache_key_with_env( // eager initializer chain in the entry object (entry.rs). // - PERRY_DEBUG_SYMBOLS=1 adds `-g` to clang → embeds DWARF sections // into the object (linker.rs). + // - PERRY_FUNCTION_SOURCE=header elides function bodies from + // `fn.toString()` metadata (#10574). // - PERRY_LLVM_CLANG selects which clang binary compiles .ll → .o; // different clang versions/builds emit different bytes (linker.rs). // - PERRY_WRITE_BARRIERS=0/off/false suppresses generated barrier @@ -1191,6 +1193,12 @@ fn compute_object_cache_key_with_env( .as_deref() .unwrap_or(""), ); + // #10574: header vs full function source changes the retained-source + // constants in `__perry_init_strings_*`. + h.field( + "env_function_source", + env_var("PERRY_FUNCTION_SOURCE").as_deref().unwrap_or(""), + ); h.field( "env_entry_symbol", env_var("PERRY_ENTRY_SYMBOL").as_deref().unwrap_or(""), diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index d7e8bae808..e1fe164098 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -737,6 +737,7 @@ fn key_changes_with_codegen_env_vars() { let opts = empty_opts(); for var in [ "PERRY_DEBUG_SYMBOLS", + "PERRY_FUNCTION_SOURCE", "PERRY_LLVM_CLANG", "PERRY_LLVM_INPROCESS", "PERRY_WRITE_BARRIERS", diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 02c3b64bb3..e0596c6081 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -617,6 +617,32 @@ pub fn run_with_parse_cache( std::env::set_var("PERRY_DEBUG_SYMBOLS", "1"); } + // #10574: `--function-source=header` elides function bodies from the + // image. Promote to `PERRY_FUNCTION_SOURCE` before rayon codegen so + // the object-cache key and the string-pool emitter observe one knob. + // Only set (never unset): an already-exported env value wins, matching + // `--debug-symbols` / `PERRY_DEBUG_SYMBOLS`. + // Precedence is CLI flag > env, matching `--cache-dir`/`PERRY_CACHE_DIR` + // and the rest of the CLI. An explicit `--function-source` always wins; + // only an omitted flag defers to an exported `PERRY_FUNCTION_SOURCE`. + match args.function_source.as_deref() { + Some(value) => std::env::set_var("PERRY_FUNCTION_SOURCE", value), + None => { + // An unknown exported value used to silently select full source, so + // a typo (`headeer`) quietly produced a binary with every function + // body retained and no diagnostic. `--function-source` is validated + // by clap; the env var has to be validated here. + if let Some(value) = std::env::var_os("PERRY_FUNCTION_SOURCE") { + let value = value.to_string_lossy().trim().to_string(); + if !matches!(value.as_str(), "full" | "header" | "elide") { + anyhow::bail!( + "PERRY_FUNCTION_SOURCE must be `full` or `header` (got `{value}`)" + ); + } + } + } + } + // `--report-size` needs a symbol table to attribute size by crate, but not // full DWARF — reuse the lighter `PERRY_KEEP_SYMBOLS` strip-skip knob // rather than `PERRY_DEBUG_SYMBOLS`, so asking for a size report doesn't diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 1316022d9c..1f8f6bac5e 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -273,6 +273,19 @@ pub struct CompileArgs { #[arg(long)] pub report_size: bool, + /// How much `Function.prototype.toString` source to keep in the binary + /// (#10574). `full` (default) stores interned original source — nested + /// functions share one module blob, so this is already the 18 MB + /// duplication win, with byte-identical `fn.toString()`. `header` stores + /// `function () { /* source elided */ }` instead: enough + /// for name extraction, Angular/Vue-style parameter-name DI, and + /// `[native code]` probes, and drops the remaining unique function + /// source (~6 MB on a tsc-sized bundle). Also `PERRY_FUNCTION_SOURCE`. + /// Programs that parse function *bodies* (`new Function(fn.toString())`, + /// `perry-threads` worker serialization) need `full`. + #[arg(long, value_parser = ["full", "header"])] + pub function_source: Option, + /// Disable the per-module object cache. /// By default Perry caches each module's object bytes keyed by a /// hash of the source plus every `CompileOptions` field that can diff --git a/crates/perry/src/commands/dev.rs b/crates/perry/src/commands/dev.rs index 921aa6d9a8..4ed7844f7d 100644 --- a/crates/perry/src/commands/dev.rs +++ b/crates/perry/src/commands/dev.rs @@ -308,6 +308,7 @@ fn build_once( no_auto_optimize: false, debug_symbols: false, report_size: false, + function_source: None, no_cache: false, // `perry dev` has no `--cache-dir` flag of its own; the resolver // still honors `PERRY_CACHE_DIR` / perry.toml `[perry] cacheDir` / diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index b38342a363..a40cb00119 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -220,6 +220,7 @@ pub fn run(args: RunArgs, format: OutputFormat, use_color: bool, verbose: u8) -> no_auto_optimize: false, debug_symbols: false, report_size: false, + function_source: None, no_cache: false, // `perry run` has no `--cache-dir` flag; the resolver still honors // `PERRY_CACHE_DIR` / perry.toml `[perry] cacheDir` / package.json diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index 255e2b120d..91dfd8e230 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -297,6 +297,7 @@ the generating asset module where applicable. | `--no-codegen` | Skip the `package.json` `perry.codegen` build-time steps (also `PERRY_SKIP_CODEGEN=1`). See [Project Configuration](../getting-started/project-config.md) | | `--keep-intermediates` | Keep `.o` and `.asm` intermediate files | | `--debug-symbols` | Retain symbols/DWARF (and emit a Windows PDB) instead of stripping the result. | +| `--function-source ` | How much `Function.prototype.toString` source to keep. `full` (default) stores interned original source (nested functions share one module blob). `header` stores `function name(params) { /* source elided */ }` instead — enough for name/parameter extraction, much smaller. Also `PERRY_FUNCTION_SOURCE`. | | `--no-cache` | Disable the per-module object cache for this build; also `PERRY_NO_CACHE=1`. | | `--cache-dir ` | Override the machine-local cache root; see [Cache Directory](cache-dir.md). | | `--verify-native-regions` | Run native-representation lowering invariants and force codegen instead of cache reuse. | diff --git a/npm-ci.log b/npm-ci.log new file mode 100644 index 0000000000..5948b8e64a --- /dev/null +++ b/npm-ci.log @@ -0,0 +1,7 @@ +npm warn EBADENGINE Unsupported engine { +npm warn EBADENGINE package: '@socketsecurity/sdk@4.1.4', +npm warn EBADENGINE required: { node: '>=24', npm: '>=12.0.1', pnpm: '>=11.0.5' }, +npm warn EBADENGINE current: { node: 'v26.8.1', npm: '11.19.0' } +npm warn EBADENGINE } + +added 32 packages in 575ms diff --git a/scripts/addr_class_ratchet_baseline.txt b/scripts/addr_class_ratchet_baseline.txt index 32642c62a4..50de5d427e 100644 --- a/scripts/addr_class_ratchet_baseline.txt +++ b/scripts/addr_class_ratchet_baseline.txt @@ -67,7 +67,6 @@ handle-floor | crates/perry-runtime/src/child_process/registry.rs | 1 handle-floor | crates/perry-runtime/src/child_process/v8_serde.rs | 1 handle-floor | crates/perry-runtime/src/closure/dispatch/validate.rs | 1 handle-floor | crates/perry-runtime/src/cluster.rs | 1 -handle-floor | crates/perry-runtime/src/collection_iter_object.rs | 1 handle-floor | crates/perry-runtime/src/date.rs | 3 handle-floor | crates/perry-runtime/src/dgram.rs | 1 handle-floor | crates/perry-runtime/src/dns.rs | 4 @@ -126,7 +125,7 @@ handle-floor | crates/perry-runtime/src/object/field_set_by_name/write_helpers.r handle-floor | crates/perry-runtime/src/object/global_this/array_error.rs | 1 handle-floor | crates/perry-runtime/src/object/global_this/ctor_thunks.rs | 1 handle-floor | crates/perry-runtime/src/object/global_this/typed_array.rs | 4 -handle-floor | crates/perry-runtime/src/object/instanceof.rs | 8 +handle-floor | crates/perry-runtime/src/object/instanceof.rs | 6 handle-floor | crates/perry-runtime/src/object/mod.rs | 1 handle-floor | crates/perry-runtime/src/object/native_call_method.rs | 3 handle-floor | crates/perry-runtime/src/object/native_call_method/collection_methods.rs | 2 diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 70d46c0e73..730efc1d1b 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -313,7 +313,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -330,7 +330,7 @@ "sources": { "crates/perry-runtime/src/gc/census.rs": "5c151725460ffb92a55a6bee781123ef5159263b4ce5958d16570f78216e0d67", "crates/perry-runtime/src/gc/cycle.rs": "b035dcb44df029358cbab0afaa526e8e506765f5178034663257e18ceefaf9df", - "crates/perry-runtime/src/gc/mod.rs": "d401b22ffd6b7423bc4709153e888f79b88ac1c33aa776edee04bc7d3f5aca84", + "crates/perry-runtime/src/gc/mod.rs": "0243f1b1b1fae870983df500898abc353086473bbde3f478162e2826867762fe", "crates/perry-runtime/src/gc/policy.rs": "895c6f4bd1a6e491adf348ecfa89985b03e354fcee7cf73826bb590f9ace9163", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } @@ -760,6 +760,12 @@ "scanner": "gc::roots::visit_global_root_slots, reached by js_gc_register_global_root (gc/roots.rs:325 pushes the slot into GLOBAL_ROOTS; gc/roots.rs:1433 hands it to the mutable-root walk)", "why": "Caches the shared VM intrinsic realm's globalThis as NaN-boxed bits. The single site that writes the cell \u2014 fresh_intrinsic_global, node_vm.rs:1080 \u2014 calls js_gc_register_global_root(slot.as_ptr()) in the same `with` closure, immediately after the store and with no allocation in between; the early return on a non-zero cell means that store happens at most once per thread, so there is no path that populates the cell without registering it. GLOBAL_ROOTS is thread_local, exactly like the cell, and visit_mutable_root_slots feeds it to BOTH the marker and the post-evacuation rewrite (gc/tests/copying.rs:1272, test_copying_minor_rewrites_shadow_and_global_roots), so the cached pointer is marked and forwarded rather than left stale." }, + { + "file": "crates/perry-runtime/src/object/class_registry/accessor_attrs.rs", + "name": "CLASS_ACCESSOR_ATTRS", + "verdict": "not_a_gc_pointer", + "why": "Reflective enumerable/configurable overrides applied by a generic Object.defineProperty(/ies) descriptor to a DECLARED class accessor (#10480), keyed by (class_id: u32, is_static: bool, name: String) to (enumerable: bool, configurable: bool). Every field is a plain scalar or an owned String \u2014 no NaN-boxed JSValue, no heap ObjectHeader address, nothing for the collector to mark or rewrite. The accessor's own getter/setter function pointers stay exactly where they already lived, in the class vtable side tables (CLASS_VTABLE_REGISTRY / CLASS_STATIC_ACCESSORS); this table only remembers which two attribute bits a generic descriptor overrode, never the accessor's identity or value." + }, { "file": "crates/perry-runtime/src/object/class_registry/state.rs", "name": "CLASS_DECLARED_STATIC_GLOBAL_SLOTS", @@ -2529,6 +2535,18 @@ "name": "WINDOW_ROOTS", "verdict": "not_a_gc_pointer", "why": "Window-root registry maps numeric window handles to numeric root-widget handles; neither value is a JavaScript heap pointer." + }, + { + "file": "crates/perry-runtime/src/gc/collection_points.rs", + "name": "ARMED_SITE", + "verdict": "test_only", + "why": "Cell>: a static string-literal site NAME an armed rooting-regression test compares against, never a GC heap pointer. Declared under #[cfg(test)] only (collection_points.rs:15); the whole module compiles to an empty inline no-op outside cfg(test), so this storage never exists in a shipped binary." + }, + { + "file": "crates/perry-runtime/src/timer/tests_inline.rs", + "name": "SELF_ID", + "verdict": "test_only", + "why": "AtomicI64 holding a scheduled mock timer's id (an i64 returned by schedule_mock_callback_timer, never a GC heap pointer) so the timer's own extern \"C\" callback can look itself up in the ref-state registry mid-dispatch. Declared under #[cfg(test)] only (tests_inline.rs, mock_dispatch_own_pin_tests), never live in a shipped binary." } ], "_FRONTIER_README": "Identity-pinned debt ratchet over new perry-ui* candidates and otherwise-unclassified core raw/Perry TLS declarations (see the census docstring, \u201cThe identity-pinned frontier\u201d). A new uncovered holder fails until it is scanned, receives a researched holders verdict, or is deliberately pinned as debt. Moving a researched false positive to holders graduates it from this list. A fixed or classified holder makes its old frontier pin stale, so the receipt must be deleted.", diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index 04886d9021..b2abe964e4 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -473,6 +473,14 @@ "classification": "representation-proven", "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." }, + { + "path": "crates/perry-codegen/src/type_analysis/numeric.rs", + "function": "is_declared_number_expr", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "A declared local number selects only js_string_concat_chain's part-classifier elision (chain_part_without_redundant_coerce); that helper tag-dispatches every part itself and falls back to js_jsvalue_to_string/js_string_materialize_to_heap for any shape that isn't a plain number, the same calls js_string_coerce forwards to for those shapes, so a lying annotation still produces byte-identical output." + }, { "path": "crates/perry-codegen/src/type_analysis/numeric.rs", "function": "is_numeric_expr", diff --git a/test-files/fixtures/issue_10477_fn_ctor/cjs_default.cjs b/test-files/fixtures/issue_10477_fn_ctor/cjs_default.cjs new file mode 100644 index 0000000000..83b05a4bc2 --- /dev/null +++ b/test-files/fixtures/issue_10477_fn_ctor/cjs_default.cjs @@ -0,0 +1,7 @@ +function CjsCtor(v) { + this.v = v; +} +CjsCtor.prototype.get = function () { + return this.v; +}; +module.exports = CjsCtor; diff --git a/test-files/fixtures/issue_10477_fn_ctor/cjs_named.cjs b/test-files/fixtures/issue_10477_fn_ctor/cjs_named.cjs new file mode 100644 index 0000000000..6137f9d086 --- /dev/null +++ b/test-files/fixtures/issue_10477_fn_ctor/cjs_named.cjs @@ -0,0 +1,4 @@ +function Named(v) { + this.v = v; +} +exports.Named = Named; diff --git a/test-files/fixtures/issue_10477_fn_ctor/default_fn.ts b/test-files/fixtures/issue_10477_fn_ctor/default_fn.ts new file mode 100644 index 0000000000..43fec252c0 --- /dev/null +++ b/test-files/fixtures/issue_10477_fn_ctor/default_fn.ts @@ -0,0 +1,3 @@ +export default function DefaultFn(this: any, v: number) { + this.v = v; +} diff --git a/test-files/fixtures/issue_10477_fn_ctor/default_var.ts b/test-files/fixtures/issue_10477_fn_ctor/default_var.ts new file mode 100644 index 0000000000..4e16f8c63f --- /dev/null +++ b/test-files/fixtures/issue_10477_fn_ctor/default_var.ts @@ -0,0 +1,11 @@ +// `var D = factory(); export default D` — decimal.js's module shape. +function factory() { + function Dec(this: any, v: number): any { + if (!(this instanceof Dec)) return new (Dec as any)(v); + this.v = v; + } + Dec.prototype = { constructor: Dec }; + return Dec; +} +var D: any = factory(); +export default D; diff --git a/test-files/fixtures/issue_10477_fn_ctor/lib.ts b/test-files/fixtures/issue_10477_fn_ctor/lib.ts new file mode 100644 index 0000000000..280b6a18ee --- /dev/null +++ b/test-files/fixtures/issue_10477_fn_ctor/lib.ts @@ -0,0 +1,92 @@ +// #10477 fixture: function constructors (not classes) exported from a module, +// checked with `instanceof` by the importer. +import { inherits } from "node:util"; + +// Prototype untouched. +export function Plain(this: any, v: number) { + this.v = v; +} +Plain.prototype.get = function (this: any) { + return this.v; +}; + +// Prototype replaced wholesale (the bignumber.js / decimal.js shape). +export function Swapped(this: any, v: number) { + this.v = v; +} +Swapped.prototype = { + constructor: Swapped, + get(this: any) { + return this.v; + }, +}; + +// Controls: real classes keep the static class-id check. +export class Klass { + v: number; + constructor(v: number) { + this.v = v; + } +} +export class SubKlass extends Klass {} +export const ExprKlass = class { + w = 2; +}; + +// ES5 inheritance, both idioms. +export function Base(this: any) { + this.base = true; +} +export function Inherited(this: any) { + Base.call(this); +} +inherits(Inherited, Base); +export function Linked(this: any) {} +Object.setPrototypeOf(Linked.prototype, Base.prototype); + +// An own Symbol.hasInstance overrides the prototype walk. +export function Duck() {} +Object.defineProperty(Duck, Symbol.hasInstance, { + value: (x: any) => !!x && x.quack === true, +}); + +// User constructors that share a name with a builtin the static path maps to a +// reserved class id. +export function Headers(this: any) { + this.h = 1; +} +export function EventEmitter(this: any) { + this.e = 1; +} +export function Stream(this: any) { + this.s = 1; +} + +// Factory-built constructor held in an exported const (decimal.js `clone()`). +function factory() { + function Made(this: any, v: number): any { + if (!(this instanceof Made)) return new (Made as any)(v); + this.v = v; + } + Made.prototype = { constructor: Made }; + return Made; +} +export const MadeConst: any = factory(); + +// A live binding: the importer must read the current value. +export let Rebound: any = function First(this: any) {}; +export function rebind() { + Rebound = function Second(this: any) {}; +} + +export const notCallable = { prototype: {} }; + +export function makePlain(v: number) { + return new (Plain as any)(v); +} +export function makeSwapped(v: number) { + return new (Swapped as any)(v); +} +export function isPlainHere(x: unknown) { + return x instanceof Plain; +} diff --git a/test-files/test_gap_10476_builtin_named_guarded_receivers.ts b/test-files/test_gap_10476_builtin_named_guarded_receivers.ts new file mode 100644 index 0000000000..c7278b05c3 --- /dev/null +++ b/test-files/test_gap_10476_builtin_named_guarded_receivers.ts @@ -0,0 +1,608 @@ +// #10476: a user method whose NAME matches a Date / Number / Array / String +// built-in must run on a receiver that is not that built-in. Pre-fix such a +// call was lowered to the built-in by name alone (NaN, "[object Object]", +// "Invalid time value", or a compile error for a zero-argument +// `endsWith`/`includes`/`startsWith`). No class here declares these names, so +// an unproven receiver takes the compiler's runtime receiver-kind check: every +// name is defined on a function-constructor prototype, a replaced prototype +// object, an object literal and an Object.create() prototype, and called +// directly, via `this` and via `any`, with real Date / number / array / string +// values as controls at the same call sites. +// Generated by a one-off script: one call site per (form, name, arity). + +const cmp = (a: number, b: number) => b - a; +const add = (acc: number, v: number) => acc + v; +const dup = (v: number) => [v, v]; + +function tag(form: string, name: string, args: any[]): string { + return form + "." + name + "/" + args.length; +} + +function show(v: any): string { + if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return String(v); + if (Array.isArray(v)) return "[" + v.join(",") + "]"; + return v === null ? "null" : typeof v; +} + +// Error messages for a missing method differ between engines; the name is +// what matters here. +function attempt(f: () => any): string { + try { + return show(f()); + } catch (e) { + return "THREW " + (e as Error).name; + } +} + +function attemptMessage(f: () => any): string { + try { + return show(f()); + } catch (e) { + return "THREW " + String(e); + } +} + +function row(label: string, ...fs: Array<() => any>): void { + console.log(label, fs.map(attempt).join(" ")); +} + +function viaAny(x: any, id: number): any { + switch (id) { + case 0: return x.getDate(); + case 1: return x.getDay(); + case 2: return x.getFullYear(); + case 3: return x.getHours(); + case 4: return x.getMilliseconds(); + case 5: return x.getMinutes(); + case 6: return x.getMonth(); + case 7: return x.getSeconds(); + case 8: return x.getTime(); + case 9: return x.getTimezoneOffset(); + case 10: return x.getUTCDate(); + case 11: return x.getUTCDay(); + case 12: return x.getUTCFullYear(); + case 13: return x.getUTCHours(); + case 14: return x.getUTCMilliseconds(); + case 15: return x.getUTCMinutes(); + case 16: return x.getUTCMonth(); + case 17: return x.getUTCSeconds(); + case 18: return x.setDate(1); + case 19: return x.setFullYear(1); + case 20: return x.setHours(1); + case 21: return x.setMilliseconds(1); + case 22: return x.setMinutes(1); + case 23: return x.setMonth(1); + case 24: return x.setSeconds(1); + case 25: return x.setTime(1); + case 26: return x.setUTCDate(1); + case 27: return x.setUTCFullYear(1); + case 28: return x.setUTCHours(1); + case 29: return x.setUTCMilliseconds(1); + case 30: return x.setUTCMinutes(1); + case 31: return x.setUTCMonth(1); + case 32: return x.setUTCSeconds(1); + case 33: return x.toISOString(); + case 34: return x.toDateString(); + case 35: return x.toTimeString(); + case 36: return x.toUTCString(); + case 37: return x.toGMTString(); + case 38: return x.toLocaleString(); + case 39: return x.toLocaleDateString(); + case 40: return x.toLocaleTimeString(); + case 41: return x.toFixed(2); + case 42: return x.toPrecision(2); + case 43: return x.toExponential(2); + case 44: return x.toFixed(); + case 45: return x.toSorted(); + case 46: return x.toSorted(cmp); + case 47: return x.toReversed(); + case 48: return x.reduceRight(add); + case 49: return x.flat(); + case 50: return x.flatMap(dup); + case 51: return x.toSpliced(1); + case 52: return x.toSpliced(0, 1); + case 53: return x.copyWithin(0, 1); + case 54: return x.endsWith(); + case 55: return x.includes(); + case 56: return x.startsWith(); + case 57: return x.endsWith('c'); + case 58: return x.includes('b'); + case 59: return x.startsWith('a'); + } +} + +function F(this: any) { this.v = 1; } +F.prototype.getDate = function (...a: any[]) { return tag("F", "getDate", a); }; +F.prototype.getDay = function (...a: any[]) { return tag("F", "getDay", a); }; +F.prototype.getFullYear = function (...a: any[]) { return tag("F", "getFullYear", a); }; +F.prototype.getHours = function (...a: any[]) { return tag("F", "getHours", a); }; +F.prototype.getMilliseconds = function (...a: any[]) { return tag("F", "getMilliseconds", a); }; +F.prototype.getMinutes = function (...a: any[]) { return tag("F", "getMinutes", a); }; +F.prototype.getMonth = function (...a: any[]) { return tag("F", "getMonth", a); }; +F.prototype.getSeconds = function (...a: any[]) { return tag("F", "getSeconds", a); }; +F.prototype.getTime = function (...a: any[]) { return tag("F", "getTime", a); }; +F.prototype.getTimezoneOffset = function (...a: any[]) { return tag("F", "getTimezoneOffset", a); }; +F.prototype.getUTCDate = function (...a: any[]) { return tag("F", "getUTCDate", a); }; +F.prototype.getUTCDay = function (...a: any[]) { return tag("F", "getUTCDay", a); }; +F.prototype.getUTCFullYear = function (...a: any[]) { return tag("F", "getUTCFullYear", a); }; +F.prototype.getUTCHours = function (...a: any[]) { return tag("F", "getUTCHours", a); }; +F.prototype.getUTCMilliseconds = function (...a: any[]) { return tag("F", "getUTCMilliseconds", a); }; +F.prototype.getUTCMinutes = function (...a: any[]) { return tag("F", "getUTCMinutes", a); }; +F.prototype.getUTCMonth = function (...a: any[]) { return tag("F", "getUTCMonth", a); }; +F.prototype.getUTCSeconds = function (...a: any[]) { return tag("F", "getUTCSeconds", a); }; +F.prototype.setDate = function (...a: any[]) { return tag("F", "setDate", a); }; +F.prototype.setFullYear = function (...a: any[]) { return tag("F", "setFullYear", a); }; +F.prototype.setHours = function (...a: any[]) { return tag("F", "setHours", a); }; +F.prototype.setMilliseconds = function (...a: any[]) { return tag("F", "setMilliseconds", a); }; +F.prototype.setMinutes = function (...a: any[]) { return tag("F", "setMinutes", a); }; +F.prototype.setMonth = function (...a: any[]) { return tag("F", "setMonth", a); }; +F.prototype.setSeconds = function (...a: any[]) { return tag("F", "setSeconds", a); }; +F.prototype.setTime = function (...a: any[]) { return tag("F", "setTime", a); }; +F.prototype.setUTCDate = function (...a: any[]) { return tag("F", "setUTCDate", a); }; +F.prototype.setUTCFullYear = function (...a: any[]) { return tag("F", "setUTCFullYear", a); }; +F.prototype.setUTCHours = function (...a: any[]) { return tag("F", "setUTCHours", a); }; +F.prototype.setUTCMilliseconds = function (...a: any[]) { return tag("F", "setUTCMilliseconds", a); }; +F.prototype.setUTCMinutes = function (...a: any[]) { return tag("F", "setUTCMinutes", a); }; +F.prototype.setUTCMonth = function (...a: any[]) { return tag("F", "setUTCMonth", a); }; +F.prototype.setUTCSeconds = function (...a: any[]) { return tag("F", "setUTCSeconds", a); }; +F.prototype.toISOString = function (...a: any[]) { return tag("F", "toISOString", a); }; +F.prototype.toDateString = function (...a: any[]) { return tag("F", "toDateString", a); }; +F.prototype.toTimeString = function (...a: any[]) { return tag("F", "toTimeString", a); }; +F.prototype.toUTCString = function (...a: any[]) { return tag("F", "toUTCString", a); }; +F.prototype.toGMTString = function (...a: any[]) { return tag("F", "toGMTString", a); }; +F.prototype.toLocaleString = function (...a: any[]) { return tag("F", "toLocaleString", a); }; +F.prototype.toLocaleDateString = function (...a: any[]) { return tag("F", "toLocaleDateString", a); }; +F.prototype.toLocaleTimeString = function (...a: any[]) { return tag("F", "toLocaleTimeString", a); }; +F.prototype.toFixed = function (...a: any[]) { return tag("F", "toFixed", a); }; +F.prototype.toPrecision = function (...a: any[]) { return tag("F", "toPrecision", a); }; +F.prototype.toExponential = function (...a: any[]) { return tag("F", "toExponential", a); }; +F.prototype.toSorted = function (...a: any[]) { return tag("F", "toSorted", a); }; +F.prototype.toReversed = function (...a: any[]) { return tag("F", "toReversed", a); }; +F.prototype.reduceRight = function (...a: any[]) { return tag("F", "reduceRight", a); }; +F.prototype.flat = function (...a: any[]) { return tag("F", "flat", a); }; +F.prototype.flatMap = function (...a: any[]) { return tag("F", "flatMap", a); }; +F.prototype.toSpliced = function (...a: any[]) { return tag("F", "toSpliced", a); }; +F.prototype.copyWithin = function (...a: any[]) { return tag("F", "copyWithin", a); }; +F.prototype.endsWith = function (...a: any[]) { return tag("F", "endsWith", a); }; +F.prototype.includes = function (...a: any[]) { return tag("F", "includes", a); }; +F.prototype.startsWith = function (...a: any[]) { return tag("F", "startsWith", a); }; +F.prototype.viaThis = function (this: any, id: number): any { + switch (id) { + case 0: return this.getDate(); + case 1: return this.getDay(); + case 2: return this.getFullYear(); + case 3: return this.getHours(); + case 4: return this.getMilliseconds(); + case 5: return this.getMinutes(); + case 6: return this.getMonth(); + case 7: return this.getSeconds(); + case 8: return this.getTime(); + case 9: return this.getTimezoneOffset(); + case 10: return this.getUTCDate(); + case 11: return this.getUTCDay(); + case 12: return this.getUTCFullYear(); + case 13: return this.getUTCHours(); + case 14: return this.getUTCMilliseconds(); + case 15: return this.getUTCMinutes(); + case 16: return this.getUTCMonth(); + case 17: return this.getUTCSeconds(); + case 18: return this.setDate(1); + case 19: return this.setFullYear(1); + case 20: return this.setHours(1); + case 21: return this.setMilliseconds(1); + case 22: return this.setMinutes(1); + case 23: return this.setMonth(1); + case 24: return this.setSeconds(1); + case 25: return this.setTime(1); + case 26: return this.setUTCDate(1); + case 27: return this.setUTCFullYear(1); + case 28: return this.setUTCHours(1); + case 29: return this.setUTCMilliseconds(1); + case 30: return this.setUTCMinutes(1); + case 31: return this.setUTCMonth(1); + case 32: return this.setUTCSeconds(1); + case 33: return this.toISOString(); + case 34: return this.toDateString(); + case 35: return this.toTimeString(); + case 36: return this.toUTCString(); + case 37: return this.toGMTString(); + case 38: return this.toLocaleString(); + case 39: return this.toLocaleDateString(); + case 40: return this.toLocaleTimeString(); + case 41: return this.toFixed(2); + case 42: return this.toPrecision(2); + case 43: return this.toExponential(2); + case 44: return this.toFixed(); + case 45: return this.toSorted(); + case 46: return this.toSorted(cmp); + case 47: return this.toReversed(); + case 48: return this.reduceRight(add); + case 49: return this.flat(); + case 50: return this.flatMap(dup); + case 51: return this.toSpliced(1); + case 52: return this.toSpliced(0, 1); + case 53: return this.copyWithin(0, 1); + case 54: return this.endsWith(); + case 55: return this.includes(); + case 56: return this.startsWith(); + case 57: return this.endsWith('c'); + case 58: return this.includes('b'); + case 59: return this.startsWith('a'); + } +}; + +function G(this: any) { this.v = 2; } +G.prototype = { + getDate: function (...a: any[]) { return tag("G", "getDate", a); }, + getDay: function (...a: any[]) { return tag("G", "getDay", a); }, + getFullYear: function (...a: any[]) { return tag("G", "getFullYear", a); }, + getHours: function (...a: any[]) { return tag("G", "getHours", a); }, + getMilliseconds: function (...a: any[]) { return tag("G", "getMilliseconds", a); }, + getMinutes: function (...a: any[]) { return tag("G", "getMinutes", a); }, + getMonth: function (...a: any[]) { return tag("G", "getMonth", a); }, + getSeconds: function (...a: any[]) { return tag("G", "getSeconds", a); }, + getTime: function (...a: any[]) { return tag("G", "getTime", a); }, + getTimezoneOffset: function (...a: any[]) { return tag("G", "getTimezoneOffset", a); }, + getUTCDate: function (...a: any[]) { return tag("G", "getUTCDate", a); }, + getUTCDay: function (...a: any[]) { return tag("G", "getUTCDay", a); }, + getUTCFullYear: function (...a: any[]) { return tag("G", "getUTCFullYear", a); }, + getUTCHours: function (...a: any[]) { return tag("G", "getUTCHours", a); }, + getUTCMilliseconds: function (...a: any[]) { return tag("G", "getUTCMilliseconds", a); }, + getUTCMinutes: function (...a: any[]) { return tag("G", "getUTCMinutes", a); }, + getUTCMonth: function (...a: any[]) { return tag("G", "getUTCMonth", a); }, + getUTCSeconds: function (...a: any[]) { return tag("G", "getUTCSeconds", a); }, + setDate: function (...a: any[]) { return tag("G", "setDate", a); }, + setFullYear: function (...a: any[]) { return tag("G", "setFullYear", a); }, + setHours: function (...a: any[]) { return tag("G", "setHours", a); }, + setMilliseconds: function (...a: any[]) { return tag("G", "setMilliseconds", a); }, + setMinutes: function (...a: any[]) { return tag("G", "setMinutes", a); }, + setMonth: function (...a: any[]) { return tag("G", "setMonth", a); }, + setSeconds: function (...a: any[]) { return tag("G", "setSeconds", a); }, + setTime: function (...a: any[]) { return tag("G", "setTime", a); }, + setUTCDate: function (...a: any[]) { return tag("G", "setUTCDate", a); }, + setUTCFullYear: function (...a: any[]) { return tag("G", "setUTCFullYear", a); }, + setUTCHours: function (...a: any[]) { return tag("G", "setUTCHours", a); }, + setUTCMilliseconds: function (...a: any[]) { return tag("G", "setUTCMilliseconds", a); }, + setUTCMinutes: function (...a: any[]) { return tag("G", "setUTCMinutes", a); }, + setUTCMonth: function (...a: any[]) { return tag("G", "setUTCMonth", a); }, + setUTCSeconds: function (...a: any[]) { return tag("G", "setUTCSeconds", a); }, + toISOString: function (...a: any[]) { return tag("G", "toISOString", a); }, + toDateString: function (...a: any[]) { return tag("G", "toDateString", a); }, + toTimeString: function (...a: any[]) { return tag("G", "toTimeString", a); }, + toUTCString: function (...a: any[]) { return tag("G", "toUTCString", a); }, + toGMTString: function (...a: any[]) { return tag("G", "toGMTString", a); }, + toLocaleString: function (...a: any[]) { return tag("G", "toLocaleString", a); }, + toLocaleDateString: function (...a: any[]) { return tag("G", "toLocaleDateString", a); }, + toLocaleTimeString: function (...a: any[]) { return tag("G", "toLocaleTimeString", a); }, + toFixed: function (...a: any[]) { return tag("G", "toFixed", a); }, + toPrecision: function (...a: any[]) { return tag("G", "toPrecision", a); }, + toExponential: function (...a: any[]) { return tag("G", "toExponential", a); }, + toSorted: function (...a: any[]) { return tag("G", "toSorted", a); }, + toReversed: function (...a: any[]) { return tag("G", "toReversed", a); }, + reduceRight: function (...a: any[]) { return tag("G", "reduceRight", a); }, + flat: function (...a: any[]) { return tag("G", "flat", a); }, + flatMap: function (...a: any[]) { return tag("G", "flatMap", a); }, + toSpliced: function (...a: any[]) { return tag("G", "toSpliced", a); }, + copyWithin: function (...a: any[]) { return tag("G", "copyWithin", a); }, + endsWith: function (...a: any[]) { return tag("G", "endsWith", a); }, + includes: function (...a: any[]) { return tag("G", "includes", a); }, + startsWith: function (...a: any[]) { return tag("G", "startsWith", a); }, +}; + +const lit = { + v: 3, + getDate(...a: any[]): any { return tag("lit", "getDate", a); }, + getDay(...a: any[]): any { return tag("lit", "getDay", a); }, + getFullYear(...a: any[]): any { return tag("lit", "getFullYear", a); }, + getHours(...a: any[]): any { return tag("lit", "getHours", a); }, + getMilliseconds(...a: any[]): any { return tag("lit", "getMilliseconds", a); }, + getMinutes(...a: any[]): any { return tag("lit", "getMinutes", a); }, + getMonth(...a: any[]): any { return tag("lit", "getMonth", a); }, + getSeconds(...a: any[]): any { return tag("lit", "getSeconds", a); }, + getTime(...a: any[]): any { return tag("lit", "getTime", a); }, + getTimezoneOffset(...a: any[]): any { return tag("lit", "getTimezoneOffset", a); }, + getUTCDate(...a: any[]): any { return tag("lit", "getUTCDate", a); }, + getUTCDay(...a: any[]): any { return tag("lit", "getUTCDay", a); }, + getUTCFullYear(...a: any[]): any { return tag("lit", "getUTCFullYear", a); }, + getUTCHours(...a: any[]): any { return tag("lit", "getUTCHours", a); }, + getUTCMilliseconds(...a: any[]): any { return tag("lit", "getUTCMilliseconds", a); }, + getUTCMinutes(...a: any[]): any { return tag("lit", "getUTCMinutes", a); }, + getUTCMonth(...a: any[]): any { return tag("lit", "getUTCMonth", a); }, + getUTCSeconds(...a: any[]): any { return tag("lit", "getUTCSeconds", a); }, + setDate(...a: any[]): any { return tag("lit", "setDate", a); }, + setFullYear(...a: any[]): any { return tag("lit", "setFullYear", a); }, + setHours(...a: any[]): any { return tag("lit", "setHours", a); }, + setMilliseconds(...a: any[]): any { return tag("lit", "setMilliseconds", a); }, + setMinutes(...a: any[]): any { return tag("lit", "setMinutes", a); }, + setMonth(...a: any[]): any { return tag("lit", "setMonth", a); }, + setSeconds(...a: any[]): any { return tag("lit", "setSeconds", a); }, + setTime(...a: any[]): any { return tag("lit", "setTime", a); }, + setUTCDate(...a: any[]): any { return tag("lit", "setUTCDate", a); }, + setUTCFullYear(...a: any[]): any { return tag("lit", "setUTCFullYear", a); }, + setUTCHours(...a: any[]): any { return tag("lit", "setUTCHours", a); }, + setUTCMilliseconds(...a: any[]): any { return tag("lit", "setUTCMilliseconds", a); }, + setUTCMinutes(...a: any[]): any { return tag("lit", "setUTCMinutes", a); }, + setUTCMonth(...a: any[]): any { return tag("lit", "setUTCMonth", a); }, + setUTCSeconds(...a: any[]): any { return tag("lit", "setUTCSeconds", a); }, + toISOString(...a: any[]): any { return tag("lit", "toISOString", a); }, + toDateString(...a: any[]): any { return tag("lit", "toDateString", a); }, + toTimeString(...a: any[]): any { return tag("lit", "toTimeString", a); }, + toUTCString(...a: any[]): any { return tag("lit", "toUTCString", a); }, + toGMTString(...a: any[]): any { return tag("lit", "toGMTString", a); }, + toLocaleString(...a: any[]): any { return tag("lit", "toLocaleString", a); }, + toLocaleDateString(...a: any[]): any { return tag("lit", "toLocaleDateString", a); }, + toLocaleTimeString(...a: any[]): any { return tag("lit", "toLocaleTimeString", a); }, + toFixed(...a: any[]): any { return tag("lit", "toFixed", a); }, + toPrecision(...a: any[]): any { return tag("lit", "toPrecision", a); }, + toExponential(...a: any[]): any { return tag("lit", "toExponential", a); }, + toSorted(...a: any[]): any { return tag("lit", "toSorted", a); }, + toReversed(...a: any[]): any { return tag("lit", "toReversed", a); }, + reduceRight(...a: any[]): any { return tag("lit", "reduceRight", a); }, + flat(...a: any[]): any { return tag("lit", "flat", a); }, + flatMap(...a: any[]): any { return tag("lit", "flatMap", a); }, + toSpliced(...a: any[]): any { return tag("lit", "toSpliced", a); }, + copyWithin(...a: any[]): any { return tag("lit", "copyWithin", a); }, + endsWith(...a: any[]): any { return tag("lit", "endsWith", a); }, + includes(...a: any[]): any { return tag("lit", "includes", a); }, + startsWith(...a: any[]): any { return tag("lit", "startsWith", a); }, +}; + +const protoLit: any = {}; +protoLit.getDate = function (...a: any[]) { return tag("oc", "getDate", a); }; +protoLit.getDay = function (...a: any[]) { return tag("oc", "getDay", a); }; +protoLit.getFullYear = function (...a: any[]) { return tag("oc", "getFullYear", a); }; +protoLit.getHours = function (...a: any[]) { return tag("oc", "getHours", a); }; +protoLit.getMilliseconds = function (...a: any[]) { return tag("oc", "getMilliseconds", a); }; +protoLit.getMinutes = function (...a: any[]) { return tag("oc", "getMinutes", a); }; +protoLit.getMonth = function (...a: any[]) { return tag("oc", "getMonth", a); }; +protoLit.getSeconds = function (...a: any[]) { return tag("oc", "getSeconds", a); }; +protoLit.getTime = function (...a: any[]) { return tag("oc", "getTime", a); }; +protoLit.getTimezoneOffset = function (...a: any[]) { return tag("oc", "getTimezoneOffset", a); }; +protoLit.getUTCDate = function (...a: any[]) { return tag("oc", "getUTCDate", a); }; +protoLit.getUTCDay = function (...a: any[]) { return tag("oc", "getUTCDay", a); }; +protoLit.getUTCFullYear = function (...a: any[]) { return tag("oc", "getUTCFullYear", a); }; +protoLit.getUTCHours = function (...a: any[]) { return tag("oc", "getUTCHours", a); }; +protoLit.getUTCMilliseconds = function (...a: any[]) { return tag("oc", "getUTCMilliseconds", a); }; +protoLit.getUTCMinutes = function (...a: any[]) { return tag("oc", "getUTCMinutes", a); }; +protoLit.getUTCMonth = function (...a: any[]) { return tag("oc", "getUTCMonth", a); }; +protoLit.getUTCSeconds = function (...a: any[]) { return tag("oc", "getUTCSeconds", a); }; +protoLit.setDate = function (...a: any[]) { return tag("oc", "setDate", a); }; +protoLit.setFullYear = function (...a: any[]) { return tag("oc", "setFullYear", a); }; +protoLit.setHours = function (...a: any[]) { return tag("oc", "setHours", a); }; +protoLit.setMilliseconds = function (...a: any[]) { return tag("oc", "setMilliseconds", a); }; +protoLit.setMinutes = function (...a: any[]) { return tag("oc", "setMinutes", a); }; +protoLit.setMonth = function (...a: any[]) { return tag("oc", "setMonth", a); }; +protoLit.setSeconds = function (...a: any[]) { return tag("oc", "setSeconds", a); }; +protoLit.setTime = function (...a: any[]) { return tag("oc", "setTime", a); }; +protoLit.setUTCDate = function (...a: any[]) { return tag("oc", "setUTCDate", a); }; +protoLit.setUTCFullYear = function (...a: any[]) { return tag("oc", "setUTCFullYear", a); }; +protoLit.setUTCHours = function (...a: any[]) { return tag("oc", "setUTCHours", a); }; +protoLit.setUTCMilliseconds = function (...a: any[]) { return tag("oc", "setUTCMilliseconds", a); }; +protoLit.setUTCMinutes = function (...a: any[]) { return tag("oc", "setUTCMinutes", a); }; +protoLit.setUTCMonth = function (...a: any[]) { return tag("oc", "setUTCMonth", a); }; +protoLit.setUTCSeconds = function (...a: any[]) { return tag("oc", "setUTCSeconds", a); }; +protoLit.toISOString = function (...a: any[]) { return tag("oc", "toISOString", a); }; +protoLit.toDateString = function (...a: any[]) { return tag("oc", "toDateString", a); }; +protoLit.toTimeString = function (...a: any[]) { return tag("oc", "toTimeString", a); }; +protoLit.toUTCString = function (...a: any[]) { return tag("oc", "toUTCString", a); }; +protoLit.toGMTString = function (...a: any[]) { return tag("oc", "toGMTString", a); }; +protoLit.toLocaleString = function (...a: any[]) { return tag("oc", "toLocaleString", a); }; +protoLit.toLocaleDateString = function (...a: any[]) { return tag("oc", "toLocaleDateString", a); }; +protoLit.toLocaleTimeString = function (...a: any[]) { return tag("oc", "toLocaleTimeString", a); }; +protoLit.toFixed = function (...a: any[]) { return tag("oc", "toFixed", a); }; +protoLit.toPrecision = function (...a: any[]) { return tag("oc", "toPrecision", a); }; +protoLit.toExponential = function (...a: any[]) { return tag("oc", "toExponential", a); }; +protoLit.toSorted = function (...a: any[]) { return tag("oc", "toSorted", a); }; +protoLit.toReversed = function (...a: any[]) { return tag("oc", "toReversed", a); }; +protoLit.reduceRight = function (...a: any[]) { return tag("oc", "reduceRight", a); }; +protoLit.flat = function (...a: any[]) { return tag("oc", "flat", a); }; +protoLit.flatMap = function (...a: any[]) { return tag("oc", "flatMap", a); }; +protoLit.toSpliced = function (...a: any[]) { return tag("oc", "toSpliced", a); }; +protoLit.copyWithin = function (...a: any[]) { return tag("oc", "copyWithin", a); }; +protoLit.endsWith = function (...a: any[]) { return tag("oc", "endsWith", a); }; +protoLit.includes = function (...a: any[]) { return tag("oc", "includes", a); }; +protoLit.startsWith = function (...a: any[]) { return tag("oc", "startsWith", a); }; +const oc = Object.create(protoLit); + +const f: any = new (F as any)(); +const g: any = new (G as any)(); + +row("getDate()", () => f.getDate(), () => f.viaThis(0), () => g.getDate(), () => lit.getDate(), () => oc.getDate(), () => viaAny(f, 0), () => viaAny(lit, 0), () => viaAny(oc, 0)); +row("getDay()", () => f.getDay(), () => f.viaThis(1), () => g.getDay(), () => lit.getDay(), () => oc.getDay(), () => viaAny(f, 1), () => viaAny(lit, 1), () => viaAny(oc, 1)); +row("getFullYear()", () => f.getFullYear(), () => f.viaThis(2), () => g.getFullYear(), () => lit.getFullYear(), () => oc.getFullYear(), () => viaAny(f, 2), () => viaAny(lit, 2), () => viaAny(oc, 2)); +row("getHours()", () => f.getHours(), () => f.viaThis(3), () => g.getHours(), () => lit.getHours(), () => oc.getHours(), () => viaAny(f, 3), () => viaAny(lit, 3), () => viaAny(oc, 3)); +row("getMilliseconds()", () => f.getMilliseconds(), () => f.viaThis(4), () => g.getMilliseconds(), () => lit.getMilliseconds(), () => oc.getMilliseconds(), () => viaAny(f, 4), () => viaAny(lit, 4), () => viaAny(oc, 4)); +row("getMinutes()", () => f.getMinutes(), () => f.viaThis(5), () => g.getMinutes(), () => lit.getMinutes(), () => oc.getMinutes(), () => viaAny(f, 5), () => viaAny(lit, 5), () => viaAny(oc, 5)); +row("getMonth()", () => f.getMonth(), () => f.viaThis(6), () => g.getMonth(), () => lit.getMonth(), () => oc.getMonth(), () => viaAny(f, 6), () => viaAny(lit, 6), () => viaAny(oc, 6)); +row("getSeconds()", () => f.getSeconds(), () => f.viaThis(7), () => g.getSeconds(), () => lit.getSeconds(), () => oc.getSeconds(), () => viaAny(f, 7), () => viaAny(lit, 7), () => viaAny(oc, 7)); +row("getTime()", () => f.getTime(), () => f.viaThis(8), () => g.getTime(), () => lit.getTime(), () => oc.getTime(), () => viaAny(f, 8), () => viaAny(lit, 8), () => viaAny(oc, 8)); +row("getTimezoneOffset()", () => f.getTimezoneOffset(), () => f.viaThis(9), () => g.getTimezoneOffset(), () => lit.getTimezoneOffset(), () => oc.getTimezoneOffset(), () => viaAny(f, 9), () => viaAny(lit, 9), () => viaAny(oc, 9)); +row("getUTCDate()", () => f.getUTCDate(), () => f.viaThis(10), () => g.getUTCDate(), () => lit.getUTCDate(), () => oc.getUTCDate(), () => viaAny(f, 10), () => viaAny(lit, 10), () => viaAny(oc, 10)); +row("getUTCDay()", () => f.getUTCDay(), () => f.viaThis(11), () => g.getUTCDay(), () => lit.getUTCDay(), () => oc.getUTCDay(), () => viaAny(f, 11), () => viaAny(lit, 11), () => viaAny(oc, 11)); +row("getUTCFullYear()", () => f.getUTCFullYear(), () => f.viaThis(12), () => g.getUTCFullYear(), () => lit.getUTCFullYear(), () => oc.getUTCFullYear(), () => viaAny(f, 12), () => viaAny(lit, 12), () => viaAny(oc, 12)); +row("getUTCHours()", () => f.getUTCHours(), () => f.viaThis(13), () => g.getUTCHours(), () => lit.getUTCHours(), () => oc.getUTCHours(), () => viaAny(f, 13), () => viaAny(lit, 13), () => viaAny(oc, 13)); +row("getUTCMilliseconds()", () => f.getUTCMilliseconds(), () => f.viaThis(14), () => g.getUTCMilliseconds(), () => lit.getUTCMilliseconds(), () => oc.getUTCMilliseconds(), () => viaAny(f, 14), () => viaAny(lit, 14), () => viaAny(oc, 14)); +row("getUTCMinutes()", () => f.getUTCMinutes(), () => f.viaThis(15), () => g.getUTCMinutes(), () => lit.getUTCMinutes(), () => oc.getUTCMinutes(), () => viaAny(f, 15), () => viaAny(lit, 15), () => viaAny(oc, 15)); +row("getUTCMonth()", () => f.getUTCMonth(), () => f.viaThis(16), () => g.getUTCMonth(), () => lit.getUTCMonth(), () => oc.getUTCMonth(), () => viaAny(f, 16), () => viaAny(lit, 16), () => viaAny(oc, 16)); +row("getUTCSeconds()", () => f.getUTCSeconds(), () => f.viaThis(17), () => g.getUTCSeconds(), () => lit.getUTCSeconds(), () => oc.getUTCSeconds(), () => viaAny(f, 17), () => viaAny(lit, 17), () => viaAny(oc, 17)); +row("setDate(1)", () => f.setDate(1), () => f.viaThis(18), () => g.setDate(1), () => lit.setDate(1), () => oc.setDate(1), () => viaAny(f, 18), () => viaAny(lit, 18), () => viaAny(oc, 18)); +row("setFullYear(1)", () => f.setFullYear(1), () => f.viaThis(19), () => g.setFullYear(1), () => lit.setFullYear(1), () => oc.setFullYear(1), () => viaAny(f, 19), () => viaAny(lit, 19), () => viaAny(oc, 19)); +row("setHours(1)", () => f.setHours(1), () => f.viaThis(20), () => g.setHours(1), () => lit.setHours(1), () => oc.setHours(1), () => viaAny(f, 20), () => viaAny(lit, 20), () => viaAny(oc, 20)); +row("setMilliseconds(1)", () => f.setMilliseconds(1), () => f.viaThis(21), () => g.setMilliseconds(1), () => lit.setMilliseconds(1), () => oc.setMilliseconds(1), () => viaAny(f, 21), () => viaAny(lit, 21), () => viaAny(oc, 21)); +row("setMinutes(1)", () => f.setMinutes(1), () => f.viaThis(22), () => g.setMinutes(1), () => lit.setMinutes(1), () => oc.setMinutes(1), () => viaAny(f, 22), () => viaAny(lit, 22), () => viaAny(oc, 22)); +row("setMonth(1)", () => f.setMonth(1), () => f.viaThis(23), () => g.setMonth(1), () => lit.setMonth(1), () => oc.setMonth(1), () => viaAny(f, 23), () => viaAny(lit, 23), () => viaAny(oc, 23)); +row("setSeconds(1)", () => f.setSeconds(1), () => f.viaThis(24), () => g.setSeconds(1), () => lit.setSeconds(1), () => oc.setSeconds(1), () => viaAny(f, 24), () => viaAny(lit, 24), () => viaAny(oc, 24)); +row("setTime(1)", () => f.setTime(1), () => f.viaThis(25), () => g.setTime(1), () => lit.setTime(1), () => oc.setTime(1), () => viaAny(f, 25), () => viaAny(lit, 25), () => viaAny(oc, 25)); +row("setUTCDate(1)", () => f.setUTCDate(1), () => f.viaThis(26), () => g.setUTCDate(1), () => lit.setUTCDate(1), () => oc.setUTCDate(1), () => viaAny(f, 26), () => viaAny(lit, 26), () => viaAny(oc, 26)); +row("setUTCFullYear(1)", () => f.setUTCFullYear(1), () => f.viaThis(27), () => g.setUTCFullYear(1), () => lit.setUTCFullYear(1), () => oc.setUTCFullYear(1), () => viaAny(f, 27), () => viaAny(lit, 27), () => viaAny(oc, 27)); +row("setUTCHours(1)", () => f.setUTCHours(1), () => f.viaThis(28), () => g.setUTCHours(1), () => lit.setUTCHours(1), () => oc.setUTCHours(1), () => viaAny(f, 28), () => viaAny(lit, 28), () => viaAny(oc, 28)); +row("setUTCMilliseconds(1)", () => f.setUTCMilliseconds(1), () => f.viaThis(29), () => g.setUTCMilliseconds(1), () => lit.setUTCMilliseconds(1), () => oc.setUTCMilliseconds(1), () => viaAny(f, 29), () => viaAny(lit, 29), () => viaAny(oc, 29)); +row("setUTCMinutes(1)", () => f.setUTCMinutes(1), () => f.viaThis(30), () => g.setUTCMinutes(1), () => lit.setUTCMinutes(1), () => oc.setUTCMinutes(1), () => viaAny(f, 30), () => viaAny(lit, 30), () => viaAny(oc, 30)); +row("setUTCMonth(1)", () => f.setUTCMonth(1), () => f.viaThis(31), () => g.setUTCMonth(1), () => lit.setUTCMonth(1), () => oc.setUTCMonth(1), () => viaAny(f, 31), () => viaAny(lit, 31), () => viaAny(oc, 31)); +row("setUTCSeconds(1)", () => f.setUTCSeconds(1), () => f.viaThis(32), () => g.setUTCSeconds(1), () => lit.setUTCSeconds(1), () => oc.setUTCSeconds(1), () => viaAny(f, 32), () => viaAny(lit, 32), () => viaAny(oc, 32)); +row("toISOString()", () => f.toISOString(), () => f.viaThis(33), () => g.toISOString(), () => lit.toISOString(), () => oc.toISOString(), () => viaAny(f, 33), () => viaAny(lit, 33), () => viaAny(oc, 33)); +row("toDateString()", () => f.toDateString(), () => f.viaThis(34), () => g.toDateString(), () => lit.toDateString(), () => oc.toDateString(), () => viaAny(f, 34), () => viaAny(lit, 34), () => viaAny(oc, 34)); +row("toTimeString()", () => f.toTimeString(), () => f.viaThis(35), () => g.toTimeString(), () => lit.toTimeString(), () => oc.toTimeString(), () => viaAny(f, 35), () => viaAny(lit, 35), () => viaAny(oc, 35)); +row("toUTCString()", () => f.toUTCString(), () => f.viaThis(36), () => g.toUTCString(), () => lit.toUTCString(), () => oc.toUTCString(), () => viaAny(f, 36), () => viaAny(lit, 36), () => viaAny(oc, 36)); +row("toGMTString()", () => f.toGMTString(), () => f.viaThis(37), () => g.toGMTString(), () => lit.toGMTString(), () => oc.toGMTString(), () => viaAny(f, 37), () => viaAny(lit, 37), () => viaAny(oc, 37)); +row("toLocaleString()", () => f.toLocaleString(), () => f.viaThis(38), () => g.toLocaleString(), () => lit.toLocaleString(), () => oc.toLocaleString(), () => viaAny(f, 38), () => viaAny(lit, 38), () => viaAny(oc, 38)); +row("toLocaleDateString()", () => f.toLocaleDateString(), () => f.viaThis(39), () => g.toLocaleDateString(), () => lit.toLocaleDateString(), () => oc.toLocaleDateString(), () => viaAny(f, 39), () => viaAny(lit, 39), () => viaAny(oc, 39)); +row("toLocaleTimeString()", () => f.toLocaleTimeString(), () => f.viaThis(40), () => g.toLocaleTimeString(), () => lit.toLocaleTimeString(), () => oc.toLocaleTimeString(), () => viaAny(f, 40), () => viaAny(lit, 40), () => viaAny(oc, 40)); +row("toFixed(2)", () => f.toFixed(2), () => f.viaThis(41), () => g.toFixed(2), () => lit.toFixed(2), () => oc.toFixed(2), () => viaAny(f, 41), () => viaAny(lit, 41), () => viaAny(oc, 41)); +row("toPrecision(2)", () => f.toPrecision(2), () => f.viaThis(42), () => g.toPrecision(2), () => lit.toPrecision(2), () => oc.toPrecision(2), () => viaAny(f, 42), () => viaAny(lit, 42), () => viaAny(oc, 42)); +row("toExponential(2)", () => f.toExponential(2), () => f.viaThis(43), () => g.toExponential(2), () => lit.toExponential(2), () => oc.toExponential(2), () => viaAny(f, 43), () => viaAny(lit, 43), () => viaAny(oc, 43)); +row("toFixed()", () => f.toFixed(), () => f.viaThis(44), () => g.toFixed(), () => lit.toFixed(), () => oc.toFixed(), () => viaAny(f, 44), () => viaAny(lit, 44), () => viaAny(oc, 44)); +row("toSorted()", () => f.toSorted(), () => f.viaThis(45), () => g.toSorted(), () => lit.toSorted(), () => oc.toSorted(), () => viaAny(f, 45), () => viaAny(lit, 45), () => viaAny(oc, 45)); +row("toSorted(cmp)", () => f.toSorted(cmp), () => f.viaThis(46), () => g.toSorted(cmp), () => lit.toSorted(cmp), () => oc.toSorted(cmp), () => viaAny(f, 46), () => viaAny(lit, 46), () => viaAny(oc, 46)); +row("toReversed()", () => f.toReversed(), () => f.viaThis(47), () => g.toReversed(), () => lit.toReversed(), () => oc.toReversed(), () => viaAny(f, 47), () => viaAny(lit, 47), () => viaAny(oc, 47)); +row("reduceRight(add)", () => f.reduceRight(add), () => f.viaThis(48), () => g.reduceRight(add), () => lit.reduceRight(add), () => oc.reduceRight(add), () => viaAny(f, 48), () => viaAny(lit, 48), () => viaAny(oc, 48)); +row("flat()", () => f.flat(), () => f.viaThis(49), () => g.flat(), () => lit.flat(), () => oc.flat(), () => viaAny(f, 49), () => viaAny(lit, 49), () => viaAny(oc, 49)); +row("flatMap(dup)", () => f.flatMap(dup), () => f.viaThis(50), () => g.flatMap(dup), () => lit.flatMap(dup), () => oc.flatMap(dup), () => viaAny(f, 50), () => viaAny(lit, 50), () => viaAny(oc, 50)); +row("toSpliced(1)", () => f.toSpliced(1), () => f.viaThis(51), () => g.toSpliced(1), () => lit.toSpliced(1), () => oc.toSpliced(1), () => viaAny(f, 51), () => viaAny(lit, 51), () => viaAny(oc, 51)); +row("toSpliced(0, 1)", () => f.toSpliced(0, 1), () => f.viaThis(52), () => g.toSpliced(0, 1), () => lit.toSpliced(0, 1), () => oc.toSpliced(0, 1), () => viaAny(f, 52), () => viaAny(lit, 52), () => viaAny(oc, 52)); +row("copyWithin(0, 1)", () => f.copyWithin(0, 1), () => f.viaThis(53), () => g.copyWithin(0, 1), () => lit.copyWithin(0, 1), () => oc.copyWithin(0, 1), () => viaAny(f, 53), () => viaAny(lit, 53), () => viaAny(oc, 53)); +row("endsWith()", () => f.endsWith(), () => f.viaThis(54), () => g.endsWith(), () => lit.endsWith(), () => oc.endsWith(), () => viaAny(f, 54), () => viaAny(lit, 54), () => viaAny(oc, 54)); +row("includes()", () => f.includes(), () => f.viaThis(55), () => g.includes(), () => lit.includes(), () => oc.includes(), () => viaAny(f, 55), () => viaAny(lit, 55), () => viaAny(oc, 55)); +row("startsWith()", () => f.startsWith(), () => f.viaThis(56), () => g.startsWith(), () => lit.startsWith(), () => oc.startsWith(), () => viaAny(f, 56), () => viaAny(lit, 56), () => viaAny(oc, 56)); +row("endsWith('c')", () => f.endsWith('c'), () => f.viaThis(57), () => g.endsWith('c'), () => lit.endsWith('c'), () => oc.endsWith('c'), () => viaAny(f, 57), () => viaAny(lit, 57), () => viaAny(oc, 57)); +row("includes('b')", () => f.includes('b'), () => f.viaThis(58), () => g.includes('b'), () => lit.includes('b'), () => oc.includes('b'), () => viaAny(f, 58), () => viaAny(lit, 58), () => viaAny(oc, 58)); +row("startsWith('a')", () => f.startsWith('a'), () => f.viaThis(59), () => g.startsWith('a'), () => lit.startsWith('a'), () => oc.startsWith('a'), () => viaAny(f, 59), () => viaAny(lit, 59), () => viaAny(oc, 59)); + +// dayjs / moment `toJSON` shape: a prototype method calls a sibling via `this`. +function Dayjs(this: any, ms: number) { this.$d = new Date(ms); } +Dayjs.prototype.isValid = function () { return true; }; +Dayjs.prototype.toISOString = function (this: any) { return "Dayjs(" + this.$d.toISOString() + ")"; }; +Dayjs.prototype.valueOf = function (this: any) { return this.$d.getTime(); }; +Dayjs.prototype.toJSON = function (this: any) { return this.isValid() ? this.toISOString() : null; }; +const dj: any = new (Dayjs as any)(7); +console.log("dayjs toJSON", dj.toJSON(), JSON.stringify({ at: dj }), +dj); + +// Controls: the real built-ins on typed receivers, `any` receivers and the +// shared `viaAny` call sites. Local-time results depend on the host zone, so +// local getters/setters are compared with UTC-derived values, not printed. +const d = new Date(Date.UTC(2024, 5, 15, 10, 20, 30, 456)); +const du: any = new Date(Date.UTC(2024, 5, 15, 10, 20, 30, 456)); +const shifted = new Date(d.getTime() - d.getTimezoneOffset() * 60000); +console.log("Date getDate", d.getDate() === shifted.getUTCDate(), du.getDate() === d.getDate(), viaAny(du, 0) === d.getDate()); +console.log("Date getDay", d.getDay() === shifted.getUTCDay(), du.getDay() === d.getDay(), viaAny(du, 1) === d.getDay()); +console.log("Date getFullYear", d.getFullYear() === shifted.getUTCFullYear(), du.getFullYear() === d.getFullYear(), viaAny(du, 2) === d.getFullYear()); +console.log("Date getHours", d.getHours() === shifted.getUTCHours(), du.getHours() === d.getHours(), viaAny(du, 3) === d.getHours()); +console.log("Date getMilliseconds", d.getMilliseconds() === shifted.getUTCMilliseconds(), du.getMilliseconds() === d.getMilliseconds(), viaAny(du, 4) === d.getMilliseconds()); +console.log("Date getMinutes", d.getMinutes() === shifted.getUTCMinutes(), du.getMinutes() === d.getMinutes(), viaAny(du, 5) === d.getMinutes()); +console.log("Date getMonth", d.getMonth() === shifted.getUTCMonth(), du.getMonth() === d.getMonth(), viaAny(du, 6) === d.getMonth()); +console.log("Date getSeconds", d.getSeconds() === shifted.getUTCSeconds(), du.getSeconds() === d.getSeconds(), viaAny(du, 7) === d.getSeconds()); +console.log("Date getTime", d.getTime(), du.getTime(), viaAny(du, 8)); +console.log("Date getTimezoneOffset", typeof d.getTimezoneOffset(), du.getTimezoneOffset() === d.getTimezoneOffset(), viaAny(du, 9) === d.getTimezoneOffset()); +console.log("Date getUTCDate", d.getUTCDate(), du.getUTCDate(), viaAny(du, 10)); +console.log("Date getUTCDay", d.getUTCDay(), du.getUTCDay(), viaAny(du, 11)); +console.log("Date getUTCFullYear", d.getUTCFullYear(), du.getUTCFullYear(), viaAny(du, 12)); +console.log("Date getUTCHours", d.getUTCHours(), du.getUTCHours(), viaAny(du, 13)); +console.log("Date getUTCMilliseconds", d.getUTCMilliseconds(), du.getUTCMilliseconds(), viaAny(du, 14)); +console.log("Date getUTCMinutes", d.getUTCMinutes(), du.getUTCMinutes(), viaAny(du, 15)); +console.log("Date getUTCMonth", d.getUTCMonth(), du.getUTCMonth(), viaAny(du, 16)); +console.log("Date getUTCSeconds", d.getUTCSeconds(), du.getUTCSeconds(), viaAny(du, 17)); +{ const t = new Date(d.getTime()); const tu: any = new Date(d.getTime()); const tv = new Date(d.getTime()); + const r = [t.setDate(1), tu.setDate(1), viaAny(tv, 18)]; + console.log("Date setDate", typeof r[0], r[0] === t.getTime(), r[1] === tu.getTime(), r[2] === tv.getTime(), t.getDate(), tu.getDate(), tv.getDate()); } +{ const t = new Date(d.getTime()); const tu: any = new Date(d.getTime()); const tv = new Date(d.getTime()); + const r = [t.setFullYear(1), tu.setFullYear(1), viaAny(tv, 19)]; + console.log("Date setFullYear", typeof r[0], r[0] === t.getTime(), r[1] === tu.getTime(), r[2] === tv.getTime(), t.getFullYear(), tu.getFullYear(), tv.getFullYear()); } +{ const t = new Date(d.getTime()); const tu: any = new Date(d.getTime()); const tv = new Date(d.getTime()); + const r = [t.setHours(1), tu.setHours(1), viaAny(tv, 20)]; + console.log("Date setHours", typeof r[0], r[0] === t.getTime(), r[1] === tu.getTime(), r[2] === tv.getTime(), t.getHours(), tu.getHours(), tv.getHours()); } +{ const t = new Date(d.getTime()); const tu: any = new Date(d.getTime()); const tv = new Date(d.getTime()); + const r = [t.setMilliseconds(1), tu.setMilliseconds(1), viaAny(tv, 21)]; + console.log("Date setMilliseconds", typeof r[0], r[0] === t.getTime(), r[1] === tu.getTime(), r[2] === tv.getTime(), t.getMilliseconds(), tu.getMilliseconds(), tv.getMilliseconds()); } +{ const t = new Date(d.getTime()); const tu: any = new Date(d.getTime()); const tv = new Date(d.getTime()); + const r = [t.setMinutes(1), tu.setMinutes(1), viaAny(tv, 22)]; + console.log("Date setMinutes", typeof r[0], r[0] === t.getTime(), r[1] === tu.getTime(), r[2] === tv.getTime(), t.getMinutes(), tu.getMinutes(), tv.getMinutes()); } +{ const t = new Date(d.getTime()); const tu: any = new Date(d.getTime()); const tv = new Date(d.getTime()); + const r = [t.setMonth(1), tu.setMonth(1), viaAny(tv, 23)]; + console.log("Date setMonth", typeof r[0], r[0] === t.getTime(), r[1] === tu.getTime(), r[2] === tv.getTime(), t.getMonth(), tu.getMonth(), tv.getMonth()); } +{ const t = new Date(d.getTime()); const tu: any = new Date(d.getTime()); const tv = new Date(d.getTime()); + const r = [t.setSeconds(1), tu.setSeconds(1), viaAny(tv, 24)]; + console.log("Date setSeconds", typeof r[0], r[0] === t.getTime(), r[1] === tu.getTime(), r[2] === tv.getTime(), t.getSeconds(), tu.getSeconds(), tv.getSeconds()); } +{ const t = new Date(d.getTime()); const tu: any = new Date(d.getTime()); const tv = new Date(d.getTime()); + console.log("Date setTime", t.setTime(1), t.getTime(), tu.setTime(1), tu.getTime(), viaAny(tv, 25), tv.getTime()); } +{ const t = new Date(d.getTime()); const tu: any = new Date(d.getTime()); const tv = new Date(d.getTime()); + console.log("Date setUTCDate", t.setUTCDate(1), t.getTime(), tu.setUTCDate(1), tu.getTime(), viaAny(tv, 26), tv.getTime()); } +{ const t = new Date(d.getTime()); const tu: any = new Date(d.getTime()); const tv = new Date(d.getTime()); + console.log("Date setUTCFullYear", t.setUTCFullYear(1), t.getTime(), tu.setUTCFullYear(1), tu.getTime(), viaAny(tv, 27), tv.getTime()); } +{ const t = new Date(d.getTime()); const tu: any = new Date(d.getTime()); const tv = new Date(d.getTime()); + console.log("Date setUTCHours", t.setUTCHours(1), t.getTime(), tu.setUTCHours(1), tu.getTime(), viaAny(tv, 28), tv.getTime()); } +{ const t = new Date(d.getTime()); const tu: any = new Date(d.getTime()); const tv = new Date(d.getTime()); + console.log("Date setUTCMilliseconds", t.setUTCMilliseconds(1), t.getTime(), tu.setUTCMilliseconds(1), tu.getTime(), viaAny(tv, 29), tv.getTime()); } +{ const t = new Date(d.getTime()); const tu: any = new Date(d.getTime()); const tv = new Date(d.getTime()); + console.log("Date setUTCMinutes", t.setUTCMinutes(1), t.getTime(), tu.setUTCMinutes(1), tu.getTime(), viaAny(tv, 30), tv.getTime()); } +{ const t = new Date(d.getTime()); const tu: any = new Date(d.getTime()); const tv = new Date(d.getTime()); + console.log("Date setUTCMonth", t.setUTCMonth(1), t.getTime(), tu.setUTCMonth(1), tu.getTime(), viaAny(tv, 31), tv.getTime()); } +{ const t = new Date(d.getTime()); const tu: any = new Date(d.getTime()); const tv = new Date(d.getTime()); + console.log("Date setUTCSeconds", t.setUTCSeconds(1), t.getTime(), tu.setUTCSeconds(1), tu.getTime(), viaAny(tv, 32), tv.getTime()); } +console.log("Date toISOString", d.toISOString(), du.toISOString(), viaAny(du, 33)); +console.log("Date toUTCString", d.toUTCString(), du.toUTCString(), viaAny(du, 36)); +console.log("Date toGMTString", d.toGMTString(), du.toGMTString(), viaAny(du, 37)); +console.log("Date toDateString", typeof d.toDateString(), du.toDateString() === d.toDateString(), viaAny(du, 34) === d.toDateString()); +console.log("Date toTimeString", typeof d.toTimeString(), du.toTimeString() === d.toTimeString(), viaAny(du, 35) === d.toTimeString()); +console.log("Date toLocaleString", typeof d.toLocaleString(), du.toLocaleString() === d.toLocaleString(), viaAny(du, 38) === d.toLocaleString()); +console.log("Date toLocaleDateString", typeof d.toLocaleDateString(), du.toLocaleDateString() === d.toLocaleDateString(), viaAny(du, 39) === d.toLocaleDateString()); +console.log("Date toLocaleTimeString", typeof d.toLocaleTimeString(), du.toLocaleTimeString() === d.toLocaleTimeString(), viaAny(du, 40) === d.toLocaleTimeString()); +// Zero-argument toLocaleString on primitives and symbols is Object.prototype's. +console.log("primitive toLocaleString", viaAny(true, 38), viaAny("abc", 38), viaAny(Symbol("s"), 38)); +const bad: any = new Date(NaN); +console.log("Invalid Date toISOString", attemptMessage(() => bad.toISOString()), attemptMessage(() => viaAny(bad, 33))); +const num = 3.14159; +const numu: any = 1234.5678; +console.log("number toFixed(2)", num.toFixed(2), numu.toFixed(2), viaAny(num, 41), viaAny(42, 41), viaAny(-0.5, 41)); +console.log("number toFixed()", num.toFixed(), numu.toFixed(), viaAny(num, 44), viaAny(42, 44), viaAny(-0.5, 44)); +console.log("number toPrecision(2)", num.toPrecision(2), numu.toPrecision(2), viaAny(num, 42), viaAny(42, 42), viaAny(-0.5, 42)); +console.log("number toExponential(2)", num.toExponential(2), numu.toExponential(2), viaAny(num, 43), viaAny(42, 43), viaAny(-0.5, 43)); +const arr = [3, 1, 2]; +const arru: any = [5, 4, 6]; +const nested = [1, [2, [3]]]; +const nestedu: any = [4, [5, [6]]]; +console.log("array toSorted()", show(arr.toSorted()), show(arru.toSorted()), show(viaAny(arr, 45)), show(arr)); +console.log("array toSorted(cmp)", show(arr.toSorted(cmp)), show(arru.toSorted(cmp)), show(viaAny(arr, 46)), show(arr)); +console.log("array toReversed()", show(arr.toReversed()), show(arru.toReversed()), show(viaAny(arr, 47)), show(arr)); +console.log("array reduceRight(add)", show(arr.reduceRight(add)), show(arru.reduceRight(add)), show(viaAny(arr, 48)), show(arr)); +console.log("array flat()", show(nested.flat()), show(nestedu.flat()), show(viaAny(nested, 49))); +console.log("array flatMap(dup)", show(arr.flatMap(dup)), show(arru.flatMap(dup)), show(viaAny(arr, 50)), show(arr)); +console.log("array toSpliced(1)", show(arr.toSpliced(1)), show(arru.toSpliced(1)), show(viaAny(arr, 51)), show(arr)); +console.log("array toSpliced(0, 1)", show(arr.toSpliced(0, 1)), show(arru.toSpliced(0, 1)), show(viaAny(arr, 52)), show(arr)); +console.log("array copyWithin(0, 1)", show([3, 1, 2].copyWithin(0, 1)), show(arru.slice().copyWithin(0, 1)), show(viaAny([7, 8, 9], 53))); +const str = "abc"; +const stru: any = "xundefined"; +console.log("string endsWith()", str.endsWith(), stru.endsWith(), viaAny(str, 54), viaAny(stru, 54)); +console.log("string endsWith('c')", str.endsWith('c'), stru.endsWith('c'), viaAny(str, 57), viaAny(stru, 57)); +console.log("string includes()", str.includes(), stru.includes(), viaAny(str, 55), viaAny(stru, 55)); +console.log("string includes('b')", str.includes('b'), stru.includes('b'), viaAny(str, 58), viaAny(stru, 58)); +console.log("string startsWith()", str.startsWith(), stru.startsWith(), viaAny(str, 56), viaAny(stru, 56)); +console.log("string startsWith('a')", str.startsWith('a'), stru.startsWith('a'), viaAny(str, 59), viaAny(stru, 59)); + +// One call site per name, the matching built-in and user objects in turn. +for (const m of [new Date(Date.UTC(2020, 0, 1)), f, g, lit, oc] as any[]) row("mixed getTime", () => viaAny(m, 8)); +for (const m of [new Date(0), f, g, lit, oc] as any[]) row("mixed toISOString", () => viaAny(m, 33)); +for (const m of [new Date(0), f, g, lit, oc] as any[]) row("mixed setUTCHours", () => viaAny(m, 28)); +for (const m of [12.345, f, g, lit, oc] as any[]) row("mixed toFixed", () => viaAny(m, 41)); +for (const m of [-7, f, g, lit, oc] as any[]) row("mixed toPrecision", () => viaAny(m, 42)); +for (const m of [[2, 1], f, g, lit, oc] as any[]) row("mixed toSorted", () => viaAny(m, 45)); +for (const m of [[[1], 2], f, g, lit, oc] as any[]) row("mixed flat", () => viaAny(m, 49)); +for (const m of ["abc", f, g, lit, oc] as any[]) row("mixed endsWith", () => viaAny(m, 54)); + +// A builtin-named user method may return anything; its result must not be +// treated as the builtin's string / number result. +const odd: any = { + toFixed(dp: number) { return 5 + dp; }, + toPrecision() { return { p: 1 }; }, + toExponential() { return null; }, + getTime() { return "t"; }, + toISOString() { return 42; }, +}; +console.log("odd", "a" + odd.toFixed(2), odd.toFixed(2) + 1, typeof odd.toFixed(2), JSON.stringify(odd.toPrecision(3))); +console.log("odd", "c" + odd.toExponential(1), odd.getTime() + 1, odd.toISOString() + 1, `${odd.toFixed(4)}!`); +let oddAcc = ""; +for (let i = 0; i < 3; i++) oddAcc += odd.toFixed(i); +console.log("odd loop", oddAcc); + +// Nullish receivers throw instead of reaching a builtin. +const nothing: any = null; +console.log("nullish", attemptMessage(() => nothing.getTime()), attemptMessage(() => nothing.toFixed(1))); diff --git a/test-files/test_gap_10476_builtin_named_user_methods.ts b/test-files/test_gap_10476_builtin_named_user_methods.ts new file mode 100644 index 0000000000..07b37e80b2 --- /dev/null +++ b/test-files/test_gap_10476_builtin_named_user_methods.ts @@ -0,0 +1,329 @@ +// #10476: a user method whose NAME matches a Date / Number / Array / String +// built-in must run on a receiver that is not that built-in. Pre-fix such a +// call was lowered to the built-in by name alone (NaN, "[object Object]", +// "Invalid time value", or a compile error for a zero-argument +// `endsWith`/`includes`/`startsWith`). This file covers class receivers: a +// class declares every name, directly and via `this`, typed and through `any`, +// plus `extends Date` subclasses with and without overrides. The sibling +// test_gap_10476_builtin_named_guarded_receivers.ts covers prototypes, object +// literals and `any` receivers in a module with no such class. +// Generated by a one-off script: one call site per (form, name, arity). + +const cmp = (a: number, b: number) => b - a; +const add = (acc: number, v: number) => acc + v; +const dup = (v: number) => [v, v]; + +function tag(form: string, name: string, args: any[]): string { + return form + "." + name + "/" + args.length; +} + +function show(v: any): string { + if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return String(v); + if (Array.isArray(v)) return "[" + v.join(",") + "]"; + return v === null ? "null" : typeof v; +} + +// Error messages for a missing method differ between engines; the name is +// what matters here. +function attempt(f: () => any): string { + try { + return show(f()); + } catch (e) { + return "THREW " + (e as Error).name; + } +} + +function attemptMessage(f: () => any): string { + try { + return show(f()); + } catch (e) { + return "THREW " + String(e); + } +} + +function row(label: string, ...fs: Array<() => any>): void { + console.log(label, fs.map(attempt).join(" ")); +} + +function viaAny(x: any, id: number): any { + switch (id) { + case 0: return x.getDate(); + case 1: return x.getDay(); + case 2: return x.getFullYear(); + case 3: return x.getHours(); + case 4: return x.getMilliseconds(); + case 5: return x.getMinutes(); + case 6: return x.getMonth(); + case 7: return x.getSeconds(); + case 8: return x.getTime(); + case 9: return x.getTimezoneOffset(); + case 10: return x.getUTCDate(); + case 11: return x.getUTCDay(); + case 12: return x.getUTCFullYear(); + case 13: return x.getUTCHours(); + case 14: return x.getUTCMilliseconds(); + case 15: return x.getUTCMinutes(); + case 16: return x.getUTCMonth(); + case 17: return x.getUTCSeconds(); + case 18: return x.setDate(1); + case 19: return x.setFullYear(1); + case 20: return x.setHours(1); + case 21: return x.setMilliseconds(1); + case 22: return x.setMinutes(1); + case 23: return x.setMonth(1); + case 24: return x.setSeconds(1); + case 25: return x.setTime(1); + case 26: return x.setUTCDate(1); + case 27: return x.setUTCFullYear(1); + case 28: return x.setUTCHours(1); + case 29: return x.setUTCMilliseconds(1); + case 30: return x.setUTCMinutes(1); + case 31: return x.setUTCMonth(1); + case 32: return x.setUTCSeconds(1); + case 33: return x.toISOString(); + case 34: return x.toDateString(); + case 35: return x.toTimeString(); + case 36: return x.toUTCString(); + case 37: return x.toGMTString(); + case 38: return x.toLocaleString(); + case 39: return x.toLocaleDateString(); + case 40: return x.toLocaleTimeString(); + case 41: return x.toFixed(2); + case 42: return x.toPrecision(2); + case 43: return x.toExponential(2); + case 44: return x.toFixed(); + case 45: return x.toSorted(); + case 46: return x.toSorted(cmp); + case 47: return x.toReversed(); + case 48: return x.reduceRight(add); + case 49: return x.flat(); + case 50: return x.flatMap(dup); + case 51: return x.toSpliced(1); + case 52: return x.toSpliced(0, 1); + case 53: return x.copyWithin(0, 1); + case 54: return x.endsWith(); + case 55: return x.includes(); + case 56: return x.startsWith(); + case 57: return x.endsWith('c'); + case 58: return x.includes('b'); + case 59: return x.startsWith('a'); + } +} + +class K { + getDate(...a: any[]): any { return tag("K", "getDate", a); } + getDay(...a: any[]): any { return tag("K", "getDay", a); } + getFullYear(...a: any[]): any { return tag("K", "getFullYear", a); } + getHours(...a: any[]): any { return tag("K", "getHours", a); } + getMilliseconds(...a: any[]): any { return tag("K", "getMilliseconds", a); } + getMinutes(...a: any[]): any { return tag("K", "getMinutes", a); } + getMonth(...a: any[]): any { return tag("K", "getMonth", a); } + getSeconds(...a: any[]): any { return tag("K", "getSeconds", a); } + getTime(...a: any[]): any { return tag("K", "getTime", a); } + getTimezoneOffset(...a: any[]): any { return tag("K", "getTimezoneOffset", a); } + getUTCDate(...a: any[]): any { return tag("K", "getUTCDate", a); } + getUTCDay(...a: any[]): any { return tag("K", "getUTCDay", a); } + getUTCFullYear(...a: any[]): any { return tag("K", "getUTCFullYear", a); } + getUTCHours(...a: any[]): any { return tag("K", "getUTCHours", a); } + getUTCMilliseconds(...a: any[]): any { return tag("K", "getUTCMilliseconds", a); } + getUTCMinutes(...a: any[]): any { return tag("K", "getUTCMinutes", a); } + getUTCMonth(...a: any[]): any { return tag("K", "getUTCMonth", a); } + getUTCSeconds(...a: any[]): any { return tag("K", "getUTCSeconds", a); } + setDate(...a: any[]): any { return tag("K", "setDate", a); } + setFullYear(...a: any[]): any { return tag("K", "setFullYear", a); } + setHours(...a: any[]): any { return tag("K", "setHours", a); } + setMilliseconds(...a: any[]): any { return tag("K", "setMilliseconds", a); } + setMinutes(...a: any[]): any { return tag("K", "setMinutes", a); } + setMonth(...a: any[]): any { return tag("K", "setMonth", a); } + setSeconds(...a: any[]): any { return tag("K", "setSeconds", a); } + setTime(...a: any[]): any { return tag("K", "setTime", a); } + setUTCDate(...a: any[]): any { return tag("K", "setUTCDate", a); } + setUTCFullYear(...a: any[]): any { return tag("K", "setUTCFullYear", a); } + setUTCHours(...a: any[]): any { return tag("K", "setUTCHours", a); } + setUTCMilliseconds(...a: any[]): any { return tag("K", "setUTCMilliseconds", a); } + setUTCMinutes(...a: any[]): any { return tag("K", "setUTCMinutes", a); } + setUTCMonth(...a: any[]): any { return tag("K", "setUTCMonth", a); } + setUTCSeconds(...a: any[]): any { return tag("K", "setUTCSeconds", a); } + toISOString(...a: any[]): any { return tag("K", "toISOString", a); } + toDateString(...a: any[]): any { return tag("K", "toDateString", a); } + toTimeString(...a: any[]): any { return tag("K", "toTimeString", a); } + toUTCString(...a: any[]): any { return tag("K", "toUTCString", a); } + toGMTString(...a: any[]): any { return tag("K", "toGMTString", a); } + toLocaleString(...a: any[]): any { return tag("K", "toLocaleString", a); } + toLocaleDateString(...a: any[]): any { return tag("K", "toLocaleDateString", a); } + toLocaleTimeString(...a: any[]): any { return tag("K", "toLocaleTimeString", a); } + toFixed(...a: any[]): any { return tag("K", "toFixed", a); } + toPrecision(...a: any[]): any { return tag("K", "toPrecision", a); } + toExponential(...a: any[]): any { return tag("K", "toExponential", a); } + toSorted(...a: any[]): any { return tag("K", "toSorted", a); } + toReversed(...a: any[]): any { return tag("K", "toReversed", a); } + reduceRight(...a: any[]): any { return tag("K", "reduceRight", a); } + flat(...a: any[]): any { return tag("K", "flat", a); } + flatMap(...a: any[]): any { return tag("K", "flatMap", a); } + toSpliced(...a: any[]): any { return tag("K", "toSpliced", a); } + copyWithin(...a: any[]): any { return tag("K", "copyWithin", a); } + endsWith(...a: any[]): any { return tag("K", "endsWith", a); } + includes(...a: any[]): any { return tag("K", "includes", a); } + startsWith(...a: any[]): any { return tag("K", "startsWith", a); } + viaThis(id: number): any { + switch (id) { + case 0: return this.getDate(); + case 1: return this.getDay(); + case 2: return this.getFullYear(); + case 3: return this.getHours(); + case 4: return this.getMilliseconds(); + case 5: return this.getMinutes(); + case 6: return this.getMonth(); + case 7: return this.getSeconds(); + case 8: return this.getTime(); + case 9: return this.getTimezoneOffset(); + case 10: return this.getUTCDate(); + case 11: return this.getUTCDay(); + case 12: return this.getUTCFullYear(); + case 13: return this.getUTCHours(); + case 14: return this.getUTCMilliseconds(); + case 15: return this.getUTCMinutes(); + case 16: return this.getUTCMonth(); + case 17: return this.getUTCSeconds(); + case 18: return this.setDate(1); + case 19: return this.setFullYear(1); + case 20: return this.setHours(1); + case 21: return this.setMilliseconds(1); + case 22: return this.setMinutes(1); + case 23: return this.setMonth(1); + case 24: return this.setSeconds(1); + case 25: return this.setTime(1); + case 26: return this.setUTCDate(1); + case 27: return this.setUTCFullYear(1); + case 28: return this.setUTCHours(1); + case 29: return this.setUTCMilliseconds(1); + case 30: return this.setUTCMinutes(1); + case 31: return this.setUTCMonth(1); + case 32: return this.setUTCSeconds(1); + case 33: return this.toISOString(); + case 34: return this.toDateString(); + case 35: return this.toTimeString(); + case 36: return this.toUTCString(); + case 37: return this.toGMTString(); + case 38: return this.toLocaleString(); + case 39: return this.toLocaleDateString(); + case 40: return this.toLocaleTimeString(); + case 41: return this.toFixed(2); + case 42: return this.toPrecision(2); + case 43: return this.toExponential(2); + case 44: return this.toFixed(); + case 45: return this.toSorted(); + case 46: return this.toSorted(cmp); + case 47: return this.toReversed(); + case 48: return this.reduceRight(add); + case 49: return this.flat(); + case 50: return this.flatMap(dup); + case 51: return this.toSpliced(1); + case 52: return this.toSpliced(0, 1); + case 53: return this.copyWithin(0, 1); + case 54: return this.endsWith(); + case 55: return this.includes(); + case 56: return this.startsWith(); + case 57: return this.endsWith('c'); + case 58: return this.includes('b'); + case 59: return this.startsWith('a'); + } + } +} + +class Sub extends K {} + +const k = new K(); +const sub = new Sub(); +const kAny: any = new K(); + +row("getDate()", () => k.getDate(), () => k.viaThis(0), () => sub.getDate(), () => kAny.getDate(), () => viaAny(k, 0)); +row("getDay()", () => k.getDay(), () => k.viaThis(1), () => sub.getDay(), () => kAny.getDay(), () => viaAny(k, 1)); +row("getFullYear()", () => k.getFullYear(), () => k.viaThis(2), () => sub.getFullYear(), () => kAny.getFullYear(), () => viaAny(k, 2)); +row("getHours()", () => k.getHours(), () => k.viaThis(3), () => sub.getHours(), () => kAny.getHours(), () => viaAny(k, 3)); +row("getMilliseconds()", () => k.getMilliseconds(), () => k.viaThis(4), () => sub.getMilliseconds(), () => kAny.getMilliseconds(), () => viaAny(k, 4)); +row("getMinutes()", () => k.getMinutes(), () => k.viaThis(5), () => sub.getMinutes(), () => kAny.getMinutes(), () => viaAny(k, 5)); +row("getMonth()", () => k.getMonth(), () => k.viaThis(6), () => sub.getMonth(), () => kAny.getMonth(), () => viaAny(k, 6)); +row("getSeconds()", () => k.getSeconds(), () => k.viaThis(7), () => sub.getSeconds(), () => kAny.getSeconds(), () => viaAny(k, 7)); +row("getTime()", () => k.getTime(), () => k.viaThis(8), () => sub.getTime(), () => kAny.getTime(), () => viaAny(k, 8)); +row("getTimezoneOffset()", () => k.getTimezoneOffset(), () => k.viaThis(9), () => sub.getTimezoneOffset(), () => kAny.getTimezoneOffset(), () => viaAny(k, 9)); +row("getUTCDate()", () => k.getUTCDate(), () => k.viaThis(10), () => sub.getUTCDate(), () => kAny.getUTCDate(), () => viaAny(k, 10)); +row("getUTCDay()", () => k.getUTCDay(), () => k.viaThis(11), () => sub.getUTCDay(), () => kAny.getUTCDay(), () => viaAny(k, 11)); +row("getUTCFullYear()", () => k.getUTCFullYear(), () => k.viaThis(12), () => sub.getUTCFullYear(), () => kAny.getUTCFullYear(), () => viaAny(k, 12)); +row("getUTCHours()", () => k.getUTCHours(), () => k.viaThis(13), () => sub.getUTCHours(), () => kAny.getUTCHours(), () => viaAny(k, 13)); +row("getUTCMilliseconds()", () => k.getUTCMilliseconds(), () => k.viaThis(14), () => sub.getUTCMilliseconds(), () => kAny.getUTCMilliseconds(), () => viaAny(k, 14)); +row("getUTCMinutes()", () => k.getUTCMinutes(), () => k.viaThis(15), () => sub.getUTCMinutes(), () => kAny.getUTCMinutes(), () => viaAny(k, 15)); +row("getUTCMonth()", () => k.getUTCMonth(), () => k.viaThis(16), () => sub.getUTCMonth(), () => kAny.getUTCMonth(), () => viaAny(k, 16)); +row("getUTCSeconds()", () => k.getUTCSeconds(), () => k.viaThis(17), () => sub.getUTCSeconds(), () => kAny.getUTCSeconds(), () => viaAny(k, 17)); +row("setDate(1)", () => k.setDate(1), () => k.viaThis(18), () => sub.setDate(1), () => kAny.setDate(1), () => viaAny(k, 18)); +row("setFullYear(1)", () => k.setFullYear(1), () => k.viaThis(19), () => sub.setFullYear(1), () => kAny.setFullYear(1), () => viaAny(k, 19)); +row("setHours(1)", () => k.setHours(1), () => k.viaThis(20), () => sub.setHours(1), () => kAny.setHours(1), () => viaAny(k, 20)); +row("setMilliseconds(1)", () => k.setMilliseconds(1), () => k.viaThis(21), () => sub.setMilliseconds(1), () => kAny.setMilliseconds(1), () => viaAny(k, 21)); +row("setMinutes(1)", () => k.setMinutes(1), () => k.viaThis(22), () => sub.setMinutes(1), () => kAny.setMinutes(1), () => viaAny(k, 22)); +row("setMonth(1)", () => k.setMonth(1), () => k.viaThis(23), () => sub.setMonth(1), () => kAny.setMonth(1), () => viaAny(k, 23)); +row("setSeconds(1)", () => k.setSeconds(1), () => k.viaThis(24), () => sub.setSeconds(1), () => kAny.setSeconds(1), () => viaAny(k, 24)); +row("setTime(1)", () => k.setTime(1), () => k.viaThis(25), () => sub.setTime(1), () => kAny.setTime(1), () => viaAny(k, 25)); +row("setUTCDate(1)", () => k.setUTCDate(1), () => k.viaThis(26), () => sub.setUTCDate(1), () => kAny.setUTCDate(1), () => viaAny(k, 26)); +row("setUTCFullYear(1)", () => k.setUTCFullYear(1), () => k.viaThis(27), () => sub.setUTCFullYear(1), () => kAny.setUTCFullYear(1), () => viaAny(k, 27)); +row("setUTCHours(1)", () => k.setUTCHours(1), () => k.viaThis(28), () => sub.setUTCHours(1), () => kAny.setUTCHours(1), () => viaAny(k, 28)); +row("setUTCMilliseconds(1)", () => k.setUTCMilliseconds(1), () => k.viaThis(29), () => sub.setUTCMilliseconds(1), () => kAny.setUTCMilliseconds(1), () => viaAny(k, 29)); +row("setUTCMinutes(1)", () => k.setUTCMinutes(1), () => k.viaThis(30), () => sub.setUTCMinutes(1), () => kAny.setUTCMinutes(1), () => viaAny(k, 30)); +row("setUTCMonth(1)", () => k.setUTCMonth(1), () => k.viaThis(31), () => sub.setUTCMonth(1), () => kAny.setUTCMonth(1), () => viaAny(k, 31)); +row("setUTCSeconds(1)", () => k.setUTCSeconds(1), () => k.viaThis(32), () => sub.setUTCSeconds(1), () => kAny.setUTCSeconds(1), () => viaAny(k, 32)); +row("toISOString()", () => k.toISOString(), () => k.viaThis(33), () => sub.toISOString(), () => kAny.toISOString(), () => viaAny(k, 33)); +row("toDateString()", () => k.toDateString(), () => k.viaThis(34), () => sub.toDateString(), () => kAny.toDateString(), () => viaAny(k, 34)); +row("toTimeString()", () => k.toTimeString(), () => k.viaThis(35), () => sub.toTimeString(), () => kAny.toTimeString(), () => viaAny(k, 35)); +row("toUTCString()", () => k.toUTCString(), () => k.viaThis(36), () => sub.toUTCString(), () => kAny.toUTCString(), () => viaAny(k, 36)); +row("toGMTString()", () => k.toGMTString(), () => k.viaThis(37), () => sub.toGMTString(), () => kAny.toGMTString(), () => viaAny(k, 37)); +row("toLocaleString()", () => k.toLocaleString(), () => k.viaThis(38), () => sub.toLocaleString(), () => kAny.toLocaleString(), () => viaAny(k, 38)); +row("toLocaleDateString()", () => k.toLocaleDateString(), () => k.viaThis(39), () => sub.toLocaleDateString(), () => kAny.toLocaleDateString(), () => viaAny(k, 39)); +row("toLocaleTimeString()", () => k.toLocaleTimeString(), () => k.viaThis(40), () => sub.toLocaleTimeString(), () => kAny.toLocaleTimeString(), () => viaAny(k, 40)); +row("toFixed(2)", () => k.toFixed(2), () => k.viaThis(41), () => sub.toFixed(2), () => kAny.toFixed(2), () => viaAny(k, 41)); +row("toPrecision(2)", () => k.toPrecision(2), () => k.viaThis(42), () => sub.toPrecision(2), () => kAny.toPrecision(2), () => viaAny(k, 42)); +row("toExponential(2)", () => k.toExponential(2), () => k.viaThis(43), () => sub.toExponential(2), () => kAny.toExponential(2), () => viaAny(k, 43)); +row("toFixed()", () => k.toFixed(), () => k.viaThis(44), () => sub.toFixed(), () => kAny.toFixed(), () => viaAny(k, 44)); +row("toSorted()", () => k.toSorted(), () => k.viaThis(45), () => sub.toSorted(), () => kAny.toSorted(), () => viaAny(k, 45)); +row("toSorted(cmp)", () => k.toSorted(cmp), () => k.viaThis(46), () => sub.toSorted(cmp), () => kAny.toSorted(cmp), () => viaAny(k, 46)); +row("toReversed()", () => k.toReversed(), () => k.viaThis(47), () => sub.toReversed(), () => kAny.toReversed(), () => viaAny(k, 47)); +row("reduceRight(add)", () => k.reduceRight(add), () => k.viaThis(48), () => sub.reduceRight(add), () => kAny.reduceRight(add), () => viaAny(k, 48)); +row("flat()", () => k.flat(), () => k.viaThis(49), () => sub.flat(), () => kAny.flat(), () => viaAny(k, 49)); +row("flatMap(dup)", () => k.flatMap(dup), () => k.viaThis(50), () => sub.flatMap(dup), () => kAny.flatMap(dup), () => viaAny(k, 50)); +row("toSpliced(1)", () => k.toSpliced(1), () => k.viaThis(51), () => sub.toSpliced(1), () => kAny.toSpliced(1), () => viaAny(k, 51)); +row("toSpliced(0, 1)", () => k.toSpliced(0, 1), () => k.viaThis(52), () => sub.toSpliced(0, 1), () => kAny.toSpliced(0, 1), () => viaAny(k, 52)); +row("copyWithin(0, 1)", () => k.copyWithin(0, 1), () => k.viaThis(53), () => sub.copyWithin(0, 1), () => kAny.copyWithin(0, 1), () => viaAny(k, 53)); +row("endsWith()", () => k.endsWith(), () => k.viaThis(54), () => sub.endsWith(), () => kAny.endsWith(), () => viaAny(k, 54)); +row("includes()", () => k.includes(), () => k.viaThis(55), () => sub.includes(), () => kAny.includes(), () => viaAny(k, 55)); +row("startsWith()", () => k.startsWith(), () => k.viaThis(56), () => sub.startsWith(), () => kAny.startsWith(), () => viaAny(k, 56)); +row("endsWith('c')", () => k.endsWith('c'), () => k.viaThis(57), () => sub.endsWith('c'), () => kAny.endsWith('c'), () => viaAny(k, 57)); +row("includes('b')", () => k.includes('b'), () => k.viaThis(58), () => sub.includes('b'), () => kAny.includes('b'), () => viaAny(k, 58)); +row("startsWith('a')", () => k.startsWith('a'), () => k.viaThis(59), () => sub.startsWith('a'), () => kAny.startsWith('a'), () => viaAny(k, 59)); + +// A Date subclass is a Date at runtime; its own overrides must still win. +class OverDate extends Date { + getTime(): any { return "OverDate.getTime"; } + toISOString(): any { return "OverDate.toISOString"; } + setUTCHours(h: number): any { return "OverDate.setUTCHours/" + h; } + toFixed(d: number): any { return "OverDate.toFixed/" + d; } +} +class PlainDate extends Date {} +const od = new OverDate(86400000); +const odAny: any = new OverDate(86400000); +const pd = new PlainDate(86400000); +const pdAny: any = new PlainDate(86400000); +row("OverDate", () => od.getTime(), () => od.toISOString(), () => od.setUTCHours(3), () => od.getUTCDate(), () => od.toFixed(1)); +row("OverDate any", () => odAny.getTime(), () => odAny.toISOString(), () => odAny.setUTCHours(3), () => odAny.getUTCDate()); +row("PlainDate", () => pd.getTime(), () => pd.toISOString(), () => pd.getUTCDate(), () => pdAny.getTime(), () => pdAny.toISOString(), () => viaAny(pdAny, 33)); + +// Real built-ins through `any` in a module whose classes declare the names. +const du: any = new Date(Date.UTC(2024, 5, 15, 10, 20, 30, 456)); +const nu: any = 1234.5678; +const au: any = [5, 4, 6]; +const su: any = "xundefined"; +row("builtins any", () => du.getTime(), () => du.toISOString(), () => du.getUTCHours(), () => nu.toFixed(2), () => nu.toPrecision(3), () => au.toSorted(), () => au.toReversed(), () => au.reduceRight(add), () => su.endsWith(), () => su.includes("und")); +{ const t: any = new Date(0); row("builtins any setter", () => t.setUTCHours(5), () => t.toISOString(), () => viaAny(t, 30)); } +// One call site per name, the matching built-in and class instances in turn. +for (const m of [new Date(0), k, sub] as any[]) row("mixed getTime", () => viaAny(m, 8)); +for (const m of [pd, k, sub] as any[]) row("mixed toISOString", () => viaAny(m, 33)); +for (const m of [7, k, sub] as any[]) row("mixed toFixed", () => viaAny(m, 41)); +for (const m of [[3, 1, 2], k, sub] as any[]) row("mixed toSorted", () => viaAny(m, 45)); +for (const m of ["xundefined", k, sub] as any[]) row("mixed endsWith", () => viaAny(m, 54)); diff --git a/test-files/test_gap_10477_instanceof_imported_function_ctor.ts b/test-files/test_gap_10477_instanceof_imported_function_ctor.ts new file mode 100644 index 0000000000..bd4e14275c --- /dev/null +++ b/test-files/test_gap_10477_instanceof_imported_function_ctor.ts @@ -0,0 +1,117 @@ +// #10477: `x instanceof F` was always false when `F` is an IMPORTED non-class +// constructor function, for every import form. HIR only routed a bare +// identifier RHS through the dynamic `js_instanceof_dynamic` path when it was a +// local / module function / native module; an imported binding fell to the +// static class-id path, which has no id for a function and folded to `false`. +// `ns.F`, a local alias, and the check inside the defining module all worked. +// Imported classes are controls: they keep the static class-id check. + +import { + Plain, + Swapped, + Klass, + SubKlass, + ExprKlass, + Base, + Inherited, + Linked, + Duck, + Headers, + EventEmitter, + Stream, + MadeConst, + Rebound, + rebind, + notCallable, + makePlain, + makeSwapped, + isPlainHere, +} from "./fixtures/issue_10477_fn_ctor/lib.ts"; +import { Plain as RenamedPlain, Klass as RenamedKlass } from "./fixtures/issue_10477_fn_ctor/lib.ts"; +import * as ns from "./fixtures/issue_10477_fn_ctor/lib.ts"; +import DefaultFn from "./fixtures/issue_10477_fn_ctor/default_fn.ts"; +import D from "./fixtures/issue_10477_fn_ctor/default_var.ts"; +import CjsCtor from "./fixtures/issue_10477_fn_ctor/cjs_default.cjs"; +import { Named } from "./fixtures/issue_10477_fn_ctor/cjs_named.cjs"; + +const show = (label: string, value: unknown) => console.log(label, value); +const P: any = Plain; +const S: any = Swapped; + +// Prototype untouched. +show("plain importer-made", new P(1) instanceof Plain); +show("plain definer-made", makePlain(1) instanceof Plain); +show("plain renamed import", new P(1) instanceof RenamedPlain); +show("plain in defining module", isPlainHere(new P(1))); +show("plain via namespace", new P(1) instanceof ns.Plain); +const Alias = Plain; +show("plain via local alias", new P(1) instanceof Alias); +show("plain Object.create", Object.create(P.prototype) instanceof Plain); +show("plain method", new P(7).get()); +show("plain vs {}", ({}) instanceof Plain); +show("plain vs number", (1 as any) instanceof Plain); +show("plain vs null", (null as any) instanceof Plain); +show("plain vs Swapped", new P(1) instanceof Swapped); + +// Prototype replaced. +show("swapped importer-made", new S(1) instanceof Swapped); +show("swapped definer-made", makeSwapped(1) instanceof Swapped); +show("swapped Object.create", Object.create(S.prototype) instanceof Swapped); +show("swapped vs Plain", new S(1) instanceof Plain); + +// Class controls. +show("klass", new Klass(1) instanceof Klass); +show("klass renamed import", new Klass(1) instanceof RenamedKlass); +show("subklass is klass", new SubKlass(1) instanceof Klass); +show("klass vs subklass", new Klass(1) instanceof SubKlass); +show("class expression", new ExprKlass() instanceof ExprKlass); +show("plain vs klass", new P(1) instanceof Klass); +show("klass vs plain", new Klass(1) instanceof Plain); + +// ES5 inheritance. +const I: any = Inherited; +const L: any = Linked; +show("util.inherits child", new I() instanceof Inherited); +show("util.inherits base", new I() instanceof Base); +show("setPrototypeOf child", new L() instanceof Linked); +show("setPrototypeOf base", new L() instanceof Base); +show("base vs child", new (Base as any)() instanceof Inherited); + +// Symbol.hasInstance override. +show("hasInstance yes", ({ quack: true }) instanceof Duck); +show("hasInstance no", ({ quack: false }) instanceof Duck); + +// Names that collide with a builtin the static path maps to a reserved class +// id. (The instances are built through a local alias: `new (Headers as any)()` +// still routes to the BUILTIN constructor, which is a separate defect.) +const UserHeaders: any = Headers; +const UserEmitter: any = EventEmitter; +const UserStream: any = Stream; +show("user Headers", new UserHeaders() instanceof Headers); +show("user EventEmitter", new UserEmitter() instanceof EventEmitter); +show("user Stream", new UserStream() instanceof Stream); +show("{} vs user Headers", ({}) instanceof Headers); + +// Other import forms. +show("factory const", MadeConst(1) instanceof MadeConst); +show("factory const new", new MadeConst(2) instanceof MadeConst); +show("export default function", new (DefaultFn as any)(1) instanceof DefaultFn); +show("export default var", D(1) instanceof D); +show("export default var new", new D(2) instanceof D); +show("cjs module.exports", new (CjsCtor as any)(1) instanceof CjsCtor); +show("cjs exports.Named", new (Named as any)(1) instanceof Named); +show("cjs vs Plain", new (CjsCtor as any)(1) instanceof Plain); + +// Live binding: the check reads the binding's current value. +const first = new Rebound(); +show("rebound before", first instanceof Rebound); +rebind(); +show("rebound after", first instanceof Rebound); +show("rebound new", new Rebound() instanceof Rebound); + +// A non-callable import is a TypeError, not a silent false. +try { + show("non-callable", ({}) instanceof notCallable); +} catch (e) { + show("non-callable throws", (e as Error).constructor.name); +} diff --git a/test-files/test_gap_10478_object_create_constructor.ts b/test-files/test_gap_10478_object_create_constructor.ts new file mode 100644 index 0000000000..10ef7b0823 --- /dev/null +++ b/test-files/test_gap_10478_object_create_constructor.ts @@ -0,0 +1,191 @@ +// #10478: `Object.create(proto).constructor` must be the inherited +// `proto.constructor`. Perry stamps an `Object.create` result with a synthetic +// class id that only indexes its prototype object, and the `constructor` +// synthesis minted an INT32 class ref for that synthetic id instead of reading +// the chain: the value was unequal to `Object` / `A`, printed as +// `[object Function]`, had no name, and `C instanceof C` segfaulted. lodash's +// `_.isEqual(_.cloneDeep(x), x)` (baseCreate + equalObjects) hit exactly that. + +const rt = (v: T): T => JSON.parse(JSON.stringify(v)); + +class A { + x = 1; + m() { + return "A.m"; + } +} +class B extends A { + y = 2; +} +function F(this: any) { + this.f = 1; +} +F.prototype.hello = function () { + return "F.hello"; +}; +function G(this: any) {} +G.prototype = { g: 1 }; + +const describe = (v: any): string => { + if (typeof v === "function") return `function ${v.name}`; + return String(v); +}; + +// --- issue repro ------------------------------------------------------------------ +const o: any = Object.create(Object.prototype); +const a: any = Object.create(A.prototype); +console.log("Object.create(Object.prototype).constructor === Object:", o.constructor === Object); +console.log("Object.create(A.prototype).constructor === A:", a.constructor === A); +console.log("Object.create({}).constructor === Object:", Object.create({}).constructor === Object); +console.log("({}).constructor === Object:", ({} as any).constructor === Object); +console.log("Object.getPrototypeOf(o).constructor === Object:", Object.getPrototypeOf(o).constructor === Object); +console.log("o.constructor.name:", o.constructor.name); +console.log("o.constructor instanceof Object:", o.constructor instanceof Object); +const C = o.constructor; +console.log("C instanceof C:", C instanceof C); + +// --- prototype kinds ---------------------------------------------------------------- +const key = rt("constructor"); +const protos: [string, () => any, any][] = [ + ["Object.prototype", () => Object.prototype, Object], + ["{}", () => ({}), Object], + ["{a:1}", () => ({ a: 1 }), Object], + ["JSON object", () => rt({ a: 1 }), Object], + ["A.prototype", () => A.prototype, A], + ["B.prototype", () => B.prototype, B], + ["F.prototype", () => F.prototype, F], + ["G.prototype (replaced)", () => G.prototype, Object], + ["Array.prototype", () => Array.prototype, Array], + ["Map.prototype", () => Map.prototype, Map], + ["Date.prototype", () => Date.prototype, Date], + ["new A()", () => new A(), A], + ["new B()", () => new B(), B], + ["Object.create(A.prototype)", () => Object.create(A.prototype), A], + ["{constructor: F}", () => ({ constructor: F }), F], + ["Object.create(null)", () => Object.create(null), undefined], + ["Object.create(Object.create(null))", () => Object.create(Object.create(null)), undefined], +]; +for (const [name, make, expected] of protos) { + const obj = Object.create(make()); + const viaDot = obj.constructor; + console.log( + `Object.create(${name}):`, + describe(viaDot), + viaDot === expected, + obj["constructor"] === expected, + obj[key] === expected, + "constructor" in obj, + Object.getOwnPropertyNames(obj).length, + ); +} + +// Methods still resolve through the same chain. +console.log("create(A.prototype).m():", Object.create(A.prototype).m()); +console.log("create(B.prototype).m():", Object.create(B.prototype).m()); +console.log("create(F.prototype).hello():", Object.create(F.prototype).hello()); +console.log("new (create(A.prototype).constructor)() instanceof A:", new (Object.create(A.prototype).constructor)() instanceof A); +console.log("create(A.prototype) instanceof A:", Object.create(A.prototype) instanceof A); +console.log("create(B.prototype) instanceof A:", Object.create(B.prototype) instanceof A); + +// Constructed instances are unaffected. +console.log("new A().constructor === A:", new A().constructor === A); +console.log("new B().constructor === B:", new B().constructor === B); +console.log("new F().constructor === F:", new (F as any)().constructor === F); +console.log("new G().constructor === Object:", new (G as any)().constructor === Object); + +// An own `constructor` on the created object still wins. +const own: any = Object.create(A.prototype); +own.constructor = F; +console.log("own constructor wins:", own.constructor === F); + +// --- the prototype link itself stays authoritative ------------------------------------ +// `Object.getPrototypeOf` must keep answering the exact object passed to +// `Object.create`, and an inherited accessor / non-writable slot must keep +// resolving through it (both are reached from the same class-id link that now +// wins over the `constructor`-derived guess). +const accessorProto: any = {}; +let setterSum = 0; +Object.defineProperty(accessorProto, "acc", { + get() { + return 41; + }, + set(v: number) { + setterSum += v; + }, +}); +Object.defineProperty(accessorProto, "frozenField", { value: "proto", writable: false }); +const viaCreate: any = Object.create(accessorProto); +console.log("getPrototypeOf identity:", Object.getPrototypeOf(viaCreate) === accessorProto); +console.log("inherited getter:", viaCreate.acc); +viaCreate.acc = 1; +viaCreate.acc = 2; +console.log("inherited setter:", setterSum, "own acc:", Object.getOwnPropertyNames(viaCreate).length); +try { + viaCreate.frozenField = "written"; + console.log("inherited non-writable: silent", viaCreate.frozenField); +} catch (e: any) { + console.log(`inherited non-writable: ${e.constructor.name}`, viaCreate.frozenField); +} +const fnInstance: any = new (F as any)(); +console.log("getPrototypeOf(new F()) === F.prototype:", Object.getPrototypeOf(fnInstance) === F.prototype); +console.log("getPrototypeOf(new G()) === G.prototype:", Object.getPrototypeOf(new (G as any)()) === G.prototype); +console.log("getPrototypeOf(new A()) === A.prototype:", Object.getPrototypeOf(new A()) === A.prototype); +function H(this: any) {} +(H as any).prototype = { late: 1 }; +// (An instance created BEFORE the reassignment keeps the old prototype in Node; +// Perry's per-class-id prototype link re-points it. Pre-existing divergence, +// unrelated to this fix, so only the post-reassignment instance is asserted.) +console.log("reassigned prototype:", Object.getPrototypeOf(new (H as any)()) === (H as any).prototype, new (H as any)().late); + +// --- lodash-style checks --------------------------------------------------------------- +// baseCreate: `Object.create(Object.getPrototypeOf(value))`. +const baseCreate = (value: any) => Object.create(Object.getPrototypeOf(value)); +// equalObjects' constructor gate (lodash.js 4.18.1). +function constructorsDiffer(object: any, other: any): boolean { + const objCtor = object.constructor; + const othCtor = other.constructor; + return ( + objCtor != othCtor && + "constructor" in object && + "constructor" in other && + !( + typeof objCtor == "function" && + objCtor instanceof objCtor && + typeof othCtor == "function" && + othCtor instanceof othCtor + ) + ); +} +const samples: [string, any][] = [ + ["plain", { a: 1, b: [1, 2] }], + ["json", rt({ a: 1 })], + ["class A", new A()], + ["class B", new B()], + ["function F", new (F as any)()], +]; +for (const [name, value] of samples) { + const clone = baseCreate(value); + Object.assign(clone, value); + console.log( + `baseCreate(${name}):`, + clone.constructor === value.constructor, + constructorsDiffer(clone, value), + Object.getPrototypeOf(clone) === Object.getPrototypeOf(value), + ); +} +const ctor = baseCreate({}).constructor; +console.log("typeof ctor:", typeof ctor, "ctor instanceof ctor:", ctor instanceof ctor); +const isPlainObjectLike = (v: any) => { + const proto = Object.getPrototypeOf(v); + if (proto === null) return true; + const Ctor = Object.prototype.hasOwnProperty.call(proto, "constructor") && proto.constructor; + return typeof Ctor == "function" && Ctor instanceof Ctor && Ctor === Object; +}; +console.log( + "isPlainObjectLike:", + isPlainObjectLike({}), + isPlainObjectLike(baseCreate({})), + isPlainObjectLike(Object.create(null)), + isPlainObjectLike(new A()), + isPlainObjectLike(Object.create(A.prototype)), +); diff --git a/test-files/test_gap_10479_instanceof_value_kinds.ts b/test-files/test_gap_10479_instanceof_value_kinds.ts new file mode 100644 index 0000000000..e19c9b2f20 --- /dev/null +++ b/test-files/test_gap_10479_instanceof_value_kinds.ts @@ -0,0 +1,204 @@ +// #10479: `x instanceof C` decoded every NaN-box tag band >= 0x7FF8 as an +// object address. A 1-5 byte inline (SSO) string produced at run time packs its +// bytes plus a length byte into a payload that lands in the heap window, so the +// runtime read a GC header below it and segfaulted (ajv 8 `compile()` -> +// `arg instanceof _Code` on the string "uri"). The same decode turned an INT32 +// class ref into an address, and the fall-through class-id walk read an +// array's `length` as a class id. Covers every LHS value kind against class, +// function and builtin right-hand sides, in the static and dynamic forms. +import * as util from "node:util"; + +const rt = (v: T): T => JSON.parse(JSON.stringify(v)); + +// --- issue repro -------------------------------------------------------------- +class K {} +for (let n = 0; n <= 7; n++) { + const s = rt("abcdefgh".slice(0, n)); + console.log(`${JSON.stringify(s)} instanceof K:`, s instanceof K); +} +const concat: any = "u" + "ri".slice(0); +console.log("concat instanceof K:", concat instanceof K); +console.log("JSON.parse('\"uri\"') instanceof K:", JSON.parse('"uri"') instanceof K); +console.log("field instanceof K:", JSON.parse('{"f":"uri"}').f instanceof K); + +// --- LHS kinds x RHS kinds ------------------------------------------------------ +class A { + x = 1; +} +class B extends A { + y = 2; +} +function F(this: any) { + this.f = 1; +} +class Even { + static [Symbol.hasInstance](v: any) { + return typeof v === "number" && v % 2 === 0; + } +} +class ShortString { + static [Symbol.hasInstance](v: any) { + return typeof v === "string" && v.length < 6; + } +} + +const lhs: [string, any][] = []; +for (let n = 0; n <= 7; n++) lhs.push([`sso${n}`, rt("abcdefgh".slice(0, n))]); +lhs.push(["concat3", concat]); +lhs.push(["literal", "uri"]); +lhs.push(["heapString", rt("x".repeat(40))]); +lhs.push(["float", rt(1.5)]); +lhs.push(["zero", rt(0)]); +lhs.push(["negZero", -0]); +lhs.push(["nan", NaN]); +lhs.push(["denormal", rt(1e-310)]); +lhs.push(["int", rt(5) | 0]); +lhs.push(["even", rt(4)]); +lhs.push(["bigint", BigInt(rt(7))]); +lhs.push(["hugeBigint", 2n ** 70n]); +lhs.push(["symbol", Symbol("s")]); +lhs.push(["wellKnownSymbol", Symbol.iterator]); +lhs.push(["null", rt(null)]); +lhs.push(["undefined", undefined]); +lhs.push(["true", rt(true)]); +lhs.push(["false", false]); +lhs.push(["function", function g() {}]); +lhs.push(["arrow", () => 1]); +lhs.push(["array2", [1, 2]]); +lhs.push(["array3", rt([1, 2, 3])]); +lhs.push(["objectLiteral", { a: 1 }]); +lhs.push(["jsonObject", rt({ a: 1 })]); +lhs.push(["newA", new A()]); +lhs.push(["newB", new B()]); +lhs.push(["newF", new (F as any)()]); +lhs.push(["createA", Object.create(A.prototype)]); +lhs.push(["createB", Object.create(B.prototype)]); +lhs.push(["createObject", Object.create({})]); +lhs.push(["proxyA", new Proxy(new A(), {})]); +lhs.push(["proxyObject", new Proxy({}, {})]); +lhs.push(["date", new Date(0)]); +lhs.push(["map", new Map()]); +lhs.push(["regexp", /x/]); +lhs.push(["typeError", new TypeError("t")]); +lhs.push(["boxedString", new String("ab")]); +lhs.push(["boxedNumber", Object(3)]); + +const pick = rt(1); +const rhs: [string, any][] = [ + ["A", A], + ["B", B], + ["F", F], + ["Object", Object], + ["Function", Function], + ["Array", Array], + ["String", String], + ["Number", Number], + ["Error", Error], + ["TypeError", TypeError], + ["Date", Date], + ["Map", Map], + ["Promise", Promise], + ["Even", Even], + ["ShortString", ShortString], + ["dynamicA", pick ? A : B], +]; + +const cell = (f: () => boolean): string => { + try { + return f() ? "T" : "F"; + } catch (e: any) { + return "E"; + } +}; + +console.log("LHS order:", lhs.map(([name]) => name).join(" ")); +for (const [name, R] of rhs) { + console.log(`dynamic ${name.padEnd(11)} ${lhs.map(([, v]) => cell(() => v instanceof R)).join("")}`); +} +// Static right-hand sides (compile-time class ids / builtin ids). +const statics: [string, (v: any) => boolean][] = [ + ["A", (v) => v instanceof A], + ["B", (v) => v instanceof B], + ["F", (v) => v instanceof F], + ["K", (v) => v instanceof K], + ["Object", (v) => v instanceof Object], + ["Function", (v) => v instanceof Function], + ["Array", (v) => v instanceof Array], + ["Error", (v) => v instanceof Error], + ["Date", (v) => v instanceof Date], + ["Map", (v) => v instanceof Map], + ["Promise", (v) => v instanceof Promise], + ["Even", (v) => v instanceof Even], +]; +for (const [name, test] of statics) { + console.log(`static ${name.padEnd(11)} ${lhs.map(([, v]) => cell(() => test(v))).join("")}`); +} + +// --- reflective @@hasInstance ----------------------------------------------------- +const hasInstance = (Function.prototype as any)[Symbol.hasInstance]; +for (const [name, v] of lhs.slice(0, 11)) { + console.log(`Function.prototype[@@hasInstance].call(K, ${name}):`, hasInstance.call(K, v)); +} + +// --- non-callable / primitive right-hand side --------------------------------------- +const badRhs: [string, any][] = [ + ["{}", {}], + ["[]", []], + ["5", rt(5)], + ["sso", rt("ab")], + ["null", rt(null)], + ["undefined", undefined], +]; +for (const [name, R] of badRhs) { + for (const [lname, v] of [["sso3", rt("uri")], ["newA", new A()], ["null", null]] as [string, any][]) { + try { + console.log(`${lname} instanceof ${name}:`, (v as any) instanceof R); + } catch (e: any) { + console.log(`${lname} instanceof ${name}: ${e.constructor.name}: ${e.message}`); + } + } +} + +// --- ajv shape: `arg instanceof _Code` over mixed code arguments -------------------- +class _CodeOrName {} +class _Code extends _CodeOrName { + _items: any[]; + constructor(items: any[]) { + super(); + this._items = items; + } +} +function addCodeArg(code: string[], arg: any): void { + if (arg instanceof _Code) code.push(...arg._items); + else if (arg instanceof _CodeOrName) code.push(""); + else code.push(typeof arg === "string" ? JSON.stringify(arg) : String(arg)); +} +const code: string[] = []; +for (const arg of rt(["uri", "a", "", "abcdef", "format", 1, null, true]) as any[]) addCodeArg(code, arg); +addCodeArg(code, new _Code(["x", "y"])); +addCodeArg(code, new _CodeOrName()); +console.log("ajv addCodeArg:", code.join(" ")); + +// --- sibling predicates that took the same decode ------------------------------------- +for (const [name, v] of lhs.slice(0, 9)) { + console.log( + `util.types ${name}:`, + util.types.isMapIterator(v), + util.types.isSetIterator(v), + util.types.isPromise(v), + util.types.isDate(v), + ); +} +console.log("util.types real iterators:", util.types.isMapIterator(new Map().keys()), util.types.isSetIterator(new Set().values())); + +// --- hot path: the class-instance hit/miss answers stay intact ------------------------ +let hits = 0; +let misses = 0; +const a = new A(); +const bb = new B(); +for (let i = 0; i < 1000; i++) { + if (a instanceof A) hits++; + if (bb instanceof A) hits++; + if (!(a instanceof B)) misses++; +} +console.log("hot loop:", hits, misses); diff --git a/test-files/test_gap_10480_define_property_generic_descriptor_accessors.ts b/test-files/test_gap_10480_define_property_generic_descriptor_accessors.ts new file mode 100644 index 0000000000..e2466bc990 --- /dev/null +++ b/test-files/test_gap_10480_define_property_generic_descriptor_accessors.ts @@ -0,0 +1,306 @@ +// #10480: an attributes-only descriptor on an existing CLASS accessor must +// keep its getter and setter and change only the attributes. +// +// `Object.defineProperties(C.prototype, { p: { enumerable: true } })` is how +// every WebIDL-generated class (whatwg-url, node-fetch, undici-style +// polyfills) publishes its prototype accessors. Perry's define path only knew +// the address-keyed descriptor tables, which never hold a ClassBody accessor, +// so it filed the key as a brand-new property with a `writable: false` data +// slot on the prototype: the setter stopped running (assignment threw +// "Cannot assign to read only property" in strict code, and was dropped in +// sloppy code), and the requested `enumerable` / `configurable` never showed +// up in the descriptor or in `Object.keys` / `for...in`. +// +// Covered here: get/set pairs, getter-only, setter-only, static accessors, +// `defineProperty` vs `defineProperties`, subclass instances, the +// non-configurable rejections, and the object-literal / `defineProperty` / +// function-prototype accessors that always worked (controls). + +function describe(object: any, key: string): string { + const descriptor = Object.getOwnPropertyDescriptor(object, key); + if (!descriptor) return "absent"; + const kind = + "get" in descriptor + ? `get=${typeof descriptor.get} set=${typeof descriptor.set}` + : `value=${typeof descriptor.value} writable=${descriptor.writable}`; + return `${kind} enumerable=${descriptor.enumerable} configurable=${descriptor.configurable}`; +} + +function outcome(fn: () => string): string { + try { + return fn(); + } catch (error: any) { + return `${error.constructor.name}`; + } +} + +function forIn(object: any): string { + const keys: string[] = []; + for (const key in object) keys.push(key); + return keys.join(","); +} + +// ── the node-fetch / whatwg-url shape ─────────────────────────────────────── +class URLLike { + _p = ""; + get pathname() { + return this._p; + } + set pathname(value: string) { + this._p = "set:" + value; + } +} +Object.defineProperties(URLLike.prototype, { pathname: { enumerable: true } }); +console.log("url-desc", describe(URLLike.prototype, "pathname")); +const url = new URLLike(); +console.log( + "url-assign", + outcome(() => { + url.pathname = "/x"; + return url.pathname; + }), +); +console.log("url-keys", Object.keys(URLLike.prototype).join(",")); +console.log("url-for-in", forIn(url)); +console.log( + "url-enumerable", + URLLike.prototype.propertyIsEnumerable("pathname"), + Object.prototype.propertyIsEnumerable.call(URLLike.prototype, "pathname"), +); + +// A dynamic receiver takes the runtime dispatch path rather than a compiled +// direct call to the declared setter. +const dynamicUrl: any = new URLLike(); +const dynamicKey = "pathname"; +dynamicUrl[dynamicKey] = "/dyn"; +console.log("url-dynamic", dynamicUrl[dynamicKey]); + +// ── defineProperty, every generic descriptor shape ────────────────────────── +class Single { + _p = ""; + get p() { + return this._p; + } + set p(value: string) { + this._p = "set:" + value; + } +} +Object.defineProperty(Single.prototype, "p", { configurable: true }); +const single = new Single(); +console.log( + "single-configurable", + outcome(() => { + single.p = "y"; + return single.p; + }), + describe(Single.prototype, "p"), +); +Object.defineProperty(Single.prototype, "p", {}); +console.log("single-empty", describe(Single.prototype, "p")); +Object.defineProperty(Single.prototype, "p", { enumerable: true }); +console.log("single-enumerable", describe(Single.prototype, "p"), Object.keys(Single.prototype).join(",")); +Object.defineProperty(Single.prototype, "p", { enumerable: false }); +console.log("single-non-enumerable", describe(Single.prototype, "p"), Object.keys(Single.prototype).join(",")); +console.log( + "single-still-set", + outcome(() => { + single.p = "z"; + return single.p; + }), +); + +// ── getter-only and setter-only halves ────────────────────────────────────── +class Halves { + _v = 0; + get readOnly() { + return "read:" + this._v; + } + set writeOnly(value: number) { + this._v = value + 1; + } +} +Object.defineProperties(Halves.prototype, { + readOnly: { enumerable: true }, + writeOnly: { enumerable: true }, +}); +console.log("halves-read", describe(Halves.prototype, "readOnly")); +console.log("halves-write", describe(Halves.prototype, "writeOnly")); +const halves: any = new Halves(); +console.log("halves-get", halves.readOnly); +halves.writeOnly = 41; +console.log("halves-set", halves._v, halves.readOnly); +console.log("halves-keys", Object.keys(Halves.prototype).join(",")); +console.log("halves-for-in", forIn(halves)); +console.log("halves-enumerable", Halves.prototype.propertyIsEnumerable("readOnly")); + +// ── inheritance: the accessor is redefined on the BASE prototype ──────────── +class Base { + _b = ""; + get tag() { + return this._b; + } + set tag(value: string) { + this._b = "base:" + value; + } +} +class Derived extends Base {} +Object.defineProperties(Base.prototype, { tag: { enumerable: true } }); +const derived = new Derived(); +console.log( + "derived-assign", + outcome(() => { + derived.tag = "v"; + return derived.tag; + }), +); +console.log("derived-for-in", forIn(derived)); +console.log("derived-own", Object.keys(Derived.prototype).join(","), Object.keys(Base.prototype).join(",")); + +// ── ClassBody order is preserved when several accessors go enumerable ─────── +class Ordered { + get b() { + return "b"; + } + m() { + return "m"; + } + get a() { + return "a"; + } + get c() { + return "c"; + } +} +Object.defineProperties(Ordered.prototype, { c: { enumerable: true }, b: { enumerable: true } }); +console.log("ordered-keys", Object.keys(Ordered.prototype).join(",")); +console.log("ordered-names", Object.getOwnPropertyNames(Ordered.prototype).join(",")); +console.log("ordered-entries", JSON.stringify(Object.entries(Ordered.prototype))); + +// ── static accessors ──────────────────────────────────────────────────────── +class Statics { + static _v = 1; + static get sv() { + return Statics._v; + } + static set sv(value: number) { + Statics._v = value * 10; + } +} +Object.defineProperty(Statics, "sv", { enumerable: true }); +console.log("static-desc", describe(Statics, "sv")); +console.log( + "static-enumerable", + Statics.propertyIsEnumerable("sv"), + Object.prototype.propertyIsEnumerable.call(Statics, "sv"), +); +Statics.sv = 2; +console.log("static-read", Statics.sv, Object.keys(Statics).join(","), forIn(Statics)); + +// ── non-configurable rejections ───────────────────────────────────────────── +class Locked { + get q() { + return 1; + } + set q(_value: number) {} +} +Object.defineProperty(Locked.prototype, "q", { configurable: false }); +console.log("locked-desc", describe(Locked.prototype, "q")); +console.log( + "locked-configurable", + outcome(() => { + Object.defineProperty(Locked.prototype, "q", { configurable: true }); + return "ok"; + }), +); +console.log( + "locked-enumerable", + outcome(() => { + Object.defineProperty(Locked.prototype, "q", { enumerable: true }); + return "ok"; + }), +); +console.log( + "locked-getter", + outcome(() => { + Object.defineProperty(Locked.prototype, "q", { + get() { + return 2; + }, + }); + return "ok"; + }), +); +console.log( + "locked-data", + outcome(() => { + Object.defineProperty(Locked.prototype, "q", { value: 3 }); + return "ok"; + }), +); +console.log( + "locked-same", + outcome(() => { + Object.defineProperty(Locked.prototype, "q", { configurable: false, enumerable: false }); + return "ok"; + }), +); +let deleted: unknown = "unset"; +try { + deleted = delete (Locked.prototype as any).q; +} catch (error: any) { + deleted = error instanceof TypeError; +} +console.log("locked-delete", deleted, describe(Locked.prototype, "q")); +console.log("locked-read", new Locked().q); + +// ── controls: shapes that always worked ───────────────────────────────────── +const literal: any = { + _v: "", + get p() { + return this._v; + }, + set p(value: string) { + this._v = "set:" + value; + }, +}; +Object.defineProperties(literal, { p: { enumerable: true } }); +literal.p = "lit"; +console.log("literal", literal.p, describe(literal, "p"), Object.keys(literal).join(",")); + +const made: any = {}; +Object.defineProperty(made, "p", { + get() { + return this._v; + }, + set(value: string) { + this._v = "set:" + value; + }, + configurable: true, +}); +Object.defineProperties(made, { p: { enumerable: true } }); +made.p = "def"; +console.log("defined", made.p, describe(made, "p")); + +function Legacy(this: any) {} +Object.defineProperty(Legacy.prototype, "p", { + get() { + return this._v; + }, + set(value: string) { + this._v = "set:" + value; + }, + configurable: true, +}); +Object.defineProperties(Legacy.prototype, { p: { enumerable: true } }); +const legacy: any = new (Legacy as any)(); +legacy.p = "fn"; +console.log("function-prototype", legacy.p, describe(Legacy.prototype, "p"), forIn(legacy)); + +// A class METHOD is a data property; a generic descriptor must keep its value. +class WithMethod { + m() { + return "m"; + } +} +Object.defineProperty(WithMethod.prototype, "m", { enumerable: true }); +console.log("method", new WithMethod().m(), describe(WithMethod.prototype, "m")); diff --git a/test-files/test_gap_10480_define_property_generic_descriptor_sloppy.cts b/test-files/test_gap_10480_define_property_generic_descriptor_sloppy.cts new file mode 100644 index 0000000000..52badb4ed7 --- /dev/null +++ b/test-files/test_gap_10480_define_property_generic_descriptor_sloppy.cts @@ -0,0 +1,42 @@ +// #10480, sloppy half: in non-strict CJS the broken define did not throw — the +// write was silently dropped, which is how it reached node-fetch's +// `Request.prototype` and whatwg-url's `URL.prototype` without an error. +// An attributes-only descriptor must leave the setter in place, so the write +// still runs it here and the getter reports the setter's value. + +class Sloppy { + _p = ""; + get pathname() { + return this._p; + } + set pathname(value: string) { + this._p = "set:" + value; + } + get readOnly() { + return "ro"; + } +} + +Object.defineProperties(Sloppy.prototype, { + pathname: { enumerable: true }, + readOnly: { enumerable: true }, +}); + +const instance = new Sloppy(); +instance.pathname = "/sloppy"; +console.log("assigned", instance.pathname, instance._p); + +const dynamic: any = new Sloppy(); +const key = "pathname"; +dynamic[key] = "/dynamic"; +console.log("dynamic", dynamic[key]); + +console.log("read-only", (instance as any).readOnly); + +const descriptor = Object.getOwnPropertyDescriptor(Sloppy.prototype, "pathname"); +console.log("descriptor", typeof descriptor.get, typeof descriptor.set, descriptor.enumerable, descriptor.configurable); + +const keys: string[] = []; +for (const name in instance) keys.push(name); +console.log("for-in", keys.join(",")); +console.log("keys", Object.keys(Sloppy.prototype).join(",")); diff --git a/test-files/test_gap_10481_inherited_symbol_getter_receiver.ts b/test-files/test_gap_10481_inherited_symbol_getter_receiver.ts new file mode 100644 index 0000000000..2660910242 --- /dev/null +++ b/test-files/test_gap_10481_inherited_symbol_getter_receiver.ts @@ -0,0 +1,173 @@ +// #10481: an INHERITED Symbol-keyed accessor must run with the ORIGINAL +// receiver as `this` ([[Get]](P, Receiver) / [[Set]](P, V, Receiver)), for +// every read form, at any prototype depth, whatever built the prototype. +// fastify 5's `lib/reply.js` `[kRouteContext]` getter crashed every request +// with `this === undefined`. + +function show(label: string, f: () => unknown): void { + try { + const v = f(); + console.log(label, typeof v === "symbol" ? String(v) : JSON.stringify(v)); + } catch (e: any) { + console.log(label, "THREW", e instanceof TypeError ? "TypeError" : String(e), e.message ?? ""); + } +} + +// --------------------------------------------------------------------------- +// 1. fastify lib/reply.js shape: function constructor + defineProperties. +// --------------------------------------------------------------------------- +const kRouteContext = Symbol("kRouteContext"); +function Reply(this: any, request: any) { + this.request = request; +} +Object.defineProperties(Reply.prototype, { + [kRouteContext]: { + get() { + return this.request[kRouteContext]; + }, + }, + routeOptions: { + get() { + return this.request[kRouteContext]; + }, + }, +}); +const reply: any = new (Reply as any)({ [kRouteContext]: "ctx" }); +show("1a symbol-keyed prototype getter (fn ctor):", () => reply[kRouteContext]); +show("1b string-keyed prototype getter (control):", () => reply.routeOptions); + +const kAsAny: any = kRouteContext; +show("1c symbol getter through an any-typed key var:", () => reply[kAsAny]); + +show("1d optional chaining on the same getter:", () => reply?.[kRouteContext]); + +show("1e destructured symbol-keyed read:", () => { + const { [kRouteContext]: v } = reply; + return v; +}); + +console.log("1f kRouteContext in reply:", kRouteContext in reply); + +// --------------------------------------------------------------------------- +// 2. object-literal `get [sym]()` reached through Object.create, one and two +// levels deep. +// --------------------------------------------------------------------------- +const k = Symbol("k"); +const proto: any = { + get [k]() { + return this === undefined ? "this===undefined" : this.v; + }, +}; +const child = Object.create(proto); +child.v = 7; +const grandchild = Object.create(child); +grandchild.v = 9; + +show("2a inherited literal getter, one level (Object.create):", () => child[k]); +show("2b inherited literal getter, two levels (Object.create):", () => grandchild[k]); + +// --------------------------------------------------------------------------- +// 3. own symbol getter (control) — must be unaffected. +// --------------------------------------------------------------------------- +const own: any = { v: 8 }; +Object.defineProperty(own, k, { + get() { + return this.v; + }, +}); +show("3a own symbol getter (control):", () => own[k]); + +// --------------------------------------------------------------------------- +// 4. Reflect.get — with and without an explicit receiver. +// --------------------------------------------------------------------------- +show("4a Reflect.get(child, k) (default receiver = child):", () => Reflect.get(child, k)); +const other: any = { v: 100 }; +show("4b Reflect.get(child, k, other) (explicit receiver):", () => Reflect.get(child, k, other)); + +// --------------------------------------------------------------------------- +// 5. a nearer own data property shadows an inherited accessor. +// --------------------------------------------------------------------------- +const shadowed: any = Object.create(proto); +shadowed.v = 1; +// `proto`'s `[k]` is getter-only, so a plain `shadowed[k] = ...` would walk up +// to it and throw (no setter) in strict mode; Object.defineProperty creates +// the OWN data property directly, without going through [[Set]]. +Object.defineProperty(shadowed, k, { value: "own-data", writable: true, enumerable: true, configurable: true }); +show("5a own data property shadows inherited accessor (read):", () => shadowed[k]); + +// --------------------------------------------------------------------------- +// 6. write forms — an inherited setter must run with the receiver (each +// instance keeps its own state), and Reflect.set must reach it too. +// --------------------------------------------------------------------------- +const wproto: any = { + _store: new Map(), + get [k]() { + return this._store.get(this); + }, + set [k](v: unknown) { + this._store.set(this, v); + }, +}; +const w1: any = Object.create(wproto); +const w2: any = Object.create(wproto); +w1[k] = "w1-value"; +w2[k] = "w2-value"; +show("6a inherited setter keeps per-receiver state (w1):", () => w1[k]); +show("6b inherited setter keeps per-receiver state (w2):", () => w2[k]); +console.log( + "6c write did not create a shadowing own property:", + Object.prototype.hasOwnProperty.call(w1, k) === false && + Object.prototype.hasOwnProperty.call(w2, k) === false, +); + +Reflect.set(w1, k, "w1-via-reflect"); +show("6d Reflect.set through the inherited setter:", () => w1[k]); + +// --------------------------------------------------------------------------- +// 7. declared class prototype accessor, inherited by a subclass instance +// (a field the subclass does NOT itself declare, so the subclass's own +// field initializer can't shadow anything — isolates receiver identity +// from unrelated field-initialization-order concerns). +// --------------------------------------------------------------------------- +const kTag = Symbol("kTag"); +class Base {} +Object.defineProperty(Base.prototype, kTag, { + get() { + return (this as any).ownVal; + }, +}); +class Sub extends Base { + ownVal = "sub-own"; +} +const sub = new Sub(); +show("7a inherited getter on a declared class prototype:", () => (sub as any)[kTag]); + +// --------------------------------------------------------------------------- +// 8. Symbol.toStringTag through Object.prototype.toString, inherited. +// --------------------------------------------------------------------------- +function Widget(this: any) {} +Object.defineProperty(Widget.prototype, Symbol.toStringTag, { + get() { + return "MyWidget"; + }, +}); +const widget = new (Widget as any)(); +console.log("8a inherited Symbol.toStringTag getter:", Object.prototype.toString.call(widget)); + +// --------------------------------------------------------------------------- +// 9. two-level inheritance via Object.setPrototypeOf on function prototypes. +// --------------------------------------------------------------------------- +function R(this: any, request: any) { + this.request = request; +} +Object.defineProperty(R.prototype, kRouteContext, { + get() { + return this.request[kRouteContext]; + }, +}); +function Two(this: any, request: any) { + this.request = request; +} +Object.setPrototypeOf(Two.prototype, R.prototype); +const two: any = new (Two as any)({ [kRouteContext]: "two-ctx" }); +show("9a two-level inherited getter via setPrototypeOf:", () => two[kRouteContext]); diff --git a/test-files/test_gap_10556_instanceof_native_emitter.ts b/test-files/test_gap_10556_instanceof_native_emitter.ts new file mode 100644 index 0000000000..b60aef5da6 --- /dev/null +++ b/test-files/test_gap_10556_instanceof_native_emitter.ts @@ -0,0 +1,81 @@ +// #10556: `new EventEmitter() instanceof EventEmitter` segfaulted. A native +// EventEmitter instance is a POINTER_TAG registry handle (a small id such as +// `0x38000`), and the `instanceof EventEmitter` brand check first asked two +// "is this a namespace / cluster worker object?" probes that only rejected +// addresses below `0x10000` before reading a GC header / class id. +import { EventEmitter } from "node:events"; +import EE from "node:events"; +import { inherits } from "node:util"; + +const rt = (v: T): T => JSON.parse(JSON.stringify(v)); + +const e = new EventEmitter(); +console.log("named:", e instanceof EventEmitter); +console.log("default:", new EE() instanceof EE); +console.log("mixed:", new EE() instanceof EventEmitter, e instanceof EE); +const Ctor: any = [EventEmitter][rt(0)]; +console.log("dynamic:", e instanceof Ctor); +console.log("reflective:", (Function.prototype as any)[Symbol.hasInstance].call(EventEmitter, e)); + +// Non-emitters of every kind answer false without crashing. +const others: [string, any][] = [ + ["sso", rt("uri")], + ["heap string", rt("x".repeat(30))], + ["number", rt(3)], + ["null", rt(null)], + ["object", {}], + ["array", [1, 2, 3]], + ["map", new Map()], + ["function", () => 1], +]; +for (const [name, v] of others) console.log(`${name} instanceof EventEmitter:`, v instanceof EventEmitter); + +// The emitter still works after the checks. +let fired = 0; +e.on("ping", (n: number) => (fired += n)); +e.emit("ping", 2); +e.emit("ping", 3); +console.log("fired:", fired, "listeners:", e.listenerCount("ping")); + +// #10556 subclass shape: `class Sub extends EventEmitter {}` compiled +// through `js_instanceof_dynamic`, which never registered/consulted the +// class-chain parent edge that Array/Map/Set/Error subclassing uses — so a +// genuine subclass instance (a real ObjectHeader carrying Sub's own class +// id, not a handle, not prototype-linked to the real +// `EventEmitter.prototype`) never matched. Covers: direct subclass +// instanceof, the subclass's own constructor, a two-level subclass, the +// default-import form, and a util.inherits-style function-constructor +// subclass (prototype-chain linking, not a class `extends` edge — a +// different code path from the class-chain parent edge above). +class Sub extends EventEmitter {} +class Sub2 extends Sub {} +class SubDefault extends EE {} + +const s = new Sub(); +console.log("sub instanceof EventEmitter:", s instanceof EventEmitter); +console.log("sub instanceof Sub:", s instanceof Sub); + +const s2 = new Sub2(); +console.log("sub2 instanceof EventEmitter:", s2 instanceof EventEmitter); +console.log("sub2 instanceof Sub:", s2 instanceof Sub); +console.log("sub2 instanceof Sub2:", s2 instanceof Sub2); + +const sd = new SubDefault(); +console.log("subDefault instanceof EE:", sd instanceof EE); +console.log("subDefault instanceof EventEmitter:", sd instanceof EventEmitter); + +// A subclass instance still behaves like a real emitter. +let subFired = 0; +s.on("ping", (n: number) => (subFired += n)); +s.emit("ping", 5); +console.log("sub fired:", subFired, "listeners:", s.listenerCount("ping")); + +// util.inherits-style function-constructor subclass: links prototypes at +// runtime rather than creating an `extends` edge, so it exercises the +// ordinary-prototype-walk fallback instead of the class-chain parent edge. +function FnEmitter(this: any) { + EventEmitter.call(this); +} +inherits(FnEmitter, EventEmitter); +const fe: any = new (FnEmitter as any)(); +console.log("fnEmitter instanceof EventEmitter:", fe instanceof EventEmitter); diff --git a/test-files/test_gap_array_map_resolved_fill_scale.ts b/test-files/test_gap_array_map_resolved_fill_scale.ts new file mode 100644 index 0000000000..6e30f52568 --- /dev/null +++ b/test-files/test_gap_array_map_resolved_fill_scale.ts @@ -0,0 +1,146 @@ +// `Array.prototype.map`'s plain-array fill used to resolve the result +// array's head once per element (avoiding a re-classification of the same +// pointer through `clean_arr_ptr`/`array_numeric_layout`) only for a source +// of at most 64 elements; longer sources fell back to the fully +// re-classifying `note_array_slot`. This fixture pins the fast path across +// that former boundary: sources both under and well over 64 (and over the +// ~2048-element / 16KB born-old allocation threshold), with callbacks +// designed to attack the specific risk of resolving the result header once +// per element instead of proving it fresh every store — a callback that +// allocates (forcing a collection between the resolve and the store), that +// returns non-numeric values (retiring the raw-f64 numeric claim mid-fill), +// that mutates the SOURCE by growing or truncating it out from under the +// still-running loop, and a sparse/holey source (skips must still land at +// the right index in the result). + +function range(n: number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(i); + return a; +} + +// ---- control: plain numeric map, both sides of the old 64 cap ----------- + +const small = range(16); +console.log("ctrl-small", JSON.stringify(small.map((v) => v + 1))); + +const mid = range(65); // one past the old cap +console.log("ctrl-mid-sum", mid.map((v) => v * 2).reduce((a, b) => a + b, 0)); +console.log("ctrl-mid-ends", JSON.stringify([mid.map((v) => v * 2)[0], mid.map((v) => v * 2)[64]])); + +const big = range(500); +const bigMapped = big.map((v) => v * 3 + 1); +console.log("ctrl-big", bigMapped.length, bigMapped[0], bigMapped[250], bigMapped[499]); +console.log("ctrl-big-sum", bigMapped.reduce((a, b) => a + b, 0)); + +// Past the born-old allocation threshold (~2048 elements / 16KB of f64s): +// the RESULT array itself starts life in the old generation. +const huge = range(5000); +const hugeMapped = huge.map((v) => v + 0.25); +console.log( + "ctrl-huge", + hugeMapped.length, + hugeMapped[0], + hugeMapped[2048], + hugeMapped[4999], + hugeMapped.reduce((a, b) => a + b, 0), +); + +// ---- non-numeric return kinds: retires the raw-f64 numeric claim -------- + +const kindsSrc = range(200); +const kindsMapped = kindsSrc.map((v) => { + if (v % 5 === 0) return `s${v}`; + if (v % 5 === 1) return { v }; + if (v % 5 === 2) return undefined; + if (v % 5 === 3) return v % 2 === 0; + return v * 1.5; +}); +console.log( + "kinds", + kindsMapped.length, + typeof kindsMapped[0], + typeof kindsMapped[1], + typeof kindsMapped[2], + kindsMapped[2], + typeof kindsMapped[3], + typeof kindsMapped[4], + kindsMapped[4], +); +console.log("kinds-json", JSON.stringify(kindsMapped)); + +// -0 / NaN survive the fast path exactly. +const zeroNan = range(100).map((v) => (v === 0 ? -0 : v === 1 ? NaN : v)); +console.log("zero-nan", Object.is(zeroNan[0], -0), zeroNan[1] !== zeroNan[1], zeroNan[99]); + +// ---- callback allocates heavily: forces collections mid-fill ------------ + +const allocSrc = range(300); +const allocMapped = allocSrc.map((v) => { + const junk = new Array(48).fill({ v, pad: [v, v, v] }); + let s = 0; + for (const j of junk) s += j.v; + return s + v; +}); +console.log("alloc-len", allocMapped.length, allocMapped[0], allocMapped[149], allocMapped[299]); +console.log("alloc-sum", allocMapped.reduce((a, b) => a + b, 0)); + +// ---- callback pushes to the source mid-fill ------------------------------ + +const pushSrc = range(120); +const pushMapped = pushSrc.map((v, i) => { + if (i < 10) pushSrc.push(1000 + i); + return v; +}); +console.log("push-mapped-len", pushMapped.length, JSON.stringify(pushMapped.slice(0, 5))); +console.log("push-mapped-tail", pushMapped[119]); +console.log("push-src-len", pushSrc.length, pushSrc[120], pushSrc[129]); + +// ---- callback truncates the source mid-fill ------------------------------ + +const truncSrc = range(150); +const truncMapped = truncSrc.map((v, i) => { + if (i === 20) truncSrc.length = 60; + return v; +}); +console.log("trunc-mapped-len", truncMapped.length); +console.log("trunc-mapped-json-head", JSON.stringify(truncMapped.slice(0, 25))); +console.log("trunc-mapped-holes", 100 in truncMapped, 61 in truncMapped, 59 in truncMapped); +console.log("trunc-src-len", truncSrc.length); + +// ---- sparse / holey source, well past the old 64-element cap ------------ + +const holey: number[] = []; +holey.length = 200; +for (let i = 0; i < 200; i++) { + if (i % 7 !== 0) holey[i] = i; +} +const holeyMapped = holey.map((v) => v * 10); +console.log( + "holey-len", + holeyMapped.length, + 0 in holeyMapped, + 7 in holeyMapped, + 14 in holeyMapped, + 1 in holeyMapped, + holeyMapped[1], + holeyMapped[199], +); +console.log("holey-json", JSON.stringify(holeyMapped.slice(0, 16))); + +// A fully empty-but-long holey source: every index skipped. +const allHoles: number[] = new Array(90); +const allHolesMapped = allHoles.map((v) => v + 1); +console.log("all-holes-len", allHolesMapped.length, JSON.stringify(Object.keys(allHolesMapped))); + +// ---- frozen source, past the old cap ------------------------------------- + +const frozenBig = Object.freeze(range(90)); +console.log("frozen-big", JSON.stringify(frozenBig.map((v) => v + 1)).length, frozenBig.map((v) => v + 1)[89]); + +// ---- object-identity payloads mixed with numbers, past the old cap ------ + +const tag = { name: "shared" }; +const identitySrc = range(80); +const identityMapped = identitySrc.map((v) => (v === 40 ? tag : v)); +console.log("identity", identityMapped[40] === tag, identityMapped[39], identityMapped[41]); diff --git a/test-files/test_gap_template_literal_leading_part.ts b/test-files/test_gap_template_literal_leading_part.ts new file mode 100644 index 0000000000..0a81f8d547 --- /dev/null +++ b/test-files/test_gap_template_literal_leading_part.ts @@ -0,0 +1,96 @@ +// A template literal that OPENS on a substitution (`` `${x}...` ``, the +// overwhelmingly common shape: no literal text before the first `${`) used +// to unconditionally seed its desugared concat chain with a real, always- +// empty `Expr::String("")` part — the leading quasi was the only one that +// never got the "skip when empty" guard interior/trailing quasis already +// had. That wasted classification slot survived all the way to +// `js_string_concat_chain`, and for a single-substitution template +// (`` `${x}` ``) it also defeated the >=3-part minimum for the n-way +// concat-chain fold entirely, forcing the pairwise path to concatenate a +// literal "" for nothing. +// +// Separately, a `number`-typed parameter's template substitution used to +// keep its `StringCoerce` wrapper (materializing an intermediate heap +// string via `js_string_coerce` before the chain call ever saw it) because +// only a *proven* (not merely declared) non-pointer local elided it — and a +// plain public-body function parameter is never proof-bearing, only +// declaration-bearing. Both fixes are covered together here because they +// compound: the leading substitution is exactly where the elided part +// becomes the flattened chain's FIRST entry rather than an interior one. + +function tpl(s: string, n: number): string { + return `${s}:${n}`; +} + +// The leading-substitution shapes the elision above targets. +function lead1(n: number): string { + return `${n}`; +} +function lead2(n: number, s: string): string { + return `${n}:${s}`; +} +function leadInt(i: number): string { + return `${i}!`; +} + +// Multi-part chain (>=3 substitutions), still opening on `${`. +function multi(a: number, b: string, c: number, d: string): string { + return `${a}-${b}-${c}-${d}`; +} + +// Integer vs non-integer interpolation. +console.log(tpl("abc", 3), tpl("abc", 3.5), tpl("abc", -0), tpl("abc", NaN)); +console.log(leadInt(0), leadInt(-1), leadInt(1000000)); + +// Leading substitution, single and multi-part. +console.log(lead1(42), lead1(1 / 3), lead1(-0), lead1(Infinity)); +console.log(lead2(7, "x"), lead2(2.5, ""), lead2(-9, "tail")); + +// Empty string operands on both sides of the elided number. +console.log(tpl("", 5), tpl("", 5.25), lead2(0, "")); + +// Multi-part chain. +console.log(multi(1, "a", 2, "b"), multi(-1.5, "", 0, "z")); + +// Result short enough for SSO (<=5 bytes total) alongside a longer one. +console.log(`${1}${2}`, `${"ab"}${12}`, `${lead1(9)}${"x"}${9}`); + +// Non-ASCII and surrogate-pair content flowing through the leading part and +// through an interior number part. +const emoji = String.fromCharCode(0xd83d) + String.fromCharCode(0xde00); +console.log(`${emoji}:${3}`, `${"héllo"}:${7.5}`, `${3}:${emoji}`); +// Adjacent lone surrogates split across TWO parts must still canonicalize +// into one astral scalar in the chained result. +const hi = String.fromCharCode(0xd83d); +const lo = String.fromCharCode(0xde00); +console.log(`${hi}${lo}:${1}`, (`${hi}${lo}:${1}`).length); + +// A lying `number` annotation reaching the LEADING (now-unwrapped) position: +// the elision must fall back to the exact same coercion `String(x)` uses, +// not misread the bits. +console.log(lead1("nine" as any), lead1(true as any), lead1(null as any)); +console.log(lead1({ toString: () => "OBJ" } as any)); +const both = { + valueOf() { + return 111; + }, + toString() { + return "STR"; + }, +}; +console.log(lead1(both as any), lead2(both as any, "s")); + +// A substitution whose (elided) coercion still must not double-evaluate or +// reorder relative to its neighbors. +let calls = 0; +function counted(): any { + calls++; + return 7; +} +console.log(`${counted()}:${counted()}`, calls); + +// Hot loop shape: repeated leading-substitution template, integer and +// fractional, accumulated. +let acc = ""; +for (let i = 0; i < 50; i++) acc = `${i}:${i / 3}`; +console.log(acc, acc.length);