diff --git a/CLAUDE.md b/CLAUDE.md index 940ea4d729..d055ea528a 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.1595 +**Current Version:** 0.5.1596 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 617abc8390..0894a6f6d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1595" +version = "0.5.1596" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6039,7 +6039,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "lru", "perry-ffi", @@ -6048,7 +6048,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "chrono", "perry-ffi", @@ -6056,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "bson", "futures-util", @@ -6068,7 +6068,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "chrono", "perry-ffi", @@ -6080,7 +6080,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "nanoid", "perry-ffi", @@ -6089,7 +6089,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "bytes", "perry-ffi", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6123,7 +6123,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "lettre", "perry-ffi", @@ -6133,7 +6133,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "notify", "perry-ffi", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "printpdf", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "sqlx", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "perry-runtime", @@ -6171,7 +6171,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "governor", "perry-ffi", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "fast_image_resize", "image", @@ -6190,7 +6190,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "lazy_static", "perry-ffi", @@ -6199,7 +6199,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-ffi", @@ -6219,7 +6219,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "perry-runtime", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "uuid", @@ -6236,7 +6236,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-ffi", "perry-validation", @@ -6245,7 +6245,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "futures-util", "lazy_static", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "brotli", "flate2", @@ -6268,7 +6268,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6278,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-api-manifest", @@ -6298,11 +6298,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1595" +version = "0.5.1596" [[package]] name = "perry-parser" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "perry-diagnostics", @@ -6315,7 +6315,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perex", "regex", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "ahash", "base64 0.22.1", @@ -6381,14 +6381,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6477,21 +6477,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "dirs", "perry-ffi", @@ -6501,7 +6501,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "jni", @@ -6516,7 +6516,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "rand 0.10.2", "serde", @@ -6526,7 +6526,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6549,7 +6549,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "block2", @@ -6566,7 +6566,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "block2", @@ -6583,7 +6583,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1595" +version = "0.5.1596" [[package]] name = "perry-ui-test" @@ -6594,11 +6594,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1595" +version = "0.5.1596" [[package]] name = "perry-ui-tvos" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "block2", @@ -6615,7 +6615,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "block2", @@ -6632,7 +6632,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "block2", "libc", @@ -6646,7 +6646,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "libc", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "base64 0.22.1", "libc", @@ -6678,7 +6678,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "anyhow", "base64 0.22.1", @@ -6693,7 +6693,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "idna", "regex", @@ -6703,7 +6703,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1595" +version = "0.5.1596" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 7eed35eb9a..32c668115e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1595" +version = "0.5.1596" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10605-replacer-native-args.md b/changelog.d/10605-replacer-native-args.md new file mode 100644 index 0000000000..c9802226b0 --- /dev/null +++ b/changelog.d/10605-replacer-native-args.md @@ -0,0 +1,3 @@ +### Faster + +- `String.prototype.replace` with a function replacement spends about 27% fewer instructions, and roughly a quarter less on non-ASCII subjects. Each match used to build a JS array to carry the replacer's arguments, growing it one push at a time and then reading it straight back out into the native slots the call actually uses; the arguments now go directly into those slots, which were already the collector's roots for them. A proxy replacer, whose `apply` trap really does receive an arguments array, is unaffected (#10165). diff --git a/changelog.d/10607-shadowed-field-most-derived-slot.md b/changelog.d/10607-shadowed-field-most-derived-slot.md new file mode 100644 index 0000000000..c3fc2c6cbd --- /dev/null +++ b/changelog.d/10607-shadowed-field-most-derived-slot.md @@ -0,0 +1,23 @@ +### Fixed + +- **A field a subclass overrides could read as the base class's value from + every read that isn't a compile-time-typed `obj.field`.** `class Sub + extends Base { tag = "sub-tag" }` (with `Base { tag = "base-tag" }`) gave + `Sub` two inline field slots named `"tag"` — the packed class layout + (`crates/perry-codegen/src/codegen/mod.rs`) never deduplicates a name a + subclass re-declares — but only the most-derived one is ever written + (`class_field_global_index` already resolves a compile-time-typed + `obj.field` to that slot, "TS shadowing"). Every *dynamic* by-name lookup — + an inherited `Object.defineProperty` accessor's `this.field`, a computed + `obj[key]`, `Reflect.get`, `hasOwnProperty` — instead returned the + ancestor's never-written slot. Fixed in three runtime lookup sites + (`object/keys_lookup.rs`, `object/shapes.rs`, `object/field_get_set/ic_miss.rs`) + to agree with the compile-time path: `keys_find_slot_by_bytes` / + `keys_find_slot_by_key_ptr` and the IC-miss fast-path scan now walk + back-to-front, and the ≥32-key indexed lookup keeps the highest matching + slot index instead of the first. No storage-layout change; validated with + `test-files/test_gap_10595_inherited_accessor_field_shape.ts` (string and + Symbol keys, getter+setter, a two-level subclass, and a field overridden + with a different runtime type) plus new `perry-runtime` unit tests. Still + open: `Object.keys()`/`for...in` list a shadowed field's name twice, since + enumeration reads the same undeduplicated keys array — a follow-up. diff --git a/changelog.d/10608-new-cast-builtin-shadow.md b/changelog.d/10608-new-cast-builtin-shadow.md new file mode 100644 index 0000000000..23fd8efc8c --- /dev/null +++ b/changelog.d/10608-new-cast-builtin-shadow.md @@ -0,0 +1,3 @@ +### Fixed + +- **`new X()` on an imported plain-function constructor whose NAME collides with a builtin (`Headers`, and any other unconditional `lower_builtin_new` arm) constructed the builtin instead of the user's own function.** `lower_new_impl_inner` (`crates/perry-codegen/src/lower_call/new.rs`) called into the unconditional builtin-constructor table for any `class_name` absent from `ctx.classes` *before* checking `ctx.import_function_prefixes` (where an imported plain-function constructor is tracked). Imported CLASSES already land in `ctx.classes` and always skipped the builtin table — only the function-declaration form was exposed. `new X()` and `new (X as any)()` are equivalent from this function downward: HIR's `peel_new_callee` strips a `TsAs` cast before `lower_new` ever branches on the callee's shape, so both forms shared the bug identically; the cast in the original report is not itself the trigger — a local-variable alias (which takes a completely different, value-based codegen path) is what actually distinguished working from broken. Fixed by adding a `user_owns_construction` guard (mirrors the existing `required_sources` provenance gate already used for `Client`/`Pool`/`Database`/etc.) to the same condition that lets classes skip the builtin block. Verified the fix is a no-op for every already-correct `new` call via byte-identical emitted LLVM IR and a `perf stat` instruction-count A/B (both within noise). diff --git a/changelog.d/10611-residual-proto-owner-bit.md b/changelog.d/10611-residual-proto-owner-bit.md new file mode 100644 index 0000000000..289bf29f23 --- /dev/null +++ b/changelog.d/10611-residual-proto-owner-bit.md @@ -0,0 +1,9 @@ +### Performance + +- **The residual prototype registry is gated on an owner header bit instead of a process-global latch (#10362).** An explicit `[[Prototype]]` on a non-meta-capable owner lives in an address-keyed registry (#9304) whose relocation obligations were gated only by `OBJECT_PROTOTYPES_NONEMPTY`. One re-prototyped object anywhere armed that latch for the rest of the run, and every traced owner-capable cell then paid a lock plus a SipHash probe to ask a question false for almost all of them — a cliff, not a slope, invisible to any benchmark without the trigger. Measured: routine code leaves it alone (`class X extends Error`, live Array/Map/Set/Error subclass instances: 372–405 relocation-hook calls), while one `Object.setPrototypeOf` on an array takes it to 400,000. + + `GC_RESIDUAL_PROTO_OWNER` reuses bit 6 of `_reserved`, which is `OBJ_FLAG_NULL_PROTO` for `GC_TYPE_OBJECT` and free for every other kind — audited to all seven readers, each provably unreachable with a non-object cell (three by an explicit `obj_type` check, three by a converter returning `None` first, one by a preceding conjunct). Since the registry excludes `GC_TYPE_OBJECT` by construction the bit covers the registry's whole population, which is why both existing witnesses exercise it — one of them a lazy array an array-scoped bit would have missed. Set-only at the single funnel, under the registry lock and before the insert, so the proof is published before the fact it guards; never cleared, because the dangerous direction (entry present, bit absent) has no producer when one writer under one lock writes both. The latch stays as a one-load first test for processes that never re-prototype anything. + + Sabotage: with the setter made a no-op, both existing relocation witnesses fail at their real verdicts. The invariant is additionally asserted under `cfg(any(test, debug_assertions))`, which does run under `cargo test --release`. + + Measured exactly: the fixture that arms the latch −0.408%, three that do not are flat (+0.015%, −0.060%, −0.021%), attributed to −94.3M `RandomState::hash_one`, −58.2M SipHash `write`, −36.9M `run_copied_minor_attempt` and −30.0M `transfer_residual_prototype`. `pointer_slots_read` is identical between arms: the collector does bit-identical work. diff --git a/changelog.d/10612-class-ctor-arguments.md b/changelog.d/10612-class-ctor-arguments.md new file mode 100644 index 0000000000..05d22ec5ec --- /dev/null +++ b/changelog.d/10612-class-ctor-arguments.md @@ -0,0 +1,34 @@ +### Fixed: `arguments` inside a class constructor now reflects the call site (#10484) + +The `arguments` object inside a class constructor didn't match the call: constructing +through a runtime value (a class stored in a variable, returned from a function, or +imported — including every CommonJS class) saw an **empty** `arguments`, and a static +`new C(x)` reported the constructor's **declared** parameter count instead of the +number of arguments actually passed. undici's `new Request(url)` and whatwg-url's +`new URL(href)` both threw spurious "argument required" errors as a result. + +**Root cause**, three layers: HIR padded a `new`-site's argument list with `undefined` +up to the declared arity before packing `arguments`, making a caller-omitted argument +indistinguishable from a declared one; the four runtime dynamic-construct paths (super- +apply, flat-ctor replay, class-object/registered-class replay) packed the synthesized +`arguments` slot like a user `...rest` parameter (only the arguments past the declared +count, empty for the common case); and codegen read a constructor's trailing-array +layout off its last declared parameter only, which misses every capturing constructor +— i.e. every CommonJS class, since Perry adds capture params mechanically. + +**Fix.** HIR skips arity padding for a constructor that reads `arguments`. The four +runtime dynamic-construct sites now share one helper, `constructor_user_arg_slots`, +that packs `arguments` from every call argument. Codegen gains a `CtorAbi` (param +count, has-rest, has-synthetic-arguments) computed from the constructor's actual +layout instead of its last parameter, threaded through constructor-contract +resolution, imported-class metadata, and cross-module `new`-site argument marshaling. + +**Validation.** New gap test `test_gap_10484_class_constructor_arguments.ts` (with an +undici-`Request`-shaped CommonJS fixture) fails on the base commit and passes here, +byte-identical to Node. A 45-test constructor/ABI regression sweep and the full +`perry-runtime`/`perry-codegen` unit suites are clean. Static construction and +value-construction without `arguments` usage are unaffected in instruction-count +perf; value-construction with an `arguments`-reading constructor — the case the bug +was actually about — costs roughly 20% more instructions on that specific path, a +correctness-necessitated cost of materializing a real `arguments` array where the +buggy path previously built nothing. diff --git a/changelog.d/10613-box-cell-release.md b/changelog.d/10613-box-cell-release.md new file mode 100644 index 0000000000..55e6c61ee5 --- /dev/null +++ b/changelog.d/10613-box-cell-release.md @@ -0,0 +1,52 @@ +### Fixed: an ordinary frame now releases the box cells it minted (#10464) + +A `let`/`var` that a closure captures and something reassigns is stored in a +malloc-side **box cell**, and every registered cell is a strong GC root +(`scan_box_roots_mut`). The only release Perry emitted was the async-to-generator +transform's terminal `Stmt::ReleaseBoxes` (#7933/#8208/#8303), so a synchronous +function, method, arrow, generator — or an `async` function with no `await` — +leaked one registered root per boxed binding per call, plus everything that +binding last pointed at. `PERRY_GC_DIAG=1` reported `releases=0` for every +non-async workload; the issue's repro reached 601 MB RSS at 100k calls where the +equal-allocation control stayed at 69 MB, and real packages accumulated cells by +the hundred thousand (qs 792k, dayjs 434k). + +**Root cause.** `js_box_alloc_bits` registers each cell for the life of the +thread, and only `perry-transform`'s async step lowering produced the +`Stmt::ReleaseBoxes` that `emit_release_boxes` lowers. Nothing named the cells of +an ordinary frame, so nothing could ever reclaim them. + +**Fix.** Codegen registers every entry slot that holds a cell *this* frame minted +(`stmt/boxed_frame_release.rs`); the return-site rewrite that already injects +`js_shadow_frame_pop` now also emits `js_box_scope_release` for each of them +before every `ret`, and a declaration inside a loop releases the previous +iteration's cell before minting the next. The runtime publishes a cell no closure +captured (de-register, cache-evict, clear, free-list push) and marks a captured +one frame-released in its capture-edge record, so the last capture edge's GC +death publishes it — the same escape contract #8303 built for async activations, +including the full-trace ephemeron rule that keeps `box -> closure -> same box` +collectable. Two holders the runtime cannot count keep their cells: a sloppy-mode +mapped `arguments` object, and a plain-async step closure's own activation cells. +A step closure's capture of an *enclosing* frame's cell is now counted, because +the activation token never covered it and that frame does release its cells. + +Fixing this exposed a latent GC hole shared with #8303: a full trace that stops +rooting a released cell must still keep it in the box young log, because a minor +walks only that log. Without it the next minor had no root for a payload a live +closure still reads — `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1 +PERRY_GC_PROTECT_FROMSPACE=1` faults on it immediately, and a unit test now +re-derives the rule. + +**Validation.** New gap test `test_gap_10464_box_cell_release.ts` (the repro's +memory shape compared against its own control in-process, plus escaping-closure, +per-iteration-binding, generator, class, async and self-cycle cases) differs from +Node on the parent commit and matches it here. GC stress over seeds 1-7 at +`PERRY_GC_SCHEDULE_RATE=1` with the from-space quarantine confirmed armed +(`[gc-fromspace-protect] retired_set=#0`, 141,635 hits over the run) held +byte-identical to Node on every deterministic output line, and +`PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1` (panics on any stale +forwarded-pointer read) passed clean. Instructions *improve* slightly on a hot +captured-variable loop (20M calls, -3.6%) and on 1M closure creations (-2.6%), +from reduced GC root-scanning pressure; the issue's `payload` repro reproduces +the RSS claim independently: 608 MB -> 152 MB peak RSS here (originally recorded +616 MB -> 163 MB on the destroyed build host). diff --git a/changelog.d/10614-eventemitter-prototype-identity.md b/changelog.d/10614-eventemitter-prototype-identity.md new file mode 100644 index 0000000000..54b7d07d4b --- /dev/null +++ b/changelog.d/10614-eventemitter-prototype-identity.md @@ -0,0 +1,25 @@ +### Fixed + +- **`class Sub extends EventEmitter {}` now links `Sub.prototype`'s `[[Prototype]]` to the real + `EventEmitter.prototype` object (#10599).** `Object.getPrototypeOf(Sub.prototype) === EventEmitter.prototype` + read `false`: `class_decl_prototype_value` resolves a registered parent class id by recursing into itself, + which bails for a RESERVED native-builtin parent id (`EventEmitter` has no `js_register_class_name` + registration of its own), so the link silently fell through to `Object.prototype`. `instanceof` and the + class-chain walk (#10592) already worked — this is specifically the `[[Prototype]]` object identity, which is + a separate mechanism. + + The fix resolves the real, closure-identity-keyed prototype through `js_function_prototype_value_for_read` — + the same helper the existing runtime-function-valued-parent branch already used — so the identity comparison + holds, not merely the shape. It only takes effect together with a codegen-side parent-edge registration + (`builtin_parent_reserved_class_id`): #10592 added that entry for plain `EventEmitter`; this PR adds the + missing `EventEmitterAsyncResource` counterpart, without which `get_parent_class_id` never resolves and the + runtime fix is unreachable. + + Verified with the runtime fix reverted (twice, independently) that every `getPrototypeOf`/`instanceof`/`in` + assertion in the new gap test reads `false` where Node reads `true`, and with it restored the test is + byte-identical to Node 26.5.1. Two pre-existing, unrelated gaps surfaced while writing the test and were left + out of it: `EventEmitterAsyncResource.prototype`'s own chain to `EventEmitter.prototype` (a + native-to-native link, not a user subclass), and `Object.keys(new Sub())` leaking `EventEmitter.prototype`'s + methods as own enumerable instance properties instead of Node's real `_events`/`_eventsCount`/`_maxListeners` + fields (CLAUDE.md's documented "native base's surface is installed at `super()` time" weak area) — both + reproduce identically with the fix reverted, so neither is caused by it. diff --git a/changelog.d/10615-class-member-var-captures.md b/changelog.d/10615-class-member-var-captures.md new file mode 100644 index 0000000000..ec85da4424 --- /dev/null +++ b/changelog.d/10615-class-member-var-captures.md @@ -0,0 +1,5 @@ +Fix class members nested in a function or CommonJS module body capturing enclosing `var`/`let` bindings by value snapshot instead of by reference: a method saw the value the binding had at class-declaration time, and writes from constructors/static methods to a captured `var` were silently lost. This blocked `@redis/client` (TypeScript's `var _a; class X {…} _a = X;` static-self-reference emit) and `sql.js`/emscripten (the `FS.nextInode` counter that never advanced, corrupting every `FSNode.id`). + +Root cause: `desugar_shared_mutable_captures` decides whether a captured local is safe to promote to a shared reference cell by counting how many times its `LocalId` is declared and requiring exactly one — but a `var` is declared twice in HIR (a body-entry predefine slot, then the declaration statement), so every `var` capture failed that check and fell back to the stale value-snapshot path. The counter is replaced with a `DeclCensus`/`CensusWalker` that walks a region in execution order and accepts an id as one binding when either it has a single declaration, or all its declarations sit in the same closure scope under the same name with the first dominating (the shape of a `var`) — declarations in different closure scopes stay ambiguous, preserving the earlier #6089 fix. A `var` redeclaration that runs after the class has already captured the id, or inside a loop, is demoted into a write to the shared cell instead of being treated as a fresh binding. + +Added `test-files/test_gap_10485_class_member_var_captures.ts` (byte-for-byte against Node 26.5.1) covering every member kind, constructor/static/setter writes, `var` redeclaration, the emscripten hoisted-factory shape, and same-named `var`+class pairs in sibling functions, plus 4 new HIR-level unit tests in `crates/perry-hir/src/lower/tests/class_member_var_captures.rs`. Proven to fail on a pristine pre-fix tree and pass on this change. diff --git a/changelog.d/10617-field-init-collection-receiver-guard.md b/changelog.d/10617-field-init-collection-receiver-guard.md new file mode 100644 index 0000000000..3cfce52f66 --- /dev/null +++ b/changelog.d/10617-field-init-collection-receiver-guard.md @@ -0,0 +1,18 @@ +### Fixed + +A class whose direct parent is a built-in (`Error`/`TypeError`/other non-user base) and that +declares its own `super()`-calling constructor now runs its own field initializers. Every other +non-user-parent `super()` arm already did this after the base constructor returns; the +`Error`-family arm was the one that skipped it, so `class E extends Error { labels = +new Set(); constructor(m) { super(m); } }` left `labels` `undefined`. + +Calling a method (`.add`/`.set`/`.get`/`.has`/`.delete`/`.clear`/`.forEach`/...) on a +statically-typed `Set`/`Map` value that holds `undefined`, `null`, or another +primitive at runtime now throws a catchable `TypeError` instead of segfaulting. The static-type +fast path unboxed the receiver's declared-type payload with no tag check; a receiver guard now +runs first, on the fast/common path costing one compare of the receiver's tag bits. + +Together these fixed a crash in the mongodb 7.5.0 driver: `MongoError`'s +`errorLabelSet: Set` field was left `undefined` by the first bug, and +`addErrorLabel()` calling `.add()` on it segfaulted via the second, about 100ms after +`client.connect()`. diff --git a/changelog.d/10618-tier-a-native-binding-removal.md b/changelog.d/10618-tier-a-native-binding-removal.md new file mode 100644 index 0000000000..c6fc6d55ce --- /dev/null +++ b/changelog.d/10618-tier-a-native-binding-removal.md @@ -0,0 +1,39 @@ +Removed the three Tier A native bindings that were safe to delete without +any compiler work: the bare-name `fetch` alias for `node-fetch`, and the +leftover in-tree accounting for `tursodb`/`iroh` (their actual crates and +`well_known_bindings.toml` entries had already moved out to +`@perryts/tursodb` / `@perryts/iroh` in v0.5.557 — this finishes the job). + +`fetch` alias: dropped `[bindings.fetch]` from `well_known_bindings.toml` +and the `"fetch"` entry from `NATIVE_MODULES`. `node-fetch` itself, its +`perry-ext-fetch` crate, and the manifest entries backing the built-in Web +Fetch API's `Response`/`Headers`/`Request`/`Blob`/`FormData` dispatch tag +(a separate mechanism, confirmed via the pre-existing +`builtin_fetch_usage_does_not_synthesize_well_known_fetch` test) are +unaffected; `"fetch"` moved to the test-only `INTERNAL_MODULE_KEYS` +allowlist so `known_modules_consistent_with_manifest` still holds. + +`tursodb`/`iroh`: removed their `NATIVE_MODULES` entries, manifest method +rows, `stdlib_features.rs` feature-gate arms, and skip-list entries in the +`unimplemented_api_check.rs` coverage sweeps. Their presence in +`NATIVE_MODULES` with no backing crate made the bare `import * as tursodb +from "tursodb"` specifier short-circuit past file resolution and silently +claim nativeness with nothing behind it — a dangling reference, not a +working feature. The real `@perryts/tursodb` / `@perryts/iroh` packages are +consumed via their scoped specifier, which was never in `NATIVE_MODULES` +and resolves through the unrelated `node_modules`/`perry.nativeLibrary` +path, untouched here. + +Regenerated `docs/src/api/reference.md`, `docs/api/perry.d.ts` (drop the +`tursodb`/`iroh` sections, keep `## fetch`), and +`docs/src/native-libraries/governance.md`'s generated table (`perry-ext-fetch`'s +package mapping drops `fetch`, keeps `node-fetch`). + +Validated: cargo tests on `perry-api-manifest`, `perry-hir`, `perry-codegen` +(`manifest_consistency`), and `perry`'s `stdlib_features`/`optimized_libs` +unit tests all green; `run_lint_gates.sh` 76/77 (the one red, +"Public benchmark evidence freshness", is pre-existing on every PR); +targeted gap suite (every fixture using `node-fetch` or built-in `fetch`) +at 100% parity; hand probes confirm `import * as tursodb from "tursodb"` +now fails at compile time with a clear error instead of compiling to a +broken no-op, and built-in `fetch`/`Response`/`Headers` still resolve. diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 98d0061f8a..0e1415b629 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -54,8 +54,6 @@ pub const NATIVE_MODULES: &[&str] = &[ "mongodb", // MongoDB driver "better-sqlite3", // synchronous SQLite (replaces the N-API addon) "sqlite", // node:sqlite builtin surface - "tursodb", // Turso/libSQL client (legacy in-tree; now @perryts/tursodb) - "iroh", // iroh p2p (legacy in-tree; now @perryts/iroh) // #6562: Bun FFI (C-ABI). The `bun:` prefix is part of the specifier // (unlike `node:`, which is stripped) — `import { dlopen } from "bun:ffi"`. "bun:ffi", @@ -158,7 +156,6 @@ pub const NATIVE_MODULES: &[&str] = &[ // ── More third-party npm packages ── "redis", // npm `redis` client (aliases ioredis) "rate-limiter-flexible", // rate limiting - "fetch", // bare-name alias for the node-fetch surface // `undici` (#466) — served by perry's native fetch stack via the // bundled perry-ext-undici wrapper (ProxyAgent / Agent / // setGlobalDispatcher / getGlobalDispatcher / fetch subset). @@ -231,9 +228,13 @@ pub const NODE_SUBMODULES: &[&str] = &[ ]; /// Internal manifest keys used by dispatch/property gates but not importable -/// module specifiers. +/// module specifiers. `fetch` covers the built-in Web Fetch API's value-typed +/// dispatch tag (`Response`/`Headers`/`Request`/`Blob`/`FormData` + the bare +/// global `fetch()` call) — real fetch/HTTP-client I/O, distinct from the +/// removed bare-name `"fetch"` alias for the `node-fetch` npm package that +/// used to double as its NATIVE_MODULES entry (see well_known_bindings.toml). #[cfg(test)] -pub(crate) const INTERNAL_MODULE_KEYS: &[&str] = &["inspector.Network", "punycode.ucs2"]; +pub(crate) const INTERNAL_MODULE_KEYS: &[&str] = &["inspector.Network", "punycode.ucs2", "fetch"]; /// Modules handled entirely by `perry-runtime` — the linker doesn't /// need to pull in `perry-stdlib` for these. Migrated from diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index 8ccae06fe6..aa51556739 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -303,35 +303,6 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ method("sqlite", "setAllowUnknownNamedParameters", true, None), method("sqlite", "sourceSQL", true, None), method("sqlite", "expandedSQL", true, None), - // tursodb (#424). open / exec / execBatch / close / - // lastInsertRowid / isAutocommit shipped in v0.5.543; queryAll / - // queryOne shipped in v0.5.553 (close the row-as-object gap by - // building shapes inside spawn_blocking and resolving with - // POINTER_TAG'd JsValues). - method("tursodb", "open", false, None), - method("tursodb", "exec", true, None), - method("tursodb", "execBatch", true, None), - method("tursodb", "queryAll", true, None), - method("tursodb", "queryOne", true, None), - method("tursodb", "close", true, None), - method("tursodb", "lastInsertRowid", true, None), - method("tursodb", "isAutocommit", true, None), - // iroh (#425). bind / nodeId / close shipped in v0.5.544; the - // peer connection + stream surface (connect / acceptOne / - // openBi / acceptBi / streamWrite / streamFinish / - // streamReadToEnd / connClose) shipped in v0.5.554. ALPN is - // hardcoded to `b"perry-iroh/0"` for v0. - method("iroh", "bind", false, None), - method("iroh", "nodeId", true, None), - method("iroh", "close", true, None), - method("iroh", "connect", true, None), - method("iroh", "acceptOne", true, None), - method("iroh", "openBi", true, None), - method("iroh", "acceptBi", true, None), - method("iroh", "streamWrite", true, None), - method("iroh", "streamFinish", true, None), - method("iroh", "streamReadToEnd", true, None), - method("iroh", "connClose", true, None), property("sea", "default"), method("sea", "isSea", false, None), method("sea", "getAsset", false, None), diff --git a/crates/perry-codegen/src/codegen/arguments.rs b/crates/perry-codegen/src/codegen/arguments.rs index 231a58fde4..ba72301e88 100644 --- a/crates/perry-codegen/src/codegen/arguments.rs +++ b/crates/perry-codegen/src/codegen/arguments.rs @@ -50,6 +50,34 @@ pub(crate) fn store_param_slot( slot } +/// #10464: a boxed parameter's cell is minted by this frame's entry block +/// (`store_param_slot`), so the frame releases it before every `ret`. +/// `materialize_arguments_object` withdraws a slot it maps into a sloppy-mode +/// `arguments` object, which holds the raw cell without a counted edge. +pub(crate) fn release_boxed_param_slots_at_exit( + lf: &mut crate::function::LlFunction, + params: &[Param], + boxed_vars: &HashSet, + slots: &std::collections::HashMap, +) { + for p in params { + if !boxed_vars.contains(&p.id) || p.arguments_object.is_some() { + continue; + } + if let Some(slot) = slots.get(&p.id) { + lf.add_pre_return_box_release(slot, "js_box_scope_release"); + } + } +} + +/// The parameter ids a synthesized `arguments` object aliases. +pub(crate) fn mapped_parameter_ids(params: &[Param]) -> HashSet { + mapped_arguments_params(params) + .into_iter() + .map(|(_, id)| id) + .collect() +} + pub(crate) fn materialize_arguments_object( ctx: &mut FnCtx<'_>, params: &[Param], @@ -110,6 +138,8 @@ pub(crate) fn materialize_arguments_object( ); for (arg_index, param_id) in mapped_arguments_params(params) { if let Some(param_slot) = ctx.locals.get(¶m_id).cloned() { + // #10464: the object aliases the cell for its own lifetime. + ctx.func.forget_pre_return_box_release(¶m_slot); let box_ptr = ctx.block().load(I64, ¶m_slot); ctx.block().call_void( "js_arguments_object_map_index", diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 2772017112..8ce9a39ab6 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -639,6 +639,7 @@ pub(super) fn compile_closure( } map }; + super::arguments::release_boxed_param_slots_at_exit(lf, params, &closure_boxed_vars, &locals); // Start with the closure's own params as local_types, then // merge in the module-wide map so captured-from-outer ids have diff --git a/crates/perry-codegen/src/codegen/constructor_contracts.rs b/crates/perry-codegen/src/codegen/constructor_contracts.rs index 040df8de36..e8165f46ad 100644 --- a/crates/perry-codegen/src/codegen/constructor_contracts.rs +++ b/crates/perry-codegen/src/codegen/constructor_contracts.rs @@ -4,14 +4,14 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; -use super::ctor_arity::{context_free_ctor_param_count, UNRESOLVED_PARENT_FWD_ARITY}; +use super::ctor_arity::{context_free_ctor_abi, CtorAbi, UNRESOLVED_PARENT_FWD_ARITY}; use super::opts::{CompileOptions, ImportedClass}; type Symbol = (String, String); enum Contract { - Params(usize), - Parent(Symbol, usize), + Params(CtorAbi), + Parent(Symbol, CtorAbi), } /// A compact graph of constructor edges in each defining module's scope. @@ -23,7 +23,7 @@ pub struct ConstructorContracts { /// The only graph-wide constructor data needed during parallel codegen. pub struct ResolvedConstructorContracts { - arities: BTreeMap, + abis: BTreeMap, } impl ConstructorContracts { @@ -51,26 +51,30 @@ impl ConstructorContracts { imports.entry(imported.effective_name()).or_insert(imported); } for class in &module.classes { - let contract = if let Some(count) = context_free_ctor_param_count(class) { - Contract::Params(count) + let contract = if let Some(abi) = context_free_ctor_abi(class) { + Contract::Params(abi) } else { let mut parent = class.extends_name.as_deref(); let mut visited = BTreeSet::new(); - let mut contract = Contract::Params(UNRESOLVED_PARENT_FWD_ARITY); + let mut contract = + Contract::Params(CtorAbi::positional(UNRESOLVED_PARENT_FWD_ARITY)); while let Some(name) = parent { if !visited.insert(name) { break; } if let Some(local) = locals.get(name) { if let Some(ctor) = &local.constructor { - contract = Contract::Params(ctor.params.len()); + // The forwarder hands every slot to this ctor's + // symbol untouched, so it inherits its ABI — packed + // trailing arrays included (#10484). + contract = Contract::Params(CtorAbi::from_params(&ctor.params)); break; } parent = local.extends_name.as_deref(); } else if let Some(imported) = imports.get(name) { contract = Contract::Parent( (imported.source_prefix.clone(), imported.name.clone()), - imported.constructor_param_count, + imported.ctor_abi(), ); break; } else { @@ -90,26 +94,26 @@ impl ConstructorContracts { for symbol in self.contracts.keys() { resolve(symbol, &self.contracts, &mut resolved, &mut BTreeSet::new()); } - ResolvedConstructorContracts { arities: resolved } + ResolvedConstructorContracts { abis: resolved } } } fn resolve( symbol: &Symbol, contracts: &BTreeMap, - resolved: &mut BTreeMap, + resolved: &mut BTreeMap, visiting: &mut BTreeSet, -) -> usize { - if let Some(count) = resolved.get(symbol) { - return *count; +) -> CtorAbi { + if let Some(abi) = resolved.get(symbol) { + return *abi; } if !visiting.insert(symbol.clone()) { // Cyclic heritage has no constructor-bearing ancestor. Keep the // standalone fallback and, crucially, the same ABI on every edge. - return UNRESOLVED_PARENT_FWD_ARITY; + return CtorAbi::positional(UNRESOLVED_PARENT_FWD_ARITY); } - let count = match &contracts[symbol] { - Contract::Params(count) => *count, + let abi = match &contracts[symbol] { + Contract::Params(abi) => *abi, Contract::Parent(parent, fallback) => { if contracts.contains_key(parent) { resolve(parent, contracts, resolved, visiting) @@ -121,8 +125,8 @@ fn resolve( } }; visiting.remove(symbol); - resolved.insert(symbol.clone(), count); - count + resolved.insert(symbol.clone(), abi); + abi } impl ResolvedConstructorContracts { @@ -132,16 +136,22 @@ impl ResolvedConstructorContracts { .classes .iter() .map(|class| { - let count = self.arities[&(prefix.to_owned(), class.name.clone())]; - (class.name.clone(), count) + let abi = self.abis[&(prefix.to_owned(), class.name.clone())]; + (class.name.clone(), abi.param_count) }) .collect(); for imported in &mut opts.imported_classes { - if let Some(count) = self - .arities + if let Some(abi) = self + .abis .get(&(imported.source_prefix.clone(), imported.name.clone())) { - imported.constructor_param_count = *count; + imported.constructor_param_count = abi.param_count; + // #10484: a no-own-ctor class emits a positional forwarder into + // its ancestor's symbol, so the `new` site here must pack the + // trailing arrays the ANCESTOR declares, not the (empty) set the + // forwarder's own class HIR shows. + imported.constructor_has_rest = abi.has_rest; + imported.constructor_has_synthetic_arguments = abi.has_synthetic_arguments; } } } diff --git a/crates/perry-codegen/src/codegen/ctor_arity.rs b/crates/perry-codegen/src/codegen/ctor_arity.rs index 4001fceb3e..2a796a9634 100644 --- a/crates/perry-codegen/src/codegen/ctor_arity.rs +++ b/crates/perry-codegen/src/codegen/ctor_arity.rs @@ -11,28 +11,74 @@ use std::collections::{BTreeMap, HashMap}; /// [`synthesized_ctor_param_count`]). pub const UNRESOLVED_PARENT_FWD_ARITY: usize = 8; -/// The standalone-constructor arity of `class` when it can be decided from the +/// The ABI of a class's standalone `_constructor` symbol: how many slots +/// it takes and which of the trailing ones are arrays a caller has to PACK — a +/// user `...rest` and/or the HIR-synthesized `arguments` slot (#10484). +/// +/// A class with no own constructor emits the `super(...args)` forwarder, which +/// passes every slot on to the ancestor's symbol unchanged, so it carries the +/// ancestor's ABI verbatim. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct CtorAbi { + pub param_count: usize, + pub has_rest: bool, + pub has_synthetic_arguments: bool, +} + +impl CtorAbi { + /// Positional-only, `param_count` slots. + pub(crate) fn positional(param_count: usize) -> Self { + CtorAbi { + param_count, + ..CtorAbi::default() + } + } + + /// Read a constructor's own parameter list. Synthesized `__perry_cap_*` + /// capture params trail the arrays and are bound from the class's capture + /// snapshot rather than from the call, so a capturing constructor reports + /// no packable slot and keeps positional marshaling. + pub(crate) fn from_params(params: &[perry_hir::Param]) -> Self { + if params.iter().any(|p| p.name.starts_with("__perry_cap_")) { + return CtorAbi::positional(params.len()); + } + CtorAbi { + param_count: params.len(), + has_rest: params + .iter() + .any(|p| p.is_rest && p.arguments_object.is_none()), + has_synthetic_arguments: params.iter().any(|p| p.arguments_object.is_some()), + } + } +} + +/// The standalone-constructor ABI of `class` when it can be decided from the /// class definition alone, without the defining module's class table or /// imports. The source-graph constructor-contract resolver uses this, so it /// MUST agree with [`synthesized_ctor_param_count`] for every case it answers: /// an own constructor, a native parent, no heritage, and a heritage that is only /// a runtime value (`extends_expr` with no resolvable `extends_name`), which -/// always synthesizes the fixed forwarding band. `None` means the arity depends -/// on the defining module's ancestor walk (#10258). -pub fn context_free_ctor_param_count(class: &perry_hir::Class) -> Option { +/// always synthesizes the fixed positional forwarding band. `None` means the ABI +/// depends on the defining module's ancestor walk (#10258). +pub fn context_free_ctor_abi(class: &perry_hir::Class) -> Option { if let Some(c) = class.constructor.as_ref() { - return Some(c.params.len()); + return Some(CtorAbi::from_params(&c.params)); } if class.native_extends.is_some() { - return Some(0); + return Some(CtorAbi::positional(0)); } match (&class.extends_name, &class.extends_expr) { - (None, None) => Some(0), - (None, Some(_)) => Some(UNRESOLVED_PARENT_FWD_ARITY), + (None, None) => Some(CtorAbi::positional(0)), + (None, Some(_)) => Some(CtorAbi::positional(UNRESOLVED_PARENT_FWD_ARITY)), _ => None, } } +/// [`context_free_ctor_abi`]'s arity half. +pub fn context_free_ctor_param_count(class: &perry_hir::Class) -> Option { + context_free_ctor_abi(class).map(|abi| abi.param_count) +} + /// The standalone-constructor arity Perry emits for `class`, accounting for the /// JS spec default ctor `constructor(...args) { super(...args) }` that a class /// with NO own constructor but WITH heritage inherits. Walks the ancestor chain @@ -115,3 +161,206 @@ pub(super) fn synthesized_ctor_param_count( // so over-declaring is correct for any (non-native) parent up to this band. UNRESOLVED_PARENT_FWD_ARITY } + +/// The parameter list whose trailing-array layout (user rest, synthesized +/// `arguments`) the emitted standalone `_constructor` symbol follows. +/// +/// An own constructor is its own layout. A class with NO own constructor emits +/// the `super(...args)` forwarder, whose `__forward_arg` params adopt the +/// nearest ctor-bearing LOCAL ancestor's params positionally and pass every slot +/// to that ancestor's symbol unchanged, so a caller has to fill them exactly as +/// it would fill the ancestor's (#10484: the ancestor's `arguments` slot must +/// receive the whole argument list, not one positional argument). +/// +/// `None` wherever the forwarder does not make that static positional call: a +/// dynamic heritage edge (`extends_expr`, forwarded through the runtime +/// dynamic-parent dispatcher as plain arguments), a native parent, an imported +/// ancestor, or an arity that does not match `emitted_param_count`. Also `None` +/// for an ancestor with `__perry_cap_*` params: the forwarder registers no +/// capture slots, so its callers bind every slot positionally as before. +pub(super) fn constructor_layout_params<'a>( + class: &'a perry_hir::Class, + class_table: &HashMap, + emitted_param_count: u32, +) -> Option<&'a [perry_hir::Param]> { + if let Some(ctor) = class.constructor.as_ref() { + return Some(&ctor.params); + } + if class.native_extends.is_some() || class.extends_expr.is_some() { + return None; + } + let mut parent = class.extends_name.as_deref(); + let mut depth = 0usize; + while let Some(name) = parent { + let ancestor = *class_table.get(name)?; + // Imported stubs carry id 0 and no constructor body in this module. + if ancestor.id == 0 || ancestor.native_extends.is_some() || depth > 64 { + return None; + } + if let Some(ctor) = ancestor.constructor.as_ref() { + let positional = ctor.params.len() == emitted_param_count as usize + && !ctor + .params + .iter() + .any(|p| p.name.starts_with("__perry_cap_")); + return positional.then_some(ctor.params.as_slice()); + } + if ancestor.extends_expr.is_some() { + return None; + } + parent = ancestor.extends_name.as_deref(); + depth += 1; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use perry_hir::types::Type; + use perry_hir::{ArgumentsObjectMeta, Class, Function, Param}; + + fn param(name: &str, is_rest: bool, arguments_object: bool) -> Param { + Param { + id: 0, + name: name.to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest, + arguments_object: arguments_object.then(|| ArgumentsObjectMeta { + strict: true, + simple_parameters: false, + mapped_parameter_ids: Vec::new(), + restricted_callee: true, + }), + } + } + + fn class(name: &str, extends: Option<&str>, ctor_params: Option>) -> Class { + Class { + id: 1, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: extends.map(|e| e.to_string()), + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: Vec::new(), + constructor: ctor_params.map(|params| Function { + id: 7, + name: format!("{}_constructor", name), + type_params: Vec::new(), + params, + return_type: Type::Void, + body: Vec::new(), + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }), + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + aliases: Vec::new(), + } + } + + fn arguments_ctor_params() -> Vec { + vec![ + param("p", false, false), + param("q", false, false), + param("arguments", true, true), + ] + } + + #[test] + fn reads_the_trailing_arrays_of_an_own_constructor() { + let abi = CtorAbi::from_params(&arguments_ctor_params()); + assert_eq!( + abi, + CtorAbi { + param_count: 3, + has_rest: false, + has_synthetic_arguments: true, + } + ); + let with_user_rest = vec![ + param("first", false, false), + param("rest", true, false), + param("arguments", false, true), + ]; + assert_eq!( + CtorAbi::from_params(&with_user_rest), + CtorAbi { + param_count: 3, + has_rest: true, + has_synthetic_arguments: true, + } + ); + } + + #[test] + fn a_capturing_constructor_reports_no_packable_slot() { + // The capture params trail the arrays, so the packed slots are not the + // last ones and every caller binds positionally instead. + let mut params = arguments_ctor_params(); + params.push(param("__perry_cap_4", false, false)); + assert_eq!(CtorAbi::from_params(¶ms), CtorAbi::positional(4)); + } + + #[test] + fn a_forwarder_adopts_its_local_ancestors_layout() { + let base = class("Base", None, Some(arguments_ctor_params())); + let middle = class("Middle", Some("Base"), None); + let leaf = class("Leaf", Some("Middle"), None); + let table: HashMap = [ + ("Base".to_string(), &base), + ("Middle".to_string(), &middle), + ("Leaf".to_string(), &leaf), + ] + .into_iter() + .collect(); + + let layout = constructor_layout_params(&leaf, &table, 3).expect("ancestor layout"); + assert!(layout.iter().any(|p| p.arguments_object.is_some())); + assert_eq!(layout.len(), 3); + + // An arity the emitted forwarder does not have must not claim a layout: + // the caller would pack an array into a slot nobody forwards. + assert!(constructor_layout_params(&leaf, &table, 2).is_none()); + } + + #[test] + fn a_dynamic_or_unknown_heritage_forwarder_has_no_layout() { + let base = class("Base", None, Some(arguments_ctor_params())); + let mut dynamic = class("Dynamic", Some("Base"), None); + dynamic.extends_expr = Some(Box::new(perry_hir::Expr::LocalGet(3))); + let orphan = class("Orphan", Some("Missing"), None); + let table: HashMap = [ + ("Base".to_string(), &base), + ("Dynamic".to_string(), &dynamic), + ("Orphan".to_string(), &orphan), + ] + .into_iter() + .collect(); + + assert!(constructor_layout_params(&dynamic, &table, 3).is_none()); + assert!(constructor_layout_params(&orphan, &table, 3).is_none()); + } +} diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index f4522072b9..cbc2d25c47 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -863,6 +863,7 @@ pub(super) fn compile_function( } map }; + super::arguments::release_boxed_param_slots_at_exit(lf, &f.params, &boxed_vars, &locals); // Param types feed local_types so type-aware dispatch (e.g. string // concat detection on a `: string` parameter) works inside the body. diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 17efc82af9..0e77806ec2 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -384,6 +384,12 @@ pub(super) fn compile_method( } (this_slot, map) }; + super::arguments::release_boxed_param_slots_at_exit( + lf, + &method.params, + &method_boxed_vars, + &locals, + ); let mut local_types: HashMap = module_global_types .iter() diff --git a/crates/perry-codegen/src/codegen/method_static.rs b/crates/perry-codegen/src/codegen/method_static.rs index f33abb28f3..eef81c0da4 100644 --- a/crates/perry-codegen/src/codegen/method_static.rs +++ b/crates/perry-codegen/src/codegen/method_static.rs @@ -122,6 +122,12 @@ pub(in crate::codegen) fn compile_static_method( } (this_slot, map) }; + crate::codegen::arguments::release_boxed_param_slots_at_exit( + lf, + &f.params, + &static_boxed_vars, + &locals, + ); // Seed with module-global declared types (mirrors compile_method / // compile_function): static-method bodies read module globals through diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 925c42f4cd..fd80687d6d 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -190,7 +190,9 @@ mod closure_collect; mod constructor_contracts; pub use constructor_contracts::{ConstructorContracts, ResolvedConstructorContracts}; mod ctor_arity; -pub use ctor_arity::{context_free_ctor_param_count, UNRESOLVED_PARENT_FWD_ARITY}; +pub use ctor_arity::{ + context_free_ctor_abi, context_free_ctor_param_count, CtorAbi, UNRESOLVED_PARENT_FWD_ARITY, +}; #[cfg(test)] mod declared_string_add_tests; #[cfg(test)] @@ -2476,6 +2478,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> has_own_constructor: ic.has_own_constructor, has_instance_fields: ic.has_instance_fields, has_rest: ic.constructor_has_rest, + has_synthetic_arguments: ic.constructor_has_synthetic_arguments, }, ) }) diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index d262040d7b..da977f7d82 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -562,14 +562,18 @@ pub struct ImportedClass { pub constructor_param_count: usize, /// Whether the source class declared its own constructor body. pub has_own_constructor: bool, - /// Whether the source class's constructor's last declared parameter is - /// `...rest`. Symmetric to `method_has_rest` but for the constructor: the + /// Whether the source class's constructor declares a user `...rest` + /// parameter. Symmetric to `method_has_rest` but for the constructor: the /// source module compiled `_constructor(this, arg0, …)` expecting /// the rest slot to receive a PACKED ARRAY of the trailing args. Without /// this flag the cross-module `new C(a, b, c)` dispatch passed the args /// positionally, so `arg0 = a` (raw) and `b`/`c` were dropped — a /// `constructor(...args)` saw `args = a`, length 1. pub constructor_has_rest: bool, + /// Whether the source constructor reads `arguments`, i.e. its signature + /// ends in the HIR-synthesized `arguments` slot (after any user rest). That + /// slot receives a packed array of EVERY argument (#10484). + pub constructor_has_synthetic_arguments: bool, /// Whether the source class has instance fields that require initializer replay. pub has_instance_fields: bool, /// Method names defined on this class. @@ -686,6 +690,17 @@ pub struct ImportedClass { } impl ImportedClass { + /// The standalone-constructor ABI this class's defining module compiled, + /// as recorded on the import route (the constructor-contract resolver + /// overwrites all three fields for classes in the source graph). + pub(crate) fn ctor_abi(&self) -> super::ctor_arity::CtorAbi { + super::ctor_arity::CtorAbi { + param_count: self.constructor_param_count, + has_rest: self.constructor_has_rest, + has_synthetic_arguments: self.constructor_has_synthetic_arguments, + } + } + /// Consumer-side registry key for this class. pub fn effective_name(&self) -> String { let member = self.local_alias.as_deref().unwrap_or(&self.name); @@ -779,10 +794,13 @@ pub(crate) struct ImportedCtor { pub param_count: usize, pub has_own_constructor: bool, pub has_instance_fields: bool, - /// True when the constructor's last declared param is `...rest`. Tells + /// True when the constructor declares a user `...rest` param. Tells /// the cross-module `new` dispatch to pack the trailing args into an /// array for the rest slot rather than passing them positionally. pub has_rest: bool, + /// True when the constructor's last param is the synthesized `arguments` + /// slot, which receives every argument packed into one array. + pub has_synthetic_arguments: bool, } impl ImportedCtor { diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index 6f68e99303..d6570e7a6b 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -7,6 +7,7 @@ use crate::module::LlModule; use crate::strings::StringPool; use crate::types::{DOUBLE, I32, I64, PTR, VOID}; +use super::ctor_arity::constructor_layout_params; use super::helpers::{sanitize, sanitize_member, scoped_static_method_name}; use super::retained_source_pool::{SourcePool, SourceRange}; use super::spec_function_length; @@ -950,12 +951,16 @@ pub(super) fn emit_string_pool( .unwrap_or(0) }); let ctor_symbol = format!("{}__{}_constructor", module_prefix, class_name); + // The trailing-array layout of the emitted standalone ctor. A class with + // no own ctor emits the `super(...args)` forwarder, which adopts the + // nearest local ancestor ctor's params positionally and hands every slot + // to that ctor unchanged, so it takes the same layout (#10484: a dynamic + // `new Sub(x)` must put all args in the ancestor's `arguments` slot). + let shape_params = constructor_layout_params(class, classes, ctor_params); // #wall3: record the rest-param position (in USER params) so the runtime // bundles trailing args at the dynamic member-new dispatch path. - if let Some(rest_idx) = class - .constructor - .as_ref() - .and_then(|c| c.params.iter().position(|p| p.is_rest)) + if let Some(rest_idx) = + shape_params.and_then(|params| params.iter().position(|p| p.is_rest)) { ctor_rest_regs.push((ctor_symbol.clone(), rest_idx)); } @@ -964,13 +969,17 @@ pub(super) fn emit_string_pool( // a synthesized `arguments` slot receives ALL args (from index 0), a // user rest param only the args from the rest position onward. { - let last = class.constructor.as_ref().and_then(|c| c.params.last()); - let ctor_has_synth = last.map(|p| p.arguments_object.is_some()).unwrap_or(false); - let ctor_has_rest = class - .constructor - .as_ref() - .map(|c| { - c.params + // `any`, not `last`: a class declared inside a function carries + // synthesized `__perry_cap_*` params AFTER the `arguments` slot, so + // reading only the final param missed every capturing class — + // including every class in a compiled CommonJS module, whose + // wrapper function is what they capture from (#10484). + let ctor_has_synth = shape_params + .map(|params| params.iter().any(|p| p.arguments_object.is_some())) + .unwrap_or(false); + let ctor_has_rest = shape_params + .map(|params| { + params .iter() .any(|p| p.is_rest && p.arguments_object.is_none()) }) diff --git a/crates/perry-codegen/src/expr/arrays_finds.rs b/crates/perry-codegen/src/expr/arrays_finds.rs index c4ea2bfbbd..d9244d2925 100644 --- a/crates/perry-codegen/src/expr/arrays_finds.rs +++ b/crates/perry-codegen/src/expr/arrays_finds.rs @@ -563,8 +563,8 @@ pub(crate) fn lower( // -------- Map.clear -------- Expr::MapClear(map) => { let m_box = lower_expr(ctx, map)?; + let m_handle = super::unbox_collection_receiver(ctx, &m_box, "clear"); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); blk.call_void("js_map_clear", &[(I64, &m_handle)]); // Map.prototype.clear() returns undefined, not 0. Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))) diff --git a/crates/perry-codegen/src/expr/bigint_set.rs b/crates/perry-codegen/src/expr/bigint_set.rs index 7cddc74221..4184dcd86f 100644 --- a/crates/perry-codegen/src/expr/bigint_set.rs +++ b/crates/perry-codegen/src/expr/bigint_set.rs @@ -20,7 +20,7 @@ use super::{ nanbox_pointer_inline, record_collection_number_key_fallback, record_collection_number_key_selected, record_collection_string_key_fallback, record_collection_string_key_selected, record_collection_typed_value_fallback, - record_collection_typed_value_selected, unbox_to_i64, FnCtx, + record_collection_typed_value_selected, unbox_collection_receiver, unbox_to_i64, FnCtx, }; fn number_coerce_operand_is_already_primitive_number(ctx: &FnCtx<'_>, operand: &Expr) -> bool { @@ -153,13 +153,16 @@ fn guarded_set_number_add(ctx: &mut FnCtx<'_>, set_handle: &str, value_box: &str /// is exactly what it was before this change. On the protected path the /// handle has to come from the *re-read* box, below the value's lowering, so /// it is derived in [`reread_set_receiver`] instead. -fn eager_set_handle(ctx: &mut FnCtx<'_>, group: &RootedGroup<'_>) -> Result> { +fn eager_set_handle( + ctx: &mut FnCtx<'_>, + group: &RootedGroup<'_>, + method: &str, +) -> Result> { if group.is_rooted() { return Ok(None); } let s_box = group.reread(ctx, 0)?; - let blk = ctx.block(); - Ok(Some(unbox_to_i64(blk, &s_box))) + Ok(Some(unbox_collection_receiver(ctx, &s_box, method))) } /// Re-derive the `Set` receiver handle AFTER `value` has been lowered (#9523). @@ -174,13 +177,13 @@ fn reread_set_receiver( ctx: &mut FnCtx<'_>, group: &RootedGroup<'_>, s_handle_unrooted: &Option, + method: &str, ) -> Result { if let Some(handle) = s_handle_unrooted { return Ok(handle.clone()); } let s_box = group.reread(ctx, 0)?; - let blk = ctx.block(); - Ok(unbox_to_i64(blk, &s_box)) + Ok(unbox_collection_receiver(ctx, &s_box, method)) } fn guarded_set_number_has(ctx: &mut FnCtx<'_>, set_handle: &str, value_box: &str) -> String { @@ -648,10 +651,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let value_i32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::I32)?; let set_box = lower_expr(ctx, &set_expr)?; - let set_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &set_box) - }; + let set_handle = unbox_collection_receiver(ctx, &set_box, "add"); let new_handle = { let blk = ctx.block(); blk.call( @@ -675,10 +675,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let value_u32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::U32)?; let set_box = lower_expr(ctx, &set_expr)?; - let set_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &set_box) - }; + let set_handle = unbox_collection_receiver(ctx, &set_box, "add"); let new_handle = { let blk = ctx.block(); blk.call( @@ -702,10 +699,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let value_f32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::F32)?; let set_box = lower_expr(ctx, &set_expr)?; - let set_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &set_box) - }; + let set_handle = unbox_collection_receiver(ctx, &set_box, "add"); let new_handle = { let blk = ctx.block(); blk.call( @@ -729,10 +723,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let value_i1 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::I1)?; let set_box = lower_expr(ctx, &set_expr)?; - let set_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &set_box) - }; + let set_handle = unbox_collection_receiver(ctx, &set_box, "add"); let new_handle = { let blk = ctx.block(); let value_i32 = blk.zext(I1, &value_i1.value, I32); @@ -756,17 +747,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if use_number_set { let v = lower_expr(ctx, value)?; let set_box = lower_expr(ctx, &set_expr)?; - let set_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &set_box) - }; + let set_handle = unbox_collection_receiver(ctx, &set_box, "add"); guarded_set_number_add(ctx, &set_handle, &v) } else { let set_box = lower_expr(ctx, &set_expr)?; - let set_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &set_box) - }; + let set_handle = unbox_collection_receiver(ctx, &set_box, "add"); if use_string_set { let value_ref = lower_expr_native( ctx, @@ -931,11 +916,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let value_collects = operand_may_collect(ctx, value); let i32_v = with_rooted_group(ctx, 1, |ctx, group| { group.lower(ctx, set, value_collects)?; - let s_handle_unrooted = eager_set_handle(ctx, group)?; + let s_handle_unrooted = eager_set_handle(ctx, group, "has")?; let i32_v = if use_i32_set { let value_i32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::I32)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "has")?; let i32_v = { let blk = ctx.block(); blk.call( @@ -958,7 +943,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if use_u32_set { let value_u32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::U32)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "has")?; let i32_v = { let blk = ctx.block(); blk.call( @@ -981,7 +966,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if use_f32_set { let value_f32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::F32)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "has")?; let i32_v = { let blk = ctx.block(); blk.call( @@ -1004,7 +989,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if use_boolean_set { let value_i1 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::I1)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "has")?; let i32_v = { let blk = ctx.block(); let value_i32 = blk.zext(I1, &value_i1.value, I32); @@ -1027,7 +1012,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { i32_v } else if use_number_set { let v_box = lower_expr(ctx, value)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "has")?; guarded_set_number_has(ctx, &s_handle, &v_box) } else { if use_string_set { @@ -1036,7 +1021,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { value, crate::native_value::ExpectedNativeRep::StringRef, )?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "has")?; let i32_v = { let blk = ctx.block(); let i32_v = blk.call( @@ -1067,7 +1052,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { i32_v } else { let v_box = lower_expr(ctx, value)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "has")?; let i32_v = { let blk = ctx.block(); blk.call(I32, "js_set_has", &[(I64, &s_handle), (DOUBLE, &v_box)]) @@ -1187,11 +1172,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let value_collects = operand_may_collect(ctx, value); let i32_v = with_rooted_group(ctx, 1, |ctx, group| { group.lower(ctx, set, value_collects)?; - let s_handle_unrooted = eager_set_handle(ctx, group)?; + let s_handle_unrooted = eager_set_handle(ctx, group, "delete")?; let i32_v = if use_i32_set { let value_i32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::I32)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; let i32_v = { let blk = ctx.block(); blk.call( @@ -1214,7 +1199,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if use_u32_set { let value_u32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::U32)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; let i32_v = { let blk = ctx.block(); blk.call( @@ -1237,7 +1222,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if use_f32_set { let value_f32 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::F32)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; let i32_v = { let blk = ctx.block(); blk.call( @@ -1260,7 +1245,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if use_boolean_set { let value_i1 = lower_expr_native(ctx, value, crate::native_value::ExpectedNativeRep::I1)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; let i32_v = { let blk = ctx.block(); let value_i32 = blk.zext(I1, &value_i1.value, I32); @@ -1283,7 +1268,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { i32_v } else if use_number_set { let v_box = lower_expr(ctx, value)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; guarded_set_number_delete(ctx, &s_handle, &v_box) } else { if use_string_set { @@ -1292,7 +1277,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { value, crate::native_value::ExpectedNativeRep::StringRef, )?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = + reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; let i32_v = { let blk = ctx.block(); let i32_v = blk.call( @@ -1323,7 +1309,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { i32_v } else { let v_box = lower_expr(ctx, value)?; - let s_handle = reread_set_receiver(ctx, group, &s_handle_unrooted)?; + let s_handle = + reread_set_receiver(ctx, group, &s_handle_unrooted, "delete")?; let i32_v = { let blk = ctx.block(); blk.call(I32, "js_set_delete", &[(I64, &s_handle), (DOUBLE, &v_box)]) @@ -1415,8 +1402,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // -------- set.size -> number -------- Expr::SetSize(set) => { let s_box = lower_expr(ctx, set)?; + let s_handle = unbox_collection_receiver(ctx, &s_box, "size"); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); let i32_v = blk.call(I32, "js_set_size", &[(I64, &s_handle)]); Ok(blk.sitofp(I32, &i32_v, DOUBLE)) } diff --git a/crates/perry-codegen/src/expr/closure.rs b/crates/perry-codegen/src/expr/closure.rs index f53c588aef..7abb8bb0a8 100644 --- a/crates/perry-codegen/src/expr/closure.rs +++ b/crates/perry-codegen/src/expr/closure.rs @@ -12,57 +12,76 @@ use crate::types::{DOUBLE, I32, I64, PTR}; use super::{lower_expr, nanbox_pointer_inline, FnCtx}; -/// Whether this is the compiler-private step closure for a lowered plain -/// async activation. `ReleaseBoxes` is emitted only in that closure's -/// terminal arms; user-authored closures can never contain it. +/// The activation cells of the compiler-private step closure for a lowered +/// plain async activation: every id its terminal `ReleaseBoxes` arms name, or +/// `None` for any other closure. `ReleaseBoxes` is emitted only in that +/// closure's terminal arms; user-authored closures can never contain it. /// /// Queued and running instances of this closure are already covered by the -/// activation token's refcount. Counting its boxed capture slots as escaping +/// activation token's refcount. Counting those boxed capture slots as escaping /// GC-closure edges would make every cell in the complete activation frame -/// wait for a full collection, even when no user closure can observe it. -fn is_plain_async_step_body(stmts: &[Stmt]) -> bool { - stmts.iter().any(|stmt| match stmt { - Stmt::ReleaseBoxes(_) => true, - Stmt::If { - then_branch, - else_branch, - .. - } => { - is_plain_async_step_body(then_branch) - || else_branch.as_deref().is_some_and(is_plain_async_step_body) - } - Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => is_plain_async_step_body(body), - Stmt::For { init, body, .. } => { - init.as_deref() - .is_some_and(|stmt| is_plain_async_step_body(std::slice::from_ref(stmt))) - || is_plain_async_step_body(body) - } - Stmt::Try { - body, - catch, - finally, - } => { - is_plain_async_step_body(body) - || catch - .as_ref() - .is_some_and(|catch| is_plain_async_step_body(&catch.body)) - || finally.as_deref().is_some_and(is_plain_async_step_body) +/// wait for a full collection, even when no user closure can observe it. A +/// cell from an ENCLOSING scope is not covered by that token (#10464: its +/// owner frame now releases it at scope exit), so only these ids go uncounted. +fn plain_async_step_release_ids(stmts: &[Stmt]) -> Option> { + fn walk(stmts: &[Stmt], out: &mut Option>) { + for stmt in stmts { + match stmt { + Stmt::ReleaseBoxes(ids) => out + .get_or_insert_with(Default::default) + .extend(ids.iter().copied()), + Stmt::If { + then_branch, + else_branch, + .. + } => { + walk(then_branch, out); + if let Some(else_branch) = else_branch { + walk(else_branch, out); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => walk(body, out), + Stmt::For { init, body, .. } => { + if let Some(init) = init { + walk(std::slice::from_ref(init.as_ref()), out); + } + walk(body, out); + } + Stmt::Try { + body, + catch, + finally, + } => { + walk(body, out); + if let Some(catch) = catch { + walk(&catch.body, out); + } + if let Some(finally) = finally { + walk(finally, out); + } + } + Stmt::Switch { cases, .. } => { + for case in cases { + walk(&case.body, out); + } + } + Stmt::Labeled { body, .. } => walk(std::slice::from_ref(body.as_ref()), out), + Stmt::Let { .. } + | Stmt::Expr(_) + | Stmt::Return(_) + | Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::Throw(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) => {} + } } - Stmt::Switch { cases, .. } => cases - .iter() - .any(|case| is_plain_async_step_body(&case.body)), - Stmt::Labeled { body, .. } => is_plain_async_step_body(std::slice::from_ref(body.as_ref())), - Stmt::Let { .. } - | Stmt::Expr(_) - | Stmt::Return(_) - | Stmt::Break - | Stmt::Continue - | Stmt::LabeledBreak(_) - | Stmt::LabeledContinue(_) - | Stmt::Throw(_) - | Stmt::PreallocateBoxes(_) - | Stmt::PreallocateTdzBoxes(_) => false, - }) + } + let mut out = None; + walk(stmts, &mut out); + out } pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { @@ -130,6 +149,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // closure body can deref it via js_box_get/set. Without // this, each closure would get a snapshot of the box's // current value. + let plain_async_step_cells = plain_async_step_release_ids(body); + let uncounted_box_capture = |cap_id: &u32| { + plain_async_step_cells + .as_ref() + .is_some_and(|cells| cells.contains(cap_id)) + }; let mut captured_value_bits: Vec = Vec::with_capacity(auto_captures.len()); for cap_id in &auto_captures { if ctx.boxed_vars.contains(cap_id) { @@ -156,6 +181,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else if let Some(slot) = ctx.locals.get(cap_id).cloned() { // Enclosing function owns the box: slot holds // the raw box pointer as i64. + if uncounted_box_capture(cap_id) { + // #10464: the activation, not this frame, owns + // the cell's lifetime from here on. + ctx.func.forget_pre_return_box_release(&slot); + } let box_ptr = ctx.block().load(I64, &slot); captured_value_bits.push(box_ptr); } else if let Some(global_name) = ctx.module_globals.get(cap_id).cloned() { @@ -297,13 +327,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // // The singleton caches therefore only serve closures the // COMPILER synthesized: the async-activation step closures - // recognized by `is_plain_async_step_body` (their terminal + // recognized by `plain_async_step_release_ids` (their terminal // `ReleaseBoxes` arms cannot appear in user code, and their // identity never escapes the runtime's promise machinery). // Those are the closures the caches were built for — re-created // per resume with the same per-activation box captures. User // arrows and function expressions always mint fresh objects. - let is_plain_async_step = is_plain_async_step_body(body); + let is_plain_async_step = plain_async_step_cells.is_some(); let singleton_identity_safe = is_plain_async_step && (*is_arrow || captures_all_boxed); let no_capture_singleton = is_plain_async_step && *is_arrow && total_caps == 0; let captured_singleton = @@ -347,9 +377,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { && !captured_singleton && total_caps > 0 && !captured_value_bits.is_empty() - && auto_captures - .iter() - .all(|cap_id| is_plain_async_step || !ctx.boxed_vars.contains(cap_id)); + && auto_captures.iter().all(|cap_id| { + !ctx.boxed_vars.contains(cap_id) || uncounted_box_capture(cap_id) + }); let closure_handle = if no_capture_singleton { let blk = ctx.block(); blk.call(I64, "js_closure_alloc_singleton", &[(PTR, &func_ref)]) @@ -435,17 +465,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // their lifetime edges are declared; fresh closures need every // slot initialized here. The compiler-private plain-async step // closure is different: its activation refcount already covers - // every queued/running instance, so declaring its whole boxed - // frame as escaped would delay every terminal cell until a full - // GC. User closures nested inside it still take the dedicated - // setter and therefore preserve #8213's escaped-cell lifetime. - let boxed_capture_slots = auto_captures + // every queued/running instance of its OWN cells, so declaring its + // whole boxed frame as escaped would delay every terminal cell + // until a full GC. User closures nested inside it still take the + // dedicated setter and therefore preserve #8213's escaped-cell + // lifetime, and so does a step closure's capture of an enclosing + // scope's cell (#10464). + let tracked_box_capture_slots = auto_captures .iter() - .map(|cap_id| ctx.boxed_vars.contains(cap_id)) + .map(|cap_id| ctx.boxed_vars.contains(cap_id) && !uncounted_box_capture(cap_id)) .collect::>(); let blk = ctx.block(); for (idx, val_bits) in captured_value_bits.iter().enumerate() { - let track_box_capture = boxed_capture_slots[idx] && !is_plain_async_step; + let track_box_capture = tracked_box_capture_slots[idx]; if bulk_fresh_init { // Every slot was written by `js_closure_alloc_init`. continue; diff --git a/crates/perry-codegen/src/expr/collection_receiver.rs b/crates/perry-codegen/src/expr/collection_receiver.rs new file mode 100644 index 0000000000..864fb93ecd --- /dev/null +++ b/crates/perry-codegen/src/expr/collection_receiver.rs @@ -0,0 +1,97 @@ +//! The receiver check in front of every static-type `Map` / `Set` fast path +//! (#10446). +//! +//! `s.add(v)`, `m.get(k)`, `this.labels.has(x)`, `m.forEach(cb)`, ... lower to +//! a direct `js_set_*` / `js_map_*` call when the receiver's DECLARED type is +//! `Set` / `Map`, and those helpers take the receiver as its unboxed +//! 48-bit payload. A declaration is not a runtime fact. `undefined` (an +//! uninitialized field, a missing option) unboxes to the address `0x1`, which +//! `js_set_add` / `js_map_get` dereferenced: SIGSEGV with no JS stack instead +//! of the catchable `TypeError: Cannot read properties of undefined (reading +//! 'add')` the same call throws when the receiver is untyped. mongodb 7.5.0 +//! died that way in `MongoError.addErrorLabel` about 100 ms after connecting. +//! +//! [`unbox_collection_receiver`] replaces the bare `unbox_to_i64` at those +//! sites. The fast path costs one compare of the box's top 16 bits against the +//! object tag, which a genuine `Map` / `Set` (or a subclass instance, or any +//! other object the runtime helpers already brand-check) always passes. The +//! miss path keeps passing through the two word shapes the helpers have always +//! accepted without a tag — an untagged raw word and a JS handle — and throws +//! for every primitive, none of which can carry a collection method. + +use crate::expr::FnCtx; +use crate::nanbox::{POINTER_MASK_I64, POINTER_TAG_TOP16_I64}; +use crate::types::{DOUBLE, I1, I64, PTR}; + +/// Top 16 bits of `JS_HANDLE_TAG` (`0x7FFB`). +const JS_HANDLE_TAG_TOP16_I64: &str = "32763"; + +/// Check that `recv_box` can be a `Map` / `Set` receiver for method `method`, +/// then return its unboxed handle (what `unbox_to_i64` returns). +/// +/// Emits at the current block, which is left at the check's success block: +/// +/// ```text +/// %bits = bitcast double %recv to i64 +/// %top16 = lshr i64 %bits, 48 +/// %obj = icmp eq i64 %top16, 32765 ; POINTER_TAG +/// br i1 %obj, label %collection_recv.ok, label %collection_recv.miss +/// collection_recv.miss: ; untagged raw word or JS handle +/// br i1 (%top16 == 0 | %top16 == 0x7FFB), label %ok, label %throw +/// collection_recv.throw: +/// call void @js_throw_collection_receiver_type_error(%recv, "") +/// unreachable +/// collection_recv.ok: +/// %handle = and i64 %bits, POINTER_MASK +/// ``` +/// +/// Emit it after the call's operands are lowered, on the box the runtime call +/// consumes (a re-read box on a rooted path): the check never collects, and +/// its throwing arm never returns, so it adds no collection point between the +/// receiver's root and its use. +pub(crate) fn unbox_collection_receiver( + ctx: &mut FnCtx<'_>, + recv_box: &str, + method: &str, +) -> String { + let (bits, top16, is_object) = { + let blk = ctx.block(); + let bits = blk.bitcast_double_to_i64(recv_box); + let top16 = blk.lshr(I64, &bits, "48"); + let is_object = blk.icmp_eq(I64, &top16, POINTER_TAG_TOP16_I64); + (bits, top16, is_object) + }; + let miss_idx = ctx.new_block("collection_recv.miss"); + let throw_idx = ctx.new_block("collection_recv.throw"); + let ok_idx = ctx.new_block("collection_recv.ok"); + let miss_label = ctx.block_label(miss_idx); + let throw_label = ctx.block_label(throw_idx); + let ok_label = ctx.block_label(ok_idx); + ctx.block().cond_br(&is_object, &ok_label, &miss_label); + + ctx.current_block = miss_idx; + { + let blk = ctx.block(); + let is_raw_word = blk.icmp_eq(I64, &top16, "0"); + let is_js_handle = blk.icmp_eq(I64, &top16, JS_HANDLE_TAG_TOP16_I64); + let passes = blk.or(I1, &is_raw_word, &is_js_handle); + blk.cond_br(&passes, &ok_label, &throw_label); + } + + ctx.current_block = throw_idx; + let method_idx = ctx.strings.intern(method); + let method_entry = ctx.strings.entry(method_idx); + let method_bytes = format!("@{}", method_entry.bytes_global); + let method_len = method_entry.byte_len.to_string(); + { + let blk = ctx.block(); + blk.call_void( + "js_throw_collection_receiver_type_error", + &[(DOUBLE, recv_box), (PTR, &method_bytes), (I64, &method_len)], + ); + blk.unreachable(); + } + + ctx.current_block = ok_idx; + ctx.block().and(I64, &bits, POINTER_MASK_I64) +} diff --git a/crates/perry-codegen/src/expr/instance_misc1.rs b/crates/perry-codegen/src/expr/instance_misc1.rs index b09695285d..536058101e 100644 --- a/crates/perry-codegen/src/expr/instance_misc1.rs +++ b/crates/perry-codegen/src/expr/instance_misc1.rs @@ -130,6 +130,14 @@ pub(crate) fn builtin_parent_reserved_class_id(name: &str) -> Option { // which don't recognize a genuine subclass ObjectHeader. Keep in // sync with `CLASS_ID_EVENT_EMITTER` there. "EventEmitter" => 0xFFFF0076, + // #10599: `class Sub extends EventEmitterAsyncResource {}` needs the + // same parent edge as plain EventEmitter above -- without it, + // `get_parent_class_id` never resolves for this id, and the + // getPrototypeOf-identity fallback in + // perry-runtime/src/object/class_registry/state.rs + // (`reserved_native_parent_prototype_bits`) never runs. Keep in sync + // with `CLASS_ID_EVENT_EMITTER_ASYNC_RESOURCE` there. + "EventEmitterAsyncResource" => 0xFFFF0077, _ => return None, }) } diff --git a/crates/perry-codegen/src/expr/logical_collections.rs b/crates/perry-codegen/src/expr/logical_collections.rs index b0c0de265c..edd96397a2 100644 --- a/crates/perry-codegen/src/expr/logical_collections.rs +++ b/crates/perry-codegen/src/expr/logical_collections.rs @@ -903,10 +903,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // #7615 slice 2: the map is live across the key's lowering. rooting::with_operands_rooted(ctx, &[map, key], |ctx, vals| { let (m_box, k_box) = (vals[0].clone(), vals[1].clone()); - let m_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &m_box) - }; + let m_handle = super::unbox_collection_receiver(ctx, &m_box, "delete"); let i32_v = if use_string_key_map { let (k_handle, i32_v) = { let blk = ctx.block(); diff --git a/crates/perry-codegen/src/expr/math_simple.rs b/crates/perry-codegen/src/expr/math_simple.rs index 6320a14954..ed9b1e51ed 100644 --- a/crates/perry-codegen/src/expr/math_simple.rs +++ b/crates/perry-codegen/src/expr/math_simple.rs @@ -19,7 +19,7 @@ use super::{ record_collection_number_key_selected, record_collection_string_key_fallback, record_collection_string_key_selected, record_collection_string_key_value_selected, record_collection_typed_value_fallback, record_collection_typed_value_selected, - unbox_str_handle, unbox_to_i64, FnCtx, + unbox_collection_receiver, unbox_str_handle, unbox_to_i64, FnCtx, }; fn is_static_string_number_map(ctx: &FnCtx<'_>, map: &Expr) -> bool { @@ -286,8 +286,7 @@ fn reread_map_set_receiver_and_key( Some(handle) => handle.clone(), None => { let m_box = values[0].clone(); - let blk = ctx.block(); - unbox_to_i64(blk, &m_box) + unbox_collection_receiver(ctx, &m_box, "set") } }; Ok((m_handle, k_box)) @@ -595,8 +594,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { None } else { let m_box = group.reread(ctx, 0)?; - let blk = ctx.block(); - Some(unbox_to_i64(blk, &m_box)) + Some(unbox_collection_receiver(ctx, &m_box, "set")) }; let new_handle = if use_string_i32_map { let value_i32 = @@ -911,10 +909,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // receiver would otherwise sit unrooted in an SSA register across it. let value = with_operands_rooted(ctx, &[map, key], |ctx, values| { let (m_box, k_box) = (values[0].clone(), values[1].clone()); - let m_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &m_box) - }; + let m_handle = unbox_collection_receiver(ctx, &m_box, "get"); let value = if use_string_key_map { let (k_handle, value) = { let blk = ctx.block(); @@ -971,10 +966,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // while the receiver is live only in an SSA register. let i32_v = with_operands_rooted(ctx, &[map, key], |ctx, values| { let (m_box, k_box) = (values[0].clone(), values[1].clone()); - let m_handle = { - let blk = ctx.block(); - unbox_to_i64(blk, &m_box) - }; + let m_handle = unbox_collection_receiver(ctx, &m_box, "has"); let i32_v = if use_string_key_map { let (k_handle, i32_v) = { let blk = ctx.block(); diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index f6c47ec5aa..02637d22a7 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -43,6 +43,7 @@ mod buffer_views; mod channel; #[cfg(test)] mod class_method_arguments_object_tests; +mod collection_receiver; #[cfg(test)] mod conforming_layout_note_tests; mod helpers; @@ -85,6 +86,7 @@ pub(crate) use channel::{ extract_array_of_object_shape, lower_channel_reduction, try_match_channel_reduction, variant_name, }; +pub(crate) use collection_receiver::unbox_collection_receiver; pub(crate) use helpers::{ array_store_needs_layout_note, array_store_needs_write_barrier, buffer_alias_metadata_suffix, class_field_store_layout_note_is_conforming, class_field_store_needs_layout_note, diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index c568fed651..689ccf3156 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -590,8 +590,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { object, property, .. } if property == "size" && is_set_expr(ctx, object) => { let recv_box = lower_expr(ctx, object)?; + let recv_handle = super::unbox_collection_receiver(ctx, &recv_box, "size"); let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); let i32_v = blk.call(I32, "js_set_size", &[(I64, &recv_handle)]); Ok(blk.sitofp(I32, &i32_v, DOUBLE)) } @@ -599,8 +599,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { object, property, .. } if property == "size" && is_map_expr(ctx, object) => { let recv_box = lower_expr(ctx, object)?; + let recv_handle = super::unbox_collection_receiver(ctx, &recv_box, "size"); let blk = ctx.block(); - let recv_handle = unbox_to_i64(blk, &recv_box); let i32_v = blk.call(I32, "js_map_size", &[(I64, &recv_handle)]); Ok(blk.sitofp(I32, &i32_v, DOUBLE)) } diff --git a/crates/perry-codegen/src/expr/readonly_collection_tests.rs b/crates/perry-codegen/src/expr/readonly_collection_tests.rs index 3e709d7b64..08682c916e 100644 --- a/crates/perry-codegen/src/expr/readonly_collection_tests.rs +++ b/crates/perry-codegen/src/expr/readonly_collection_tests.rs @@ -182,6 +182,7 @@ fn imported_archetype() -> ImportedClass { constructor_param_count: 0, has_own_constructor: true, constructor_has_rest: false, + constructor_has_synthetic_arguments: false, has_instance_fields: true, method_names: Vec::new(), proven_this_method_names: Vec::new(), diff --git a/crates/perry-codegen/src/expr/string_regex_proc.rs b/crates/perry-codegen/src/expr/string_regex_proc.rs index 6414f4491d..6d0c05e2e1 100644 --- a/crates/perry-codegen/src/expr/string_regex_proc.rs +++ b/crates/perry-codegen/src/expr/string_regex_proc.rs @@ -19,8 +19,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { match expr { Expr::SetClear(s) => { let s_box = lower_expr(ctx, s)?; + let s_handle = super::unbox_collection_receiver(ctx, &s_box, "clear"); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); blk.call_void("js_set_clear", &[(I64, &s_handle)]); Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))) } diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index 0da471ab7c..18b51c5578 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -1359,6 +1359,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } } bind_derived_this_after_super(ctx); + // #10443: derived field initializers run once `super()` + // returns, exactly as in every other arm of this block. + // This was the one arm without it, so `class E extends + // Error { labels = new Set(); constructor(m) { super(m); } }` + // constructed directly left `labels` undefined (mongodb's + // `MongoError.errorLabelSet`). A root reached as an + // ANCESTOR is not staged up front for the same reason + // (`root_fields_run_at_own_super`), so this runs once. + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); } }; diff --git a/crates/perry-codegen/src/function.rs b/crates/perry-codegen/src/function.rs index dff3fb48e2..641036385a 100644 --- a/crates/perry-codegen/src/function.rs +++ b/crates/perry-codegen/src/function.rs @@ -178,6 +178,14 @@ pub struct LlFunction { /// Entry/module-init functions use this for process-level diagnostics /// that must run regardless of which block reaches the normal epilogue. pre_return_void_calls: Vec, + /// #10464: entry-alloca slots holding a variable-box cell this frame + /// minted, paired with the kind's `js_*box_scope_release`. Each `ret` + /// hands the slot's current cell back to the runtime (a no-op for a slot + /// still holding its TAG_UNDEFINED entry sentinel). + pre_return_box_releases: Vec<(String, &'static str)>, + /// Slots withdrawn by [`Self::forget_pre_return_box_release`]; a later + /// registration of the same slot stays withdrawn. + withheld_box_release_slots: Vec, } /// Render the frame-push instruction. Kept in one place so the eager @@ -282,6 +290,8 @@ impl LlFunction { stack_map_slot_count: 0, force_shadow_frame: false, pre_return_void_calls: Vec::new(), + pre_return_box_releases: Vec::new(), + withheld_box_release_slots: Vec::new(), } } @@ -497,6 +507,29 @@ impl LlFunction { self.pre_return_void_calls.push(func_name.into()); } + /// #10464: release the variable-box cell held by `slot` before every + /// `ret`. `slot` must be an entry-block alloca whose value is a box + /// pointer or TAG_UNDEFINED on every path. Idempotent per slot. + pub fn add_pre_return_box_release(&mut self, slot: &str, release_fn: &'static str) { + if !self.pre_return_box_releases.iter().any(|(s, _)| s == slot) + && !self.withheld_box_release_slots.iter().any(|s| s == slot) + { + self.pre_return_box_releases + .push((slot.to_string(), release_fn)); + } + } + + /// Withdraw a slot registered by [`Self::add_pre_return_box_release`] + /// because a holder the runtime does not count (a mapped `arguments` + /// object, a plain-async step closure) received its cell. Sticky: the + /// slot is never released by this frame afterwards. + pub fn forget_pre_return_box_release(&mut self, slot: &str) { + self.pre_return_box_releases.retain(|(s, _)| s != slot); + if !self.withheld_box_release_slots.iter().any(|s| s == slot) { + self.withheld_box_release_slots.push(slot.to_string()); + } + } + /// Invoke-EH (#7302): enter/leave a handler scope. While a scope is /// active, every potentially-throwing call any block of this function /// emits carries an unwind edge to the scope's landing-pad label. @@ -1092,8 +1125,9 @@ impl LlFunction { &self, sink: &mut dyn FnMut(FinalItem<'_>) -> Result<(), E>, ) -> Result<(), E> { - let rewrite_rets = - self.shadow_frame_slot.is_some() || !self.pre_return_void_calls.is_empty(); + let rewrite_rets = self.shadow_frame_slot.is_some() + || !self.pre_return_void_calls.is_empty() + || !self.pre_return_box_releases.is_empty(); let mut seq: u32 = 0; for (i, blk) in self.blocks.iter().enumerate() { if i > 0 { @@ -1203,6 +1237,18 @@ impl LlFunction { for func_name in &self.pre_return_void_calls { sink(FinalItem::Text(&format!(" call void @{}()", func_name)))?; } + for (slot, release_fn) in &self.pre_return_box_releases { + let load_reg = format!("%box_release_l_{}", seq); + *seq += 1; + sink(FinalItem::Text(&format!( + " {} = load i64, ptr {}", + load_reg, slot + )))?; + sink(FinalItem::Text(&format!( + " call void @{}(i64 {})", + release_fn, load_reg + )))?; + } if let Some(handle_slot) = &self.shadow_frame_slot { let load_reg = format!("%shadow_pop_l_{}", seq); *seq += 1; diff --git a/crates/perry-codegen/src/gc_call_effects.rs b/crates/perry-codegen/src/gc_call_effects.rs index 265da914e7..6be9a6379a 100644 --- a/crates/perry-codegen/src/gc_call_effects.rs +++ b/crates/perry-codegen/src/gc_call_effects.rs @@ -258,7 +258,13 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect { // collection trigger — the same audit as the accessors above. | "js_box_release" | "js_i32_box_release" - | "js_bool_box_release" => GcCallEffect::CannotCollect, + | "js_bool_box_release" + // #10464 scope-exit release: a registry probe, a capture-count + // lookup, then either the same publish (registry remove, cache evict, + // raw clear, TLS free-list push) or a TLS pending-map insert. + | "js_box_scope_release" + | "js_i32_box_scope_release" + | "js_bool_box_scope_release" => GcCallEffect::CannotCollect, // Audited allocate-but-never-reenter helpers (2026-07-31): each body // was checked for closure invocation, coercion (valueOf/toString), // and accessor dispatch — none present, and none takes a receiver @@ -835,6 +841,9 @@ mod tests { "js_box_release", "js_i32_box_release", "js_bool_box_release", + "js_box_scope_release", + "js_i32_box_scope_release", + "js_bool_box_scope_release", ] { assert_eq!( classify_direct_callee(name), diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 9da92cfed1..1ed8982196 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -76,12 +76,13 @@ pub mod types; pub mod unit_cache; pub use codegen::{ - compile_module, context_free_ctor_param_count, namespace_member_class_key, - namespace_member_func_key, namespace_member_var_key, resolve_target_triple, - short_spread_method_capabilities, user_function_symbol, AppMetadata, CompileOptions, - ConstructorContracts, ExportedObjectLiteralCapability, FpContractMode, ImportedClass, - ImportedObjectLiteral, ImportedObjectLiteralMethod, NamespaceEntry, NamespaceEntryKind, - ObjectLiteralMethodCandidate, ResolvedConstructorContracts, ShortSpreadMethodCandidate, + compile_module, context_free_ctor_abi, context_free_ctor_param_count, + namespace_member_class_key, namespace_member_func_key, namespace_member_var_key, + resolve_target_triple, short_spread_method_capabilities, user_function_symbol, AppMetadata, + CompileOptions, ConstructorContracts, CtorAbi, ExportedObjectLiteralCapability, FpContractMode, + ImportedClass, ImportedObjectLiteral, ImportedObjectLiteralMethod, NamespaceEntry, + NamespaceEntryKind, ObjectLiteralMethodCandidate, ResolvedConstructorContracts, + ShortSpreadMethodCandidate, }; pub use collectors::CjsPreambleCensus; // #9843: the segment-view for-of matcher's counter. Exported so the diff --git a/crates/perry-codegen/src/lower_call/field_init.rs b/crates/perry-codegen/src/lower_call/field_init.rs index 012acd8a75..9ef5391122 100644 --- a/crates/perry-codegen/src/lower_call/field_init.rs +++ b/crates/perry-codegen/src/lower_call/field_init.rs @@ -500,6 +500,44 @@ pub(crate) enum FieldInitMode { FromInclusive(String), } +/// Does the inheritance-chain ROOT `root` install its own field initializers +/// at its own `super()` call? +/// +/// A root has no user-class parent. When it also owns a constructor body, that +/// body's `super(...)` lowers through the non-user-parent block of +/// `expr/this_super_call.rs` — a built-in base (`Error`, `EventEmitter`, `Map`, +/// a stream, ...) or a runtime `extends ` value — and every arm of that +/// block applies the root's fields (`SelfOnly`) once the base constructor has +/// returned, which is the spec position for a derived class. +/// +/// The construction-time staging below (`AncestorsOnly` / `UpToInclusive`) +/// must then leave the root out. Staging it as well ran every initializer +/// twice when the root was built as an ANCESTOR (a private `#field` throws on +/// the second install), and ahead of the base constructor. #10443: the `Error` +/// arm was the one arm that did not apply the fields, so `class E extends +/// Error { labels = new Set(); constructor(m) { super(m); } }` constructed +/// directly never ran its initializers at all, while a subclass of `E` only +/// got them through this up-front staging. +fn root_fields_run_at_own_super(ctx: &FnCtx<'_>, root: &str) -> bool { + if ctx.imported_class_ctors.contains_key(root) { + return false; + } + let Some(class) = ctx.classes.get(root).copied() else { + return false; + }; + if class.constructor.is_none() { + return false; + } + // Mirrors `this_super_call.rs`'s `static_parent_lookup`: a dynamic + // heritage value, or a parent name that is not a local class, reaches the + // non-user-parent block. + class.extends_expr.is_some() + || class + .extends_name + .as_deref() + .is_some_and(|parent| !ctx.classes.contains_key(parent)) +} + /// Whether a named public field initializer can populate the allocation's /// predeclared own slot through the ordinary by-name store. /// @@ -583,8 +621,11 @@ pub(crate) fn apply_field_initializers_recursive( // SuperCall site (`expr.rs::Expr::SuperCall`'s post-body // intermediate-walk added in this commit). Root's fields // need to be applied here because root has no super() and - // its body may reference its own fields directly. - if chain.len() <= 1 { + // its body may reference its own fields directly — unless the + // root's own constructor calls `super()` into a non-user parent, + // which installs them itself (#10443, see + // `root_fields_run_at_own_super`). + if chain.len() <= 1 || root_fields_run_at_own_super(ctx, &chain[0]) { Vec::new() } else { vec![chain[0].clone()] @@ -599,7 +640,9 @@ pub(crate) fn apply_field_initializers_recursive( } FieldInitMode::UpToInclusive(stop_at) => { if let Some(idx) = chain.iter().position(|n| n == stop_at) { - chain[..=idx].to_vec() + // Same root exception as `AncestorsOnly` (#10443). + let start = usize::from(root_fields_run_at_own_super(ctx, &chain[0])); + chain[start..=idx].to_vec() } else { Vec::new() } diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index 77cffef49f..28111be44f 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -85,6 +85,8 @@ mod native_module_rooting_tests; mod native_table; mod new; pub(crate) mod new_alloc; +#[cfg(test)] +mod new_builtin_shadow_tests; mod new_ctor_args; mod new_error_init; mod new_helpers; diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 744750c441..ab8ed03fdf 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -276,7 +276,24 @@ fn lower_new_impl_inner<'a>( // These are checked BEFORE the ctx.classes lookup because the user // code may shadow the name — if they do, the class lookup below // wins. - if !ctx.classes.contains_key(class_name) { + // + // #10589: that shadowing check only covered CLASSES — imported classes + // ARE registered in `ctx.classes` for the importing module, so they + // already skip this block. A user-imported PLAIN FUNCTION constructor + // of the same name (`import { Headers } from "./lib.ts"`) never lands + // in `ctx.classes`, so any builtin arm not gated by `required_sources` + // (`Headers`, `EventEmitter`, …) fired unconditionally and constructed + // the BUILTIN instead of the user's function — for a bare identifier + // callee exactly as much as for one wrapped in `(X as any)`, since + // `peel_new_callee` strips that cast before `lower_new` ever branches + // on the callee shape. Route a genuine imported-function-constructor + // name past the whole builtin block the same way `ctx.classes` already + // does for classes; it falls through to the `import_function_prefixes` + // arm below `ctx.classes.get(class_name)`, which constructs the user's + // function correctly via `js_new_function_construct`. + let user_owns_construction = ctx.import_function_prefixes.contains_key(class_name) + && !ctx.import_function_v8_specifiers.contains_key(class_name); + if !ctx.classes.contains_key(class_name) && !user_owns_construction { if matches!(class_name, "Crypto" | "CryptoKey" | "SubtleCrypto") { for a in args { let _ = lower_expr(ctx, a)?; diff --git a/crates/perry-codegen/src/lower_call/new_builtin_shadow_tests.rs b/crates/perry-codegen/src/lower_call/new_builtin_shadow_tests.rs new file mode 100644 index 0000000000..98cbad3866 --- /dev/null +++ b/crates/perry-codegen/src/lower_call/new_builtin_shadow_tests.rs @@ -0,0 +1,159 @@ +//! #10589: `new X(...)` where `X` is a bare identifier whose NAME collides +//! with an unconditional (not `required_sources`-gated) builtin constructor +//! arm in `lower_builtin_new` must build the USER'S imported binding when one +//! exists, not the builtin. +//! +//! `lower_new_impl_inner` called `lower_builtin_new` for any `class_name` +//! absent from `ctx.classes` before ever checking `ctx.import_function_prefixes` +//! (where an imported PLAIN FUNCTION constructor is tracked — imported +//! CLASSES already land in `ctx.classes` and skip this block entirely, see +//! the `Headers`-as-class regression guard below, which passed before this +//! fix too). Since `"Headers"` has no `required_sources` gate, it fired +//! unconditionally. +//! +//! `new X()` and `new (X as any)()` are equivalent from `lower_new_impl_inner` +//! downward — HIR's `peel_new_callee` strips a `TsAs` cast before `lower_new` +//! ever branches on the callee's shape, so both forms lower to the identical +//! `Expr::New { class_name: "Headers", .. }`. There is deliberately no +//! separate "cast" test here for that reason; the two forms are provably one +//! code path once the AST reaches `Expr::New`. + +use crate::{compile_module, CompileOptions, ImportedClass}; +use perry_hir::{Expr, Module, Stmt}; + +fn new_headers_call() -> Module { + let mut module = Module::new("new_builtin_shadow.ts"); + module.init = vec![Stmt::Expr(Expr::New { + class_name: "Headers".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + })]; + module +} + +fn compile(opts: CompileOptions) -> String { + let bytes = compile_module(&new_headers_call(), opts).expect("module compiles"); + String::from_utf8(bytes).expect("LLVM IR is UTF-8") +} + +#[test] +fn imported_function_constructor_shadows_the_builtin_arm() { + let mut opts = CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + opts.import_function_prefixes + .insert("Headers".to_string(), "lib_ts".to_string()); + let ir = compile(opts); + + assert!( + ir.contains("call double @js_new_function_construct("), + "an imported function constructor named `Headers` must construct \ + through the imported-function path:\n{ir}" + ); + assert!( + !ir.contains("call double @js_headers_new("), + "the builtin fetch Headers constructor must not fire once `Headers` \ + resolves to an imported function (#10589):\n{ir}" + ); +} + +#[test] +fn unshadowed_builtin_name_still_builds_the_builtin() { + // No `import_function_prefixes` entry for "Headers": nothing shadows the + // name, so the builtin fetch API constructor must still fire. Guards + // against an overly broad fix that stops builtin `new Headers()` from + // working when the program never imports anything of that name. + let ir = compile(CompileOptions { + emit_ir_only: true, + ..Default::default() + }); + + assert!( + ir.contains("call double @js_headers_new("), + "an unshadowed `Headers` must still build the builtin:\n{ir}" + ); + assert!( + !ir.contains("call double @js_new_function_construct("), + "nothing resolves this name to an imported function value:\n{ir}" + ); +} + +#[test] +fn v8_fallback_import_of_the_same_name_still_builds_the_builtin() { + // A V8-fallback specifier for "Headers" (present in + // `import_function_prefixes` but ALSO in `import_function_v8_specifiers`) + // is not a compiled-source binding this fix can construct via + // `js_new_function_construct` — it must keep falling through to the + // builtin, same as the codegen's existing `import_function_v8_specifiers` + // exclusion at the later `import_function_prefixes` arm. + let mut opts = CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + opts.import_function_prefixes + .insert("Headers".to_string(), "lib_ts".to_string()); + opts.import_function_v8_specifiers + .insert("Headers".to_string(), "./lib.ts".to_string()); + let ir = compile(opts); + + assert!( + ir.contains("call double @js_headers_new("), + "a V8-fallback import must still build the builtin:\n{ir}" + ); +} + +#[test] +fn imported_class_of_the_same_name_already_shadowed_the_builtin() { + // Regression guard for the OTHER half of the shadowing story, unchanged + // by this fix: an imported CLASS named "Headers" lands in `ctx.classes` + // and always skipped the builtin block, function-constructor collisions + // aside. + let mut opts = CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + opts.imported_classes.push(ImportedClass { + name: "Headers".to_string(), + local_alias: None, + namespace: None, + source_prefix: "lib_ts".to_string(), + constructor_param_count: 0, + has_own_constructor: false, + 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(9101), + return_shape_imports: Vec::new(), + object_literal: None, + }); + let ir = compile(opts); + + assert!( + !ir.contains("call double @js_headers_new("), + "an imported class must already shadow the builtin, before and \ + after #10589's fix:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/lower_call/new_ctor_args.rs b/crates/perry-codegen/src/lower_call/new_ctor_args.rs index de9bd00491..d50e73cf41 100644 --- a/crates/perry-codegen/src/lower_call/new_ctor_args.rs +++ b/crates/perry-codegen/src/lower_call/new_ctor_args.rs @@ -75,6 +75,7 @@ pub(crate) fn bind_inline_constructor_params( .collect(); crate::codegen::arguments::add_arguments_mapped_boxes(params, &mut ctx.boxed_vars); + let mapped_param_ids = crate::codegen::arguments::mapped_parameter_ids(params); let values = inline_constructor_param_values_with_class(ctx, params, lowered_args, capture_fill); for ((param, arg_val), proof) in params @@ -86,7 +87,21 @@ pub(crate) fn bind_inline_constructor_params( let slot = ctx .func .alloca_entry(if boxed_param { I64 } else { DOUBLE }); - if boxed_param { + if boxed_param && !mapped_param_ids.contains(¶m.id) { + // #10464: this frame mints the cell (again per iteration when the + // `new` sits in a loop), so it also releases it. + let arg_bits = ctx.block().bitcast_double_to_i64(arg_val); + ctx.func + .entry_allocas_push_store(I64, crate::nanbox::TAG_UNDEFINED_I64, &slot); + use crate::stmt::boxed_frame_release as frame_release; + frame_release::mint_frame_cell( + ctx, + &slot, + "js_box_alloc_bits", + &[(I64, &arg_bits)], + frame_release::JS_BOX_SCOPE_RELEASE, + ); + } else if boxed_param { let arg_bits = ctx.block().bitcast_double_to_i64(arg_val); let box_ptr = ctx .block() @@ -297,12 +312,13 @@ pub(super) fn lower_constructor_arg(ctx: &mut FnCtx<'_>, arg: &Expr) -> Result_constructor(this, p0, …)` with `ctor.param_count` -/// explicit slots. When the constructor's last param is `...rest` -/// (`ctor.has_rest`), that final slot must receive a PACKED ARRAY of every -/// trailing arg — not the first trailing arg passed raw. Mirrors the -/// inline-ctor `inline_constructor_param_values` rest packing and the -/// `method_has_rest` path for imported methods (#672). Returns exactly -/// `ctor.param_count` value strings; missing leading args are padded with +/// explicit slots, laid out as `[fixed..., user_rest?, arguments?]`. A user +/// `...rest` slot (`ctor.has_rest`) must receive a PACKED ARRAY of every +/// trailing arg — not the first trailing arg passed raw — and the synthesized +/// `arguments` slot (`ctor.has_synthetic_arguments`, #10484) a packed array of +/// EVERY arg. Mirrors the inline-ctor `inline_constructor_param_values` +/// packing and the `method_has_rest` path for imported methods (#672). Returns +/// exactly `ctor.param_count` value strings; missing fixed args are padded with /// `undefined`. pub(super) fn marshal_imported_ctor_args( ctx: &mut FnCtx<'_>, @@ -311,10 +327,9 @@ pub(super) fn marshal_imported_ctor_args( ) -> Vec { let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); let param_count = ctor.param_count; - if ctor.has_rest && param_count > 0 { - // The first `param_count - 1` slots are positional; the last slot is - // the rest array packing every remaining arg. - let n_positional = param_count - 1; + let trailing = usize::from(ctor.has_rest) + usize::from(ctor.has_synthetic_arguments); + if trailing > 0 && param_count >= trailing { + let n_positional = param_count - trailing; let mut out: Vec = Vec::with_capacity(param_count); for i in 0..n_positional { out.push( @@ -324,8 +339,13 @@ pub(super) fn marshal_imported_ctor_args( .unwrap_or_else(|| undef.clone()), ); } - let tail: Vec = lowered_args.iter().skip(n_positional).cloned().collect(); - out.push(pack_lowered_args_array(ctx, &tail)); + if ctor.has_rest { + let tail: Vec = lowered_args.iter().skip(n_positional).cloned().collect(); + out.push(pack_lowered_args_array(ctx, &tail)); + } + if ctor.has_synthetic_arguments { + out.push(pack_lowered_args_array(ctx, lowered_args)); + } out } else { // No rest: positional, padded to `param_count` with `undefined`. diff --git a/crates/perry-codegen/src/lower_call/property_get/map_set.rs b/crates/perry-codegen/src/lower_call/property_get/map_set.rs index d3304ca3e1..2b0013f533 100644 --- a/crates/perry-codegen/src/lower_call/property_get/map_set.rs +++ b/crates/perry-codegen/src/lower_call/property_get/map_set.rs @@ -26,7 +26,9 @@ use anyhow::Result; use perry_hir::Expr; -use crate::expr::{lower_expr, nanbox_pointer_inline, unbox_to_i64, FnCtx}; +use crate::expr::{ + lower_expr, nanbox_pointer_inline, unbox_collection_receiver, unbox_to_i64, FnCtx, +}; use crate::nanbox::double_literal; use crate::rooting; use crate::type_analysis::{ @@ -86,8 +88,8 @@ pub(crate) fn try_lower_map_set_methods( |ctx, vals| { let (m_box, k_box, v_box) = (vals[0].clone(), vals[1].clone(), vals[2].clone()); + let m_handle = unbox_collection_receiver(ctx, &m_box, "set"); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); // #9523: `js_map_set` returns the RECEIVER as it stands // after the insert. For a `class X extends Map` instance // that receiver is a movable `ObjectHeader` the runtime @@ -113,8 +115,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (m_box, k_box) = (vals[0].clone(), vals[1].clone()); + let m_handle = unbox_collection_receiver(ctx, &m_box, "get"); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); Ok(Some(blk.call( DOUBLE, "js_map_get", @@ -127,8 +129,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (m_box, k_box) = (vals[0].clone(), vals[1].clone()); + let m_handle = unbox_collection_receiver(ctx, &m_box, "has"); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); let i32_v = blk.call( crate::types::I32, "js_map_has", @@ -142,8 +144,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (m_box, k_box) = (vals[0].clone(), vals[1].clone()); + let m_handle = unbox_collection_receiver(ctx, &m_box, "delete"); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); let i32_v = blk.call( crate::types::I32, "js_map_delete", @@ -154,8 +156,8 @@ pub(crate) fn try_lower_map_set_methods( } "clear" if args.is_empty() => { let m_box = lower_expr(ctx, object)?; + let m_handle = unbox_collection_receiver(ctx, &m_box, "clear"); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); blk.call_void("js_map_clear", &[(I64, &m_handle)]); return Ok(Some(double_literal(f64::from_bits( crate::nanbox::TAG_UNDEFINED, @@ -179,8 +181,8 @@ pub(crate) fn try_lower_map_set_methods( // `Expr::MapEntries`/etc HIR variants. "entries" | "keys" | "values" if args.is_empty() => { let m_box = lower_expr(ctx, object)?; + let m_handle = unbox_collection_receiver(ctx, &m_box, property); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); let runtime_fn = match property { "entries" => "js_map_entries_iter_obj", "keys" => "js_map_keys_iter_obj", @@ -200,8 +202,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (s_box, v_box) = (vals[0].clone(), vals[1].clone()); + let s_handle = unbox_collection_receiver(ctx, &s_box, "add"); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); blk.call_void("js_set_add", &[(I64, &s_handle), (DOUBLE, &v_box)]); Ok(Some(s_box)) }); @@ -211,8 +213,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (s_box, v_box) = (vals[0].clone(), vals[1].clone()); + let s_handle = unbox_collection_receiver(ctx, &s_box, "has"); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); let i32_v = blk.call( crate::types::I32, "js_set_has", @@ -226,8 +228,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (s_box, v_box) = (vals[0].clone(), vals[1].clone()); + let s_handle = unbox_collection_receiver(ctx, &s_box, "delete"); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); let i32_v = blk.call( crate::types::I32, "js_set_delete", @@ -238,8 +240,8 @@ pub(crate) fn try_lower_map_set_methods( } "clear" if args.is_empty() => { let s_box = lower_expr(ctx, object)?; + let s_handle = unbox_collection_receiver(ctx, &s_box, "clear"); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); blk.call_void("js_set_clear", &[(I64, &s_handle)]); return Ok(Some(double_literal(f64::from_bits( crate::nanbox::TAG_UNDEFINED, @@ -260,8 +262,8 @@ pub(crate) fn try_lower_map_set_methods( // typed-Set HIR path; for Sets `entries` yields `[v, v]` pairs. "values" | "keys" | "entries" if args.is_empty() => { let s_box = lower_expr(ctx, object)?; + let s_handle = unbox_collection_receiver(ctx, &s_box, property); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); let runtime_fn = match property { "values" => "js_set_values_iter_obj", "keys" => "js_set_keys_iter_obj", @@ -281,8 +283,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (s_box, other_box) = (vals[0].clone(), vals[1].clone()); + let s_handle = unbox_collection_receiver(ctx, &s_box, property); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); let runtime_fn = match property { "union" => "js_set_union", "intersection" => "js_set_intersection", @@ -300,8 +302,8 @@ pub(crate) fn try_lower_map_set_methods( // receiver is live only in an SSA register. return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { let (s_box, other_box) = (vals[0].clone(), vals[1].clone()); + let s_handle = unbox_collection_receiver(ctx, &s_box, property); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); let runtime_fn = match property { "isSubsetOf" => "js_set_is_subset_of", "isSupersetOf" => "js_set_is_superset_of", @@ -352,8 +354,8 @@ pub(crate) fn try_lower_collection_foreach( double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) }); { + let m_handle = unbox_collection_receiver(ctx, &m_box, "forEach"); let blk = ctx.block(); - let m_handle = unbox_to_i64(blk, &m_box); blk.call_void( "js_map_foreach", &[(I64, &m_handle), (DOUBLE, &cb_box), (DOUBLE, &this_arg)], @@ -379,8 +381,8 @@ pub(crate) fn try_lower_collection_foreach( double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) }); { + let s_handle = unbox_collection_receiver(ctx, &s_box, "forEach"); let blk = ctx.block(); - let s_handle = unbox_to_i64(blk, &s_box); blk.call_void( "js_set_foreach", &[(I64, &s_handle), (DOUBLE, &cb_box), (DOUBLE, &this_arg)], diff --git a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs index fead87a8c6..ef8c19df45 100644 --- a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs +++ b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs @@ -492,6 +492,7 @@ fn imported_remote() -> ImportedClass { constructor_param_count: 1, has_own_constructor: true, constructor_has_rest: false, + constructor_has_synthetic_arguments: false, has_instance_fields: true, method_names: vec!["read".to_string()], proven_this_method_names: Vec::new(), diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index a73cd55079..6a813563e6 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -396,6 +396,14 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { VOID, &[I32, PTR, I64], ); + // #10446: the failure arm of the static-type Map/Set receiver guard + // (`expr::collection_receiver`). Args: (receiver, method_ptr, method_len). + // Helper diverges (`-> !`); declared as void-return for LLVM purposes. + module.declare_function( + "js_throw_collection_receiver_type_error", + VOID, + &[DOUBLE, PTR, I64], + ); // Issue #510: thrown by `lower_string_method`'s unknown-method // catch-all for primitive (string-typed) receivers. Args: // (kind_ptr, kind_len, prop_ptr, prop_len). Helper diverges @@ -1117,6 +1125,10 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_box_release", VOID, &[I64]); module.declare_function("js_i32_box_release", VOID, &[I64]); module.declare_function("js_bool_box_release", VOID, &[I64]); + // #10464: frame-exit release of cells an ordinary frame minted. + module.declare_function("js_box_scope_release", VOID, &[I64]); + module.declare_function("js_i32_box_scope_release", VOID, &[I64]); + module.declare_function("js_bool_box_scope_release", VOID, &[I64]); module.declare_function("js_bool_box_alloc", I64, &[I32]); module.declare_function("js_bool_box_get", I32, &[I64]); module.declare_function("js_bool_box_set", VOID, &[I64, I32]); diff --git a/crates/perry-codegen/src/stmt/boxed_frame_release.rs b/crates/perry-codegen/src/stmt/boxed_frame_release.rs new file mode 100644 index 0000000000..f01aef30a0 --- /dev/null +++ b/crates/perry-codegen/src/stmt/boxed_frame_release.rs @@ -0,0 +1,65 @@ +//! #10464: hand a frame's variable-box cells back to the runtime when the +//! frame can no longer name them. +//! +//! A boxed local's entry alloca holds the cell this frame minted (or its +//! TAG_UNDEFINED entry sentinel). Two points end the frame's hold on that +//! cell: every `ret` (registered here, emitted by the `LlFunction` return-site +//! rewrite so no return path can be missed), and a declaration inside a loop +//! minting the next iteration's cell into the same slot. The runtime +//! (`perry_runtime::r#box::scope_release`) publishes a cell nothing else +//! captured and leaves a closure-captured cell to its closures' GC death. +//! +//! Only holders the runtime counts may keep a released cell alive. The two +//! compiler-emitted holders it does not count withdraw the slot instead: +//! a mapped sloppy-mode `arguments` object (`codegen/arguments.rs`) and a +//! plain-async step closure's own activation cells (`expr/closure.rs`). + +use crate::expr::FnCtx; +use crate::types::{LlvmType, I64}; + +pub(crate) const JS_BOX_SCOPE_RELEASE: &str = "js_box_scope_release"; +pub(crate) const I32_BOX_SCOPE_RELEASE: &str = "js_i32_box_scope_release"; +pub(crate) const BOOL_BOX_SCOPE_RELEASE: &str = "js_bool_box_scope_release"; + +/// Release `slot`'s cell before every `ret` of the current function. +pub(crate) fn release_at_frame_exit(ctx: &mut FnCtx<'_>, slot: &str, release_fn: &'static str) { + ctx.func.add_pre_return_box_release(slot, release_fn); +} + +/// A declaration about to mint a fresh cell into `slot` re-executes only in a +/// loop; the previous iteration's cell is then unnameable by this frame. +/// Outside a loop the slot still holds its entry sentinel, so nothing is +/// emitted. `switch` frames push an empty continue label and do not count. +pub(crate) fn release_previous_iteration_cell( + ctx: &mut FnCtx<'_>, + slot: &str, + release_fn: &'static str, +) { + let in_loop = ctx + .loop_targets + .iter() + .any(|(continue_label, _, _)| !continue_label.is_empty()); + if !in_loop { + return; + } + let previous = ctx.block().load(I64, slot); + ctx.block().call_void(release_fn, &[(I64, &previous)]); +} + +/// Store a freshly minted cell (`alloc_fn(args)`) into this frame's entry +/// `slot`: release the previous iteration's cell first (so an uncaptured one +/// is reused by this very allocation), and release the slot at frame exit. +/// Returns the new cell pointer. +pub(crate) fn mint_frame_cell( + ctx: &mut FnCtx<'_>, + slot: &str, + alloc_fn: &str, + args: &[(LlvmType, &str)], + release_fn: &'static str, +) -> String { + release_previous_iteration_cell(ctx, slot, release_fn); + let cell = ctx.block().call(I64, alloc_fn, args); + ctx.block().store(I64, &cell, slot); + release_at_frame_exit(ctx, slot, release_fn); + cell +} diff --git a/crates/perry-codegen/src/stmt/boxed_frame_release_tests.rs b/crates/perry-codegen/src/stmt/boxed_frame_release_tests.rs new file mode 100644 index 0000000000..08f10d66ba --- /dev/null +++ b/crates/perry-codegen/src/stmt/boxed_frame_release_tests.rs @@ -0,0 +1,293 @@ +//! #10464: an ordinary frame releases the box cells it minted. +//! +//! Each fixture pairs the released shape with the holder the runtime cannot +//! count, so the assertions discriminate in both directions: dropping the +//! frame release loses the positive assertions, releasing too much trips the +//! negative ones. + +use perry_hir::types::Type; +use perry_hir::{ArgumentsObjectMeta, Expr, Function, Module, Param, Stmt}; + +const RELEASE: &str = "call void @js_box_scope_release(i64 "; + +fn param(id: u32, name: &str) -> Param { + Param { + id, + name: name.into(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn closure(func_id: u32, body: Vec, captures: Vec) -> Expr { + Expr::Closure { + func_id, + params: Vec::new(), + return_type: Type::Any, + body, + mutable_captures: captures.clone(), + captures, + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: true, + } +} + +fn let_stmt(id: u32, init: Expr) -> Stmt { + Stmt::Let { + id, + name: format!("v{id}"), + ty: Type::Any, + mutable: true, + init: Some(init), + } +} + +fn function(name: &str, params: Vec, body: Vec) -> Function { + Function { + id: 1, + name: name.into(), + type_params: Vec::new(), + params, + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +fn function_ir(f: Function) -> String { + let mut module = Module::new("boxed_frame_release.ts"); + let needle = format!("__{}(", f.name); + module.functions.push(f); + let ir = String::from_utf8( + crate::compile_module(&module, super::prealloc_module_global_tests::ir_opts()).unwrap(), + ) + .unwrap(); + ir.split("\ndefine ") + .find(|block| block.lines().next().is_some_and(|l| l.contains(&needle))) + .unwrap_or_else(|| panic!("no define for {needle}:\n{ir}")) + .to_string() +} + +/// Every `ret` of the frame is preceded by one release per slot it names. +fn releases_before_each_ret(ir: &str) -> Vec { + let lines: Vec<&str> = ir.lines().collect(); + let mut counts = Vec::new(); + for (i, line) in lines.iter().enumerate() { + if line.trim_start().starts_with("ret ") { + let mut n = 0; + for prev in lines[..i].iter().rev() { + let t = prev.trim_start(); + if t.starts_with("call void @js_shadow_frame_pop") || t.contains("= load i64, ptr") + { + continue; + } + if t.starts_with(RELEASE) { + n += 1; + continue; + } + break; + } + counts.push(n); + } + } + counts +} + +#[test] +fn captured_reassigned_let_is_released_at_every_return() { + // function counter(flag) { let n = 0; const inc = () => { n = 1 }; + // if (flag) return inc; return n; } + let body = vec![ + let_stmt(10, Expr::Integer(0)), + let_stmt( + 11, + closure( + 2, + vec![Stmt::Expr(Expr::LocalSet(10, Box::new(Expr::Integer(1))))], + vec![10], + ), + ), + Stmt::If { + condition: Expr::LocalGet(1), + then_branch: vec![Stmt::Return(Some(Expr::LocalGet(11)))], + else_branch: None, + }, + Stmt::Return(Some(Expr::LocalGet(10))), + ]; + let ir = function_ir(function("counter", vec![param(1, "flag")], body)); + assert!( + ir.contains("call i64 @js_box_alloc_bits("), + "premise: n is boxed\n{ir}" + ); + assert!( + ir.contains("js_closure_set_box_capture_ptr"), + "premise: counted edge\n{ir}" + ); + let per_ret = releases_before_each_ret(&ir); + assert!(per_ret.len() >= 2, "both returns must be lowered:\n{ir}"); + assert!( + per_ret.iter().all(|n| *n == 1), + "each return releases the single frame-owned cell ({per_ret:?}):\n{ir}" + ); + // Outside a loop the declaration runs once: no previous-iteration release. + let alloc = ir.find("call i64 @js_box_alloc_bits(").unwrap(); + assert!( + !ir[..alloc].contains(RELEASE), + "no release before a one-shot mint:\n{ir}" + ); +} + +#[test] +fn loop_declaration_releases_the_previous_iterations_cell_before_minting() { + // while (flag) { let x = 0; keep = () => { x = 1 }; } + let body = vec![ + Stmt::While { + condition: Expr::LocalGet(1), + body: vec![ + let_stmt(20, Expr::Integer(0)), + let_stmt( + 21, + closure( + 3, + vec![Stmt::Expr(Expr::LocalSet(20, Box::new(Expr::Integer(1))))], + vec![20], + ), + ), + ], + }, + Stmt::Return(Some(Expr::Undefined)), + ]; + let ir = function_ir(function("looped", vec![param(1, "flag")], body)); + let lines: Vec<&str> = ir.lines().map(str::trim_start).collect(); + let alloc = lines + .iter() + .position(|l| l.contains("call i64 @js_box_alloc_bits(")) + .expect("premise: x is boxed"); + let cell = lines[alloc].split(" = ").next().unwrap(); + let slot = lines[alloc..] + .iter() + .find_map(|l| l.strip_prefix(&format!("store i64 {cell}, ptr "))) + .expect("the minted cell is stored in its slot"); + let previous = lines[..alloc] + .iter() + .rev() + .take(4) + .find_map(|l| l.strip_suffix(&format!(" = load i64, ptr {slot}"))) + .unwrap_or_else(|| panic!("the slot's previous cell is loaded before minting:\n{ir}")); + assert!( + lines[..alloc] + .iter() + .rev() + .take(4) + .any(|l| l.starts_with(&format!("{RELEASE}{previous})"))), + "and released before the next iteration's cell is minted:\n{ir}" + ); + assert!( + releases_before_each_ret(&ir).iter().all(|n| *n == 1), + "{ir}" + ); +} + +#[test] +fn mapped_arguments_parameter_is_never_released_by_its_frame() { + // Sloppy `function f(a, b) { const g = () => { a = 1; b = 2 }; return arguments; }` + // with only `a` mapped: the Arguments object aliases a's cell raw. + let mut arguments = param(3, "arguments"); + arguments.arguments_object = Some(ArgumentsObjectMeta { + strict: false, + simple_parameters: true, + mapped_parameter_ids: vec![(0, 1)], + restricted_callee: false, + }); + let body = vec![ + let_stmt( + 30, + closure( + 4, + vec![ + Stmt::Expr(Expr::LocalSet(1, Box::new(Expr::Integer(1)))), + Stmt::Expr(Expr::LocalSet(2, Box::new(Expr::Integer(2)))), + ], + vec![1, 2], + ), + ), + Stmt::Return(Some(Expr::LocalGet(3))), + ]; + let mut f = function( + "sloppy", + vec![param(1, "a"), param(2, "b"), arguments], + body, + ); + f.is_strict = false; + let ir = function_ir(f); + assert!( + ir.contains("js_arguments_object_map_index"), + "premise: a is mapped\n{ir}" + ); + assert_eq!( + ir.matches("call i64 @js_box_alloc_bits(").count(), + 2, + "premise: both parameters are boxed:\n{ir}" + ); + let per_ret = releases_before_each_ret(&ir); + assert!( + !per_ret.is_empty() && per_ret.iter().all(|n| *n == 1), + "only the unmapped parameter is released ({per_ret:?}):\n{ir}" + ); +} + +#[test] +fn plain_async_step_counts_only_enclosing_cells_and_frame_keeps_its_own() { + // An activation frame: OWN is named by the step closure's terminal + // `ReleaseBoxes`, OUTER is an ordinary captured-and-reassigned local. + const OWN: u32 = 40; + const OUTER: u32 = 41; + let step = closure( + 5, + vec![ + Stmt::Expr(Expr::LocalSet(OUTER, Box::new(Expr::Integer(1)))), + Stmt::Expr(Expr::LocalSet(OWN, Box::new(Expr::Integer(2)))), + Stmt::ReleaseBoxes(vec![OWN]), + Stmt::Return(Some(Expr::Undefined)), + ], + vec![OWN, OUTER], + ); + let body = vec![ + Stmt::PreallocateBoxes(vec![OWN]), + let_stmt(OUTER, Expr::Integer(0)), + let_stmt(42, step), + Stmt::Return(Some(Expr::LocalGet(42))), + ]; + let ir = function_ir(function("activation", Vec::new(), body)); + assert_eq!( + ir.matches("call i64 @js_box_alloc_bits(").count(), + 2, + "premise: both cells are minted by this frame:\n{ir}" + ); + assert_eq!( + ir.matches("call void @js_closure_set_box_capture_ptr(") + .count(), + 1, + "the enclosing cell is a counted edge, the activation's own is not:\n{ir}" + ); + let per_ret = releases_before_each_ret(&ir); + assert!( + !per_ret.is_empty() && per_ret.iter().all(|n| *n == 1), + "the frame releases OUTER only; OWN belongs to the activation ({per_ret:?}):\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/stmt/boxed_local_init.rs b/crates/perry-codegen/src/stmt/boxed_local_init.rs index 418f7f2377..9fd2dc5d8d 100644 --- a/crates/perry-codegen/src/stmt/boxed_local_init.rs +++ b/crates/perry-codegen/src/stmt/boxed_local_init.rs @@ -24,18 +24,30 @@ pub(super) fn ensure_reused_box_is_initialized(ctx: &mut FnCtx<'_>, id: u32) { let ready_label = ctx.block_label(ready); ctx.block().cond_br(&missing, &allocate_label, &ready_label); ctx.current_block = allocate; - let cell = if crate::expr::is_compiler_private_async_i32_control_local(ctx, id) { - ctx.block().call(I64, "js_i32_box_alloc", &[(I32, "0")]) + use super::boxed_frame_release as frame_release; + let (cell, release_fn) = if crate::expr::is_compiler_private_async_i32_control_local(ctx, id) { + ( + ctx.block().call(I64, "js_i32_box_alloc", &[(I32, "0")]), + frame_release::I32_BOX_SCOPE_RELEASE, + ) } else if crate::expr::is_compiler_private_async_i1_control_local(ctx, id) { - ctx.block().call(I64, "js_bool_box_alloc", &[(I32, "0")]) + ( + ctx.block().call(I64, "js_bool_box_alloc", &[(I32, "0")]), + frame_release::BOOL_BOX_SCOPE_RELEASE, + ) } else { - ctx.block().call( - I64, - "js_box_alloc_bits", - &[(I64, crate::nanbox::TAG_UNDEFINED_I64)], + ( + ctx.block().call( + I64, + "js_box_alloc_bits", + &[(I64, crate::nanbox::TAG_UNDEFINED_I64)], + ), + frame_release::JS_BOX_SCOPE_RELEASE, ) }; ctx.block().store(I64, &cell, &slot); + // #10464: the cell is this frame's (a withdrawn slot stays withdrawn). + frame_release::release_at_frame_exit(ctx, &slot, release_fn); super::record_boxed_slot_js_value_bits(ctx, id, &cell, "boxed_let.reused_missing_box"); ctx.block().br(&ready_label); ctx.current_block = ready; diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 251f4bbd8a..f360f0a353 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -1242,13 +1242,6 @@ pub(crate) fn lower_let( } return Ok(()); } - // Step 1: allocate box with undefined sentinel bits. - let blk = ctx.block(); - let box_ptr = blk.call( - crate::types::I64, - "js_box_alloc_bits", - &[(I64, crate::nanbox::TAG_UNDEFINED_I64)], - ); // Slot must live in the entry block — closures from sibling // branches may capture this id later, and an alloca placed // here would not dominate those branches' loads. @@ -1267,7 +1260,14 @@ pub(crate) fn lower_let( // deterministically. let undef_bits = crate::nanbox::TAG_UNDEFINED_I64.to_string(); ctx.func.entry_allocas_push_store(I64, &undef_bits, &slot); - ctx.block().store(I64, &box_ptr, &slot); + // Step 1: allocate the box (#10464: released by this frame). + let box_ptr = super::boxed_frame_release::mint_frame_cell( + ctx, + &slot, + "js_box_alloc_bits", + &[(I64, crate::nanbox::TAG_UNDEFINED_I64)], + super::boxed_frame_release::JS_BOX_SCOPE_RELEASE, + ); super::record_boxed_slot_js_value_bits(ctx, id, &box_ptr, "boxed_let.box_ptr_slot"); // Step 2: register BEFORE lowering init. ctx.locals.insert(id, slot); diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index a6ca584e6a..4846931d95 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -13,6 +13,9 @@ use crate::types::DOUBLE; #[cfg(test)] mod boxed_continuation_tests; +pub(crate) mod boxed_frame_release; +#[cfg(test)] +mod boxed_frame_release_tests; mod boxed_local_init; #[cfg(test)] mod boxed_slot_no_root_tests; @@ -665,6 +668,14 @@ pub(crate) fn lower_stmt(ctx: &mut FnCtx<'_>, stmt: &Stmt) -> Result<()> { } fn emit_preallocate_boxes(ctx: &mut FnCtx<'_>, ids: &[u32], tdz: bool) -> Result<()> { + // #10464: a generator/async activation frame's list names its + // compiler-private control cells. That list runs once per frame, and a + // plain-async step closure holds its cells without a counted capture edge, + // so it never releases a "previous iteration" cell. + let activation_frame = ids.iter().any(|id| { + ctx.compiler_private_async_i32_control_locals.contains(id) + || ctx.compiler_private_async_i1_control_locals.contains(id) + }); for id in ids { // #7521: a module-level binding promoted to `@perry_global___` // ALREADY has the shared, forward-visible, GC-rooted cell a prealloc box @@ -701,41 +712,35 @@ fn emit_preallocate_boxes(ctx: &mut FnCtx<'_>, ids: &[u32], tdz: bool) -> Result } let is_i32_control = crate::expr::is_compiler_private_async_i32_control_local(ctx, *id); let is_i1_control = crate::expr::is_compiler_private_async_i1_control_local(ctx, *id); - let blk = ctx.block(); - let (box_ptr, cell_note) = if is_i32_control { + // Seed the JSValue box with TAG_TDZ (Temporal Dead Zone) when + // requested -- a read before the declaration runs throws a spec + // ReferenceError via the runtime `js_box_get_bits` choke point. + // Compiler-private i32/i1 control cells are never TDZ. + let seed_bits = if tdz { + crate::nanbox::TAG_TDZ_I64.to_string() + } else { + crate::nanbox::TAG_UNDEFINED_I64.to_string() + }; + use boxed_frame_release as frame_release; + let (alloc_fn, alloc_arg, release_fn, cell_note) = if is_i32_control { ( - blk.call( - crate::types::I64, - "js_i32_box_alloc", - &[(crate::types::I32, "0")], - ), + "js_i32_box_alloc", + (crate::types::I32, "0"), + frame_release::I32_BOX_SCOPE_RELEASE, "primitive_i32_control_cell", ) } else if is_i1_control { ( - blk.call( - crate::types::I64, - "js_bool_box_alloc", - &[(crate::types::I32, "0")], - ), + "js_bool_box_alloc", + (crate::types::I32, "0"), + frame_release::BOOL_BOX_SCOPE_RELEASE, "primitive_i1_control_cell", ) } else { - // Seed the JSValue box with TAG_TDZ (Temporal Dead Zone) when - // requested -- a read before the declaration runs throws a spec - // ReferenceError via the runtime `js_box_get_bits` choke point. - // Compiler-private i32/i1 control cells are never TDZ. - let seed_bits = if tdz { - crate::nanbox::TAG_TDZ_I64.to_string() - } else { - crate::nanbox::TAG_UNDEFINED_I64.to_string() - }; ( - blk.call( - crate::types::I64, - "js_box_alloc_bits", - &[(crate::types::I64, &seed_bits)], - ), + "js_box_alloc_bits", + (crate::types::I64, seed_bits.as_str()), + frame_release::JS_BOX_SCOPE_RELEASE, "jsvalue_box_cell", ) }; @@ -762,7 +767,12 @@ fn emit_preallocate_boxes(ctx: &mut FnCtx<'_>, ids: &[u32], tdz: bool) -> Result .entry_allocas_push_store(crate::types::I64, &undef_bits, &slot); slot }; + if !activation_frame { + frame_release::release_previous_iteration_cell(ctx, &slot, release_fn); + } + let box_ptr = ctx.block().call(crate::types::I64, alloc_fn, &[alloc_arg]); ctx.block().store(crate::types::I64, &box_ptr, &slot); + frame_release::release_at_frame_exit(ctx, &slot, release_fn); record_boxed_slot_js_value_bits(ctx, *id, &box_ptr, "preallocate_boxes.box_ptr_slot"); if cell_note != "jsvalue_box_cell" { let lowered = LoweredValue::js_value_bits(&box_ptr); diff --git a/crates/perry-codegen/tests/error_subclass_field_init.rs b/crates/perry-codegen/tests/error_subclass_field_init.rs new file mode 100644 index 0000000000..ef60d3a2ed --- /dev/null +++ b/crates/perry-codegen/tests/error_subclass_field_init.rs @@ -0,0 +1,283 @@ +//! #10443: `super()` into a built-in Error must be followed by the derived +//! class's own field initializers. +//! +//! Every other arm of the non-user-parent `super()` block (`EventEmitter`, +//! `Map`/`Set`, the streams, `Promise`, `DOMException`, ...) applies +//! `FieldInitMode::SelfOnly` once the base is installed. The Error-family arm +//! did not, so `class E extends Error { labels = new Set(); constructor(m) { +//! super(m); } }` constructed directly ran no initializer at all and every +//! field read `undefined` — mongodb's `MongoError.errorLabelSet`, and then a +//! SIGSEGV in `js_set_add` (#10446). +//! +//! An IR census rather than an execution test because the ORDER is the +//! contract: the field install has to come after the base's `super()` work +//! (spec: derived field initializers run when `super()` returns), and it has +//! to be emitted exactly once — a second copy is what the staging side of the +//! fix (`root_fields_run_at_own_super`) exists to prevent, and for a private +//! field it would throw at run time. + +use perry_codegen::{compile_module, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Class, ClassField, Expr, Function, Module, ModuleInitKind, Stmt}; + +const CAPTURE_STACK: &str = "@js_error_subclass_capture_stack("; +const FIELD_INSTALL: [&str; 2] = ["@js_class_field_add(", "@js_object_set_field_by_name("]; + +fn ir_opts() -> CompileOptions { + CompileOptions { + is_entry_module: true, + emit_ir_only: true, + output_type: "executable".to_string(), + ..Default::default() + } +} + +fn field(name: &str, init: Expr) -> ClassField { + ClassField { + name: name.to_string(), + key_expr: None, + ty: Type::Number, + init: Some(init), + is_private: false, + is_readonly: false, + decorators: Vec::new(), + } +} + +fn ctor(id: u32, body: Vec) -> Function { + Function { + id, + name: "constructor".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Void, + body, + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +fn class( + id: u32, + name: &str, + extends: &str, + fields: Vec, + ctor: Option, +) -> Class { + Class { + id, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: Some(extends.to_string()), + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields, + constructor: ctor, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + } +} + +fn module_with(classes: Vec, body: Vec) -> Module { + Module { + name: "error_subclass_field_init.ts".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes, + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions: vec![Function { + id: 1, + name: "probe".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Void, + body, + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }], + init_is_strict: false, + init: Vec::new(), + classic_for_lexical_bindings: std::collections::HashSet::new(), + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + script_global_functions: Vec::new(), + references_global_this: false, + annexb_global_undefined_names: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: std::collections::HashSet::new(), + closure_display_names: std::collections::HashMap::new(), + class_display_names: std::collections::HashMap::new(), + closure_source_text: std::collections::HashMap::new(), + class_source_text: std::collections::HashMap::new(), + async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), + gen_param_prologue_len: std::collections::HashMap::new(), + } +} + +fn ir_for(classes: Vec, body: Vec) -> String { + String::from_utf8(compile_module(&module_with(classes, body), ir_opts()).unwrap()) + .expect("LLVM IR should be UTF-8") +} + +fn new_expr(name: &str) -> Expr { + Expr::New { + class_name: name.to_string(), + args: vec![Expr::String("m".to_string())], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + } +} + +/// The body of the emitted function whose definition line contains `needle`. +/// +/// Counting installs over the whole module would mix the inlined `new` site +/// with the per-class standalone `_constructor` symbol, which installs +/// the same fields for the cross-module construction path. Both are checked, +/// one at a time. +fn function_body<'a>(ir: &'a str, needle: &str) -> &'a str { + let define_at = ir + .match_indices("define ") + .find(|(idx, _)| { + let line_end = ir[*idx..].find('\n').map(|e| idx + e).unwrap_or(ir.len()); + ir[*idx..line_end].contains(needle) + }) + .map(|(idx, _)| idx) + .unwrap_or_else(|| panic!("no emitted function matching {needle}:\n{ir}")); + let rest = &ir[define_at..]; + let end = rest.find("\n}").map(|e| e + 2).unwrap_or(rest.len()); + &rest[..end] +} + +/// Byte offsets of every field-install call in `ir`. +fn field_install_offsets(ir: &str) -> Vec { + let mut offsets: Vec = Vec::new(); + for needle in FIELD_INSTALL { + let mut from = 0usize; + while let Some(idx) = ir[from..].find(needle) { + offsets.push(from + idx); + from += idx + needle.len(); + } + } + offsets.sort_unstable(); + offsets +} + +/// Assert `body` installs exactly `expected` fields, all of them after the +/// Error base's `super()` work. +fn assert_installs_after_super(body: &str, expected: usize, what: &str) { + let super_at = body + .find(CAPTURE_STACK) + .unwrap_or_else(|| panic!("{what}: the Error super() arm must run:\n{body}")); + let installs = field_install_offsets(body); + assert_eq!( + installs.len(), + expected, + "{what}: one install per declared field, no duplicate staging \ + (installs at {installs:?}):\n{body}" + ); + assert!( + installs.iter().all(|at| *at > super_at), + "{what}: field initializers run AFTER super() returns (super at \ + {super_at}, installs at {installs:?}):\n{body}" + ); +} + +#[test] +fn own_ctor_error_subclass_installs_its_fields_after_super() { + let e = class( + 5, + "E", + "Error", + vec![field("n", Expr::Integer(7))], + Some(ctor( + 2, + vec![Stmt::Expr(Expr::SuperCall(vec![Expr::String( + "m".to_string(), + )]))], + )), + ); + let ir = ir_for(vec![e], vec![Stmt::Expr(new_expr("E"))]); + + // The standalone `_constructor` symbol is the body every `new E` + // runs (the call site routes through it when the class owns a ctor), and + // the one a cross-module `new` or a dynamic construct replay reaches. + assert_installs_after_super(function_body(&ir, "E_constructor"), 1, "E ctor"); +} + +#[test] +fn an_error_rooted_chain_installs_each_level_once() { + // `class Mid extends Error { m = 1; ctor }` + `class Leaf extends Mid + // { n = 2; ctor }`: Mid is the chain ROOT, so before the fix its fields + // were staged up front by the construction site AND (once the Error arm + // started applying them) would have been installed a second time at its + // own `super()`. Two fields, two installs. + let mid = class( + 5, + "Mid", + "Error", + vec![field("m", Expr::Integer(1))], + Some(ctor( + 2, + vec![Stmt::Expr(Expr::SuperCall(vec![Expr::String( + "m".to_string(), + )]))], + )), + ); + let leaf = class( + 6, + "Leaf", + "Mid", + vec![field("n", Expr::Integer(2))], + Some(ctor( + 3, + vec![Stmt::Expr(Expr::SuperCall(vec![Expr::String( + "m".to_string(), + )]))], + )), + ); + let ir = ir_for(vec![mid, leaf], vec![Stmt::Expr(new_expr("Leaf"))]); + + // Leaf's constructor inlines Mid's body: `m` and `n`, once each. + assert_installs_after_super(function_body(&ir, "Leaf_constructor"), 2, "Leaf ctor"); + // Mid's own standalone symbol installs only Mid's field. + assert_installs_after_super(function_body(&ir, "Mid_constructor"), 1, "Mid ctor"); +} diff --git a/crates/perry-codegen/tests/typed_collection_receiver_guard.rs b/crates/perry-codegen/tests/typed_collection_receiver_guard.rs new file mode 100644 index 0000000000..ce2dd016c2 --- /dev/null +++ b/crates/perry-codegen/tests/typed_collection_receiver_guard.rs @@ -0,0 +1,203 @@ +//! #10446: the static-type `Map` / `Set` fast paths must check their receiver. +//! +//! Codegen picks `js_map_get` / `js_set_add` from the receiver's DECLARED +//! type and hands them the unboxed 48-bit payload. A declaration is not a +//! runtime fact: `undefined` unboxes to the address `0x1`, which those helpers +//! dereferenced (SIGSEGV, no JS stack, uncatchable — mongodb 7.5.0 died that +//! way inside `MongoError.addErrorLabel`). +//! +//! This is an IR census, and both halves matter. +//! +//! The positive half asserts the guard is LIVE: the object-tag compare, the +//! throw block, and the diverging call are all emitted, so a guard that is +//! silently never emitted fails here rather than in a segfault months later. +//! +//! The negative half asserts the guard is still a GUARD and not a detour: the +//! fast path keeps calling the same runtime helper it always did, and the +//! throwing arm ends in `unreachable` so the check adds no reachable call — and +//! therefore no collection point — between the receiver and its use. + +use perry_codegen::{compile_module, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Expr, Function, Module, ModuleInitKind, Stmt}; + +/// The emitted blocks that exist only when the guard was lowered. +const THROW_BLOCK: &str = "collection_recv.throw"; +const OK_BLOCK: &str = "collection_recv.ok"; +/// `POINTER_TAG >> 48` — the one compare the fast path pays. +const OBJECT_TAG_TOP16: &str = "32765"; +/// The CALL, not the module's unconditional `declare` of the same symbol. +const THROW_CALL: &str = "call void @js_throw_collection_receiver_type_error("; + +fn ir_opts() -> CompileOptions { + CompileOptions { + is_entry_module: true, + emit_ir_only: true, + output_type: "executable".to_string(), + ..Default::default() + } +} + +fn map_type() -> Type { + Type::Generic { + base: "Map".to_string(), + type_args: vec![Type::String, Type::Number], + } +} + +fn set_type() -> Type { + Type::Generic { + base: "Set".to_string(), + type_args: vec![Type::String], + } +} + +fn module_with(body: Vec) -> Module { + Module { + name: "typed_collection_receiver_guard.ts".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes: Vec::new(), + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions: vec![Function { + id: 1, + name: "probe".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Void, + body, + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }], + init_is_strict: false, + init: Vec::new(), + classic_for_lexical_bindings: std::collections::HashSet::new(), + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + script_global_functions: Vec::new(), + references_global_this: false, + annexb_global_undefined_names: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: std::collections::HashSet::new(), + closure_display_names: std::collections::HashMap::new(), + class_display_names: std::collections::HashMap::new(), + closure_source_text: std::collections::HashMap::new(), + class_source_text: std::collections::HashMap::new(), + async_generator_funcs: std::collections::HashSet::new(), + local_source_spans: std::collections::HashMap::new(), + gen_param_prologue_len: std::collections::HashMap::new(), + } +} + +fn ir_for(body: Vec) -> String { + String::from_utf8(compile_module(&module_with(body), ir_opts()).unwrap()) + .expect("LLVM IR should be UTF-8") +} + +/// Every emitted call to `name`, with the instruction that follows it. +fn call_lines_with_successor<'a>(ir: &'a str, name: &str) -> Vec<(&'a str, &'a str)> { + let lines: Vec<&str> = ir.lines().collect(); + lines + .iter() + .enumerate() + .filter(|(_, line)| line.contains(name)) + .map(|(idx, line)| (*line, lines.get(idx + 1).copied().unwrap_or(""))) + .collect() +} + +#[test] +fn map_get_guards_its_receiver_before_the_runtime_call() { + let ir = ir_for(vec![ + Stmt::Let { + id: 1, + name: "m".to_string(), + ty: map_type(), + mutable: false, + init: Some(Expr::MapNew), + }, + Stmt::Expr(Expr::MapGet { + map: Box::new(Expr::LocalGet(1)), + key: Box::new(Expr::String("k".to_string())), + }), + ]); + + assert!( + ir.contains(THROW_BLOCK) && ir.contains(OK_BLOCK), + "the receiver guard's blocks must be emitted:\n{ir}" + ); + assert!( + ir.contains(OBJECT_TAG_TOP16), + "the fast path must test the object tag ({OBJECT_TAG_TOP16}):\n{ir}" + ); + // The guard is a guard: the ordinary lowering still reaches the helper. + assert!( + ir.contains("@js_map_get("), + "the guarded fast path must still call js_map_get:\n{ir}" + ); + + let throws = call_lines_with_successor(&ir, THROW_CALL); + assert!( + !throws.is_empty(), + "the miss path must call the diverging helper:\n{ir}" + ); + for (call, next) in throws { + assert!( + next.trim() == "unreachable", + "the throw helper diverges, so its block must end there \ + (call: {call}, next: {next})" + ); + } +} + +#[test] +fn set_add_guards_its_receiver_before_the_runtime_call() { + let ir = ir_for(vec![ + Stmt::Let { + id: 1, + name: "s".to_string(), + ty: set_type(), + mutable: false, + init: Some(Expr::SetNew), + }, + Stmt::Expr(Expr::SetAdd { + set_id: 1, + value: Box::new(Expr::String("x".to_string())), + }), + ]); + + assert!( + ir.contains(THROW_BLOCK) && ir.contains(THROW_CALL), + "set.add must guard its receiver:\n{ir}" + ); + assert!( + ir.contains("@js_set_add"), + "the guarded fast path must still call a js_set_add helper:\n{ir}" + ); +} + +#[test] +fn a_guardless_collection_free_body_emits_no_guard() { + // The control: nothing about the guard is unconditional module prelude. + let ir = ir_for(vec![Stmt::Expr(Expr::Integer(1))]); + assert!( + !ir.contains(THROW_BLOCK) && !ir.contains(THROW_CALL), + "a body with no collection receiver must emit no guard:\n{ir}" + ); +} diff --git a/crates/perry-hir/src/destructuring/var_decl.rs b/crates/perry-hir/src/destructuring/var_decl.rs index 920a424882..21a52f018e 100644 --- a/crates/perry-hir/src/destructuring/var_decl.rs +++ b/crates/perry-hir/src/destructuring/var_decl.rs @@ -257,6 +257,19 @@ pub(crate) fn lower_var_decl_with_destructuring( // Alias / prototype / static-method tracking for the freshly- // bound identifier (extracted to `alias_tracking`). track_decl_aliases(ctx, decl, &name, id, &init); + // #10489: remember WHICH class this binding holds, so a `new ()` + // resolves to it even when another binding claimed the same name. + if let Some(key) = init.as_ref().and_then(class_expr_value_key) { + if decl + .init + .as_deref() + .is_some_and(crate::lower::expr_assign::rhs_accepts_assignment_name) + && ctx.inferred_class_bindings.contains(&name) + { + ctx.inferred_class_bindings + .record_binding(id, key.to_string()); + } + } // `with (o) { var foo = v; }` — the binding `foo` is hoisted to // the enclosing var scope, but the *initialisation* is a normal // PutValue under the with environment: when `o` has a `foo` @@ -528,3 +541,22 @@ pub(crate) fn lower_var_decl_with_destructuring( Ok(result) } + +/// The registration key of the class a lowered class-expression initializer +/// yields: a bare `ClassRef`/`ClassExprFresh`, or the tail of the sequence +/// `lower_class_expr` wraps it in (parent registration, computed names, the +/// #6654 capture-owner `LocalSet(owner, fresh), LocalGet(owner)` pair). +fn class_expr_value_key(expr: &Expr) -> Option<&str> { + match expr { + Expr::ClassRef(key) => Some(key), + Expr::ClassExprFresh { template, .. } => Some(template), + Expr::Sequence(items) => match items.as_slice() { + [.., Expr::LocalSet(owner, value), Expr::LocalGet(read)] if owner == read => { + class_expr_value_key(value) + } + [.., last] => class_expr_value_key(last), + [] => None, + }, + _ => None, + } +} diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 3d4e00a380..6d1da1665b 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -127,7 +127,7 @@ impl LoweringContext { class_display_names: HashMap::new(), gen_param_prologue_len: HashMap::new(), assignment_inferred_name: None, - inferred_class_bindings: std::collections::HashSet::new(), + inferred_class_bindings: Default::default(), closure_source_text: HashMap::new(), class_source_text: HashMap::new(), func_return_native_instances: Vec::new(), diff --git a/crates/perry-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index a947b6cdf9..83bda62ad3 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -829,9 +829,10 @@ fn lower_assignment_target( && ctx.lookup_func(&cls_name).is_none() { None - } else if ctx.lookup_local(&cls_name).is_some() - && !ctx.inferred_class_bindings.contains(cls_name.as_str()) - { + } else if ctx.lookup_local(&cls_name).is_some_and(|local| { + ctx.inferred_class_bindings.class_key_for(local, &cls_name) + != Some(cls_name.as_str()) + }) { // A lexical local shadows any same-named // module-scope class for this write too // (wall 7's disease, 4th surface): the diff --git a/crates/perry-hir/src/lower/expr_member.rs b/crates/perry-hir/src/lower/expr_member.rs index d6f5b3f391..08bc5cfc1b 100644 --- a/crates/perry-hir/src/lower/expr_member.rs +++ b/crates/perry-hir/src/lower/expr_member.rs @@ -1064,8 +1064,11 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re // so reading through the shared template's `StaticFieldGet` loses both // its value and its property-presence semantics. This mirrors the // static-call guard in `expr_call/static_and_instance.rs`. - let local_shadows_class = ctx.lookup_local(source_name).is_some() - && !ctx.inferred_class_bindings.contains(source_name); + let local_shadows_class = ctx.lookup_local(source_name).is_some_and(|local| { + ctx.inferred_class_bindings + .class_key_for(local, source_name) + != Some(source_name) + }); let obj_name = ctx.resolve_class_name(source_name); if !local_shadows_class && ctx.lookup_class(&obj_name).is_some() { if let ast::MemberProp::Ident(prop_ident) = &member.prop { diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 573bcf30b4..0d1b42f541 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -1543,15 +1543,21 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R // attach), which the generic dynamic construct does not. // A shadowing local over an UNRELATED same-named class // declaration has no alias entry, so wall 7's reroute keeps - // firing. - let local_is_class_alias = - ctx.inferred_class_bindings.contains(class_name.as_str()); - if !local_is_class_alias { - return Ok(Expr::NewDynamic { - callee: Box::new(Expr::LocalGet(local_id)), - args, - byte_offset: new_byte_offset, - }); + // firing. When two class expressions claimed this name, the + // binding's OWN class is used (#10489), never the first + // claimant's. + match ctx + .inferred_class_bindings + .class_key_for(local_id, &class_name) + { + Some(key) => class_name = key.to_string(), + None => { + return Ok(Expr::NewDynamic { + callee: Box::new(Expr::LocalGet(local_id)), + args, + byte_offset: new_byte_offset, + }); + } } } // Issue #838 followup (b): when `` is NOT a real diff --git a/crates/perry-hir/src/lower/lower_expr/arm_class.rs b/crates/perry-hir/src/lower/lower_expr/arm_class.rs index 273e1f1d7f..4bd9822d1d 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_class.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_class.rs @@ -92,6 +92,7 @@ pub(crate) fn lower_class_expr( // for this same expression. Record the BINDING name so // `new ()` keeps the static construct path. ctx.inferred_class_bindings.insert(name.clone()); + ctx.inferred_class_bindings.mark_contested(&name); display_override = Some(name.clone()); format!("{}__anon_dup_{}", name, ctx.fresh_class()) } diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index d00b76034f..aac869aceb 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -12,6 +12,61 @@ use std::collections::{HashMap, HashSet}; use crate::ir::*; use crate::ClassAccessorNames; +/// Binding names whose class registration was created by the binding's own +/// class-expression initializer (see `LoweringContext::inferred_class_bindings`). +/// +/// The name set alone cannot tell two same-named bindings apart. When a second +/// class expression infers an already-claimed name (the #5592 `__anon_dup_` +/// arm: `function a() { const K = class {…} }` next to `function b() { const +/// K = class {…} }`), `new K()` inside `b` resolved `K` to `a`'s class and +/// appended `a`'s capture ids, so `b`'s constructor ran against another +/// function's locals (#10489). Such CONTESTED names resolve per binding local: +/// a declaration records which class its local holds, and an unrecorded local +/// constructs its runtime value. +#[derive(Debug, Default)] +pub(crate) struct InferredClassBindings { + names: HashSet, + contested: HashSet, + by_local: HashMap, +} + +impl InferredClassBindings { + pub(crate) fn contains(&self, name: &str) -> bool { + self.names.contains(name) + } + + pub(crate) fn insert(&mut self, name: String) -> bool { + self.names.insert(name) + } + + pub(crate) fn remove(&mut self, name: &str) -> bool { + self.names.remove(name) + } + + /// A second class expression claimed `name` under a disambiguated key. + pub(crate) fn mark_contested(&mut self, name: &str) { + self.contested.insert(name.to_string()); + } + + /// The declaration of binding `local` evaluated the class registered as + /// `class_key`. + pub(crate) fn record_binding(&mut self, local: LocalId, class_key: String) { + self.by_local.insert(local, class_key); + } + + /// The registration key of the class the in-scope local `local` (a binding + /// named `name`) provably holds, or `None` when it must be treated as an + /// arbitrary runtime value. + pub(crate) fn class_key_for(&self, local: LocalId, name: &str) -> Option<&str> { + if let Some(key) = self.by_local.get(&local) { + return Some(key.as_str()); + } + (self.names.contains(name) && !self.contested.contains(name)) + .then(|| self.names.get(name).map(String::as_str)) + .flatten() + } +} + #[derive(Debug, Clone, Copy)] pub(crate) struct WithEnvFrame { pub(crate) local_id: LocalId, @@ -361,8 +416,10 @@ pub struct LoweringContext { /// bind name too). At a `new ()` site, such a name's local provably /// holds that same class, so the static construct path (with its exact /// builtin-parent handling) is correct; any OTHER in-scope local shadows - /// whatever same-named class exists and must construct dynamically. - pub(crate) inferred_class_bindings: std::collections::HashSet, + /// whatever same-named class exists and must construct dynamically. Names + /// claimed by more than one class expression resolve by binding identity + /// instead (see [`InferredClassBindings::class_key_for`]). + pub(crate) inferred_class_bindings: InferredClassBindings, /// #4101: original source text keyed by FuncId, captured by slicing the /// module source against each function's AST span at lowering time. /// Flushed into `Module.closure_source_text` alongside `pending_functions`. diff --git a/crates/perry-hir/src/lower/shared_mutable_capture.rs b/crates/perry-hir/src/lower/shared_mutable_capture.rs index 144cb4d05a..de84c599cd 100644 --- a/crates/perry-hir/src/lower/shared_mutable_capture.rs +++ b/crates/perry-hir/src/lower/shared_mutable_capture.rs @@ -75,6 +75,7 @@ fn is_cap_name_of(name: &str, ids: &HashSet) -> bool { struct BodySharedCaptures { ids: HashSet, by_class: HashMap>, + census: DeclCensus, } pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { @@ -112,27 +113,19 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { let fn_shared: Vec = module .functions .iter() - .map(|f| detect_shared_in_body(&f.body, &classes)) + .map(|f| detect_shared_in_body(&f.params, &f.body, &classes)) .collect(); - let init_shared = detect_shared_in_body(&module.init, &classes); + let init_shared = detect_shared_in_body(&[], &module.init, &classes); (fn_shared, init_shared) }; - // Keep only ids that are UNAMBIGUOUS within their body (declared exactly - // once across deep `Let`s + nested closure params — see - // `retain_unambiguous`). Nested closures restart their id spaces, so a - // numeric rewrite over the whole body is only sound for unique ids. - for (f, shared) in module.functions.iter().zip(fn_shared.iter_mut()) { + // Keep only ids that denote ONE binding within their body (see + // `DeclCensus::is_one_binding`). Nested closures restart their id spaces, + // so a numeric rewrite over the whole body is only sound for those. + for shared in fn_shared.iter_mut() { if shared.ids.is_empty() { continue; } - let mut counts: HashMap = HashMap::new(); - for p in &f.params { - *counts.entry(p.id).or_default() += 1; - } - for st in &f.body { - collect_declared_counts_stmt(st, &mut counts); - } - retain_unambiguous(&mut shared.ids, &counts); + retain_unambiguous(&mut shared.ids, &shared.census); let retained = &shared.ids; for ids in shared.by_class.values_mut() { ids.retain(|id| retained.contains(id)); @@ -140,11 +133,7 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { shared.by_class.retain(|_, ids| !ids.is_empty()); } if !init_shared.ids.is_empty() { - let mut counts: HashMap = HashMap::new(); - for st in &module.init { - collect_declared_counts_stmt(st, &mut counts); - } - retain_unambiguous(&mut init_shared.ids, &counts); + retain_unambiguous(&mut init_shared.ids, &init_shared.census); let retained = &init_shared.ids; for ids in init_shared.by_class.values_mut() { ids.retain(|id| retained.contains(id)); @@ -167,6 +156,7 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { .extend(ids.iter().copied()); } } + propagate_cells_to_nested_classes(module, &mut shared_by_class); // ---- declaring bodies: rewrite with ONLY the ids detected in them ------- for (f, shared) in module.functions.iter_mut().zip(fn_shared.iter()) { @@ -192,6 +182,7 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { } }) .collect(); + demote_var_redeclarations(&f.params, &mut f.body, ids); rewrite_stmts(&mut f.body, ids, ids); for id in shared_params.into_iter().rev() { f.body.insert( @@ -205,6 +196,7 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { } } if !init_shared.ids.is_empty() { + demote_var_redeclarations(&[], &mut module.init, &init_shared.ids); rewrite_stmts(&mut module.init, &init_shared.ids, &init_shared.ids); } @@ -257,24 +249,22 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { } if !ctor_ids.is_empty() { // Uniqueness over the whole ctor+fields region (one scope). - let mut counts: HashMap = HashMap::new(); + let mut census = DeclCensus::default(); + let mut walker = CensusWalker::new(&mut census); + let scope = walker.open_scope(); if let Some(ctor) = &c.constructor { - for p in &ctor.params { - *counts.entry(p.id).or_default() += 1; - } - for st in &ctor.body { - collect_declared_counts_stmt(st, &mut counts); - } + walker.params(&ctor.params, scope); + walker.stmts(&ctor.body, scope, 0, true); } for f in &c.fields { if let Some(init) = &f.init { - collect_declared_counts_expr(init, &mut counts); + walker.expr(init, scope, 0); } if let Some(key) = &f.key_expr { - collect_declared_counts_expr(key, &mut counts); + walker.expr(key, scope, 0); } } - retain_unambiguous(&mut ctor_ids, &counts); + retain_unambiguous(&mut ctor_ids, &census); } if !ctor_ids.is_empty() { if let Some(ctor) = &mut c.constructor { @@ -338,8 +328,8 @@ fn collect_fn_target_ids(f: &Function, targets: &HashSet, out: &mut Has } /// Rewrite one lifted member body with ONLY its own rebind ids — and only -/// those that are UNAMBIGUOUS within the member (declared exactly once across -/// its params and deep `Let`s/closure params; see `retain_unambiguous`). +/// those that are UNAMBIGUOUS within the member (one binding across its params +/// and deep `Let`s/closure params; see `retain_unambiguous`). fn rewrite_member_scoped( f: &mut Function, targets: &HashSet, @@ -350,20 +340,14 @@ fn rewrite_member_scoped( if ids.is_empty() { return; } - let mut counts: HashMap = HashMap::new(); - for p in &f.params { - *counts.entry(p.id).or_default() += 1; - } - for s in &f.body { - collect_declared_counts_stmt(s, &mut counts); - } - retain_unambiguous(&mut ids, &counts); + let census = DeclCensus::of_body(&f.params, &f.body); + retain_unambiguous(&mut ids, &census); if !ids.is_empty() { rewrite_stmts(&mut f.body, no_shared, &ids); } } -/// Drop every id that is declared more than once in the rewritten region. +/// Drop every id that does not denote exactly ONE binding in the region. /// /// LocalIds restart per closure scope (#5143 family): inside a CJS module /// wrapper the whole module body is ONE function whose nested closures reuse @@ -374,40 +358,366 @@ fn rewrite_member_scoped( /// every route of the Next.js standalone server (#6089). An ambiguous id is /// skipped: its capture stays a split cell (the lesser, pre-#6054 behavior) /// instead of corrupting unrelated code. -fn retain_unambiguous(ids: &mut HashSet, counts: &HashMap) { +fn retain_unambiguous(ids: &mut HashSet, census: &DeclCensus) { if std::env::var("PERRY_5951_TRACE").as_deref() == Ok("1") { let dropped: Vec = ids .iter() .copied() - .filter(|id| counts.get(id).copied().unwrap_or(0) != 1) + .filter(|id| !census.is_one_binding(*id)) .collect(); if !dropped.is_empty() { eprintln!("[5951] skipped ambiguous ids {dropped:?}"); } } - ids.retain(|id| counts.get(id).copied().unwrap_or(0) == 1); + ids.retain(|id| census.is_one_binding(*id)); } -/// Count declarations per id across a region: `Let`s plus nested closure -/// PARAMS (which `collect_let_names_*` ignores), descending into closures. -fn collect_declared_counts_stmt(stmt: &Stmt, out: &mut HashMap) { - if let Stmt::Let { id, .. } = stmt { - *out.entry(*id).or_default() += 1; - } - for_each_child_stmt(stmt, &mut |s| collect_declared_counts_stmt(s, out)); - for_each_top_expr(stmt, &mut |e| collect_declared_counts_expr(e, out)); +/// One declaration of a `LocalId` inside a detection region. +#[derive(Debug)] +struct DeclSite { + /// Closure scope the declaration lives in (0 = the region's own body). + scope: u32, + name: String, + /// A parameter, or a `Let` that is a direct statement of its scope's body + /// outside any loop: it runs once, before every later site of that scope. + dominating: bool, } -fn collect_declared_counts_expr(expr: &Expr, out: &mut HashMap) { - if let Expr::Closure { params, body, .. } = expr { +/// Every declaration of every id in a region (params, `Let`s, nested closure +/// params and bodies), in execution order, plus the `var` re-declarations that +/// are real writes to a class-captured binding. +#[derive(Default, Debug)] +struct DeclCensus { + sites: HashMap>, + /// Ids re-declared (`var x = v` after the body-entry `var` slot) at a point + /// where a class has ALREADY captured the binding, or inside a loop that + /// can re-run the declaration after a capture. Those declarations are + /// assignments to a captured binding, exactly like `x = v`. + late_redeclared: HashSet, +} + +impl DeclCensus { + fn of_body(params: &[Param], body: &[Stmt]) -> Self { + let mut census = DeclCensus::default(); + let mut walker = CensusWalker::new(&mut census); + let scope = walker.open_scope(); + walker.params(params, scope); + walker.stmts(body, scope, 0, true); + census + } + + /// Is `id` exactly one binding in this region? + /// + /// A single declaration trivially is. Several are one binding only when + /// they all sit in the SAME closure scope under the SAME name and the first + /// dominates the rest. That is the shape of a `var`: lowering declares it at + /// body entry (`predefine_var_bindings_in_function_body`) or reuses a + /// same-named parameter, and every `var x = v` statement re-declares the + /// same id (#10485/#10489). Treating those as distinct bindings left every + /// `var` captured by a class on the value-snapshot path, so class members + /// never saw later writes and their own writes were lost. Declarations of + /// one id in different closure scopes stay ambiguous (#6089). + fn is_one_binding(&self, id: LocalId) -> bool { + match self.sites.get(&id).map(Vec::as_slice) { + None | Some([]) => false, + Some([_]) => true, + Some([first, rest @ ..]) => { + first.dominating + && rest + .iter() + .all(|site| site.scope == first.scope && site.name == first.name) + } + } + } +} + +/// Execution-order walk that fills a [`DeclCensus`]. Closure bodies open a new +/// scope with a fresh loop depth: each closure invocation gets its own +/// bindings, so an enclosing loop does not re-run a closure-local declaration. +struct CensusWalker<'a> { + census: &'a mut DeclCensus, + next_scope: u32, + /// Ids captured by a class registration seen so far. + captured: HashSet, +} + +impl<'a> CensusWalker<'a> { + fn new(census: &'a mut DeclCensus) -> Self { + CensusWalker { + census, + next_scope: 0, + captured: HashSet::new(), + } + } + + fn open_scope(&mut self) -> u32 { + let scope = self.next_scope; + self.next_scope += 1; + scope + } + + fn declare(&mut self, id: LocalId, name: &str, scope: u32, dominating: bool) { + self.census.sites.entry(id).or_default().push(DeclSite { + scope, + name: name.to_string(), + dominating, + }); + } + + fn params(&mut self, params: &[Param], scope: u32) { for p in params { - *out.entry(p.id).or_default() += 1; + if let Some(default) = &p.default { + self.expr(default, scope, 0); + } + self.declare(p.id, &p.name, scope, true); } - for s in body { - collect_declared_counts_stmt(s, out); + } + + fn stmts(&mut self, stmts: &[Stmt], scope: u32, loop_depth: u32, top: bool) { + for s in stmts { + self.stmt(s, scope, loop_depth, top); + } + } + + fn stmt(&mut self, stmt: &Stmt, scope: u32, loop_depth: u32, top: bool) { + match stmt { + Stmt::Let { id, name, init, .. } => { + if let Some(e) = init { + self.expr(e, scope, loop_depth); + } + let redeclaration = self.census.sites.contains_key(id); + if redeclaration && init.is_some() && (loop_depth > 0 || self.captured.contains(id)) + { + self.census.late_redeclared.insert(*id); + } + self.declare(*id, name, scope, top && loop_depth == 0); + } + Stmt::Expr(e) | Stmt::Throw(e) | Stmt::Return(Some(e)) => { + self.expr(e, scope, loop_depth) + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + self.expr(condition, scope, loop_depth); + self.stmts(then_branch, scope, loop_depth, false); + if let Some(e) = else_branch { + self.stmts(e, scope, loop_depth, false); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + self.expr(condition, scope, loop_depth + 1); + self.stmts(body, scope, loop_depth + 1, false); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(i) = init { + self.stmt(i, scope, loop_depth, false); + } + if let Some(c) = condition { + self.expr(c, scope, loop_depth + 1); + } + self.stmts(body, scope, loop_depth + 1, false); + if let Some(u) = update { + self.expr(u, scope, loop_depth + 1); + } + } + Stmt::Labeled { body, .. } => self.stmt(body, scope, loop_depth, false), + Stmt::Try { + body, + catch, + finally, + } => { + self.stmts(body, scope, loop_depth, false); + if let Some(c) = catch { + self.stmts(&c.body, scope, loop_depth, false); + } + if let Some(fin) = finally { + self.stmts(fin, scope, loop_depth, false); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + self.expr(discriminant, scope, loop_depth); + for case in cases { + if let Some(t) = &case.test { + self.expr(t, scope, loop_depth); + } + self.stmts(&case.body, scope, loop_depth, false); + } + } + Stmt::Return(None) + | Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) + | Stmt::ReleaseBoxes(_) => {} + } + } + + fn expr(&mut self, expr: &Expr, scope: u32, loop_depth: u32) { + match expr { + Expr::Closure { params, body, .. } => { + let inner = self.open_scope(); + self.params(params, inner); + self.stmts(body, inner, 0, true); + return; + } + Expr::RegisterClassCaptures { captures, .. } + | Expr::ClassExprFresh { + captured_args: captures, + .. + } => { + for capture in captures { + if let Expr::LocalGet(id) = capture { + self.captured.insert(*id); + } + } + } + _ => {} } + walk_expr_children(expr, &mut |e| self.expr(e, scope, loop_depth)); + } +} + +/// Turn every re-declaration of a shared id into a plain assignment, so the +/// rewrite below writes the binding's EXISTING cell (`x[0] = v`) instead of +/// minting a new one that classes captured earlier would never see. A `var x;` +/// re-declaration without an initializer does not touch the binding at all. +/// Only ids `DeclCensus::is_one_binding` accepted reach here, so the first +/// site seen in execution order is the dominating declaration. +fn demote_var_redeclarations(params: &[Param], body: &mut [Stmt], ids: &HashSet) { + let mut seen: HashSet = params + .iter() + .map(|p| p.id) + .filter(|id| ids.contains(id)) + .collect(); + for s in body.iter_mut() { + demote_redeclarations_stmt(s, ids, &mut seen); } - walk_expr_children(expr, &mut |e| collect_declared_counts_expr(e, out)); +} + +fn demote_redeclarations_stmt( + stmt: &mut Stmt, + ids: &HashSet, + seen: &mut HashSet, +) { + if let Stmt::Let { id, init, .. } = stmt { + if let Some(e) = init { + demote_redeclarations_expr(e, ids, seen); + } + if ids.contains(id) && !seen.insert(*id) { + *stmt = Stmt::Expr(match init.take() { + Some(value) => Expr::LocalSet(*id, Box::new(value)), + None => Expr::Undefined, + }); + } + return; + } + let stmts = |body: &mut [Stmt], seen: &mut HashSet| { + for s in body.iter_mut() { + demote_redeclarations_stmt(s, ids, seen); + } + }; + match stmt { + Stmt::Expr(e) | Stmt::Throw(e) | Stmt::Return(Some(e)) => { + demote_redeclarations_expr(e, ids, seen) + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + demote_redeclarations_expr(condition, ids, seen); + stmts(then_branch, seen); + if let Some(e) = else_branch { + stmts(e, seen); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + demote_redeclarations_expr(condition, ids, seen); + stmts(body, seen); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(i) = init { + demote_redeclarations_stmt(i, ids, seen); + } + if let Some(c) = condition { + demote_redeclarations_expr(c, ids, seen); + } + stmts(body, seen); + if let Some(u) = update { + demote_redeclarations_expr(u, ids, seen); + } + } + Stmt::Labeled { body, .. } => demote_redeclarations_stmt(body, ids, seen), + Stmt::Try { + body, + catch, + finally, + } => { + stmts(body, seen); + if let Some(c) = catch { + stmts(&mut c.body, seen); + } + if let Some(fin) = finally { + stmts(fin, seen); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + demote_redeclarations_expr(discriminant, ids, seen); + for case in cases { + if let Some(t) = &mut case.test { + demote_redeclarations_expr(t, ids, seen); + } + stmts(&mut case.body, seen); + } + } + Stmt::Let { .. } + | Stmt::Return(None) + | Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) + | Stmt::ReleaseBoxes(_) => {} + } +} + +fn demote_redeclarations_expr( + expr: &mut Expr, + ids: &HashSet, + seen: &mut HashSet, +) { + if let Expr::Closure { params, body, .. } = expr { + for p in params.iter() { + if ids.contains(&p.id) { + seen.insert(p.id); + } + } + for s in body.iter_mut() { + demote_redeclarations_stmt(s, ids, seen); + } + } + walk_expr_children_mut(expr, &mut |e| demote_redeclarations_expr(e, ids, seen)); } fn retype_capture_holders( @@ -522,7 +832,11 @@ fn retype_lets_in_expr(expr: &mut Expr, targets: &HashSet) { /// Detect the shared-mutable capture ids declared in ONE body. The returned /// ids are meaningful only within that body's scope — callers must not apply /// them to other functions (LocalIds repeat across scopes; see #6089). -fn detect_shared_in_body(body: &[Stmt], classes: &HashMap<&str, &Class>) -> BodySharedCaptures { +fn detect_shared_in_body( + params: &[Param], + body: &[Stmt], + classes: &HashMap<&str, &Class>, +) -> BodySharedCaptures { let mut shared = BodySharedCaptures::default(); let mut regs = Vec::new(); for s in body { @@ -531,10 +845,14 @@ fn detect_shared_in_body(body: &[Stmt], classes: &HashMap<&str, &Class>) -> Body if regs.is_empty() { return shared; } + shared.census = DeclCensus::of_body(params, body); let mut assigned: HashSet = HashSet::new(); for s in body { collect_assigned_deep_stmt(s, &mut assigned); } + // A `var x = v` re-declaration that runs after a class captured `x` writes + // the captured binding just like `x = v` does. + assigned.extend(shared.census.late_redeclared.iter().copied()); for (class_name, ids) in ®s { for id in ids { // Declaring-function-side mutation (`c = 99` after `new T()`). @@ -544,7 +862,7 @@ fn detect_shared_in_body(body: &[Stmt], classes: &HashMap<&str, &Class>) -> Body } // Class-side mutation: a member assigns rebind local `__perry_cap_`. if let Some(c) = classes.get(class_name.as_str()) { - if class_mutates_capture(c, *id) { + if class_mutates_capture(classes, c, *id, 0) { shared.ids.insert(*id); } } @@ -566,16 +884,209 @@ fn detect_shared_in_body(body: &[Stmt], classes: &HashMap<&str, &Class>) -> Body shared } -fn class_mutates_capture(c: &Class, id: LocalId) -> bool { +/// Does class `c` (or a class NESTED in one of its member bodies) assign the +/// capture of `id`? +/// +/// A member's own write lands on its rebind local `__perry_cap_`. A class +/// declared inside that member body (`class Outer { make() { return class Inner +/// { constructor() { n++ } } } }`, the emscripten/`FS` shape) captures the +/// REBIND local instead, and its write lands one level deeper — invisible to a +/// walk of `c` alone, because a nested class's members live in their own +/// `module.classes` entry, not inside the method body (#10489). +fn class_mutates_capture( + classes: &HashMap<&str, &Class>, + c: &Class, + id: LocalId, + depth: u32, +) -> bool { let id_name = collect_class_names(c); let assigned = collect_class_assigned(c); - assigned.iter().any(|aid| { + let names_id = |aid: &LocalId| { id_name .get(aid) .is_some_and(|n| crate::cap_fields::cap_field_outer_id(n) == Some(id)) + }; + if assigned.iter().any(names_id) { + return true; + } + // Bounded: each level is one class nesting, and the chain is finite. + if depth >= MAX_NESTED_CLASS_DEPTH { + return false; + } + for_each_nested_capture(c, &HashSet::from([id]), |nested_name, outer_id| { + classes + .get(nested_name) + .is_some_and(|nested| class_mutates_capture(classes, nested, outer_id, depth + 1)) }) } +/// How far the nested-class walks descend (`class` inside a member body, whose +/// member body declares another class, …). Deep enough for real code, bounded +/// so a cyclic registration cannot loop. +const MAX_NESTED_CLASS_DEPTH: u32 = 8; + +/// Call `visit(nested_class_name, outer_id)` for every class registered inside +/// a member body of `c` that captures that member's rebind local for `outer_id` +/// (an id in `targets`). Returns true as soon as `visit` does. +/// +/// The nested class was lowered BEFORE `synthesize_class_captures` renamed the +/// enclosing member's references, so its own rebind holders are still named for +/// the ORIGINAL outer id — which is what the reported id must be for the +/// name-keyed matching in `rewrite_member_scoped` / `retype_capture_holders`. +fn for_each_nested_capture( + c: &Class, + targets: &HashSet, + mut visit: impl FnMut(&str, LocalId) -> bool, +) -> bool { + for f in class_member_fns(c) { + let mut rebinds = member_rebind_targets(f, targets); + let mut regs = Vec::new(); + for s in &f.body { + find_regs_stmt(s, &mut regs); + find_cap_arg_news_stmt(s, &mut regs); + } + // A field initializer shares the constructor's scope (it is lowered + // into the ctor body), so a class declared in one holds the ctor's + // rebind params. + if c.constructor.as_ref().is_some_and(|ctor| ctor.id == f.id) { + for field in &c.fields { + for expr in field.init.iter().chain(field.key_expr.iter()) { + let mut names: HashMap = HashMap::new(); + collect_let_names_expr(expr, &mut names); + for (id, name) in names { + if let Some(outer) = crate::cap_fields::cap_field_outer_id(&name) { + if targets.contains(&outer) { + rebinds.insert(id, outer); + } + } + } + find_regs_expr(expr, &mut regs); + find_cap_arg_news_expr(expr, &mut regs); + } + } + } + if rebinds.is_empty() { + continue; + } + for (nested_name, ids) in regs { + for id in ids { + if let Some(outer_id) = rebinds.get(&id) { + if visit(&nested_name, *outer_id) { + return true; + } + } + } + } + } + false +} + +/// Class constructions whose trailing `cap_args_appended` arguments forward +/// capture handles. An IMMEDIATELY constructed class expression (`new (class { +/// … })()`) has neither a `RegisterClassCaptures` nor a `ClassExprFresh` node — +/// `lower_new` lowers it straight to `Expr::New` — so `find_regs_stmt` alone +/// misses it, and its members kept reading the raw cell (#10485's nested-class +/// row printed `[[2],["set"]]` instead of the values). +fn find_cap_arg_news_stmt(stmt: &Stmt, out: &mut Vec<(String, Vec)>) { + for_each_child_stmt(stmt, &mut |s| find_cap_arg_news_stmt(s, out)); + for_each_top_expr(stmt, &mut |e| find_cap_arg_news_expr(e, out)); +} + +fn find_cap_arg_news_expr(expr: &Expr, out: &mut Vec<(String, Vec)>) { + if let Expr::New { + class_name, + args, + cap_args_appended, + .. + } = expr + { + let appended = *cap_args_appended as usize; + if appended > 0 && args.len() >= appended { + let ids: Vec = args[args.len() - appended..] + .iter() + .filter_map(|a| match a { + Expr::LocalGet(id) => Some(*id), + _ => None, + }) + .collect(); + if !ids.is_empty() { + out.push((class_name.clone(), ids)); + } + } + } + if let Expr::Closure { body, .. } = expr { + for s in body { + find_cap_arg_news_stmt(s, out); + } + } + walk_expr_children(expr, &mut |e| find_cap_arg_news_expr(e, out)); +} + +/// The member's capture holders (`__perry_cap_` params and `Let`s), +/// mapped back to the outer id each one rebinds. +fn member_rebind_targets(f: &Function, targets: &HashSet) -> HashMap { + let mut rebinds: HashMap = HashMap::new(); + let record = |id: LocalId, name: &str, out: &mut HashMap| { + if let Some(outer) = crate::cap_fields::cap_field_outer_id(name) { + if targets.contains(&outer) { + out.insert(id, outer); + } + } + }; + for p in &f.params { + record(p.id, &p.name, &mut rebinds); + } + let mut names: HashMap = HashMap::new(); + for s in &f.body { + collect_let_names_stmt(s, &mut names); + } + for (id, n) in names { + record(id, &n, &mut rebinds); + } + rebinds +} + +/// A class nested in a member body holds its captures through the member's +/// REBIND locals, which by then carry the shared cell — so ITS members must +/// index through `[0]` too. Walk the nesting chain and mark those classes. +fn propagate_cells_to_nested_classes( + module: &Module, + shared_by_class: &mut HashMap>, +) { + for _ in 0..MAX_NESTED_CLASS_DEPTH { + let mut discovered: Vec<(String, LocalId)> = Vec::new(); + for c in &module.classes { + let Some(targets) = shared_by_class.get(&c.name) else { + continue; + }; + for_each_nested_capture(c, targets, |nested_name, outer_id| { + discovered.push((nested_name.to_string(), outer_id)); + false + }); + } + let mut added = false; + for (class_name, id) in discovered { + added |= shared_by_class.entry(class_name).or_default().insert(id); + } + if !added { + break; + } + } +} + +/// Every member function of a class: methods, accessors, statics, computed +/// members and the constructor (whose scope the field initializers share). +fn class_member_fns(c: &Class) -> Vec<&Function> { + let mut v: Vec<&Function> = Vec::new(); + v.extend(c.methods.iter()); + v.extend(c.getters.iter().map(|(_, g)| g)); + v.extend(c.setters.iter().map(|(_, s)| s)); + v.extend(c.static_methods.iter()); + v.extend(c.computed_members.iter().map(|m| &m.function)); + v.extend(c.constructor.iter()); + v +} + /// id -> name across a class: every member function's PARAMS (a field-init /// closure captures the constructor param `__perry_cap_`, id-only in the /// closure) plus every `Let` name in member bodies and field initializers. @@ -622,8 +1133,8 @@ fn collect_class_names(c: &Class) -> HashMap { /// descending into closures). fn collect_class_assigned(c: &Class) -> HashSet { let mut assigned = HashSet::new(); - for body in class_member_bodies(c) { - for s in body { + for f in class_member_fns(c) { + for s in &f.body { collect_assigned_deep_stmt(s, &mut assigned); } } @@ -638,29 +1149,6 @@ fn collect_class_assigned(c: &Class) -> HashSet { assigned } -fn class_member_bodies(c: &Class) -> Vec<&Vec> { - let mut v: Vec<&Vec> = Vec::new(); - for m in &c.methods { - v.push(&m.body); - } - for (_, g) in &c.getters { - v.push(&g.body); - } - for (_, s) in &c.setters { - v.push(&s.body); - } - for sm in &c.static_methods { - v.push(&sm.body); - } - for member in &c.computed_members { - v.push(&member.function.body); - } - if let Some(ctor) = &c.constructor { - v.push(&ctor.body); - } - v -} - // ---- read-only walkers (exhaustive over Stmt; exprs recurse into closures) -- fn collect_let_names_stmt(stmt: &Stmt, out: &mut HashMap) { @@ -807,6 +1295,27 @@ fn for_each_top_expr(stmt: &Stmt, f: &mut dyn FnMut(&Expr)) { // Rewrite (mutable, exhaustive over Stmt) // --------------------------------------------------------------------------- +/// `Sequence([LocalSet(id, _) | Update { id }, this.__perry_cap_N = LocalGet(id)])` +/// for a cell id — see the `Expr::Sequence` arm of [`rewrite_expr`]. +fn is_redundant_cell_propagation(items: &[Expr], index_uses: &HashSet) -> bool { + let [write, Expr::PropertySet { + object, + property, + value, + }] = items + else { + return false; + }; + let written = match write { + Expr::LocalSet(id, _) | Expr::Update { id, .. } => *id, + _ => return false, + }; + index_uses.contains(&written) + && matches!(object.as_ref(), Expr::This) + && property.starts_with("__perry_cap_") + && matches!(value.as_ref(), Expr::LocalGet(id) if *id == written) +} + fn rewrite_stmts(stmts: &mut [Stmt], shared: &HashSet, index_uses: &HashSet) { for s in stmts.iter_mut() { rewrite_stmt(s, shared, index_uses); @@ -947,6 +1456,19 @@ fn rewrite_stmt(stmt: &mut Stmt, shared: &HashSet, index_uses: &HashSet fn rewrite_expr(expr: &mut Expr, shared: &HashSet, index_uses: &HashSet) { match expr { + // A member's write to a captured local arrives wrapped by the field + // propagation of `synthesize_class_captures`: + // `Sequence([write, this.__perry_cap_N = LocalGet(rebind)])`, which + // keeps a value SNAPSHOT field in step with the member's local. A + // shared cell needs no propagation — the field already holds the same + // cell — and keeping it makes the sequence yield the cell handle instead + // of the write's value (`return n++` returned `[3]`, not 2; #10489). + Expr::Sequence(items) if is_redundant_cell_propagation(items, index_uses) => { + let write = items.swap_remove(0); + *expr = write; + rewrite_expr(expr, shared, index_uses); + return; + } // A value read of a boxed id -> `id[0]`. The synthesized `LocalGet` is // the ARRAY handle and is not re-rewritten. Expr::LocalGet(id) if index_uses.contains(id) => { diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index 4e6bd026fa..bc94fe146c 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -100,6 +100,8 @@ fn emit_class_expression_value_binding( // The binding's local holds ITS OWN class — `new ()` must keep // the static construct path (see `inferred_class_bindings`). ctx.inferred_class_bindings.insert(bind_name.to_string()); + ctx.inferred_class_bindings + .record_binding(id, bind_name.to_string()); module.init.push(Stmt::Let { id, name: bind_name.to_string(), diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index afa26dba29..54aa8f471c 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1989,6 +1989,7 @@ mod unresolved_new_global; mod global_this_new_shadowed; mod capture_stash; +mod class_member_var_captures; mod function_ctor_runtime_routing; mod mixin_parent_chain; mod native_module_sync; diff --git a/crates/perry-hir/src/lower/tests/class_member_var_captures.rs b/crates/perry-hir/src/lower/tests/class_member_var_captures.rs new file mode 100644 index 0000000000..caf348f965 --- /dev/null +++ b/crates/perry-hir/src/lower/tests/class_member_var_captures.rs @@ -0,0 +1,213 @@ +//! #10485/#10489: class members share the enclosing bindings they capture. +//! +//! Two lowering properties are asserted here, both invisible to a runtime +//! probe until the miscompiled program prints a stale value: +//! +//! 1. a `var` captured and mutated across the class boundary becomes ONE +//! shared cell (`crate::lower::shared_mutable_capture`), even though a +//! `var` is declared twice in HIR (body-entry slot + declaration), and the +//! re-declaration writes that cell instead of minting a second one; +//! 2. a `new K()` resolves to the class the BINDING holds, not to whatever +//! class first claimed the name `K` in the module. + +use crate::ir::{Expr, Stmt}; + +fn lower(source: &str) -> crate::ir::Module { + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + super::super::lower_module(&module, "t", "t.ts").expect("source lowers") +} + +fn function<'a>(module: &'a crate::ir::Module, name: &str) -> &'a crate::ir::Function { + module + .functions + .iter() + .find(|f| f.name == name) + .unwrap_or_else(|| panic!("fixture declares function {name}")) +} + +/// Statements of `body` (recursing into nested statement bodies) that declare +/// `name`, rendered compactly. +fn declarations_of<'a>(body: &'a [Stmt], name: &str) -> Vec<&'a Stmt> { + let mut out = Vec::new(); + for stmt in body { + if matches!(stmt, Stmt::Let { name: n, .. } if n == name) { + out.push(stmt); + } + if let Stmt::For { body, .. } | Stmt::While { body, .. } = stmt { + out.extend(declarations_of(body, name)); + } + } + out +} + +fn compact(value: &impl std::fmt::Debug) -> String { + format!("{value:?}") + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect() +} + +/// A `var` mutated from a constructor is the same binding on both sides: it +/// lowers to a one-element cell, and every use — the declaring function's read +/// and the member's write — goes through it. Before #10489 the two HIR `Let`s a +/// `var` produces read as two bindings, so the desugar skipped the id and the +/// class kept a private copy (`declCtor` returned 0, not 3). +#[test] +fn var_mutated_from_a_constructor_becomes_one_shared_cell() { + let module = lower( + r#" + function declCtor() { + var a = 0; + class A { constructor() { a++; } } + new A(); new A(); + return a; + } + declCtor(); + "#, + ); + let decls = declarations_of(&function(&module, "declCtor").body, "a"); + assert_eq!( + decls.len(), + 1, + "the `var` re-declaration must write the cell, not re-declare it: {decls:#?}" + ); + assert!( + matches!(decls[0], Stmt::Let { init: Some(Expr::Array(items)), .. } if items.len() == 1), + "the captured `var` must lower to a one-element cell: {:#?}", + decls[0] + ); + let body = compact(&function(&module, "declCtor").body); + assert!( + body.contains("IndexSet{object:LocalGet"), + "the `var a = 0` re-declaration must write the existing cell: {body}" + ); + let class = module + .classes + .iter() + .find(|c| c.name == "A") + .expect("fixture declares class A"); + let ctor = compact(&class.constructor.as_ref().expect("class A has a ctor").body); + assert!( + ctor.contains("IndexUpdate{"), + "the constructor's `a++` must update the shared cell: {ctor}" + ); +} + +/// The control: a capture nobody mutates keeps the cheap value snapshot, so +/// the cell rewrite cannot quietly become universal (it costs an indirection +/// on every read). +#[test] +fn an_unmutated_var_capture_keeps_its_value_snapshot() { + let module = lower( + r#" + function readOnly() { + var a = 0; + class A { value() { return a; } } + return new A().value(); + } + readOnly(); + "#, + ); + let body = compact(&function(&module, "readOnly").body); + assert!( + !body.contains("Array([Undefined])") && !body.contains("IndexSet{object:LocalGet"), + "an unmutated capture must not be boxed into a cell: {body}" + ); +} + +/// A class nested in a member body captures the member's rebind local, which +/// holds the cell — so its own members must index through it too. The +/// immediately-constructed form has no `RegisterClassCaptures` node at all +/// (`lower_new` emits a bare `Expr::New`), which is how it was missed. +#[test] +fn a_class_nested_in_a_member_shares_the_same_cell() { + let module = lower( + r#" + function outerFn() { + var made = 0; + class Outer { + make() { return class Inner { constructor() { made++; } }; } + immediate() { return new (class { read() { return made; } })().read(); } + } + const Inner = new Outer().make(); + new Inner(); + return [made, new Outer().immediate()]; + } + outerFn(); + "#, + ); + // A named class EXPRESSION registers under a disambiguated key, so match + // the source name as a prefix. + let inner = module + .classes + .iter() + .find(|c| c.name.starts_with("Inner")) + .expect("fixture declares the nested class Inner"); + let inner_ctor = compact(&inner.constructor.as_ref().expect("Inner has a ctor").body); + assert!( + inner_ctor.contains("IndexUpdate{"), + "a nested class's write must reach the shared cell: {inner_ctor}" + ); + let anon = module + .classes + .iter() + .find(|c| c.name.starts_with("__anon_class_")) + .expect("fixture declares an immediately-constructed anonymous class"); + let read = compact( + &anon + .methods + .first() + .expect("the anon class has read()") + .body, + ); + assert!( + read.contains("IndexGet{"), + "an immediately-constructed nested class must read the cell's value: {read}" + ); +} + +/// Two sibling functions that each bind `const K = class {…}`: the second +/// binding holds its OWN class (registered under a disambiguated key), so +/// `new K()` inside it must not construct the first function's class with the +/// first function's capture ids (#10489's `twinTwo` returned 0). +#[test] +fn sibling_same_named_class_bindings_construct_their_own_class() { + let module = lower( + r#" + function twinOne() { var n = 0; const K = class { constructor() { n++; } }; new K(); return n; } + function twinTwo() { var n = 0; const K = class { constructor() { n += 10; } }; new K(); return n; } + twinOne(); twinTwo(); + "#, + ); + let one = compact(&function(&module, "twinOne").body); + let two = compact(&function(&module, "twinTwo").body); + let key_of = |body: &str| -> String { + let at = body + .find("New{class_name:\"") + .expect("the fixture constructs its class statically"); + let rest = &body[at + "New{class_name:\"".len()..]; + rest[..rest.find('"').expect("class name is terminated")].to_string() + }; + let (one_key, two_key) = (key_of(&one), key_of(&two)); + assert_ne!( + one_key, two_key, + "sibling functions must construct distinct classes, both built {one_key}" + ); + for (key, body) in [(&one_key, &one), (&two_key, &two)] { + let class = module + .classes + .iter() + .find(|c| &c.name == key) + .unwrap_or_else(|| panic!("the constructed class {key} exists")); + let ctor = compact(&class.constructor.as_ref().expect("has a ctor").body); + assert!( + ctor.contains("IndexUpdate{") || ctor.contains("IndexSet{object:LocalGet"), + "{key}'s constructor must write its own function's cell: {ctor}" + ); + assert!( + body.contains("init:Some(Array([Undefined]))") + && body.contains("IndexSet{object:LocalGet"), + "each function declares its own counter cell and initializes it: {body}" + ); + } +} diff --git a/crates/perry-hir/src/monomorph/defaults.rs b/crates/perry-hir/src/monomorph/defaults.rs index 1e87a1f7cd..1d5e7c21c3 100644 --- a/crates/perry-hir/src/monomorph/defaults.rs +++ b/crates/perry-hir/src/monomorph/defaults.rs @@ -34,6 +34,16 @@ pub(crate) fn fill_default_arguments(module: &mut Module) { let mut ctors: HashMap>> = HashMap::new(); for class in &module.classes { if let Some(ref ctor) = class.constructor { + // #10484: a constructor that reads `arguments` observes the call + // site's argument COUNT, and an appended `undefined` is + // indistinguishable from one the caller wrote (`new C("x")` + // reported `arguments.length === 2` for `constructor(p, q)`). + // Every constructor call path already binds an omitted parameter + // to `undefined` itself, so skip these exactly like the + // synth-`arguments` free functions below. + if ctor.params.iter().any(|p| p.arguments_object.is_some()) { + continue; + } // Stop at a trailing rest parameter. `constructor(...e)` (or // `constructor(a, b = 5, ...e)`) accepts zero or more trailing // args, so the call-site padding below must never synthesize an diff --git a/crates/perry-hir/src/monomorph/tests.rs b/crates/perry-hir/src/monomorph/tests.rs index 71b357be6f..533d60c064 100644 --- a/crates/perry-hir/src/monomorph/tests.rs +++ b/crates/perry-hir/src/monomorph/tests.rs @@ -1022,3 +1022,118 @@ fn a_specialized_class_reports_the_generics_display_name() { "only the specialization gets an entry" ); } + +/// #10484: a constructor that reads `arguments` must see the call site's own +/// argument count, so the default-fill pass may not pad its `new` sites. +#[test] +fn fill_defaults_skips_constructors_that_read_arguments() { + fn class_with_ctor(name: &str, id: u32, reads_arguments: bool) -> Class { + let mut params = vec![ + Param { + id: 1, + name: "p".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }, + Param { + id: 2, + name: "q".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }, + ]; + if reads_arguments { + params.push(Param { + id: 3, + name: "arguments".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: true, + arguments_object: Some(crate::ArgumentsObjectMeta { + strict: true, + simple_parameters: false, + mapped_parameter_ids: Vec::new(), + restricted_callee: true, + }), + }); + } + Class { + id, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: Vec::new(), + constructor: Some(Function { + id: 100 + id, + name: format!("{}_constructor", name), + type_params: Vec::new(), + params, + return_type: Type::Void, + body: Vec::new(), + is_async: false, + is_generator: false, + is_strict: true, + was_plain_async: false, + was_unrolled: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + }), + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + aliases: Vec::new(), + } + } + + let mut module = Module::new("test"); + module.classes.push(class_with_ctor("Plain", 1, false)); + module.classes.push(class_with_ctor("Args", 2, true)); + for class_name in ["Plain", "Args"] { + module.init.push(Stmt::Expr(Expr::New { + class_name: class_name.to_string(), + args: vec![Expr::String("x".to_string())], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + })); + } + + super::fill_default_arguments(&mut module); + + let arg_counts: Vec = module + .init + .iter() + .map(|stmt| match stmt { + Stmt::Expr(Expr::New { args, .. }) => args.len(), + other => panic!("unexpected statement {:?}", other), + }) + .collect(); + assert_eq!( + arg_counts, + vec![2, 1], + "the plain constructor keeps its `undefined` padding; the one reading \ + `arguments` must observe exactly the argument the call site passed" + ); +} diff --git a/crates/perry-hir/tests/unimplemented_api_check.rs b/crates/perry-hir/tests/unimplemented_api_check.rs index a9120201d7..14fa823fbc 100644 --- a/crates/perry-hir/tests/unimplemented_api_check.rs +++ b/crates/perry-hir/tests/unimplemented_api_check.rs @@ -364,20 +364,11 @@ fn perry_native_namespace_rejects_unknown_call_in_strict_mode() { /// /// Side-effect-only sub-paths (`dotenv/config`) are skipped — they have /// no value binding to read properties off, so the gate doesn't apply. -/// `tursodb` and `iroh` are external bindings (live in standalone -/// `@perryts/*` repos as of v0.5.557) — their manifest entries exist -/// but the in-tree resolver doesn't recognise them as a `NativeModuleRef` -/// without `node_modules//package.json` declaring `perry.nativeLibrary`, -/// so the gate's prerequisite shape never triggers in this isolated -/// HIR test. #[test] fn every_supported_module_rejects_bogus_member() { const SKIP: &[&str] = &[ // Side-effect-only — no value binding to access. "dotenv/config", - // External (non-bundled) bindings — out-of-tree as of v0.5.557. - "tursodb", - "iroh", ]; let mut failures: Vec = Vec::new(); @@ -455,9 +446,6 @@ fn every_supported_module_rejects_bogus_call() { const SKIP: &[&str] = &[ // Side-effect-only — no value binding to access. "dotenv/config", - // External (non-bundled) bindings — out-of-tree as of v0.5.557. - "tursodb", - "iroh", ]; let mut failures: Vec = Vec::new(); diff --git a/crates/perry-runtime/src/box.rs b/crates/perry-runtime/src/box.rs index 4eae4b605b..77e7ebf71b 100644 --- a/crates/perry-runtime/src/box.rs +++ b/crates/perry-runtime/src/box.rs @@ -457,27 +457,29 @@ fn push_free_cell(addr: usize, head: &'static crate::tls_hot::HotKey { - BOX_REGISTRY.with(|r| { - r.borrow_mut().remove(&addr); - }); + if !BOX_REGISTRY.with(|r| r.borrow_mut().remove(&addr)) { + return; + } box_ptr_cache_evict(box_ptr_cache(), addr); unsafe { (*(addr as *mut Box)).value = crate::value::TAG_UNDEFINED }; push_free_cell(addr, &BOX_FREE_HEAD); } ASYNC_RELEASE_I32 => { - I32_BOX_REGISTRY.with(|r| { - r.borrow_mut().remove(&addr); - }); + if !I32_BOX_REGISTRY.with(|r| r.borrow_mut().remove(&addr)) { + return; + } box_ptr_cache_evict(i32_box_ptr_cache(), addr); unsafe { (*(addr as *mut I32Box)).value = -1 }; push_free_cell(addr, &I32_BOX_FREE_HEAD); } ASYNC_RELEASE_BOOL => { - BOOL_BOX_REGISTRY.with(|r| { - r.borrow_mut().remove(&addr); - }); + if !BOOL_BOX_REGISTRY.with(|r| r.borrow_mut().remove(&addr)) { + return; + } box_ptr_cache_evict(bool_box_ptr_cache(), addr); unsafe { (*(addr as *mut BoolBox)).value = true }; push_free_cell(addr, &BOOL_BOX_FREE_HEAD); @@ -485,7 +487,10 @@ fn publish_box_cell(addr: usize, tag: usize) { _ => unreachable!("invalid async released-cell tag"), } ASYNC_PENDING_RELEASES.with(|pending| { - pending.borrow_mut().remove(&addr); + let mut pending = pending.borrow_mut(); + if !pending.is_empty() { + pending.remove(&addr); + } }); BOX_FLUSH_PUBLISHED.fetch_add(1, Ordering::Relaxed); } @@ -499,21 +504,22 @@ pub(crate) fn box_capture_count_reached_zero(addr: usize) { } } -/// Expose a drained, closure-owned JS box's payload to the closure tracer. -/// The exact-capture table may also contain i32/bool box addresses; requiring -/// the pending JS tag is the authoritative type discriminator before the -/// pointer is dereferenced as [`Box`]. +/// Expose a released, closure-owned JS box's payload to the closure tracer — +/// a drained async activation cell, or one its ordinary frame released +/// (#10464). The exact-capture table may also contain i32/bool box addresses; +/// requiring the JS tag on the release record is the authoritative type +/// discriminator before the pointer is dereferenced as [`Box`]. pub(crate) fn visit_pending_captured_js_box_payload_slot( addr: usize, visit: &mut dyn FnMut(*mut u64), ) { - let is_pending_js = ASYNC_PENDING_RELEASES.with(|pending| { + let is_released_js = ASYNC_PENDING_RELEASES.with(|pending| { pending .borrow() .get(&addr) .is_some_and(|tag| *tag == (ASYNC_RELEASE_JS | ASYNC_RELEASE_DRAINED)) - }); - if is_pending_js && BOX_REGISTRY.with(|registry| registry.borrow().contains(&addr)) { + }) || crate::closure::frame_released_js_cell(addr, ASYNC_RELEASE_JS); + if is_released_js && BOX_REGISTRY.with(|registry| registry.borrow().contains(&addr)) { let ptr = addr as *mut Box; unsafe { visit(&raw mut (*ptr).value) }; } @@ -965,19 +971,6 @@ pub fn scan_box_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { BOX_REGISTRY.with(|r| { let r = r.borrow(); for &addr in r.iter() { - // A drained box is retained only by exact closure-capture - // metadata. During a full trace its payload is reached from - // each live closure instead. Rooting it here as well would - // make `box -> closure -> same box` an uncollectable native - // cycle. Minors retain the old strong-root rule because they - // cannot adjudicate old-closure liveness. - if full_trace - && pending - .get(&addr) - .is_some_and(|tag| *tag == (ASYNC_RELEASE_JS | ASYNC_RELEASE_DRAINED)) - { - continue; - } let ptr = addr as *mut Box; // Defensive: the registry should only contain valid live // pointers, but if a stale entry slipped through we'd @@ -985,15 +978,40 @@ pub fn scan_box_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { // address (alloc gives 8-aligned pointers in user space) // matches `is_plausible_box_ptr` to keep this a no-op for // any pathological entry. - if addr >= 0x1000 && (addr as u64) < 0x0001_0000_0000_0000 && addr % 8 == 0 { + if !(addr >= 0x1000 && (addr as u64) < 0x0001_0000_0000_0000 && addr % 8 == 0) { + continue; + } + // A released box (a drained async cell, or a cell its + // ordinary frame released — #10464) is retained only by exact + // closure-capture metadata. During a full trace its payload is + // reached from each live closure instead. Rooting it here as + // well would make `box -> closure -> same box` an uncollectable + // native cycle. Minors retain the old strong-root rule because + // they cannot adjudicate old-closure liveness — and a minor + // walks ONLY the log below, so a young payload must stay logged + // even when this trace does not visit it. Dropping it here left + // the next minor with no root for a live payload + // (`gc-fromspace-protect` catches the stale use immediately). + if full_trace + && (pending + .get(&addr) + .is_some_and(|tag| *tag == (ASYNC_RELEASE_JS | ASYNC_RELEASE_DRAINED)) + || crate::closure::frame_released_js_cell(addr, ASYNC_RELEASE_JS)) + { unsafe { - visitor.visit_nanbox_u64_raw_slot(&raw mut (*ptr).value); if crate::gc::young_log::bits_are_minor_relevant((*ptr).value) { kept.push(addr); } } - visited += 1; + continue; } + unsafe { + visitor.visit_nanbox_u64_raw_slot(&raw mut (*ptr).value); + if crate::gc::young_log::bits_are_minor_relevant((*ptr).value) { + kept.push(addr); + } + } + visited += 1; } }); }); @@ -1905,3 +1923,11 @@ mod tests { #[cfg(test)] #[path = "box/release_tests.rs"] mod release_tests; + +// #10464: scope-exit release for frame-owned cells. +#[path = "box/scope_release.rs"] +mod scope_release; +pub(crate) use scope_release::publish_frame_released_cell; +pub use scope_release::{ + js_bool_box_scope_release, js_box_scope_release, js_i32_box_scope_release, +}; diff --git a/crates/perry-runtime/src/box/scope_release.rs b/crates/perry-runtime/src/box/scope_release.rs new file mode 100644 index 0000000000..b9274fc5ef --- /dev/null +++ b/crates/perry-runtime/src/box/scope_release.rs @@ -0,0 +1,346 @@ +//! #10464: release the box cells an ordinary frame minted once that frame can +//! no longer name them. +//! +//! A variable box is minted per execution of the declaration of a captured and +//! reassigned binding, and every registered cell is a strong GC root +//! (`scan_box_roots_mut`). Before #10464 only a lowered plain-async activation +//! ever released its cells (#7933/#8208), so a synchronous function, method, +//! arrow, generator, or an `async` function without `await` kept one registered +//! root per boxed binding per call alive for the life of the thread, together +//! with everything that binding last pointed at. +//! +//! Codegen now names each frame-owned cell before every `ret` of the frame and +//! before a declaration inside a loop mints the next iteration's cell. The +//! frame is the holder that disappears at those points; every other holder is +//! a compiler-declared closure capture edge (`js_closure_set_box_capture_ptr`), +//! which is exactly the edge set #8303 counts. So: +//! +//! - a cell with no capture edge publishes immediately: de-registered, +//! positive-cache-evicted, cleared, and pushed on its kind's free list; +//! - a captured cell stays registered and readable, and its capture-edge record +//! is marked frame-released. It publishes when authoritative GC death +//! pruning removes its last capture edge. Like a drained async terminal cell, +//! a full trace reaches its payload only through live capturing closures (the +//! ephemeron half), so a payload that references its own closure does not +//! keep either alive. +//! +//! A running closure keeps its own edges: every capturing closure body spills +//! `%this_closure` into a frame-long root slot (#7055), so a cell a closure body +//! cached at entry cannot be published while that body is still executing. +//! +//! These entries are deliberately separate from `js_*box_release`, which parks +//! into the AMBIENT async activation: a synchronous callee running inside an +//! async step must not append its own cells to the caller activation's +//! contiguous terminal release range. + +use super::*; + +#[inline] +fn release_scope_cell(addr: usize, tag: usize, registered: bool) { + // Not registered: a TAG_UNDEFINED slot whose declaration never ran, an + // already-published cell, or a foreign address. All are no-ops, which is + // what makes a repeated release unable to push one cell twice. + if !registered { + return; + } + // An async activation's terminal cell: its activation already decided. + let async_pending = ASYNC_PENDING_RELEASES.with(|pending| { + let pending = pending.borrow(); + !pending.is_empty() && pending.contains_key(&addr) + }); + if async_pending { + return; + } + match crate::closure::note_frame_released_cell(addr, tag) { + crate::closure::FrameRelease::Uncaptured => { + BOX_RELEASE_COUNT.fetch_add(1, Ordering::Relaxed); + publish_box_cell(addr, tag); + } + crate::closure::FrameRelease::Deferred => { + BOX_RELEASE_COUNT.fetch_add(1, Ordering::Relaxed); + } + crate::closure::FrameRelease::AlreadyReleased => {} + } +} + +/// The last capture edge of a frame-released cell disappeared (GC death +/// pruning): publish it for reuse. +pub(crate) fn publish_frame_released_cell(addr: usize, tag: usize) { + publish_box_cell(addr, tag); +} + +/// Release a JSValue box cell owned by the exiting (or re-entering) frame. +#[no_mangle] +pub extern "C" fn js_box_scope_release(ptr: *mut Box) { + release_scope_cell(ptr as usize, ASYNC_RELEASE_JS, is_registered_box_ptr(ptr)); +} + +/// [`js_box_scope_release`] for the compiler-private i32 control cells of a +/// generator frame. +#[no_mangle] +pub extern "C" fn js_i32_box_scope_release(ptr: *mut I32Box) { + release_scope_cell( + ptr as usize, + ASYNC_RELEASE_I32, + is_registered_i32_box_ptr(ptr), + ); +} + +/// [`js_box_scope_release`] for the compiler-private boolean control cells of +/// a generator frame. +#[no_mangle] +pub extern "C" fn js_bool_box_scope_release(ptr: *mut BoolBox) { + release_scope_cell( + ptr as usize, + ASYNC_RELEASE_BOOL, + is_registered_bool_box_ptr(ptr), + ); +} + +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_JS_BOX_SCOPE_RELEASE: extern "C" fn(*mut Box) = js_box_scope_release; +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_JS_I32_BOX_SCOPE_RELEASE: extern "C" fn(*mut I32Box) = js_i32_box_scope_release; +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_JS_BOOL_BOX_SCOPE_RELEASE: extern "C" fn(*mut BoolBox) = js_bool_box_scope_release; + +#[cfg(test)] +mod tests { + use super::*; + + fn int_bits(value: i32) -> i64 { + crate::value::JSValue::int32(value).bits() as i64 + } + + fn free_list_contains(addr: usize) -> bool { + let mut cursor = BOX_FREE_HEAD.with(std::cell::Cell::get); + let mut steps = 0; + while cursor != 0 && steps < 1 << 20 { + if cursor == addr { + return true; + } + cursor = unsafe { (cursor as *const usize).read() }; + steps += 1; + } + false + } + + /// The common case: the frame was the cell's only holder. The cell is + /// inert at once and the next allocation reuses it; repeating the release + /// (a second `ret` path, a loop that exits right after re-entering) must + /// not push it onto the free list a second time. + #[test] + fn uncaptured_cell_publishes_at_scope_exit_exactly_once() { + test_clear_box_registry(); + let cell = js_box_alloc_bits(int_bits(7)); + js_box_scope_release(cell); + assert!(!is_registered_box_ptr(cell), "published cell de-registers"); + assert_eq!(js_box_get_bits(cell) as u64, crate::value::TAG_UNDEFINED); + js_box_scope_release(cell); + js_box_scope_release(cell); + + let first = js_box_alloc_bits(int_bits(1)); + let second = js_box_alloc_bits(int_bits(2)); + assert_eq!(first, cell, "the published cell is reused immediately"); + assert_ne!( + first, second, + "a repeated release must not alias two live bindings onto one cell" + ); + assert_eq!(js_box_get_bits(first), int_bits(1)); + assert_eq!(js_box_get_bits(second), int_bits(2)); + } + + /// A slot whose declaration never ran still holds TAG_UNDEFINED; codegen + /// releases it unconditionally at `ret`. Foreign pointers are rejected + /// by the same registry gate as every other box entry point. + #[test] + fn unminted_slot_values_and_foreign_pointers_are_no_ops() { + test_clear_box_registry(); + let live = js_box_alloc_bits(int_bits(3)); + js_box_scope_release(crate::value::TAG_UNDEFINED as usize as *mut Box); + js_box_scope_release(std::ptr::null_mut()); + static RODATA: [u64; 1] = [0xDEAD_BEEF]; + js_box_scope_release((&RODATA[0] as *const u64) as *mut Box); + assert_eq!(RODATA[0], 0xDEAD_BEEF); + assert!(is_registered_box_ptr(live)); + assert_eq!(js_box_get_bits(live), int_bits(3)); + assert!(!free_list_contains(live as usize)); + } + + /// `function counter() { let n = 0; return () => ++n; }`: the returned + /// closure outlives the frame. Its cell stays readable and writable after + /// scope exit, a closure created later from that closure adds its own + /// edge, and only the last closure's death publishes the cell. + #[test] + fn escaped_closure_keeps_its_cell_until_gc_death() { + test_clear_box_registry(); + let cell = js_box_alloc_bits(int_bits(0)); + let counter = crate::closure::js_closure_alloc(std::ptr::null(), 1); + crate::closure::js_closure_set_box_capture_ptr(counter, 0, cell as i64); + js_box_scope_release(cell); + + assert!(is_registered_box_ptr(cell), "an escaped closure owns it"); + js_box_set_bits(cell, int_bits(1)); + assert_eq!(js_box_get_bits(cell), int_bits(1)); + assert!(!free_list_contains(cell as usize)); + js_box_scope_release(cell); + assert!( + crate::closure::frame_released_js_cell(cell as usize, ASYNC_RELEASE_JS), + "a repeated scope release leaves the released state unchanged" + ); + assert_eq!(crate::closure::box_capture_count(cell as usize), 1); + + let child = crate::closure::js_closure_alloc(std::ptr::null(), 1); + crate::closure::js_closure_set_box_capture_ptr(child, 0, cell as i64); + crate::closure::prune_dead_closure_box_capture_owners(&|owner| owner == counter as usize); + assert!(is_registered_box_ptr(cell), "the child closure is live"); + assert_eq!(js_box_get_bits(cell), int_bits(1)); + + crate::closure::prune_dead_closure_box_capture_owners(&|owner| owner == child as usize); + assert!(!is_registered_box_ptr(cell)); + assert!(free_list_contains(cell as usize)); + let reused = js_box_alloc_bits(int_bits(9)); + assert_eq!(reused, cell, "last closure death publishes the cell"); + } + + /// A scope-released captured cell must follow the #8303 full-trace rule: + /// not a global root (else a payload that references its own closure is + /// immortal), but traced through a closure the mark set proved live. + #[test] + fn scope_released_captured_cell_is_an_ephemeron_edge_in_a_full_trace() { + test_clear_box_registry(); + let cell = js_box_alloc_bits(crate::value::TAG_UNDEFINED as i64); + let closure = crate::closure::js_closure_alloc(std::ptr::null(), 1); + crate::closure::js_closure_set_box_capture_ptr(closure, 0, cell as i64); + let closure_bits = crate::value::js_nanbox_pointer(closure as i64).to_bits(); + js_box_set_bits(cell, closure_bits as i64); + + let mut minor_roots = Vec::new(); + scan_box_roots(&mut |value| minor_roots.push(value.to_bits())); + assert!( + minor_roots.contains(&closure_bits), + "sabotage check: before release the registry roots the payload" + ); + js_box_scope_release(cell); + + crate::gc::begin_full_trace(); + let mut rooted = Vec::new(); + scan_box_roots(&mut |value| rooted.push(value.to_bits())); + assert!( + !rooted.contains(&closure_bits), + "a scope-released cell must not root its own closure in a full trace" + ); + let header = unsafe { + (closure as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader + }; + let saved_flags = unsafe { (*header).gc_flags }; + unsafe { (*header).gc_flags |= crate::gc::GC_FLAG_MARKED }; + let slots = crate::gc::test_gc_rewrite_slot_addresses(closure as usize) + .expect("closure rewrite descriptor"); + unsafe { (*header).gc_flags = saved_flags }; + crate::gc::finish_full_trace(); + assert!( + slots.contains(&(cell as usize)), + "a marked closure must trace the scope-released cell's payload" + ); + + crate::closure::prune_dead_closure_box_capture_owners(&|owner| owner == closure as usize); + assert!(!is_registered_box_ptr(cell)); + } + + /// A minor walks ONLY the box young log. A full trace that stops rooting a + /// released cell must therefore still LOG it while its payload is young, + /// or the next minor has no root for a payload a live closure still reads. + /// `PERRY_GC_PROTECT_FROMSPACE` caught exactly this as a stale from-space + /// closure call; this test is its cheap, deterministic twin. + #[test] + fn a_full_trace_keeps_a_released_cell_in_the_minor_remembered_set() { + test_clear_box_registry(); + let cell = js_box_alloc_bits(crate::value::TAG_UNDEFINED as i64); + let closure = crate::closure::js_closure_alloc(std::ptr::null(), 1); + crate::closure::js_closure_set_box_capture_ptr(closure, 0, cell as i64); + let payload = crate::value::js_nanbox_pointer(closure as i64).to_bits(); + js_box_set_bits(cell, payload as i64); + assert!( + crate::gc::young_log::bits_are_minor_relevant(payload), + "premise: the payload is a young object a minor must rewrite" + ); + js_box_scope_release(cell); + + crate::gc::begin_full_trace(); + scan_box_roots(&mut |_| {}); + crate::gc::finish_full_trace(); + + BOX_YOUNG_ROOTS.with(|log| { + log.borrow() + .debug_assert_logged(BOX_YOUNG_LOG_NAME, &relevant_box_roots()) + }); + crate::closure::prune_dead_closure_box_capture_owners(&|owner| owner == closure as usize); + } + + /// A synchronous callee running inside an async step must not park its + /// cells into the caller's activation range (that is what + /// `js_box_release` would do): the scope release is independent of the + /// ambient activation, and the activation's own terminal release still + /// works afterwards. + #[test] + fn scope_release_ignores_the_ambient_async_activation() { + test_clear_box_registry(); + let activation = new_async_box_activation(); + retain_async_box_activation(activation); + let previous = crate::promise::INLINE_TRAP.with(|trap| { + trap.replace(crate::promise::InlineTrap { + trap_next: std::ptr::null_mut(), + current_step: 0, + box_activation: activation, + }) + }); + let callee_cell = js_box_alloc_bits(int_bits(4)); + js_box_scope_release(callee_cell); + assert!(!is_registered_box_ptr(callee_cell)); + assert_eq!( + unsafe { (*activation).release_start.get() }, + NO_RELEASE_RANGE, + "the callee's cell must not enter the caller's release range" + ); + + let frame_cell = js_box_alloc_bits(int_bits(5)); + js_box_release(frame_cell); + assert!(is_registered_box_ptr(frame_cell), "a step still owns it"); + release_async_box_activation(activation); + assert!(!is_registered_box_ptr(frame_cell)); + crate::promise::INLINE_TRAP.with(|trap| trap.set(previous)); + } + + /// Generator frames also own compiler-private i32/bool control cells. + /// Each kind publishes through its own registry, and a cell of one kind + /// is never accepted by another kind's release entry. + #[test] + fn primitive_control_cells_release_through_their_own_registries() { + test_clear_box_registry(); + let state = js_i32_box_alloc(3); + let done = js_bool_box_alloc(1); + let ordinary = js_box_alloc_bits(int_bits(6)); + + js_i32_box_scope_release(ordinary.cast::()); + js_bool_box_scope_release(ordinary.cast::()); + js_box_scope_release(state.cast::()); + assert!(is_registered_box_ptr(ordinary)); + assert!(is_registered_i32_box_ptr(state)); + + let closure = crate::closure::js_closure_alloc(std::ptr::null(), 1); + crate::closure::js_closure_set_box_capture_ptr(closure, 0, done as i64); + js_i32_box_scope_release(state); + js_bool_box_scope_release(done); + assert!(!is_registered_i32_box_ptr(state)); + assert!(is_registered_bool_box_ptr(done), "a live closure reads it"); + assert_eq!(js_bool_box_get(done), 1); + crate::closure::prune_dead_closure_box_capture_owners(&|owner| owner == closure as usize); + assert!(!is_registered_bool_box_ptr(done)); + assert_eq!(js_i32_box_alloc(8), state, "i32 free list reuses it"); + assert_eq!(js_bool_box_alloc(0), done, "bool free list reuses it"); + } +} diff --git a/crates/perry-runtime/src/closure/box_captures.rs b/crates/perry-runtime/src/closure/box_captures.rs index 2cc7fd7b4a..bdc7b3fda4 100644 --- a/crates/perry-runtime/src/closure/box_captures.rs +++ b/crates/perry-runtime/src/closure/box_captures.rs @@ -6,56 +6,162 @@ //! pointer-shaped bits. Once its async activation drains, the box runtime //! publishes an unobserved cell immediately and leaves only a captured cell //! pending. Closure moves rekey the per-closure index; authoritative GC death -//! pruning drops the corresponding per-cell counts. +//! pruning drops the corresponding per-cell counts. A cell released by the +//! ordinary frame that minted it (#10464) follows the same edges. use super::ClosureHeader; use std::cell::RefCell; -type BoxCaptureSlots = Vec<(u32, usize)>; +/// One closure's `(capture index, box address)` edges. Nearly every closure +/// declares one or two, and one is stored inline: a closure capturing a +/// reassigned local then costs no heap allocation for its lifetime record +/// (#10464 made those records a per-call cost of ordinary frames). +#[derive(Clone, Default)] +struct BoxCaptureSlots { + first: Option<(u32, usize)>, + rest: Vec<(u32, usize)>, +} + +impl BoxCaptureSlots { + fn take(&mut self, index: u32) -> Option { + if self.first.is_some_and(|(slot, _)| slot == index) { + let cell = self.first.take().map(|(_, cell)| cell); + self.first = self.rest.pop(); + return cell; + } + let pos = self.rest.iter().position(|(slot, _)| *slot == index)?; + Some(self.rest.swap_remove(pos).1) + } + + fn push(&mut self, index: u32, cell: usize) { + if self.first.is_none() { + self.first = Some((index, cell)); + } else { + self.rest.push((index, cell)); + } + } + + /// `rest` is non-empty only while `first` is occupied. + fn is_empty(&self) -> bool { + self.first.is_none() + } + + fn cells(&self) -> impl Iterator + '_ { + self.first + .iter() + .chain(self.rest.iter()) + .map(|(_, cell)| *cell) + } +} crate::perry_thread_local! { /// Closure address -> compiler-declared `(capture index, box address)` edges. static CLOSURE_BOX_CELLS: RefCell> = RefCell::new(crate::fast_hash::new_ptr_hash_map()); - /// Box address -> total number of capture slots naming it. + /// Box address -> packed edge record: the number of capture slots naming + /// it (`EDGE_COUNT_MASK`) plus, once the frame that minted the cell has + /// released it (#10464), `FRAME_RELEASED` and the cell-kind tag. Keeping + /// the release state in the record the capture edges already maintain + /// makes a frame exit one probe, and lets the final edge's removal publish + /// the cell without a second table. static BOX_CAPTURE_COUNTS: RefCell> = RefCell::new(crate::fast_hash::new_ptr_hash_map()); } +const FRAME_RELEASED: usize = 1 << 63; +const FRAME_RELEASE_TAG_SHIFT: u32 = 60; +const FRAME_RELEASE_TAG_MASK: usize = 0b11 << FRAME_RELEASE_TAG_SHIFT; +const EDGE_COUNT_MASK: usize = (1 << FRAME_RELEASE_TAG_SHIFT) - 1; + fn increment_cell_capture_count(cell: usize, amount: usize) { BOX_CAPTURE_COUNTS.with(|counts| { let mut counts = counts.borrow_mut(); - let count = counts.entry(cell).or_default(); - *count = count + let record = counts.entry(cell).or_default(); + let count = (*record & EDGE_COUNT_MASK) .checked_add(amount) + .filter(|count| *count <= EDGE_COUNT_MASK) .expect("box capture count overflow"); + *record = (*record & !EDGE_COUNT_MASK) | count; }); } fn decrement_cell_capture_count(cell: usize, amount: usize) { + // `Some(record)` when the final edge disappeared. let reached_zero = BOX_CAPTURE_COUNTS.with(|counts| { let mut counts = counts.borrow_mut(); - let Some(count) = counts.get_mut(&cell) else { - return false; - }; - debug_assert!(*count >= amount); - *count -= amount; - if *count == 0 { - counts.remove(&cell); - true + let record = counts.get_mut(&cell)?; + debug_assert!(*record & EDGE_COUNT_MASK >= amount); + *record -= amount; + if *record & EDGE_COUNT_MASK == 0 { + counts.remove(&cell) } else { - false + None } }); - if reached_zero { - crate::r#box::box_capture_count_reached_zero(cell); + match reached_zero { + Some(record) if record & FRAME_RELEASED != 0 => crate::r#box::publish_frame_released_cell( + cell, + (record & FRAME_RELEASE_TAG_MASK) >> FRAME_RELEASE_TAG_SHIFT, + ), + Some(_) => crate::r#box::box_capture_count_reached_zero(cell), + None => {} } } pub(crate) fn box_capture_count(cell: usize) -> usize { BOX_CAPTURE_COUNTS - .with(|counts| counts.borrow().get(&cell).copied()) - .unwrap_or(0) + .with(|counts| { + let counts = counts.borrow(); + if counts.is_empty() { + None + } else { + counts.get(&cell).copied() + } + }) + .map_or(0, |record| record & EDGE_COUNT_MASK) +} + +/// Outcome of [`note_frame_released_cell`]. +pub(crate) enum FrameRelease { + /// No closure edge names the cell: the frame was its only holder. + Uncaptured, + /// Captured; the final edge's removal now publishes it. + Deferred, + /// Captured and already released by its frame. + AlreadyReleased, +} + +/// #10464: the frame that minted `cell` (of kind `tag`, 1..=3) can no longer +/// name it. A captured cell is marked so its last capture edge publishes it. +pub(crate) fn note_frame_released_cell(cell: usize, tag: usize) -> FrameRelease { + debug_assert!((1..=3).contains(&tag)); + BOX_CAPTURE_COUNTS.with(|counts| { + let mut counts = counts.borrow_mut(); + if counts.is_empty() { + return FrameRelease::Uncaptured; + } + match counts.get_mut(&cell) { + None => FrameRelease::Uncaptured, + Some(record) if *record & FRAME_RELEASED != 0 => FrameRelease::AlreadyReleased, + Some(record) => { + *record |= FRAME_RELEASED | (tag << FRAME_RELEASE_TAG_SHIFT); + FrameRelease::Deferred + } + } + }) +} + +/// Whether `cell` is a frame-released, still-captured JSValue box: during a +/// full trace such a cell is reached only through its live closures. +pub(crate) fn frame_released_js_cell(cell: usize, js_tag: usize) -> bool { + BOX_CAPTURE_COUNTS.with(|counts| { + let counts = counts.borrow(); + !counts.is_empty() + && counts.get(&cell).is_some_and(|record| { + *record & FRAME_RELEASED != 0 + && (*record & FRAME_RELEASE_TAG_MASK) >> FRAME_RELEASE_TAG_SHIFT == js_tag + }) + }) } /// Visit the JSValue payload slots reached through one live closure's exact @@ -72,7 +178,7 @@ pub(crate) fn visit_closure_box_payload_slots_mut(closure: usize, mut visit: imp let Some(cells) = captures.get(&closure) else { return; }; - for &(_, cell) in cells { + for cell in cells.cells() { crate::r#box::visit_pending_captured_js_box_payload_slot(cell, &mut visit); } }); @@ -91,12 +197,9 @@ pub(super) fn set_closure_box_capture( let previous = CLOSURE_BOX_CELLS.with(|captures| { let mut captures = captures.borrow_mut(); let slots = captures.entry(closure).or_default(); - let previous = slots - .iter() - .position(|(slot, _)| *slot == index) - .map(|pos| slots.swap_remove(pos).1); + let previous = slots.take(index); if let Some(cell) = cell { - slots.push((index, cell)); + slots.push(index, cell); } if slots.is_empty() { captures.remove(&closure); @@ -133,7 +236,7 @@ pub(crate) fn clone_closure_box_captures( } copied }); - for (_, cell) in copied { + for cell in copied.cells() { increment_cell_capture_count(cell, 1); } } @@ -152,21 +255,22 @@ pub(crate) fn closure_box_captures_owner_moved(old_owner: usize, new_owner: usiz } pub(crate) fn prune_dead_closure_box_capture_owners(is_dead_closure: &dyn Fn(usize) -> bool) { - let dead_keys = CLOSURE_BOX_CELLS.with(|captures| { - captures - .borrow() - .keys() - .copied() - .filter(|owner| is_dead_closure(*owner)) - .collect::>() + // One pass: dropping a dead owner and collecting its edges together avoids + // a second probe per dead closure. Counts (and any publication they + // trigger) are settled after the table borrow ends. + let mut dead_cells = Vec::new(); + CLOSURE_BOX_CELLS.with(|captures| { + captures.borrow_mut().retain(|owner, slots| { + if is_dead_closure(*owner) { + dead_cells.extend(slots.cells()); + false + } else { + true + } + }); }); - for closure in dead_keys { - let cells = CLOSURE_BOX_CELLS - .with(|captures| captures.borrow_mut().remove(&closure)) - .unwrap_or_default(); - for (_, cell) in cells { - decrement_cell_capture_count(cell, 1); - } + for cell in dead_cells { + decrement_cell_capture_count(cell, 1); } } diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index 57d7404170..9930d058b8 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -73,7 +73,8 @@ pub use unbox::{js_closure_unbox_callee_checked, js_closure_unbox_callee_checked pub(crate) use box_captures::test_clear_closure_box_capture_indexes; pub(crate) use box_captures::{ box_capture_count, clone_closure_box_captures, closure_box_captures_owner_moved, - prune_dead_closure_box_capture_owners, visit_closure_box_payload_slots_mut, + frame_released_js_cell, note_frame_released_cell, prune_dead_closure_box_capture_owners, + visit_closure_box_payload_slots_mut, FrameRelease, }; #[cfg(feature = "wasm-host")] pub(crate) use dynamic_props::register_wasm_funcref_external; diff --git a/crates/perry-runtime/src/collection_receiver.rs b/crates/perry-runtime/src/collection_receiver.rs new file mode 100644 index 0000000000..a3c6378081 --- /dev/null +++ b/crates/perry-runtime/src/collection_receiver.rs @@ -0,0 +1,63 @@ +//! The failure half of the receiver check codegen emits in front of every +//! static-type `Map` / `Set` fast path (#10446). +//! +//! Codegen lowers `s.add(v)`, `m.get(k)`, `this.labels.has(x)`, ... to a +//! direct `js_set_*` / `js_map_*` call when the receiver's DECLARED type is +//! `Set` / `Map`. Those helpers take the receiver as an unboxed 48-bit +//! payload, and a declared type is not a runtime fact: an uninitialized field +//! reads `undefined`, whose payload is the address `0x1`, and `js_set_add` +//! dereferenced it (SIGSEGV instead of a catchable TypeError — mongodb's +//! `MongoError.addErrorLabel` on an `errorLabelSet` that #10443 had left +//! undefined). +//! +//! The emitted guard is one compare of the box's top 16 bits against the +//! object tag. Every receiver that fails it and is not one of the untagged or +//! handle words the helpers have always accepted lands here. None of them +//! (`undefined`, `null`, booleans, numbers, strings, bigints) can have a +//! collection method, so this throws what the ordinary property lookup plus +//! call would have thrown. + +use crate::value::JSValue; + +/// Throw the `TypeError` for calling collection method `method` on the +/// non-object `receiver`. +/// +/// `undefined` / `null` produce V8's `Cannot read properties of undefined +/// (reading 'add')`; any other primitive produces perry's generic +/// `(number).add is not a function`, the same text the untyped method +/// dispatch throws for that receiver. +/// +/// `C-unwind` because generated code catches this through the same exception +/// path as `js_throw_type_error_property_access`, which it delegates to. +#[no_mangle] +pub extern "C-unwind" fn js_throw_collection_receiver_type_error( + receiver: f64, + method_ptr: *const u8, + method_len: usize, +) -> ! { + let value = JSValue::from_bits(receiver.to_bits()); + if value.is_undefined() || value.is_null() { + crate::error::js_throw_type_error_property_access( + u32::from(value.is_null()), + method_ptr, + method_len, + ); + } + let kind: &[u8] = if value.is_bool() { + b"boolean" + } else if value.is_any_string() { + b"string" + } else if value.is_bigint() { + b"bigint" + } else if value.is_int32() || value.is_number() { + b"number" + } else { + b"" + }; + crate::error::js_throw_type_error_not_a_function( + kind.as_ptr(), + kind.len(), + method_ptr, + method_len, + ) +} diff --git a/crates/perry-runtime/src/gc/layout/transfer.rs b/crates/perry-runtime/src/gc/layout/transfer.rs index e8eb7bbd98..c4fc849f45 100644 --- a/crates/perry-runtime/src/gc/layout/transfer.rs +++ b/crates/perry-runtime/src/gc/layout/transfer.rs @@ -95,10 +95,17 @@ pub(crate) unsafe fn layout_transfer(old_user: *mut u8, new_user: *mut u8) { // layout kinds (module docs). The latch first: it is one byte load, false // for any process that never re-prototyped a non-object, and the move is // out of line. + let relocating_header = header_from_user_ptr(old_user as *const u8); if crate::object::prototype_chain::object_static_prototypes_maybe_nonempty() && crate::object::prototype_chain::residual_prototype_owner_type( - (*header_from_user_ptr(old_user as *const u8)).obj_type, + (*relocating_header).obj_type, ) + // #10362: the per-OWNER half, same bit and same proof as the trace + // path's. `_reserved` rides every relocation by construction — all four + // callers copy it before calling here and + // `assert_relocation_copied_the_header` enforces that — so the bit is + // already correct at this point and needs no transfer of its own. + && crate::object::prototype_chain::residual_entry_possible_for(relocating_header) { transfer_residual_prototype(old_user as usize, new_user as usize); } diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index b14865203b..85ddbe7316 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -249,6 +249,12 @@ pub(super) unsafe fn visit_gc_rewrite_slot_descriptors( // arms, so no arm's early return can skip it. if crate::object::prototype_chain::object_static_prototypes_maybe_nonempty() && crate::object::prototype_chain::residual_prototype_owner_type(obj_type) + // #10362: the per-OWNER half. The latch above is exact for a process + // that never re-prototyped a non-object and useless for one that has — + // it is what made a single `Object.setPrototypeOf(anArray, p)` charge + // every traced cell of every owner-capable kind a global mutex and a + // SipHash probe. This asks the owner's own header instead. + && crate::object::prototype_chain::residual_entry_possible_for(header) { crate::object::prototype_chain::visit_object_static_prototype_slot_mut( user_ptr as usize, diff --git a/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs b/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs index 2340ea7f0a..12cb6ce779 100644 --- a/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs +++ b/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs @@ -175,6 +175,80 @@ fn test_lazy_array_explicit_prototype_survives_a_copying_minor() { ); } +/// #10362 SABOTAGE: with `GC_RESIDUAL_PROTO_OWNER` never set, the per-owner +/// gates answer "no entry here" and the collector skips the prototype edge. +/// +/// This is the test that proves the bit is load-bearing rather than +/// documentation. Its subject is the same one the two tests around it assert on +/// — the entry must follow the owner and the recorded address must be rewritten +/// — so a green run here with the bit suppressed would mean the gates it feeds +/// are not gating anything, and A' should be withdrawn. +/// +/// The membership assertion (`debug_assert_residual_owner_bit`) stands itself +/// down while the sabotage is armed, so this test fails at the real verdict +/// rather than at an assertion the sabotage itself provoked. +#[test] +fn test_suppressing_the_residual_owner_bit_loses_the_prototype() { + let _serialized = crate::array::test_serialize(); + let _feedback = crate::typed_feedback::typed_feedback_test_lock(); + let _latch = ArrayPrototypeLatchRestore::capture(); + let _guard = CopyingNurseryTestGuard::new(1); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + + let owner = crate::array::js_array_alloc(4) as usize; + let obj_type = obj_type_at(owner); + assert!( + crate::arena::pointer_in_nursery(owner) && crate::gc::gc_type_is_movable(obj_type), + "premise: a nursery owner of a movable kind" + ); + assert_ne!( + obj_type, GC_TYPE_OBJECT, + "premise: the subject must be a kind the bit actually gates" + ); + js_shadow_slot_set(0, ptr_bits(owner)); + + let sabotage = crate::object::prototype_chain::residual_proto_bit_sabotage::Guard::arm(); + let proto = marked_prototype(); + crate::object::js_object_set_prototype_of( + f64::from_bits(ptr_bits(owner)), + f64::from_bits(ptr_bits(proto)), + ); + let owner = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_eq!( + crate::object::prototype_chain::object_static_prototype(owner), + Some(ptr_bits(proto)), + "premise: the entry is recorded even with the bit suppressed — the \ + sabotage removes the PROOF, not the entry" + ); + unsafe { + let header = + (owner as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + assert_eq!( + (*header)._reserved & crate::gc::GC_RESIDUAL_PROTO_OWNER, + 0, + "premise: the sabotage really did suppress the bit" + ); + } + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + let owner_after = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_ne!(owner_after, owner, "premise: the owner must actually move"); + + let recorded = crate::object::prototype_chain::object_static_prototype(owner_after); + forget_owners(&[owner, owner_after]); + js_shadow_slot_set(0, 0); + drop(sabotage); + + assert!( + recorded.is_none(), + "the bit is not load-bearing: with `GC_RESIDUAL_PROTO_OWNER` suppressed the \ + entry still followed its owner, so the per-owner gates in \ + `gc/layout_slot_visit.rs` and `gc/layout/transfer.rs` are not gating \ + anything. A' is documentation — withdraw it." + ); +} + /// Every movable receiver kind that keeps its prototype in the residual /// registry, with the prototype held by NOTHING but that entry. One copying /// minor has to rekey the entry, retain the prototype through it, and rewrite diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index 835cb92074..2bbe5eaa2e 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -1294,6 +1294,44 @@ pub const OBJ_FLAG_TYPED_ARRAY_PROTO: u16 = 0x100; /// `JSValue` slots. This is only meaningful for `GC_TYPE_ARRAY`; object /// flags share the same `_reserved` word but never inspect this bit. pub(crate) const GC_ARRAY_RAW_F64_LAYOUT: u16 = 0x80; +/// #10362: this cell owns an entry in the residual static-prototype registry +/// (`object::prototype_chain`) — i.e. an `Object.setPrototypeOf` whose receiver +/// `meta_capable_object` turned away, so the prototype could not go in a meta +/// record and went into the address-keyed table instead. +/// +/// The registry's readers used to ask only the process-global +/// `OBJECT_PROTOTYPES_NONEMPTY` latch, which is exact for a process that has +/// never re-prototyped a non-object and useless for one that has: a single +/// `Object.setPrototypeOf(anArray, p)` made EVERY traced cell of every +/// owner-capable kind take the registry's global mutex and a SipHash probe, and +/// every relocation of one call the rekey hook. Measured on a 200k-array +/// fixture: 400,000 armed hook calls from three lines of setup, against 155 +/// unarmed. This bit answers the same question per OWNER, so an armed process +/// pays only for the cells that actually have an entry. +/// +/// Bit 6, shared with `OBJ_FLAG_NULL_PROTO` exactly as bits 7..12 are already +/// shared between the OBJECT and ARRAY namespaces, and disjoint from it by +/// `obj_type`: `OBJ_FLAG_NULL_PROTO` has one setter, +/// `js_object_alloc_null_proto`, which returns `*mut ObjectHeader`, and every +/// reader of it is behind an `obj_type == GC_TYPE_OBJECT` guard (audited: +/// `field_get_set/accessors.rs`, `field_get_set/for_in_stable.rs`, +/// `field_set_by_name.rs`, `field_set_by_name/tail.rs` via `object_is_regular`, +/// `builtins/formatting.rs` via both callers' `gc_type` dispatch, +/// `builtins/formatting/prototype_equality.rs` via `heap_object_addr`, +/// `native_call_method/object_proto.rs` via `object_ptr_from_value`). +/// So this bit is **only meaningful for `obj_type != GC_TYPE_OBJECT`**, and a +/// `GC_TYPE_OBJECT` owner that reaches the registry anyway keeps the +/// latch-only gate (see `residual_entry_possible_for`). +/// +/// Set-only, like `GC_ARRAY_NAMED_PROPS` and for the same reason: an entry is +/// never deleted while its owner lives (a second `setPrototypeOf` overwrites +/// the same key; the prune only removes DEAD owners, whose header is gone; the +/// two rekey paths remove-then-insert and the entry survives). So the error is +/// on the safe side by construction — a stale-set bit costs one probe that the +/// map answers `None` to, while the dangerous direction (entry present, bit +/// absent) has no code path that can produce it, because the only writer of the +/// entry is also the only writer of the bit, under one lock. +pub(crate) const GC_RESIDUAL_PROTO_OWNER: u16 = 0x40; /// Array was synthesized for a function's `arguments` binding. This is only /// meaningful for `GC_TYPE_ARRAY`; it lets `util.types.isArgumentsObject` /// distinguish Perry's internal `arguments` arrays from user rest arrays. diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index f0f359f1f2..ceec019f32 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -91,6 +91,7 @@ pub mod cluster; pub mod cluster_sched; pub mod collection_iter; pub mod collection_iter_object; +pub mod collection_receiver; pub mod color_parse; pub mod date; #[cfg(feature = "mod-dgram")] diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 6286afc168..bd930c6ac2 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -189,6 +189,78 @@ fn lookup_class_constructor_flags(class_id: u32) -> (bool, bool) { .unwrap_or((false, false)) } +/// Bind the USER parameter slots of `ctor_cid`'s registered constructor (every +/// slot before its trailing `__perry_cap_*` params) from a construct call's +/// full argument list. +/// +/// Codegen lowers the trailing array parameters as +/// `[fixed..., user_rest?, synthesized_arguments?]` and registers the position +/// of the first one in the closure-rest table (`ctor_rest_regs`). The flags say +/// which arrays follow: a user rest receives the arguments from that position +/// on, the synthesized `arguments` slot receives EVERY argument. +/// +/// #10484: the dynamic construct paths used to pack only a user-rest tail at +/// that position, so a constructor reading `arguments` saw just the arguments +/// past its declared parameters. `new K("x")` for `constructor(p, q)` reported +/// `arguments.length === 0`, which is how undici's `new Request(url)` failed +/// its own `argumentLengthCheck(arguments, 1)`. +/// +/// The returned words are not rooted. Callers hand them to the constructor call +/// without allocating in between. +unsafe fn constructor_user_arg_slots( + ctor_ptr: usize, + ctor_cid: u32, + user_params: usize, + args_ptr: *const f64, + args_len: usize, +) -> Vec { + let undef = f64::from_bits(crate::value::TAG_UNDEFINED); + let arg = |i: usize| { + if !args_ptr.is_null() && i < args_len { + *args_ptr.add(i) + } else { + undef + } + }; + let (has_synth, flagged_rest) = lookup_class_constructor_flags(ctor_cid); + // An unflagged registration is a plain `constructor(a, ...rest)`. + let has_rest = flagged_rest || !has_synth; + let trailing = usize::from(has_rest) + usize::from(has_synth); + let Some(fixed) = crate::closure::lookup_closure_rest(ctor_ptr as *const u8) + .map(|fixed| fixed as usize) + .filter(|fixed| fixed + trailing <= user_params) + else { + return (0..user_params).map(arg).collect(); + }; + + let scope = crate::gc::RuntimeHandleScope::new(); + let supplied: Vec = (0..args_len).map(arg).collect(); + let supplied_handles = scope.root_nanbox_f64_slice(&supplied); + let user_rest = has_rest.then(|| { + let refreshed = + crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&supplied_handles); + let tail = &refreshed[fixed.min(refreshed.len())..]; + scope.root_nanbox_f64(crate::closure::build_rest_array(tail, false)) + }); + let arguments = has_synth.then(|| { + let refreshed = + crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&supplied_handles); + scope.root_nanbox_f64(crate::closure::build_rest_array(&refreshed, true)) + }); + + let mut slots = Vec::with_capacity(user_params); + for i in 0..fixed { + slots.push( + supplied_handles + .get(i) + .map_or(undef, |handle| handle.get_nanbox_f64()), + ); + } + slots.extend(user_rest.map(|handle| handle.get_nanbox_f64())); + slots.extend(arguments.map(|handle| handle.get_nanbox_f64())); + slots +} + crate::perry_thread_local! { /// Decl-site snapshots of a function-nested class DECLARATION's captured /// outer locals, keyed by class_id. Filled by the codegen-emitted @@ -514,37 +586,16 @@ pub unsafe extern "C" fn js_super_construct_apply( // would swallow) — then append exactly `sig_caps` snapshot // values. Call with synth/rest OFF since we packed the trailing // slot manually. - let mut fa: Vec = Vec::with_capacity(total_params as usize); - let rest_idx = crate::closure::lookup_closure_rest(ctor_ptr as *const u8) - .map(|ri| ri as usize) - .filter(|ri| *ri < user_params); - if let Some(ri) = rest_idx { - for i in 0..ri { - fa.push(if i < n { - crate::array::js_array_get_f64(arr, i as u32) - } else { - undef - }); - } - let mut rest_arr = crate::array::js_array_alloc(0); - let mut i = ri; - while i < n { - rest_arr = crate::array::js_array_push_f64( - rest_arr, - crate::array::js_array_get_f64(arr, i as u32), - ); - i += 1; - } - fa.push(crate::value::js_nanbox_pointer(rest_arr as i64)); - } else { - for i in 0..user_params { - fa.push(if i < n { - crate::array::js_array_get_f64(arr, i as u32) - } else { - undef - }); - } - } + let spread: Vec = (0..n) + .map(|i| crate::array::js_array_get_f64(arr, i as u32)) + .collect(); + let mut fa = constructor_user_arg_slots( + ctor_ptr, + cur, + user_params, + spread.as_ptr(), + spread.len(), + ); for slot in 0..sig_caps as usize { fa.push(caps.get(slot).map(|b| f64::from_bits(*b)).unwrap_or(undef)); } @@ -923,33 +974,8 @@ pub(crate) unsafe fn run_class_constructor_on_this_flat( crate::closure::lookup_closure_rest(ctor_ptr as *const u8) ); } - let get = |i: usize| -> f64 { - if !args_ptr.is_null() && i < args_len { - unsafe { *args_ptr.add(i) } - } else { - undef - } - }; - let mut final_args: Vec = Vec::with_capacity(total_params as usize); - let rest_idx = crate::closure::lookup_closure_rest(ctor_ptr as *const u8) - .map(|ri| ri as usize) - .filter(|ri| *ri < user_params); - if let Some(ri) = rest_idx { - for i in 0..ri { - final_args.push(get(i)); - } - let mut rest_arr = crate::array::js_array_alloc(0); - let mut i = ri; - while i < args_len { - rest_arr = crate::array::js_array_push_f64(rest_arr, get(i)); - i += 1; - } - final_args.push(crate::value::js_nanbox_pointer(rest_arr as i64)); - } else { - for i in 0..user_params { - final_args.push(get(i)); - } - } + let mut final_args = + constructor_user_arg_slots(ctor_ptr, cur, user_params, args_ptr, args_len); for slot in 0..sig_caps as usize { final_args.push(caps.get(slot).map(|b| f64::from_bits(*b)).unwrap_or(undef)); } @@ -1276,45 +1302,17 @@ pub(crate) unsafe fn replay_class_object_constructor( // exists, and the old `max(per-eval, snapshot)` subtraction ate user args). let user_params = (total_params as usize).saturating_sub(sig_caps as usize); let undef = f64::from_bits(crate::value::TAG_UNDEFINED); - let mut final_args: Vec = Vec::with_capacity(total_params as usize); // #wall3: a `constructor(...args)` (rest param) called via the dynamic // member-new path (`new ns.Sub(opts)` → js_new_function_construct → // is_class_object_value → here) must BUNDLE the trailing call args into a JS // array for the rest slot. call_vtable_method's own `has_rest` can't do it // because the rest param is NOT last here — the positional `__perry_cap_*` - // capture params follow it — so we pack the rest array ourselves at the rest - // index, then append caps. Without this the rest binds to the first arg as a + // capture params follow it — so the trailing arrays are packed before the + // caps are appended. Without this the rest binds to the first arg as a // scalar (`args`=opts, not [opts]) and `super(...args)` spreads a bare object // → 0x400000000 mis-box → crash (Next.js `new c.AppPageRouteModule({...})`). - let rest_idx = crate::closure::lookup_closure_rest(ctor_ptr as *const u8) - .map(|ri| ri as usize) - .filter(|ri| *ri < user_params); - if let Some(ri) = rest_idx { - for i in 0..ri { - if !args_ptr.is_null() && i < args_len { - final_args.push(*args_ptr.add(i)); - } else { - final_args.push(undef); - } - } - let mut rest_arr = crate::array::js_array_alloc(0); - if !args_ptr.is_null() { - let mut i = ri; - while i < args_len { - rest_arr = crate::array::js_array_push_f64(rest_arr, *args_ptr.add(i)); - i += 1; - } - } - final_args.push(crate::value::js_nanbox_pointer(rest_arr as i64)); - } else { - for i in 0..user_params { - if !args_ptr.is_null() && i < args_len { - final_args.push(*args_ptr.add(i)); - } else { - final_args.push(undef); - } - } - } + let mut final_args = + constructor_user_arg_slots(ctor_ptr, ctor_cid, user_params, args_ptr, args_len); // Exactly `sig_caps` trailing cap slots: per-evaluation snapshot first // (class EXPRESSIONS carry `__perry_ctor_caps`), decl-site snapshot second // (class DECLARATIONS reached as heap values), undefined last. @@ -1407,46 +1405,18 @@ pub(crate) unsafe fn replay_registered_class_constructor( let user_params = (total_params as usize).saturating_sub(sig_caps as usize); let undef = f64::from_bits(crate::value::TAG_UNDEFINED); - let mut final_args: Vec = Vec::with_capacity(total_params as usize); // #wall3: a `constructor(...args)` reached via the dynamic class-REF member-new // path (`new ns.Sub(opts)` where ns.Sub resolves to an INT32 ClassRef at // runtime → js_new_function_construct → constructor_class_ref_id → // construct_registered_class_ref → here) must BUNDLE trailing call args into a // JS array for the rest slot. The rest is NOT the last ctor param (positional // `__perry_cap_*` capture params follow it), so call_vtable_method's own - // `has_rest` can't pack it — we pack the rest array ourselves at the rest - // index, then append caps. Without this the rest binds to the first arg as a + // `has_rest` can't pack it — the trailing arrays are packed before the caps + // are appended. Without this the rest binds to the first arg as a // scalar (`args`=opts, not [opts]) and `super(...args)` spreads a bare object // → 0x400000000 mis-box → crash (Next.js `new c.AppPageRouteModule({...})`). - let rest_idx = crate::closure::lookup_closure_rest(ctor_ptr as *const u8) - .map(|ri| ri as usize) - .filter(|ri| *ri < user_params); - if let Some(ri) = rest_idx { - for i in 0..ri { - if !args_ptr.is_null() && i < args_len { - final_args.push(*args_ptr.add(i)); - } else { - final_args.push(undef); - } - } - let mut rest_arr = crate::array::js_array_alloc(0); - if !args_ptr.is_null() { - let mut i = ri; - while i < args_len { - rest_arr = crate::array::js_array_push_f64(rest_arr, *args_ptr.add(i)); - i += 1; - } - } - final_args.push(crate::value::js_nanbox_pointer(rest_arr as i64)); - } else { - for i in 0..user_params { - if !args_ptr.is_null() && i < args_len { - final_args.push(*args_ptr.add(i)); - } else { - final_args.push(undef); - } - } - } + let mut final_args = + constructor_user_arg_slots(ctor_ptr, ctor_cid, user_params, args_ptr, args_len); for slot in 0..sig_caps as usize { final_args.push(caps.get(slot).map(|b| f64::from_bits(*b)).unwrap_or(undef)); } @@ -1460,3 +1430,107 @@ pub(crate) unsafe fn replay_registered_class_constructor( false, ) } + +#[cfg(test)] +mod constructor_arg_slot_tests { + use super::*; + + /// Distinct registry keys per case: `CLASS_CONSTRUCTOR_FLAGS` is + /// process-global and the closure-rest table is keyed by function pointer. + fn key(case: u32) -> (usize, u32) { + // Any stable non-null address works — the helper only READS the + // registrations under this key, it never calls through the pointer. + let ptr = (0x10_484_000usize) + case as usize * 0x40; + (ptr, 10_484_000 + case) + } + + fn array_of(value: f64) -> (usize, u32) { + let arr = crate::value::js_nanbox_get_pointer(value) as *const crate::array::ArrayHeader; + assert!(!arr.is_null(), "expected a packed array, got {value}"); + let len = crate::array::js_array_length(arr); + (arr as usize, len) + } + + fn element(value: f64, index: u32) -> f64 { + let arr = crate::value::js_nanbox_get_pointer(value) as *const crate::array::ArrayHeader; + crate::array::js_array_get_f64(arr, index) + } + + #[test] + fn the_synthesized_arguments_slot_takes_every_argument() { + let (ptr, cid) = key(1); + // `constructor(p, q)` reading `arguments`: two fixed slots, then the + // synthesized array at index 2. + js_register_class_constructor_flags(cid as i64, 1, 0); + crate::closure::js_register_closure_rest(ptr as *const u8, 2); + + let args = [11.0, 22.0, 33.0]; + let slots = unsafe { constructor_user_arg_slots(ptr, cid, 3, args.as_ptr(), args.len()) }; + assert_eq!(slots.len(), 3); + assert_eq!(slots[0], 11.0); + assert_eq!(slots[1], 22.0); + let (arr, len) = array_of(slots[2]); + assert_eq!(len, 3, "`arguments` must hold every supplied argument"); + assert_eq!(element(slots[2], 2), 33.0); + assert!( + unsafe { + crate::array::array_has_arguments_object_flag( + arr as *const crate::array::ArrayHeader, + ) + }, + "the packed array must be marked as an Arguments object" + ); + + // Fewer arguments than declared parameters: the fixed slots pad with + // `undefined` while `arguments.length` stays at what was passed. + let one = [11.0]; + let slots = unsafe { constructor_user_arg_slots(ptr, cid, 3, one.as_ptr(), one.len()) }; + assert_eq!(slots[1].to_bits(), crate::value::TAG_UNDEFINED); + assert_eq!(array_of(slots[2]).1, 1); + + let slots = unsafe { constructor_user_arg_slots(ptr, cid, 3, std::ptr::null(), 0) }; + assert_eq!(array_of(slots[2]).1, 0); + } + + #[test] + fn a_user_rest_and_arguments_constructor_fills_both_arrays() { + let (ptr, cid) = key(2); + // `constructor(first, ...rest)` reading `arguments`: one fixed slot, + // the rest array at index 1, the full argument list at index 2. + js_register_class_constructor_flags(cid as i64, 1, 1); + crate::closure::js_register_closure_rest(ptr as *const u8, 1); + + let args = [11.0, 22.0, 33.0]; + let slots = unsafe { constructor_user_arg_slots(ptr, cid, 3, args.as_ptr(), args.len()) }; + assert_eq!(slots.len(), 3); + assert_eq!(slots[0], 11.0); + assert_eq!(array_of(slots[1]).1, 2, "rest holds the tail only"); + assert_eq!(element(slots[1], 0), 22.0); + assert_eq!(array_of(slots[2]).1, 3, "`arguments` holds all three"); + } + + #[test] + fn an_unflagged_rest_constructor_keeps_tail_only_packing() { + let (ptr, cid) = key(3); + // No flags registered at all — a plain `constructor(a, ...rest)`. + crate::closure::js_register_closure_rest(ptr as *const u8, 1); + + let args = [11.0, 22.0, 33.0]; + let slots = unsafe { constructor_user_arg_slots(ptr, cid, 2, args.as_ptr(), args.len()) }; + assert_eq!(slots.len(), 2); + assert_eq!(slots[0], 11.0); + assert_eq!(array_of(slots[1]).1, 2); + } + + #[test] + fn a_constructor_with_no_trailing_array_stays_positional() { + let (_, cid) = key(4); + // An unregistered constructor pointer: positional, padded to the + // declared parameter count. + let args = [11.0]; + let slots = unsafe { constructor_user_arg_slots(0x10_484_900, cid, 2, args.as_ptr(), 1) }; + assert_eq!(slots.len(), 2); + assert_eq!(slots[0], 11.0); + assert_eq!(slots[1].to_bits(), crate::value::TAG_UNDEFINED); + } +} diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index c8b5a74ff9..f01604a586 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -953,6 +953,46 @@ fn class_parent_prototype_bits(value: f64) -> Option { (unsafe { crate::symbol::js_is_symbol(value) } == 0).then_some(bits) } +/// #10599: resolve the real `.prototype` object for a RESERVED native-builtin +/// parent class id -- one `builtin_parent_reserved_class_id` (perry-codegen) +/// wires as a class-registry parent edge for a native base that has no +/// declared-class registration of its own (`class Sub extends EventEmitter +/// {}` has no `js_register_class_name` call for `EventEmitter`). Without this, +/// `class_decl_prototype_value` bails immediately for such an id +/// (`class_name_for_id` returns `None`), so `Sub.prototype`'s `[[Prototype]]` +/// silently fell through to `Object.prototype` instead of +/// `EventEmitter.prototype` -- `Object.getPrototypeOf(Sub.prototype) !== +/// EventEmitter.prototype`, even though `new Sub() instanceof EventEmitter` +/// (a different mechanism -- the class-chain walk in `js_instanceof`) already +/// worked. +/// +/// Scoped to the ids whose only registered subclassing surface is this +/// generic declared-class-prototype path: EventEmitter and its +/// AsyncResource variant, both bound as ordinary native-module callable +/// exports (`bound_native_callable_export_value`) whose own `.prototype` is +/// the same lazily-materialized, closure-identity-keyed object any bound +/// function's `.prototype` read produces +/// (`js_function_prototype_value_for_read`). Resolving through that exact +/// helper -- the same one the dynamic-parent branch below already uses for a +/// runtime function-valued superclass -- is what makes +/// `Object.getPrototypeOf(Sub.prototype) === EventEmitter.prototype` hold by +/// identity, not merely by shape. Array/Map/Set/Error/typed-array subclasses +/// have their own dedicated instance/prototype modeling and don't reach this +/// fallback the same way. +fn reserved_native_parent_prototype_bits(parent_id: u32) -> Option { + const CLASS_ID_EVENT_EMITTER: u32 = 0xFFFF0076; + const CLASS_ID_EVENT_EMITTER_ASYNC_RESOURCE: u32 = 0xFFFF0077; + let (module, symbol) = match parent_id { + CLASS_ID_EVENT_EMITTER => ("events", "EventEmitter"), + CLASS_ID_EVENT_EMITTER_ASYNC_RESOURCE => ("events", "EventEmitterAsyncResource"), + _ => return None, + }; + let func_value = + super::super::native_module::bound_native_callable_export_value(module, symbol); + let parent_proto = super::function_prototype::js_function_prototype_value_for_read(func_value); + class_parent_prototype_bits(parent_proto) +} + pub(crate) fn class_decl_prototype_value(class_id: u32) -> f64 { // #7757: a specialization answers with its generic's prototype. let class_id = decl_prototype_identity_id(class_id); @@ -1046,7 +1086,22 @@ pub(crate) fn class_decl_prototype_value(class_id: u32) -> f64 { .and_then(|parent_id| { let parent_proto = class_decl_prototype_value(parent_id); let parent_bits = parent_proto.to_bits(); - ((parent_bits >> 48) == 0x7FFD).then_some(parent_bits) + if (parent_bits >> 48) == 0x7FFD { + return Some(parent_bits); + } + // #10599: `parent_id` may be a RESERVED native-builtin class id + // rather than a declared class -- `builtin_parent_reserved_class_id` + // in perry-codegen wires this edge for `class Sub extends + // EventEmitter {}`, which has no `js_register_class_name` + // registration of its own. `class_decl_prototype_value` bails + // immediately for such an id (`class_name_for_id` is `None`), so + // without this fallback the lookup above always misses and + // execution falls through to the runtime-function-valued branch + // below, which also misses (there is no dynamic-parent VALUE for + // a statically-resolved reserved id) -- landing `Sub.prototype`'s + // `[[Prototype]]` on `Object.prototype` instead of + // `EventEmitter.prototype`. + reserved_native_parent_prototype_bits(parent_id) }); if registered_parent_proto.is_some() { registered_parent_proto diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index 9d3b4ea774..4005e85cf2 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -893,7 +893,8 @@ pub(super) fn get_field_ic_miss_impl( let key_count = shape.logical_key_count as usize; let keys_data = (keys as *const u8).add(8) as *const f64; let alloc_limit = shape.live_inline_slot_count as usize; - for i in 0..key_count { + for i in (0..key_count).rev() { + // #10595: back-to-front so a shadowed field's most-derived slot wins; see keys_lookup.rs. let k_bits = (*keys_data.add(i)).to_bits(); let k_ptr = (k_bits & 0x0000_FFFF_FFFF_FFFF) as *const crate::StringHeader; if !k_ptr.is_null() && crate::string::js_string_equals(k_ptr, key) != 0 { diff --git a/crates/perry-runtime/src/object/keys_lookup.rs b/crates/perry-runtime/src/object/keys_lookup.rs index fcff25538c..111b783a1d 100644 --- a/crates/perry-runtime/src/object/keys_lookup.rs +++ b/crates/perry-runtime/src/object/keys_lookup.rs @@ -100,7 +100,20 @@ pub(crate) unsafe fn keys_find_slot_by_bytes( } let n = (key_count as usize).min(slot_len); let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; - for i in 0..n { + // #10595: scan back-to-front. A subclass field that re-declares an + // ancestor's field name (`class Sub extends Base { tag = ... }` where + // `Base` also declares `tag`) is NOT deduplicated in the packed keys — + // `codegen/mod.rs` lists ancestor fields first, then the class's own, so + // the array holds one entry per DECLARATION, oldest ancestor first, most + // derived last. `class_field_global_index` (the compile-time-typed read's + // index resolver) already picks the most-derived declaration ("TS + // shadowing"); this dynamic by-name lookup must agree, or a receiver + // whose static type is unknown (an inherited accessor's `this.field`, a + // computed `obj[key]`) sees the ancestor's stale slot instead of the + // override. Scanning in reverse finds that same most-derived match first, + // with no change to storage layout and no cost in the (common, no + // shadowing) case where a name occurs once. + for i in (0..n).rev() { let v = crate::JSValue::from_bits((*slots.add(i)).to_bits()); if let Some(stored) = crate::string::js_string_key_bytes(v, &mut sso) { if stored == key_bytes { @@ -204,7 +217,8 @@ pub(crate) unsafe fn keys_find_slot_by_key_ptr( return None; } let n = (key_count as usize).min(slot_len); - for i in 0..n { + // #10595: same most-derived-wins scan direction as the fast path above. + for i in (0..n).rev() { let v = crate::JSValue::from_bits((*slots.add(i)).to_bits()); if crate::string::js_string_key_matches(v, key) { return Some(i as u32); @@ -249,3 +263,74 @@ pub(crate) fn keys_index_insert( } shapes::shape_note_append(keys, new_count, key_hash, slot); } + +#[cfg(test)] +mod tests_10595 { + use super::*; + + /// #10595: a subclass field that re-declares an ancestor's field name is + /// not deduplicated in the packed keys array built by + /// `crates/perry-codegen/src/codegen/mod.rs` (ancestor fields first, + /// then the class's own) — the array genuinely holds two entries for + /// one logical property, oldest declaration first. Only the LAST + /// (most-derived) slot is ever written, matching + /// `class_field_global_index`'s "TS shadowing" resolution for the + /// compile-time-typed path. A dynamic by-name lookup that returned the + /// first match instead found the never-initialized ancestor slot. + #[test] + fn duplicate_key_name_resolves_to_the_last_occurrence() { + let ancestor_key = crate::string::js_string_from_bytes(b"tag".as_ptr(), 3); + let override_key = crate::string::js_string_from_bytes(b"tag".as_ptr(), 3); + let keys = crate::array::js_array_alloc(4); + let keys = crate::array::js_array_push(keys, JSValue::string_ptr(ancestor_key)); + let keys = crate::array::js_array_push(keys, JSValue::string_ptr(override_key)); + + let lookup_key = crate::string::js_string_from_bytes(b"tag".as_ptr(), 3); + unsafe { + assert_eq!( + keys_find_slot_by_key_ptr(keys, 2, lookup_key), + Some(1), + "must resolve to the most-derived slot (index 1), not the ancestor's (index 0)" + ); + // `keys_find_slot_by_bytes` is the byte-slice twin the pointer + // form delegates to for a valid header; pin it directly too. + assert_eq!( + keys_find_slot_by_bytes(keys, 2, b"tag"), + Some(1), + "byte-slice lookup must agree with the pointer-key lookup" + ); + } + } + + /// The common (non-shadowing) case — a name that occurs exactly once — + /// must be completely unaffected by scanning in reverse. + #[test] + fn single_occurrence_key_is_unaffected_by_scan_direction() { + let a = crate::string::js_string_from_bytes(b"x".as_ptr(), 1); + let b = crate::string::js_string_from_bytes(b"y".as_ptr(), 1); + let keys = crate::array::js_array_alloc(4); + let keys = crate::array::js_array_push(keys, JSValue::string_ptr(a)); + let keys = crate::array::js_array_push(keys, JSValue::string_ptr(b)); + + let lookup_x = crate::string::js_string_from_bytes(b"x".as_ptr(), 1); + let lookup_y = crate::string::js_string_from_bytes(b"y".as_ptr(), 1); + unsafe { + assert_eq!(keys_find_slot_by_key_ptr(keys, 2, lookup_x), Some(0)); + assert_eq!(keys_find_slot_by_key_ptr(keys, 2, lookup_y), Some(1)); + } + } + + /// A key that is genuinely absent must still miss, in both scan + /// directions. + #[test] + fn absent_key_is_not_found() { + let a = crate::string::js_string_from_bytes(b"tag".as_ptr(), 3); + let keys = crate::array::js_array_alloc(4); + let keys = crate::array::js_array_push(keys, JSValue::string_ptr(a)); + + let lookup = crate::string::js_string_from_bytes(b"tagViaGetter".as_ptr(), 12); + unsafe { + assert_eq!(keys_find_slot_by_key_ptr(keys, 1, lookup), None); + } + } +} diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index 3376c3a07b..ff8f7a20eb 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -160,6 +160,130 @@ pub(crate) fn any_user_prototype_override() -> bool { USER_PROTO_OVERRIDE_EVER.load(Ordering::Acquire) } +/// #10362: mark `obj_ptr`'s own header as an owner in the residual registry. +/// +/// Called under the registry lock and BEFORE the insert, the same discipline +/// `OBJECT_PROTOTYPES_NONEMPTY` uses one line below: the proof is published +/// before the fact it guards, so a reader that can observe the entry already +/// observes the bit. +/// +/// Silently does nothing for a `GC_TYPE_OBJECT` owner. Such an owner normally +/// never reaches the registry at all (`meta_capable_object` takes it), but it +/// can when that function turns it away for a non-type reason — and bit 6 means +/// `OBJ_FLAG_NULL_PROTO` there, so it must not be reused. Those owners keep the +/// latch-only gate, which is what every owner had before this change. +unsafe fn set_residual_proto_owner_bit(obj_ptr: usize) { + #[cfg(test)] + if residual_proto_bit_sabotage::suppressed() { + return; + } + let Some(header) = crate::value::addr_class::try_read_gc_header(obj_ptr) else { + return; + }; + if header.obj_type == crate::gc::GC_TYPE_OBJECT { + return; + } + let header = (obj_ptr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + (*header)._reserved |= crate::gc::GC_RESIDUAL_PROTO_OWNER; +} + +/// Can the cell at `header` own a residual-prototype entry, judged from its own +/// header rather than from the process-global latch? +/// +/// This is the per-owner half of the registry's gate. Callers keep asking +/// [`object_static_prototypes_maybe_nonempty`] FIRST — it is one byte load and +/// false for any process that never re-prototyped a non-object — and ask this +/// second, which is what stops an ARMED process paying per traced cell. +/// +/// Conservative for `GC_TYPE_OBJECT`: see `set_residual_proto_owner_bit`. +/// +/// # Safety +/// +/// `header` is a readable `GcHeader` of a live allocation. +#[inline] +pub(crate) unsafe fn residual_entry_possible_for(header: *const crate::gc::GcHeader) -> bool { + if (*header).obj_type == crate::gc::GC_TYPE_OBJECT { + return true; + } + (*header)._reserved & crate::gc::GC_RESIDUAL_PROTO_OWNER != 0 +} + +/// The invariant the collector's gates rest on: a LIVE non-object owner that +/// has an entry in the registry carries the bit. +/// +/// Asserted in test and debug builds — including `cargo test --release`, how the +/// GC suites run — for the same reason +/// `gc::layout::transfer::assert_relocation_copied_the_header` is: a test proves +/// today's code, an assertion proves tomorrow's. A future path that inserts an +/// entry without the bit would make every collector gate skip that owner's +/// prototype edge, which is #10493's bug exactly — correct before a collection, +/// wrong after, exit code 0 and no warning. +/// +/// Only this direction is an invariant. The reverse (bit set implies an entry) +/// is deliberately NOT asserted: the bit is set-only, and the two rekey paths +/// remove-then-insert with the lock released in between, so a bit without an +/// entry is a legal transient and a benign steady state. +#[inline] +pub(crate) unsafe fn debug_assert_residual_owner_bit(obj_ptr: usize) { + #[cfg(any(test, debug_assertions))] + { + #[cfg(test)] + if residual_proto_bit_sabotage::suppressed() { + return; + } + if let Some(header) = crate::value::addr_class::try_read_gc_header(obj_ptr) { + if header.obj_type != crate::gc::GC_TYPE_OBJECT { + assert!( + header._reserved & crate::gc::GC_RESIDUAL_PROTO_OWNER != 0, + "residual prototype registry: owner {obj_ptr:#x} (obj_type {}) has an \ + entry but not `GC_RESIDUAL_PROTO_OWNER`, so every collector gate will \ + skip its prototype edge — the prototype is neither retained nor \ + rewritten (#10493's failure mode)", + header.obj_type + ); + } + } + } + #[cfg(not(any(test, debug_assertions)))] + { + let _ = obj_ptr; + } +} + +/// Test-only sabotage for [`set_residual_proto_owner_bit`]: the bit is never +/// set, so every per-owner gate falls back to "no entry here" and the collector +/// skips the prototype edge. +/// +/// Both tests in `gc/tests/residual_prototype_relocation.rs` MUST fail while +/// this is armed. If they pass, the bit is not load-bearing and is +/// documentation — the failure mode CLAUDE.md calls "a gate that cannot fail". +#[cfg(test)] +pub(crate) mod residual_proto_bit_sabotage { + use std::cell::Cell; + + thread_local! { + static SUPPRESSED: Cell = const { Cell::new(false) }; + } + + pub(crate) fn suppressed() -> bool { + SUPPRESSED.with(Cell::get) + } + + pub(crate) struct Guard(bool); + + impl Guard { + pub(crate) fn arm() -> Self { + Self(SUPPRESSED.with(|s| s.replace(true))) + } + } + + impl Drop for Guard { + fn drop(&mut self) { + SUPPRESSED.with(|s| s.set(self.0)); + } + } +} + fn get_object_prototypes() -> &'static Mutex> { OBJECT_PROTOTYPES.get_or_init(|| Mutex::new(HashMap::new())) } @@ -325,6 +449,9 @@ fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: // The publish property is unchanged: a reader that sees `true` takes // the lock and therefore sees whatever the writer committed. OBJECT_PROTOTYPES_NONEMPTY.store(true, Ordering::Release); + // #10362: the per-OWNER half of the same proof, published under the + // same lock and before the same insert, for the same reason. + unsafe { set_residual_proto_owner_bit(obj_ptr) }; let slot = map.entry(obj_ptr).or_insert(0); *slot = proto_bits; slot_addr = slot as *mut u64 as usize; @@ -360,10 +487,17 @@ pub fn object_static_prototype(obj_ptr: usize) -> Option { if !OBJECT_PROTOTYPES_NONEMPTY.load(Ordering::Acquire) { return None; } - get_object_prototypes() + let recorded = get_object_prototypes() .lock() .ok() - .and_then(|map| map.get(&obj_ptr).copied()) + .and_then(|map| map.get(&obj_ptr).copied()); + // #10362: the invariant every collector gate rests on, checked on the read + // paths that are NOT gated by the bit — asserting it inside a bit-gated + // path would be vacuous. + if recorded.is_some() { + unsafe { debug_assert_residual_owner_bit(obj_ptr) }; + } + recorded } /// Look up the residual prototype registry for a caller that has already @@ -383,10 +517,14 @@ pub(crate) fn object_static_prototype_known_non_meta(obj_ptr: usize) -> Option = None; for i in shape.slots.candidates(key_hash) { if (i as usize) >= slot_len || i >= key_count { continue; @@ -2034,10 +2050,13 @@ pub(crate) unsafe fn shape_slot_lookup_verdict( let v = crate::JSValue::from_bits((*slots.add(i as usize)).to_bits()); if let Some(stored) = crate::string::js_string_key_bytes(v, &mut sso) { if stored == key_bytes { - return KeysIndexVerdict::Found(i); + found = Some(found.map_or(i, |prev| prev.max(i))); } } } + if let Some(i) = found { + return KeysIndexVerdict::Found(i); + } // Hash-bucket candidates existed but none matched: with a complete index // that still proves absence (the bucket held colliding OTHER keys). absent diff --git a/crates/perry-runtime/src/object/shapes_tests.rs b/crates/perry-runtime/src/object/shapes_tests.rs index 4325b432f9..5fd8e5fa71 100644 --- a/crates/perry-runtime/src/object/shapes_tests.rs +++ b/crates/perry-runtime/src/object/shapes_tests.rs @@ -1182,3 +1182,40 @@ fn the_ordinary_slot_query_declines_a_class_kind_shape() { ); } } + +#[cfg(test)] +mod issue_10595_tests { + use super::*; + + /// #10595: the >=`KEYS_INDEX_THRESHOLD` indexed lookup must agree with + /// the linear-scan lookups fixed in `object/keys_lookup.rs` — a + /// duplicate key name (a subclass field re-declaring an ancestor's + /// field, never deduplicated in the packed keys) must resolve to the + /// HIGHEST slot index among the candidates a hash bucket returns, not + /// whichever one the open-addressing probe order happens to visit + /// first. + #[test] + fn indexed_lookup_duplicate_key_name_resolves_to_the_highest_slot() { + let ancestor_key = crate::string::js_string_from_bytes(b"tag".as_ptr(), 3); + let override_key = crate::string::js_string_from_bytes(b"tag".as_ptr(), 3); + let keys = crate::array::js_array_alloc(4); + let keys = crate::array::js_array_push(keys, crate::JSValue::string_ptr(ancestor_key)); + let keys = crate::array::js_array_push(keys, crate::JSValue::string_ptr(override_key)); + + let h = crate::object::key_bytes_hash(b"tag".as_ptr(), 3); + unsafe { + // build=true: force the index to cover both slots regardless of + // KEYS_INDEX_THRESHOLD — the verdict function itself does not + // gate on that threshold, only its linear-scan callers do. + let verdict = shape_slot_lookup_verdict(keys, b"tag", h, 2, true); + match verdict { + KeysIndexVerdict::Found(slot) => assert_eq!( + slot, 1, + "must resolve to the most-derived slot (index 1), not the ancestor's (index 0)" + ), + KeysIndexVerdict::Absent => panic!("expected Found(1), got Absent"), + KeysIndexVerdict::Unindexed => panic!("expected Found(1), got Unindexed"), + } + } + } +} diff --git a/crates/perry-runtime/src/regex/perex_replace_direct.rs b/crates/perry-runtime/src/regex/perex_replace_direct.rs index 85561d67e2..dd715048ff 100644 --- a/crates/perry-runtime/src/regex/perex_replace_direct.rs +++ b/crates/perry-runtime/src/regex/perex_replace_direct.rs @@ -20,7 +20,9 @@ use super::perex_api::{self as api, ExecOutput, Reuse}; use super::perex_match_search::advance; use super::perex_memory::{MemoryBudget, StorageError}; use super::perex_owner::HeapSubject; -use super::perex_replace_storage::{boxed, call, length, text, List, Pieces, Units}; +use super::perex_replace_storage::{ + boxed, call, call_native, length, text, List, NativeArgs, Pieces, Units, +}; use super::perex_runtime::{self as host, EngineError}; use super::perex_strings::SpanCopies; use super::RegExpHeader; @@ -251,6 +253,16 @@ pub(super) fn replace( } else { Pieces::new(scope)? }; + // An ordinary replacer's arguments never reach user code as an array, so + // they are produced straight into shadow-stack slots. A proxy replacer's do + // reach it, through the `apply` trap, and keep the JS array. Sized once: + // the program's capture count fixes the argument count for every match. + let mut native_args = + if tokens.is_some() || crate::proxy::js_proxy_is_proxy(replacement.get_nanbox_f64()) == 1 { + None + } else { + Some(NativeArgs::new(captures + 3)?) + }; let mut next_source = 0; for record in spans.values.chunks_exact(width) { let local = RuntimeHandleScope::new(); @@ -284,6 +296,30 @@ pub(super) fn replace( } } } + } else if let Some(args) = native_args.as_mut() { + let this = local.root_nanbox_f64(f64::from_bits(TAG_UNDEFINED)); + let copies = &mut copies; + let value = call_native(replacement, &this, args, memory, |set| { + let matched = copies.copy(start, end, budget)?; + set(0, js_nanbox_string(matched as i64)); + let mut slot = 1; + for pair in record[2..].as_chunks::<2>().0 { + // An unset capture is the `undefined` the slot already holds. + if pair[0] != u32::MAX { + let capture = copies.copy(pair[0] as usize, pair[1] as usize, budget)?; + set(slot, js_nanbox_string(capture as i64)); + } + slot += 1; + } + set(slot, position as f64); + set(slot + 1, boxed(input)); + Ok(()) + })?; + let value = local.root_nanbox_f64(value); + let value = text(&local, &value)?; + if accepted { + output.whole(&value, budget)?; + } } else { let mut args = List::new(&local)?; let matched = copies.copy(start, end, budget)?; diff --git a/crates/perry-runtime/src/regex/perex_replace_storage.rs b/crates/perry-runtime/src/regex/perex_replace_storage.rs index bc8f0fb1c1..27e9bdbcc4 100644 --- a/crates/perry-runtime/src/regex/perex_replace_storage.rs +++ b/crates/perry-runtime/src/regex/perex_replace_storage.rs @@ -130,6 +130,90 @@ pub(super) fn call( result } +/// Arguments for a replacer call, produced straight into shadow-stack slots. +/// +/// `call` builds its slots by copying a JS array the caller filled one push at +/// a time. For a replacer invoked once per match that array is pure overhead: +/// it is allocated, grown as each argument is pushed, read back out, and +/// dropped, and no user code can observe it. Here the slots are bound to the +/// shadow stack *before* any argument is produced, so a value is a traced root +/// from the moment it is written and producing the next one may allocate and +/// collect freely. The buffer is sized once and outlives the match loop. +/// +/// Not usable for a proxy replacer: `js_proxy_apply` takes the arguments as a +/// JS array, which the `apply` trap observes, so that path keeps `List`. +pub(super) struct NativeArgs { + slots: Vec>, +} + +impl NativeArgs { + pub(super) fn new(count: usize) -> Result { + let mut slots = Vec::new(); + slots + .try_reserve_exact(count) + .map_err(|_| StorageError::Allocation)?; + for _ in 0..count { + slots.push(std::cell::UnsafeCell::new(f64::from_bits( + crate::value::TAG_UNDEFINED, + ))); + } + Ok(Self { slots }) + } +} + +/// `call` for an ordinary replacer, with the arguments produced by `fill` +/// directly into `args`. +/// +/// Every slot is reset to `undefined` and bound before `fill` runs, so an +/// argument `fill` does not write stays `undefined` (an unset capture), and one +/// it does write is rooted immediately. `fill` may therefore allocate between +/// arguments, which is what producing a match's capture strings does. +pub(super) fn call_native( + method: &RuntimeHandle<'_>, + receiver: &RuntimeHandle<'_>, + args: &mut NativeArgs, + memory: &MemoryBudget, + fill: impl FnOnce(&mut dyn FnMut(usize, f64)) -> Result<(), EngineError>, +) -> Result { + struct Frame(u64); + impl Drop for Frame { + fn drop(&mut self) { + crate::gc::js_shadow_frame_pop(self.0); + } + } + let scope = RuntimeHandleScope::new(); + let previous = scope.root_nanbox_f64(crate::object::js_implicit_this_get()); + let slots = &args.slots; + for slot in slots { + // SAFETY: nothing else holds a reference to these cells, and the + // shadow stack is not yet bound to them. + unsafe { *slot.get() = f64::from_bits(crate::value::TAG_UNDEFINED) }; + } + let frame = Frame(crate::gc::js_shadow_frame_push(slots.len() as u32)); + for (i, slot) in slots.iter().enumerate() { + crate::gc::js_shadow_slot_bind(i as u32, slot.get().cast()); + } + // SAFETY as above; the slots are bound, so a write publishes a root. + let mut set = |i: usize, value: f64| unsafe { *slots[i].get() = value }; + fill(&mut set)?; + let reservation = Reservation::new( + memory, + slots.len().checked_mul(8).ok_or(StorageError::Limit)?, + )?; + let result = api::caught(|| unsafe { + crate::object::js_implicit_this_set(receiver.get_nanbox_f64()); + crate::closure::js_native_call_value( + method.get_nanbox_f64(), + slots.as_ptr().cast(), + slots.len(), + ) + }); + crate::object::js_implicit_this_set(previous.get_nanbox_f64()); + drop(reservation); + drop(frame); + result +} + /// A reusable original-input reader. A read retains only Perex offsets across /// collection, and adjacent reads do not repeat the initial Unicode seek. pub(super) struct Units<'a, 's> { diff --git a/crates/perry-runtime/src/regex/tests.rs b/crates/perry-runtime/src/regex/tests.rs index 4eb2544d33..5abf8edf85 100644 --- a/crates/perry-runtime/src/regex/tests.rs +++ b/crates/perry-runtime/src/regex/tests.rs @@ -89,6 +89,53 @@ fn js_replacement_expands_special_patterns() { } } +/// A replacer's argument buffer is reused across matches, so it must not carry +/// one match's capture into the next. `(a)|(b)` over "aba" participates in +/// capture 1, then capture 2, then capture 1 again, so each match has an unset +/// capture that the previous match set. +/// +/// This is a regression test with a measured reason to exist: with the reset in +/// `call_native` removed, the whole 3,984-test lib suite still passed, and this +/// case returns `` instead of the correct answer. +#[test] +fn direct_replace_callback_does_not_carry_a_capture_between_matches() { + let _lock = crate::gc::global_side_table_test_lock(); + let scope = crate::gc::RuntimeHandleScope::new(); + let pattern = scope.root_string_ptr(make_string("(a)|(b)")); + let flags = scope.root_string_ptr(make_string("g")); + let re = scope.root_raw_mut_ptr( + pattern.with_const_ptr(|p| flags.with_const_ptr(|f| js_regexp_new(p, f))), + ); + let subject = scope.root_string_ptr(make_string("aba")); + let replacer = scope.root_nanbox_f64(crate::dyn_eval::dyn_function_from_strings(&[ + "m".to_string(), + "p1".to_string(), + "p2".to_string(), + "return '<' + String(p1) + ',' + String(p2) + '>';".to_string(), + ])); + let before = super::perex_replace_direct::direct_replaces(); + let out = subject.with_const_ptr::(|s| { + re.with_const_ptr::(|r| { + super::perex_replace::js_string_replace_js( + crate::value::js_nanbox_string(s as i64), + crate::value::js_nanbox_pointer(r as i64), + replacer.get_nanbox_f64(), + ) + }) + }); + // Without this the test would pass just as well against the exec-object + // fallback, which has no shared buffer and so cannot show the bug. + assert!( + super::perex_replace_direct::direct_replaces() > before, + "did not reach the direct replace path, so this test proves nothing" + ); + let out = crate::value::js_nanbox_get_pointer(out) as *const StringHeader; + assert_eq!( + string_as_str(out), + "" + ); +} + fn replace_case(pattern: &str, subject: &str, replacement: &str) -> String { let scope = crate::gc::RuntimeHandleScope::new(); let pattern = scope.root_string_ptr(make_string(pattern)); diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 01c0f0f466..21868bf7f4 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -651,6 +651,14 @@ fn compute_object_cache_key_with_env( buf.push_str(namespace); buf.push('|'); } + // #10484: the constructor's trailing-array layout decides how a + // `new` site packs its arguments. Only constructors reading + // `arguments` add a component, so other keys stay byte-identical. + if c.constructor_has_synthetic_arguments { + buf.push_str(":ctor_arguments=1:ctor_rest="); + buf.push_str(if c.constructor_has_rest { "1" } else { "0" }); + buf.push('|'); + } buf.push_str("method_rest="); buf.push_str( &c.method_has_rest 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 e1fe164098..2060069aa7 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 @@ -354,6 +354,7 @@ fn key_stable_for_nested_type_hashmap_order() { constructor_param_count: 0, has_own_constructor: false, constructor_has_rest: false, + constructor_has_synthetic_arguments: false, has_instance_fields: true, method_names: vec![], proven_this_method_names: vec![], @@ -426,6 +427,7 @@ fn key_changes_with_imported_class_signature() { constructor_param_count: 1, has_own_constructor: true, constructor_has_rest: false, + constructor_has_synthetic_arguments: false, has_instance_fields: true, method_names: vec!["bar".into()], proven_this_method_names: vec![], @@ -460,6 +462,7 @@ fn key_changes_with_imported_class_signature() { constructor_param_count: 2, // different arity has_own_constructor: true, constructor_has_rest: false, + constructor_has_synthetic_arguments: false, has_instance_fields: true, method_names: vec!["bar".into()], proven_this_method_names: vec![], @@ -502,6 +505,7 @@ fn key_changes_with_imported_class_codegen_surface() { constructor_param_count: 1, has_own_constructor: true, constructor_has_rest: false, + constructor_has_synthetic_arguments: false, has_instance_fields: true, method_names: vec!["bar".into()], proven_this_method_names: vec![], diff --git a/crates/perry/src/commands/compile/optimized_libs/freshness.rs b/crates/perry/src/commands/compile/optimized_libs/freshness.rs index b2e25ccde3..8aab6c74c7 100644 --- a/crates/perry/src/commands/compile/optimized_libs/freshness.rs +++ b/crates/perry/src/commands/compile/optimized_libs/freshness.rs @@ -749,7 +749,6 @@ pub(crate) fn binding_needs_shared_tokio(module: &str) -> bool { // HTTP clients (reqwest, hyper) | "axios" | "node-fetch" - | "fetch" // undici — glue over the native fetch stack (network I/O family). // The wrapper itself has no tokio dep today, but it rides the // shared build so the driver auto-builds its archive alongside diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index e0596c6081..557f9b1a4d 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -288,6 +288,7 @@ fn imported_class_from_hir( proven_this_method_names: Vec, proven_this_tower_method_names: Vec, ) -> perry_codegen::ImportedClass { + let ctor_abi = perry_codegen::context_free_ctor_abi(class).unwrap_or_default(); perry_codegen::ImportedClass { name: class.name.clone(), local_alias, @@ -300,11 +301,11 @@ fn imported_class_from_hir( .as_ref() .map_or(0, |ctor| ctor.params.len()), has_own_constructor: class.constructor.is_some(), - constructor_has_rest: class - .constructor - .as_ref() - .map(|ctor| ctor.params.iter().any(|param| param.is_rest)) - .unwrap_or(false), + // Trailing array slots this class's own constructor declares. A + // no-own-ctor class emits a positional forwarder, so it reports none + // until the constructor contract resolves its ancestor's ABI (#10484). + constructor_has_rest: ctor_abi.has_rest, + constructor_has_synthetic_arguments: ctor_abi.has_synthetic_arguments, has_instance_fields: !class.fields.is_empty(), method_names: class .methods @@ -436,6 +437,7 @@ fn imported_object_literal_from_capability( constructor_param_count: capability.field_names.len(), has_own_constructor: true, constructor_has_rest: false, + constructor_has_synthetic_arguments: false, has_instance_fields: !capability.field_names.is_empty(), method_names: Vec::new(), proven_this_method_names: Vec::new(), diff --git a/crates/perry/src/commands/stdlib_features.rs b/crates/perry/src/commands/stdlib_features.rs index bc51e18251..a7f4b0561b 100644 --- a/crates/perry/src/commands/stdlib_features.rs +++ b/crates/perry/src/commands/stdlib_features.rs @@ -81,16 +81,6 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { // `database-sqlite` feature with better-sqlite3 — DatabaseSync / // StatementSync route to the same `js_sqlite_*` runtime. "sqlite" | "bun:sqlite" => &["database-sqlite"], - // tursodb (#424) lives in the external - // `PerryTS/tursodb-bindings` repo (`bun add @perryts/tursodb`) - // since v0.5.557 — perry's package.json `perry.nativeLibrary` - // resolution path picks it up from `node_modules/`. No - // perry-stdlib feature gate to manage. - "tursodb" => &[], - // iroh (#425) lives in the external `PerryTS/iroh-bindings` - // repo (`bun add @perryts/iroh`) since v0.5.557 — same model - // as tursodb above. - "iroh" => &[], // Redis is detected via the ioredis class name in collect_modules, // but if it shows up as an explicit import we still need the feature. // `database-redis` umbrella retained for backwards-compat; diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index b72de46d51..59e13d4598 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -511,14 +511,6 @@ repo = "https://github.com/node-fetch/node-fetch" ref = "8b3320d2a7c07bce4afc6b2bf6c3bbddda85b01f" ported-at = "3.3.2" date = "2026-07-30" -[bindings.fetch] -crate = "perry-ext-fetch" -lib = "perry_ext_fetch" -tracking = "#466" -# Bare `fetch` is an alias for the node-fetch surface (same wrapper crate); it -# is not itself an npm package, so it inherits node-fetch's provenance. -alias-of = "node-fetch" - [bindings.ws] crate = "perry-ext-ws" lib = "perry_ext_ws" diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index cb700c98cd..87dc9693bb 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2095 entries across 138 modules +// Coverage: 2093 entries across 136 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -2096,11 +2096,6 @@ declare module "iovalkey" { export function createClient(...args: any[]): any; } -declare module "iroh" { - /** stdlib */ - export function bind(...args: any[]): any; -} - declare module "jsonwebtoken" { /** stdlib */ export function decode(token: string): any; @@ -4141,11 +4136,6 @@ declare module "tty" { export function isatty(...args: any[]): any; } -declare module "tursodb" { - /** stdlib */ - export function open(...args: any[]): any; -} - declare module "typescript" { /** stdlib */ export const DiagnosticCategory: any; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 457840e2d4..afcbe5c54a 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 3054 entries across 140 modules. +Total: 3035 entries across 138 modules. ## Modules @@ -64,7 +64,6 @@ Total: 3054 entries across 140 modules. - [`inspector/promises`](#inspectorpromises) - [`ioredis`](#ioredis) - [`iovalkey`](#iovalkey) -- [`iroh`](#iroh) - [`jsonwebtoken`](#jsonwebtoken) - [`lodash`](#lodash) - [`lru-cache`](#lru-cache) @@ -132,7 +131,6 @@ Total: 3054 entries across 140 modules. - [`timers/promises`](#timerspromises) - [`tls`](#tls) - [`tty`](#tty) -- [`tursodb`](#tursodb) - [`typescript`](#typescript) - [`undici`](#undici) - [`url`](#url) @@ -2014,22 +2012,6 @@ Total: 3054 entries across 140 modules. - `createClient` — module -## `iroh` - -### Methods - -- `acceptBi` — instance -- `acceptOne` — instance -- `bind` — module -- `close` — instance -- `connClose` — instance -- `connect` — instance -- `nodeId` — instance -- `openBi` — instance -- `streamFinish` — instance -- `streamReadToEnd` — instance -- `streamWrite` — instance - ## `jsonwebtoken` ### Methods @@ -3786,19 +3768,6 @@ Total: 3054 entries across 140 modules. - `removeListener` — instance *(class: `WriteStream`)* - `setRawMode` — instance *(class: `ReadStream`)* -## `tursodb` - -### Methods - -- `close` — instance -- `exec` — instance -- `execBatch` — instance -- `isAutocommit` — instance -- `lastInsertRowid` — instance -- `open` — module -- `queryAll` — instance -- `queryOne` — instance - ## `typescript` ### Methods diff --git a/docs/src/native-libraries/governance.md b/docs/src/native-libraries/governance.md index dd9a302695..1591f27649 100644 --- a/docs/src/native-libraries/governance.md +++ b/docs/src/native-libraries/governance.md @@ -99,7 +99,7 @@ from `well_known_bindings.toml`. Regenerate this table with | `perry-ext-events` | `events` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | | `perry-ext-exponential-backoff` | `exponential-backoff` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-fastify` | `fastify` | Source package | Compile the upstream package source | Bundled; migration pending | -| `perry-ext-fetch` | `fetch`
`node-fetch` | Source package | Compile the upstream package source | Bundled; migration pending | +| `perry-ext-fetch` | `node-fetch` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-http` | `http`
`http2`
`https` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | | `perry-ext-ioredis` | `ioredis`
`iovalkey`
`redis` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-jsonwebtoken` | `jsonwebtoken` | Source package | Compile the upstream package source | Bundled; migration pending | diff --git a/scripts/addr_class_allowlist.txt b/scripts/addr_class_allowlist.txt index 4ab811e64c..ce58bea327 100644 --- a/scripts/addr_class_allowlist.txt +++ b/scripts/addr_class_allowlist.txt @@ -176,3 +176,5 @@ crates/perry-runtime/src/hot_diag/receiver_repr.rs | let derived_ptr = derived.a crates/perry-runtime/src/hot_diag/receiver_repr.rs | (addr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; | #9973 debug-only trust-the-tag audit, and the raw read is the POINT of it: the block compares the ownership-derived header against a direct byte-offset read and counts disagreements in DIRECT_MISMATCH. Routing this side through `try_read_gc_header` would validate the address first and return None for exactly the implausible cases the audit exists to catch, so the canonical predicate cannot stand in here. `#[cfg(debug_assertions)]`; absent from release builds. crates/perry-runtime/src/json/stringify_record_output.rs | as *const crate::gc::GcHeader | #10004 repeated-output hit: `arr` is reread from a field of the live receiver already classified by `try_object`, and its full tagged bits must equal the array token admitted by `dense_array`; no allocation or GC occurs between that reread and this header check. Moving GC changes the receiver or field token into a miss before this read. Repeating the tracked-range lookup here would tax every admitted memo hit without strengthening that ownership proof. crates/perry-runtime/src/array/storage.rs | let header = (arr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; | #10060 logical array storage primitive: its unsafe contract requires a live, forwarding-resolved GC_TYPE_ARRAY supplied by the allocator, collector, or a validated array receiver. It never accepts a NaN-box payload or registry handle; the preceding allocation header is part of that ownership proof. No allocation or safepoint occurs during the read. +crates/perry-runtime/src/box/scope_release.rs | (closure as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader | #10613 test-only: inside a `#[test]` in this file's `mod tests`, which sets GC_FLAG_MARKED on a closure the test itself just allocated and then restores it. `try_read_gc_header` reads a header and cannot hand back the MUTABLE pointer the flag write needs, and the address is owned by the test rather than derived from untrusted input. Never compiled into a shipped binary. +crates/perry-runtime/src/object/prototype_chain.rs | let header = (obj_ptr as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; | #10614 residual-prototype owner marking: the address is validated through the canonical predicate immediately above — `let Some(header) = try_read_gc_header(obj_ptr) else { return }`, plus an obj_type check — and this cast only re-derives a WRITABLE pointer to the header that predicate already proved present, so it can set GC_RESIDUAL_PROTO_OWNER in `_reserved`. No allocation or safepoint occurs between the validation and the write. diff --git a/scripts/check_file_size.sh b/scripts/check_file_size.sh index f34edab097..27dc952b75 100755 --- a/scripts/check_file_size.sh +++ b/scripts/check_file_size.sh @@ -80,6 +80,12 @@ crates/perry-hir/src/lower_decl/body_stmt.rs # finally-wrapper tail (~790 lines) into a sibling module — is a mechanical # cut deferred to a focused follow-up, same pattern as body_stmt.rs above. crates/perry-runtime/src/promise/then.rs +# #10595 shadowed-field fix: `get_field_ic_miss_impl`'s inline own-key scan +# now walks back-to-front (1 line) with a 1-line rationale comment, matching +# the compile-time-typed path's most-derived-wins rule. The file was already +# exactly at the 2000-line gate; a structural split of the IC-miss ladder +# into a sibling module is deferred to a focused follow-up. +crates/perry-runtime/src/object/field_get_set/ic_miss.rs # --- Representation-aware type lowering (#5466 / #5464) --- # These files crossed the gate on the type-lowering branch (native i32/u32/f64/ # i128/StringRef reps, guarded fast/fallback splits, and the material-evidence diff --git a/scripts/gc_root_dominance_check.py b/scripts/gc_root_dominance_check.py index 015a5f9590..c7d6e82707 100755 --- a/scripts/gc_root_dominance_check.py +++ b/scripts/gc_root_dominance_check.py @@ -522,6 +522,10 @@ def build_cfg(f): # (#7510: letting these two lists drift one-sided printed 358 spurious # violations once the corpus widened). "js_box_release", "js_i32_box_release", "js_bool_box_release", + # #10464 scope-exit release (box/scope_release.rs): the same publish, or a + # TLS pending-map insert for a closure-captured cell. + "js_box_scope_release", "js_i32_box_scope_release", + "js_bool_box_scope_release", "js_write_barrier", # gc/barrier.rs:930 "js_tdz_suppress_begin", "js_tdz_suppress_end", # box.rs:242/248 counter "js_array_note_numeric_write", # array/header.rs:1443 @@ -2742,6 +2746,11 @@ def _probe_boxes_outside_the_gc_heap(): still resume, which is a use-after-release aliasing hazard that this exemption would otherwise silently suppress. The old global quarantine is retained only as a conservative fallback for untracked callers. + + #10464 adds the scope-exit release of an ordinary frame's cells + (`box/scope_release.rs`). It may publish directly only because it is gated + on the cell's closure capture count; a captured cell must take the same + drained-pending path closure death pruning publishes from. """ try: with open("crates/perry-runtime/src/box.rs", @@ -2785,6 +2794,22 @@ def _probe_boxes_outside_the_gc_heap(): "park until its activation reaches zero references. " "Publishing overwrites the terminal value a stray " "resume still writes through.") + scope_path = "crates/perry-runtime/src/box/scope_release.rs" + scope = rust_fn_body(scope_path, "release_scope_cell") + if scope is None: + return (False, "release_scope_cell not found in box/scope_release.rs; " + "the #10464 scope-exit release changed shape") + if "note_frame_released_cell" not in scope \ + or "FrameRelease::Deferred" not in scope: + return (False, "scope-exit release no longer defers a closure-captured " + "cell to closure death") + try: + with open(scope_path, encoding="utf-8", errors="replace") as fh: + scope_src = fh.read() + except OSError: + return (False, f"{scope_path} not readable") + if re.search(r"\bdealloc\s*\(|arena_alloc\w*\s*\(", scope_src): + return (False, "box/scope_release.rs frees or arena-allocates cells") release_ref = rust_fn_body("crates/perry-runtime/src/box.rs", "release_async_box_activation") if release_ref is None or "if new == 0" not in release_ref \ diff --git a/test-files/_helpers/class_member_var_captures_10485.cjs b/test-files/_helpers/class_member_var_captures_10485.cjs new file mode 100644 index 0000000000..cb5e2c5627 --- /dev/null +++ b/test-files/_helpers/class_member_var_captures_10485.cjs @@ -0,0 +1,27 @@ +"use strict"; +// Helper for test_gap_10485_class_member_var_captures.ts: the shape TypeScript +// emits for a class whose static members refer to the class itself +// (`var _a; class X { … _a … } _a = X;`), as in @redis/client 6.1.0's +// dist/lib/client/index.js. A CommonJS module body is a function scope, so +// every read of `_a` below is a class-member capture of a module-body `var`. +var _a; +var hits = 0; +function attachConfig({ BaseClass, tag }) { + return class extends BaseClass { + tagged() { return tag + ":" + this.describe(); } + }; +} +class Client { + constructor(name) { this.name = name; hits++; } + static create(name) { return _a.factory(name); } + static factory(name) { + const Commanded = attachConfig({ BaseClass: _a, tag: "cmd" }); + return new Commanded(name); + } + static hits() { return hits; } + describe() { return "client(" + this.name + ") hits=" + _a.hits(); } +} +_a = Client; +exports.Client = Client; +exports.bumpHits = (n) => { hits += n; }; +exports.readHits = () => hits; diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_default.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_default.ts new file mode 100644 index 0000000000..89569c6e33 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_default.ts @@ -0,0 +1,26 @@ +import EventEmitter from "./emitter_class_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new EventEmitter())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (EventEmitter as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = EventEmitter; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_named.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_named.ts new file mode 100644 index 0000000000..34eefcea6f --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_class_named.ts @@ -0,0 +1,26 @@ +import { EventEmitter } from "./emitter_class_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new EventEmitter())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (EventEmitter as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = EventEmitter; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_default.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_default.ts new file mode 100644 index 0000000000..23919779ee --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_default.ts @@ -0,0 +1,26 @@ +import EventEmitter from "./emitter_fn_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new EventEmitter())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (EventEmitter as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = EventEmitter; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_named.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_named.ts new file mode 100644 index 0000000000..8d23686566 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_named.ts @@ -0,0 +1,26 @@ +import { EventEmitter } from "./emitter_fn_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new EventEmitter())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (EventEmitter as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = EventEmitter; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_default.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_default.ts new file mode 100644 index 0000000000..b3444dc1f8 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_default.ts @@ -0,0 +1,26 @@ +import Headers from "./headers_class_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Headers(1))); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Headers as any)(1))); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Headers; + try { + results.push("alias=" + isUser(new alias(1))); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_named.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_named.ts new file mode 100644 index 0000000000..5166ce2a45 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_class_named.ts @@ -0,0 +1,26 @@ +import { Headers } from "./headers_class_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Headers(1))); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Headers as any)(1))); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Headers; + try { + results.push("alias=" + isUser(new alias(1))); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_default.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_default.ts new file mode 100644 index 0000000000..dc8e83591c --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_default.ts @@ -0,0 +1,26 @@ +import Headers from "./headers_fn_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Headers(1))); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Headers as any)(1))); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Headers; + try { + results.push("alias=" + isUser(new alias(1))); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_named.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_named.ts new file mode 100644 index 0000000000..185a73681a --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_headers_fn_named.ts @@ -0,0 +1,26 @@ +import { Headers } from "./headers_fn_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Headers(1))); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Headers as any)(1))); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Headers; + try { + results.push("alias=" + isUser(new alias(1))); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_default.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_default.ts new file mode 100644 index 0000000000..13ae582025 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_default.ts @@ -0,0 +1,26 @@ +import Stream from "./stream_class_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Stream())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Stream as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Stream; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_named.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_named.ts new file mode 100644 index 0000000000..2548c21c1c --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_class_named.ts @@ -0,0 +1,26 @@ +import { Stream } from "./stream_class_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Stream())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Stream as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Stream; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_default.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_default.ts new file mode 100644 index 0000000000..16f9b972bf --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_default.ts @@ -0,0 +1,26 @@ +import Stream from "./stream_fn_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Stream())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Stream as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Stream; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_named.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_named.ts new file mode 100644 index 0000000000..271e11039f --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/d_stream_fn_named.ts @@ -0,0 +1,26 @@ +import { Stream } from "./stream_fn_lib.ts"; + +function isUser(o: any): boolean { + return !!(o && (o as any).__mark === "user"); +} + +export function run(): string { + const results: string[] = []; + try { + results.push("plain=" + isUser(new Stream())); + } catch (e: any) { + results.push("plain=THROW:" + (e && e.message)); + } + try { + results.push("cast=" + isUser(new (Stream as any)())); + } catch (e: any) { + results.push("cast=THROW:" + (e && e.message)); + } + const alias: any = Stream; + try { + results.push("alias=" + isUser(new alias())); + } catch (e: any) { + results.push("alias=THROW:" + (e && e.message)); + } + return results.join(" "); +} diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/emitter_class_lib.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/emitter_class_lib.ts new file mode 100644 index 0000000000..237e123f88 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/emitter_class_lib.ts @@ -0,0 +1,4 @@ +export class EventEmitter { + __mark = "user"; +} +export default EventEmitter; diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/emitter_fn_lib.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/emitter_fn_lib.ts new file mode 100644 index 0000000000..cd9d1faa1f --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/emitter_fn_lib.ts @@ -0,0 +1,4 @@ +export function EventEmitter(this: any) { + this.__mark = "user"; +} +export default EventEmitter; diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/headers_class_lib.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/headers_class_lib.ts new file mode 100644 index 0000000000..00bc0fe762 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/headers_class_lib.ts @@ -0,0 +1,8 @@ +export class Headers { + v: number; + __mark = "user"; + constructor(v: number) { + this.v = v; + } +} +export default Headers; diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/headers_fn_lib.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/headers_fn_lib.ts new file mode 100644 index 0000000000..c556d475c0 --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/headers_fn_lib.ts @@ -0,0 +1,5 @@ +export function Headers(this: any, v: number) { + this.v = v; + this.__mark = "user"; +} +export default Headers; diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/stream_class_lib.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/stream_class_lib.ts new file mode 100644 index 0000000000..008a388d9a --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/stream_class_lib.ts @@ -0,0 +1,4 @@ +export class Stream { + __mark = "user"; +} +export default Stream; diff --git a/test-files/_helpers/new_cast_builtin_shadow_10589/stream_fn_lib.ts b/test-files/_helpers/new_cast_builtin_shadow_10589/stream_fn_lib.ts new file mode 100644 index 0000000000..8135a29e9c --- /dev/null +++ b/test-files/_helpers/new_cast_builtin_shadow_10589/stream_fn_lib.ts @@ -0,0 +1,4 @@ +export function Stream(this: any) { + this.__mark = "user"; +} +export default Stream; diff --git a/test-files/fixtures/issue_10484_ctor_arguments/classes.ts b/test-files/fixtures/issue_10484_ctor_arguments/classes.ts new file mode 100644 index 0000000000..7130c87f27 --- /dev/null +++ b/test-files/fixtures/issue_10484_ctor_arguments/classes.ts @@ -0,0 +1,55 @@ +// #10484: classes whose constructors read `arguments`, imported by +// test_gap_10484_class_constructor_arguments.ts through ESM bindings. + +export function fmtArgs(a: ArrayLike): string { + return a.length + ":" + JSON.stringify(Array.from(a)); +} + +export class ExpTwo { + r: string; + constructor(p?: any, q?: any) { + this.r = fmtArgs(arguments); + } +} + +export class ExpDefault { + r: string; + constructor(p: any, q: any = "dq") { + this.r = fmtArgs(arguments) + " q=" + q; + } +} + +export class ExpRest { + r: string; + constructor(first?: any, ...rest: any[]) { + this.r = fmtArgs(arguments) + " rest=" + JSON.stringify(rest); + } +} + +export class ExpLength { + n: number; + constructor(p?: any, q?: any) { + this.n = arguments.length; + } +} + +export class ExpDerived extends ExpTwo { + constructor(x?: any) { + super(...arguments); + } +} + +export class ExpNoCtorDerived extends ExpTwo {} + +export class ExpNoCtorRest extends ExpRest {} + +const tag = "cap"; +export function makeCapturing() { + const local = tag + "-" + "tured"; + return class Capturing { + r: string; + constructor(p?: any) { + this.r = fmtArgs(arguments) + " " + local; + } + }; +} diff --git a/test-files/fixtures/issue_10484_ctor_arguments/request.cjs b/test-files/fixtures/issue_10484_ctor_arguments/request.cjs new file mode 100644 index 0000000000..80108b1375 --- /dev/null +++ b/test-files/fixtures/issue_10484_ctor_arguments/request.cjs @@ -0,0 +1,46 @@ +'use strict'; +// #10484: undici 8.x `lib/web/fetch/request.js` / webidl2js shapes. Every class +// in a compiled CommonJS package is constructed through a runtime value. + +function argumentLengthCheck({ length }, min, ctx) { + if (length < min) { + throw new TypeError(`${ctx}: ${min} argument${min !== 1 ? 's' : ''} required, but only ${length} found.`); + } +} + +class Request { + constructor(input, init = {}) { + argumentLengthCheck(arguments, 1, 'Request constructor'); + this.url = input; + this.init = init; + this.argc = arguments.length; + } +} + +class Headers { + constructor(init = undefined) { + this.argc = arguments.length; + this.list = Array.from(arguments); + } +} + +// A subclass that forwards `arguments` the way transpiled code does. +class StrictRequest extends Request { + constructor() { + super(...arguments); + this.forwarded = arguments.length; + } +} + +// `fn.apply(this, arguments)` inside a constructor. +function record(self, a, b) { + self.applied = [a, b]; +} +class Applier { + constructor(a, b) { + record.apply(null, [this].concat(Array.prototype.slice.call(arguments))); + this.argc = arguments.length; + } +} + +module.exports = { Request, Headers, StrictRequest, Applier, argumentLengthCheck }; diff --git a/test-files/test_gap_10443_error_subclass_field_init.ts b/test-files/test_gap_10443_error_subclass_field_init.ts new file mode 100644 index 0000000000..174f0a826b --- /dev/null +++ b/test-files/test_gap_10443_error_subclass_field_init.ts @@ -0,0 +1,245 @@ +// #10443: instance field initializers of a class whose DIRECT parent is a +// built-in Error constructor never ran when the class had its own constructor, +// so every field stayed `undefined` (mongodb's `MongoError.errorLabelSet`). +// Covers the whole Error family, with and without an explicit constructor, +// private `#fields`, static fields, and multi-level subclassing. + +function show(label: string, value: unknown): void { + console.log(label, value); +} + +class WithCtor extends Error { + n = 1; + labels: Set = new Set(); + list: string[] = []; + #secret = 'p'; + static kind = 'WithCtor'; + constructor(message: string) { + super(message); + show('in ctor after super():', `${this.n} ${typeof this.labels}`); + } + get secret(): string { + return this.#secret; + } +} + +const a = new WithCtor('m'); +show('WithCtor n/labels/list:', `${a.n} ${a.labels instanceof Set} ${a.list.length}`); +show('WithCtor private:', a.secret); +show('WithCtor static:', WithCtor.kind); +show('WithCtor message/name:', `${a.message} ${a.name}`); +show('WithCtor instanceof:', `${a instanceof WithCtor} ${a instanceof Error}`); +show('WithCtor stack:', typeof a.stack === 'string' && a.stack.length > 0); +a.labels.add('x'); +show('WithCtor labels after add:', a.labels.size); + +class NoCtor extends Error { + n = 2; + labels: Set = new Set(); +} +const b = new NoCtor('m2'); +show('NoCtor n/labels/message:', `${b.n} ${b.labels instanceof Set} ${b.message}`); + +// Every native error base, with an explicit constructor. +class TE extends TypeError { + n = 3; + constructor(m: string) { + super(m); + } +} +class RE extends RangeError { + n = 4; + constructor(m: string) { + super(m); + } +} +class SE extends SyntaxError { + n = 5; + constructor(m: string) { + super(m); + } +} +class RfE extends ReferenceError { + n = 6; + constructor(m: string) { + super(m); + } +} +class EE extends EvalError { + n = 7; + constructor(m: string) { + super(m); + } +} +class UE extends URIError { + n = 8; + constructor(m: string) { + super(m); + } +} +show('TypeError:', `${new TE('t').n} ${new TE('t').message} ${new TE('t') instanceof TypeError}`); +show('RangeError:', `${new RE('r').n} ${new RE('r').message} ${new RE('r') instanceof RangeError}`); +show('SyntaxError:', `${new SE('s').n} ${new SE('s').message}`); +show('ReferenceError:', `${new RfE('rf').n} ${new RfE('rf').message}`); +show('EvalError:', `${new EE('e').n} ${new EE('e').message}`); +show('URIError:', `${new UE('u').n} ${new UE('u').message}`); + +class AE extends AggregateError { + n = 9; + constructor(errors: Error[], m: string) { + super(errors, m); + } +} +const agg = new AE([new Error('inner')], 'agg'); +// Only the field-initializer half is compared: perry maps `super(errors, +// message)` to `super(message)` for an AggregateError subclass, so its +// `message` / `errors` are a separate, pre-existing defect. +show('AggregateError:', `${agg.n} ${agg instanceof AggregateError} ${agg instanceof Error}`); + +class DE extends DOMException { + n = 18; + constructor(m: string) { + super(m, 'AbortError'); + } +} +const dom = new DE('dm'); +show('DOMException:', `${dom.n} ${dom.message} ${dom.name} ${dom instanceof DOMException}`); + +class DENoCtor extends DOMException { + n = 19; +} +const dom2 = new DENoCtor('dm2', 'DataError'); +show('DOMException no ctor:', `${dom2.n} ${dom2.message} ${dom2.name}`); + +// Zero-argument constructor, statement before super(), class expression, +// class declared inside a function. +class ZeroArg extends Error { + n = 10; + constructor() { + super('zero'); + } +} +show('zero-arg ctor:', `${new ZeroArg().n} ${new ZeroArg().message}`); + +class BeforeSuper extends Error { + n = 11; + constructor(m: string) { + const upper = m.toUpperCase(); + super(upper); + } +} +show('stmt before super():', `${new BeforeSuper('bs').n} ${new BeforeSuper('bs').message}`); + +const Expr = class extends Error { + n = 12; + constructor(m: string) { + super(m); + } +}; +show('class expression:', `${new Expr('ce').n} ${new Expr('ce').message}`); + +function makeLocal(): Error & { n: number } { + class Local extends Error { + n = 13; + constructor(m: string) { + super(m); + } + } + return new Local('local'); +} +const local = makeLocal(); +show('class in function:', `${local.n} ${local.message}`); + +// Multi-level: fields on every level, each level with its own constructor. +class MidA extends Error { + m = 14; + constructor(msg: string) { + super(msg); + } +} +class LeafB extends MidA { + n = 15; + constructor(msg: string) { + super(msg); + } +} +const leaf = new LeafB('leaf'); +show('grandchild m/n/message:', `${leaf.m} ${leaf.n} ${leaf.message}`); +show('grandchild instanceof:', `${leaf instanceof LeafB} ${leaf instanceof MidA} ${leaf instanceof Error}`); + +// A ctor-less level in the middle, and a ctor-less leaf. +class MidC extends Error { + m = 16; + constructor(msg: string) { + super(msg); + } +} +class LeafD extends MidC { + n = 17; +} +const leafD = new LeafD('leafD'); +show('ctor-less leaf m/n/message:', `${leafD.m} ${leafD.n} ${leafD.message}`); + +// Field initializers must run exactly once — a side effect proves it. +let inits = 0; +class CountedBase extends Error { + tick = ++inits; + constructor(msg: string) { + super(msg); + } +} +class CountedLeaf extends CountedBase { + own = ++inits; + constructor(msg: string) { + super(msg); + } +} +const counted = new CountedLeaf('counted'); +show('init order/count:', `${counted.tick} ${counted.own} ${inits}`); + +// A private field installed twice would throw; this proves it is installed once. +class PrivBase extends Error { + #tag = 'base'; + constructor(msg: string) { + super(msg); + } + get tag(): string { + return this.#tag; + } +} +class PrivLeaf extends PrivBase { + #own = 'leaf'; + constructor(msg: string) { + super(msg); + } + get own(): string { + return this.#own; + } +} +const priv = new PrivLeaf('priv'); +show('private chain:', `${priv.tag} ${priv.own} ${priv.message}`); + +// The mongodb shape: a Set field plus a method that mutates it. +class MongoLikeError extends Error { + private readonly errorLabelSet: Set = new Set(); + constructor(message: string) { + super(message); + } + addErrorLabel(label: string): void { + this.errorLabelSet.add(label); + } + hasErrorLabel(label: string): boolean { + return this.errorLabelSet.has(label); + } +} +const mongo = new MongoLikeError('conn'); +mongo.addErrorLabel('ResetPool'); +show('mongo-like:', `${mongo.hasErrorLabel('ResetPool')} ${mongo.hasErrorLabel('Other')} ${mongo.message}`); + +// Error subclass used through a catch clause keeps its fields. +try { + throw new WithCtor('thrown'); +} catch (e: unknown) { + const err = e as WithCtor; + show('caught:', `${err.n} ${err.message} ${err instanceof WithCtor}`); +} diff --git a/test-files/test_gap_10446_typed_collection_receiver.ts b/test-files/test_gap_10446_typed_collection_receiver.ts new file mode 100644 index 0000000000..23388eabe9 --- /dev/null +++ b/test-files/test_gap_10446_typed_collection_receiver.ts @@ -0,0 +1,162 @@ +// #10446: a `Set`/`Map`-typed value holding `undefined` (or null, or a +// primitive) reached the direct js_set_*/js_map_* fast path with no receiver +// check, so the process died with SIGSEGV instead of throwing a catchable +// TypeError. Node's message text for a non-nullish receiver names the source +// expression (`h.set.add is not a function`), which perry does not reproduce +// on any path, so those rows print the error CLASS only. + +function nullish(label: string, fn: () => unknown): void { + try { + fn(); + console.log(label, 'no throw'); + } catch (e: unknown) { + const err = e as Error; + console.log(label, err.constructor.name, err.message); + } +} + +function threw(label: string, fn: () => unknown): void { + try { + fn(); + console.log(label, 'no throw'); + } catch (e: unknown) { + const err = e as Error; + console.log(label, 'threw', err.constructor.name); + } +} + +class Holder { + set: Set = new Set(); + map: Map = new Map(); + add(x: string): void { + this.set.add(x); + } + has(x: string): boolean { + return this.set.has(x); + } + del(x: string): boolean { + return this.set.delete(x); + } + clearSet(): void { + this.set.clear(); + } + setSize(): number { + return this.set.size; + } + put(k: string, v: number): void { + this.map.set(k, v); + } + get(k: string): number | undefined { + return this.map.get(k); + } + hasKey(k: string): boolean { + return this.map.has(k); + } + delKey(k: string): boolean { + return this.map.delete(k); + } + clearMap(): void { + this.map.clear(); + } + mapSize(): number { + return this.map.size; + } + eachSet(): void { + this.set.forEach(() => {}); + } + eachMap(): void { + this.map.forEach(() => {}); + } +} + +// The working shape first: a real Set/Map must behave exactly as before. +const ok = new Holder(); +ok.add('a'); +ok.put('k', 1); +console.log('works:', ok.has('a'), ok.get('k'), ok.setSize(), ok.mapSize(), ok.del('a'), ok.delKey('k')); + +// Field receivers holding undefined / null. +for (const bad of [undefined, null]) { + const h = new Holder(); + (h as any).set = bad; + (h as any).map = bad; + const tag = bad === null ? 'null' : 'undefined'; + nullish(`field set.add ${tag}:`, () => h.add('x')); + nullish(`field set.has ${tag}:`, () => h.has('x')); + nullish(`field set.delete ${tag}:`, () => h.del('x')); + nullish(`field set.clear ${tag}:`, () => h.clearSet()); + nullish(`field set.size ${tag}:`, () => h.setSize()); + nullish(`field set.forEach ${tag}:`, () => h.eachSet()); + nullish(`field map.set ${tag}:`, () => h.put('x', 1)); + nullish(`field map.get ${tag}:`, () => h.get('x')); + nullish(`field map.has ${tag}:`, () => h.hasKey('x')); + nullish(`field map.delete ${tag}:`, () => h.delKey('x')); + nullish(`field map.clear ${tag}:`, () => h.clearMap()); + nullish(`field map.size ${tag}:`, () => h.mapSize()); + nullish(`field map.forEach ${tag}:`, () => h.eachMap()); +} + +// Local bindings whose declared type is Set/Map. +function localSet(value: unknown, tag: string): void { + const s: Set = value as Set; + nullish(`local set.add ${tag}:`, () => s.add('x')); + nullish(`local set.has ${tag}:`, () => s.has('x')); + nullish(`local set.delete ${tag}:`, () => s.delete('x')); + nullish(`local set.clear ${tag}:`, () => s.clear()); +} + +function localMap(value: unknown, tag: string): void { + const m: Map = value as Map; + nullish(`local map.set ${tag}:`, () => m.set('x', 1)); + nullish(`local map.get ${tag}:`, () => m.get('x')); + nullish(`local map.has ${tag}:`, () => m.has('x')); + nullish(`local map.delete ${tag}:`, () => m.delete('x')); + nullish(`local map.clear ${tag}:`, () => m.clear()); +} + +localSet(undefined, 'undefined'); +localSet(null, 'null'); +localMap(undefined, 'undefined'); +localMap(null, 'null'); + +// Wrong-type receivers: a number, a string and a boolean can carry no +// collection method, so every engine throws a TypeError. Only the class is +// compared (see the header note about Node's expression-naming message). +function wrongType(value: unknown, tag: string): void { + const s: Set = value as Set; + const m: Map = value as Map; + threw(`local set.add ${tag}:`, () => s.add('x')); + threw(`local set.has ${tag}:`, () => s.has('x')); + threw(`local map.get ${tag}:`, () => m.get('x')); + threw(`local map.set ${tag}:`, () => m.set('x', 1)); + const h = new Holder(); + (h as any).set = value; + (h as any).map = value; + threw(`field set.add ${tag}:`, () => h.add('x')); + threw(`field map.get ${tag}:`, () => h.get('x')); +} + +wrongType(42, 'number'); +wrongType('str', 'string'); +wrongType(true, 'boolean'); + +// A method call on a genuine Set/Map reached through the same typed paths +// after all the throwing above still works. +const after = new Holder(); +after.add('z'); +after.put('z', 26); +console.log('still works:', after.has('z'), after.get('z'), after.setSize(), after.mapSize()); + +// Number- and string-keyed fast paths (the typed helper variants) on a +// nullish receiver take the same guard. +function typedKeys(): void { + const numKeys: Map = undefined as unknown as Map; + const strSet: Set = undefined as unknown as Set; + const numSet: Set = undefined as unknown as Set; + nullish('number-key map.set:', () => numKeys.set(1, 2)); + nullish('number-key map.get:', () => numKeys.get(1)); + nullish('string set.add:', () => strSet.add('s')); + nullish('number set.add:', () => numSet.add(1)); + nullish('number set.has:', () => numSet.has(1)); +} +typedKeys(); diff --git a/test-files/test_gap_10464_box_cell_release.ts b/test-files/test_gap_10464_box_cell_release.ts new file mode 100644 index 0000000000..58b0f24268 --- /dev/null +++ b/test-files/test_gap_10464_box_cell_release.ts @@ -0,0 +1,305 @@ +// #10464: a `let`/`var` captured by a closure and reassigned lives in a box +// cell. Outside a lowered async state machine those cells were never released, +// so each call leaked a registered GC root plus everything the binding last +// referenced. This test checks both halves of the fix: memory stays bounded +// relative to an equal-allocation control, and cells that escape through +// closures (returned, stored, nested, generators, async) keep their values +// across many collections. + +const maybeGc = (globalThis as any).gc as (() => void) | undefined; + +function churn(rounds: number): number { + let total = 0; + for (let r = 0; r < rounds; r++) { + const junk: Array<{ i: number; s: string; a: number[] }> = []; + for (let i = 0; i < 2000; i++) junk.push({ i, s: "x" + i, a: [i, i + 1] }); + total += junk.length; + } + if (typeof maybeGc === "function") maybeGc(); + return total; +} + +// ---- memory: the issue's repro, compared in-process against its control ---- + +function payload(i: number): number { + let buf: number[] = new Array(512).fill(i); + const swap = () => { + buf = new Array(512).fill(i + 1); + }; + swap(); + return buf.length; +} + +function noBox(i: number): number { + const holder = { buf: new Array(512).fill(i) }; + const swap = () => { + holder.buf = new Array(512).fill(i + 1); + }; + swap(); + return holder.buf.length; +} + +function counterCell(i: number): number { + let x = i; + const bump = () => { + x += 1; + }; + bump(); + return x; +} + +const rssMb = () => process.memoryUsage().rss / 1048576; + +function growthMb(run: (i: number) => number, calls: number): number { + const before = rssMb(); + let acc = 0; + for (let i = 1; i <= calls; i++) acc += run(i); + if (acc <= 0) throw new Error("no work done"); + return rssMb() - before; +} + +// Warm both shapes so allocator and heap high-water marks settle first; the +// control runs before the boxed shape so RSS reuse can only favor the control. +growthMb(noBox, 5000); +growthMb(payload, 5000); +const CALLS = 60000; +const controlGrowth = growthMb(noBox, CALLS); +const payloadGrowth = growthMb(payload, CALLS); +// Leaking every call's 512-element array costs ~5 KB * 60k = ~300 MB. +console.log("payload growth bounded by control:", payloadGrowth < Math.max(controlGrowth, 0) + 96); + +let cellAcc = 0; +for (let i = 1; i <= 200000; i++) cellAcc += counterCell(i); +console.log("counter cells:", cellAcc); + +// ---- escaping closures keep their cells ---- + +function makeCounter(start: number) { + let n = start; + return { + inc: () => ++n, + add: (k: number) => { + n += k; + return n; + }, + get: () => n, + }; +} + +const counters: Array> = []; +for (let i = 0; i < 3000; i++) counters.push(makeCounter(i)); +churn(20); +let counterSum = 0; +for (let i = 0; i < counters.length; i++) { + counters[i].inc(); + counterSum += counters[i].add(i); +} +churn(20); +for (let i = 0; i < counters.length; i += 7) counterSum += counters[i].get(); +console.log("returned counters:", counterSum); + +function makeAdders(): Map number> { + const map = new Map number>(); + for (let i = 0; i < 500; i++) { + let base = i; + map.set("k" + i, (v: number) => (base += v)); + } + return map; +} +const adders = makeAdders(); +churn(15); +let adderSum = 0; +for (const [, fn] of adders) adderSum += fn(1); +churn(15); +for (const [, fn] of adders) adderSum += fn(2); +console.log("per-iteration map closures:", adderSum); + +// A loop whose iterations sometimes capture and sometimes do not: released +// uncaptured cells are reused by the next iteration, captured ones must not be. +function mixedLoop(): number[] { + const kept: Array<() => number> = []; + for (let i = 0; i < 400; i++) { + let value = i * 3; + const touch = () => (value += 1); + if (i % 3 === 0) kept.push(touch); + else touch(); + value += 0; + } + churn(10); + return kept.map((f) => f()).slice(0, 8); +} +console.log("mixed loop:", mixedLoop().join(",")); + +// Nested closure created after the outer frame returned. +function outerFactory() { + let state = "a"; + const middle = () => { + state += "b"; + return () => { + state += "c"; + return state; + }; + }; + return middle; +} +const middles: Array<() => () => string> = []; +for (let i = 0; i < 200; i++) middles.push(outerFactory()); +churn(10); +const inners = middles.map((m) => m()); +churn(10); +console.log("nested after return:", inners[0](), inners[199](), middles[5]()()); + +// Self-referencing payload: the cell's value references its own closure. +function selfCycle(i: number) { + let self: any = null; + const getSelf = () => self; + self = { i, getSelf }; + return getSelf; +} +const cycles: Array<() => any> = []; +for (let i = 0; i < 20000; i++) { + const g = selfCycle(i); + if (i % 1000 === 0) cycles.push(g); +} +churn(15); +console.log("self cycles:", cycles.map((g) => g().getSelf().i).join(",")); + +// Generators capture boxed state across suspensions; abandoned ones are freed. +function* running(limit: number) { + let total = 0; + const add = (v: number) => { + total += v; + }; + for (let i = 1; i <= limit; i++) { + add(i); + yield total; + } + return total; +} +const gens: Array> = []; +for (let i = 0; i < 50; i++) gens.push(running(5)); +let genSum = 0; +for (let step = 0; step < 6; step++) { + churn(3); + for (const g of gens) { + const r = g.next(); + genSum += r.value ?? 0; + } +} +for (let i = 0; i < 5000; i++) running(3).next(); +console.log("generators:", genSum); + +// Class method frames and closures stored on the instance. +class Account { + report: () => string = () => ""; + owner: string; + constructor(owner: string) { + this.owner = owner; + } + open(deposit: number) { + let balance = deposit; + let history = [deposit]; + this.report = () => `${this.owner}:${balance}:${history.length}`; + return (amount: number) => { + balance += amount; + history = history.concat([amount]); + return balance; + }; + } +} +const accounts: Account[] = []; +const deposits: Array<(n: number) => number> = []; +for (let i = 0; i < 300; i++) { + const acct = new Account("u" + i); + accounts.push(acct); + deposits.push(acct.open(i)); +} +churn(10); +for (let i = 0; i < deposits.length; i++) deposits[i](10); +churn(10); +console.log("class frames:", accounts[0].report(), accounts[299].report()); + +// Early returns, throws and finally on frames that own cells. +function exits(mode: number): number { + let seen = mode; + const mark = () => (seen += 100); + try { + if (mode === 0) return mark(); + if (mode === 1) throw new Error("boom" + seen); + mark(); + } finally { + seen += 1; + } + return seen; +} +let exitSum = 0; +for (let i = 0; i < 3000; i++) { + try { + exitSum += exits(i % 3); + } catch (e) { + exitSum += (e as Error).message.length; + } +} +console.log("exits:", exitSum); + +// Recursion: every frame owns its own cell. +function depth(n: number): number { + let local = n; + const bump = () => (local += 1); + if (n > 0) local += depth(n - 1); + bump(); + return local; +} +console.log("recursion:", depth(200)); + +// Parameters captured and reassigned are boxed too. +function paramCell(a: number, b: string) { + const again = () => { + a += 1; + b = b + a; + }; + again(); + return () => b + ":" + a; +} +const paramFns: Array<() => string> = []; +for (let i = 0; i < 1000; i++) paramFns.push(paramCell(i, "p")); +churn(10); +console.log("params:", paramFns[0](), paramFns[999]()); + +// async without await, and an await-ing async closure that captures a cell +// owned by an enclosing synchronous frame which returns before it resumes. +async function noAwait(i: number) { + let y = i; + const f = () => { + y++; + }; + f(); + return y; +} + +function syncOwner(seed: number) { + let shared = seed; + const read = () => shared; + void (async () => { + await null; + churn(2); + shared += 1000; + })(); + shared += 1; + return read; +} + +async function main() { + let asyncSum = 0; + for (let i = 0; i < 2000; i++) asyncSum += await noAwait(i); + console.log("async no await:", asyncSum); + + const readers: Array<() => number> = []; + for (let i = 0; i < 20; i++) readers.push(syncOwner(i)); + churn(10); + await new Promise((resolve) => setTimeout(resolve, 10)); + churn(10); + console.log("async capture of outer cell:", readers.map((r) => r()).join(",")); +} + +main().then(() => console.log("done")); diff --git a/test-files/test_gap_10484_class_constructor_arguments.ts b/test-files/test_gap_10484_class_constructor_arguments.ts new file mode 100644 index 0000000000..b3cf23e410 --- /dev/null +++ b/test-files/test_gap_10484_class_constructor_arguments.ts @@ -0,0 +1,291 @@ +// #10484: `arguments` inside a class CONSTRUCTOR must describe the call site. +// +// Two defects, one object: +// - a static `new C(x)` reported the DECLARED parameter count, because the +// call site was padded with `undefined` up to the declared arity before +// the arguments list was packed; +// - construction through a runtime value (`const K = C; new K(x)`, a class +// returned from a function, an imported or CommonJS class, +// `Reflect.construct`) reported an EMPTY list, because the dynamic +// construct path bound the synthesized `arguments` slot like a user rest +// parameter (only the arguments past the declared ones). +// +// undici 8.9.0's `new Request(url)` (webidl `argumentLengthCheck(arguments, 1)`) +// and whatwg-url's `new URL(href)` (a class declared inside `install()` that +// checks `arguments.length < 1`) both threw "1 argument required, but 0 found". +import cjs from "./fixtures/issue_10484_ctor_arguments/request.cjs"; +import * as esm from "./fixtures/issue_10484_ctor_arguments/classes.ts"; +import { ExpTwo, ExpDefault, fmtArgs } from "./fixtures/issue_10484_ctor_arguments/classes.ts"; + +function row(label: string, f: () => any): void { + try { + const o = f(); + console.log(label, "=>", o.r ?? o.n ?? o.argc ?? o.href ?? o.url); + } catch (e: any) { + console.log(label, "=> threw", e.constructor.name, e.message); + } +} + +// ── module-level classes ── +class Two { + r: string; + constructor(p?: any, q?: any) { + this.r = fmtArgs(arguments); + } +} +class NoParams { + r: string; + constructor() { + this.r = fmtArgs(arguments); + } +} +class WithDefault { + r: string; + constructor(p: any, q: any = "dq") { + this.r = fmtArgs(arguments) + " q=" + q; + } +} +class WithRest { + r: string; + constructor(first?: any, ...rest: any[]) { + this.r = fmtArgs(arguments) + " rest=" + JSON.stringify(rest); + } +} +class LengthOnly { + n: number; + constructor(p?: any, q?: any) { + this.n = arguments.length; + } +} +class Indexed { + r: string; + constructor(p?: any) { + this.r = [arguments[0], arguments[1], arguments[2]].map(String).join(","); + } +} +class WithField { + tag = "field"; + p: any; + r: string; + constructor(p?: any) { + this.p = p; + this.r = fmtArgs(arguments) + " " + this.tag + " p=" + this.p; + } +} + +console.log("-- static new --"); +row("Two()", () => new Two()); +row("Two(a)", () => new Two("a")); +row("Two(a,b)", () => new Two("a", "b")); +row("Two(a,b,c)", () => new Two("a", "b", "c")); +row("Two(undefined)", () => new Two(undefined)); +row("Two(a,undefined)", () => new Two("a", undefined)); +row("NoParams()", () => new NoParams()); +row("NoParams(a,b)", () => new NoParams("a", "b")); +row("WithDefault(a)", () => new WithDefault("a")); +row("WithDefault(a,b,c)", () => new WithDefault("a", "b", "c")); +row("WithRest()", () => new WithRest()); +row("WithRest(a)", () => new WithRest("a")); +row("WithRest(a,b,c)", () => new WithRest("a", "b", "c")); +row("LengthOnly()", () => new LengthOnly()); +row("LengthOnly(a)", () => new LengthOnly("a")); +row("LengthOnly(a,b,c)", () => new LengthOnly("a", "b", "c")); +row("Indexed(a)", () => new Indexed("a")); +row("Indexed(a,b,c)", () => new Indexed("a", "b", "c")); +row("WithField(a)", () => new WithField("a")); +row("WithField()", () => new WithField()); + +console.log("-- spread and Reflect.construct --"); +const none: any[] = []; +const one: any[] = ["s1"]; +const three: any[] = ["s1", "s2", "s3"]; +row("Two(...[])", () => new Two(...none)); +row("Two(...[1])", () => new Two(...one)); +row("Two(...[3])", () => new Two(...three)); +row("WithRest(...[3])", () => new WithRest(...three)); +row("Reflect.construct(Two,[])", () => Reflect.construct(Two, [])); +row("Reflect.construct(Two,[1])", () => Reflect.construct(Two, ["x"])); +row("Reflect.construct(Two,[3])", () => Reflect.construct(Two, ["x", "y", "z"])); +row("Reflect.construct(WithDefault,[1])", () => Reflect.construct(WithDefault, ["x"])); +row("Reflect.construct(LengthOnly,[1])", () => Reflect.construct(LengthOnly, ["x"])); + +console.log("-- class stored in a variable --"); +const V: any = Two; +const VD: any = WithDefault; +const VR: any = WithRest; +const VL: any = LengthOnly; +const VN: any = NoParams; +row("V()", () => new V()); +row("V(a)", () => new V("a")); +row("V(a,b,c)", () => new V("a", "b", "c")); +row("V(...[3])", () => new V(...three)); +row("VD(a)", () => new VD("a")); +row("VR(a,b,c)", () => new VR("a", "b", "c")); +row("VL(a)", () => new VL("a")); +row("VN(a,b)", () => new VN("a", "b")); +const table: Record = { Two, WithField }; +row("table.Two(a)", () => new table.Two("a")); +row("table[WithField](a)", () => new table["WithField"]("a")); + +console.log("-- class returned from / declared inside a function --"); +function makeInner() { + return class Inner { + r: string; + constructor(p?: any) { + this.r = fmtArgs(arguments); + } + }; +} +const Inner = makeInner(); +row("Inner()", () => new Inner()); +row("Inner(a)", () => new Inner("a")); +row("Inner(a,b)", () => new (Inner as any)("a", "b")); + +function makeCapturing(suffix: string) { + class Local { + r: string; + constructor(p?: any, q?: any) { + this.r = fmtArgs(arguments) + " " + suffix; + } + } + const direct = new Local("inside"); + console.log("Local(inside) static =>", direct.r); + return Local; +} +const Local: any = makeCapturing("captured"); +row("Local(a)", () => new Local("a")); +row("Local(a,b,c)", () => new Local("a", "b", "c")); + +// whatwg-url shape: class declared inside `install`, arity checked by hand. +function install(globalObject: any) { + const prefix = "Failed to construct 'URL': "; + class URL { + href: string; + constructor(url: any) { + if (arguments.length < 1) { + throw new TypeError(prefix + "1 argument required, but only " + arguments.length + " present."); + } + const args: string[] = []; + { + const curArg = arguments[0]; + args.push(String(curArg)); + } + { + const curArg = arguments[1]; + if (curArg !== undefined) args.push(String(curArg)); + } + this.href = args.join(" @ "); + } + } + globalObject.URL = URL; +} +const exportsObj: any = {}; +install(exportsObj); +row("URL(href)", () => new exportsObj.URL("http://a/")); +row("URL(href,base)", () => new exportsObj.URL("/p", "http://b/")); +row("URL()", () => new exportsObj.URL()); + +console.log("-- subclasses --"); +class SpreadArgs extends Two { + constructor(x?: any) { + super(...arguments); + } +} +class NoCtor extends Two {} +class Explicit extends Two { + d: string; + constructor(x?: any, y?: any, z?: any) { + super(x); + this.d = fmtArgs(arguments); + } +} +class RestForward extends Two { + constructor(...args: any[]) { + super(...args); + } +} +row("SpreadArgs(a)", () => new SpreadArgs("a")); +row("SpreadArgs(a,b,c)", () => new (SpreadArgs as any)("a", "b", "c")); +row("NoCtor()", () => new NoCtor()); +row("NoCtor(a)", () => new NoCtor("a")); +row("NoCtor(a,b,c)", () => new (NoCtor as any)("a", "b", "c")); +row("Explicit(a,b) base", () => new Explicit("a", "b")); +console.log("Explicit(a,b) own =>", new Explicit("a", "b").d); +row("RestForward(a)", () => new RestForward("a")); +row("RestForward(a,b,c)", () => new RestForward("a", "b", "c")); +const VS: any = SpreadArgs; +const VNC: any = NoCtor; +row("VS(a,b)", () => new VS("a", "b")); +row("VNC(a)", () => new VNC("a")); +row("VNC(a,b,c)", () => new VNC("a", "b", "c")); +const nt: any = Reflect.construct(Two, ["n1"], RestForward); +console.log("Reflect.construct(Two,[1],RestForward) =>", nt.r, nt instanceof RestForward); +class FromValue extends (V as any) { + constructor(a?: any) { + super(a, "extra"); + } +} +row("FromValue(a)", () => new FromValue("a")); + +console.log("-- imported ESM classes --"); +row("ExpTwo()", () => new ExpTwo()); +row("ExpTwo(a)", () => new ExpTwo("a")); +row("ExpTwo(a,b,c)", () => new (ExpTwo as any)("a", "b", "c")); +row("ExpDefault(a)", () => new ExpDefault("a")); +row("esm.ExpTwo(a)", () => new esm.ExpTwo("a")); +row("esm.ExpRest(a,b,c)", () => new esm.ExpRest("a", "b", "c")); +row("esm.ExpLength(a)", () => new esm.ExpLength("a")); +row("esm.ExpDerived(a)", () => new esm.ExpDerived("a")); +row("esm.ExpNoCtorDerived(a)", () => new esm.ExpNoCtorDerived("a")); +row("esm.ExpNoCtorDerived(a,b,c)", () => new (esm.ExpNoCtorDerived as any)("a", "b", "c")); +row("esm.ExpNoCtorRest(a,b,c)", () => new esm.ExpNoCtorRest("a", "b", "c")); +const ENCD: any = esm.ExpNoCtorDerived; +row("value ExpNoCtorDerived(a)", () => new ENCD("a")); +const Capturing: any = esm.makeCapturing(); +row("Capturing(a,b)", () => new Capturing("a", "b")); +const EV: any = esm.ExpTwo; +row("EV(a)", () => new EV("a")); + +console.log("-- CommonJS classes --"); +row("cjs.Request(url)", () => new cjs.Request("http://127.0.0.1:1/")); +row("cjs.Request(url,init)", () => new cjs.Request("http://127.0.0.1:1/", { method: "POST" })); +row("cjs.Request()", () => new cjs.Request()); +row("cjs.Headers()", () => new cjs.Headers()); +row("cjs.Headers(a,b)", () => new cjs.Headers("a", "b")); +row("cjs.StrictRequest(url)", () => new cjs.StrictRequest("http://x/")); +row("cjs.StrictRequest()", () => new cjs.StrictRequest()); +const applier = new cjs.Applier("p", "q"); +console.log("cjs.Applier(p,q) =>", applier.argc, JSON.stringify(applier.applied)); +const { Request: DestructuredRequest } = cjs; +row("DestructuredRequest(url)", () => new DestructuredRequest("http://d/")); + +// webidl shape declared in TypeScript. +function argumentLengthCheck({ length }: { length: number }, min: number, ctx: string) { + if (length < min) throw new TypeError(`${ctx}: ${min} argument required, but only ${length} found.`); +} +class WebRequest { + url: any; + constructor(input: any, init: any = {}) { + argumentLengthCheck(arguments, 1, "Request constructor"); + this.url = input; + } +} +row("WebRequest(url)", () => new WebRequest("http://w/")); +row("WebRequest() via value", () => new (WebRequest as any)()); + +console.log("-- function constructor controls --"); +function FnCtor(this: any, p?: any, q?: any) { + this.r = fmtArgs(arguments); +} +const FV: any = FnCtor; +row("FnCtor(a)", () => new (FnCtor as any)("a")); +row("FV()", () => new FV()); +row("FV(a,b,c)", () => new FV("a", "b", "c")); +row("Reflect.construct(FnCtor,[1])", () => Reflect.construct(FnCtor as any, ["x"])); + +console.log("-- hot loop --"); +let total = 0; +for (let i = 0; i < 1000; i++) { + total += new LengthOnly(i).n + new V(i, i).r.length + (i % 2 ? new VL() : new VL(i, i, i)).n; +} +console.log("total", total); diff --git a/test-files/test_gap_10485_class_member_var_captures.ts b/test-files/test_gap_10485_class_member_var_captures.ts new file mode 100644 index 0000000000..45cf4c4caf --- /dev/null +++ b/test-files/test_gap_10485_class_member_var_captures.ts @@ -0,0 +1,179 @@ +// #10485 / #10489: class members nested in a function scope (a function body, +// or a CommonJS module body) must share the enclosing bindings they capture, +// exactly like closures do: they see writes made after the class was declared, +// and their own writes are visible outside and to every other member/instance. +// +// Perry lifts class members out of their function and hands them captures as +// value snapshots; a mutable capture is shared through a one-element cell +// (#5951). A `var` is declared twice in HIR (the body-entry slot, then the +// declaration statement), which the cell rewrite read as two bindings and +// skipped, so every `var` capture stayed a stale copy. Separately, two +// functions that each bound `const K = class {…}` constructed the FIRST +// function's class from inside the second one. +// +// Validated byte-for-byte against `node --experimental-strip-types`. +import cjs from "./_helpers/class_member_var_captures_10485.cjs"; + +const show = (label: string, value: unknown) => console.log(label, JSON.stringify(value)); + +// ── 1. TypeScript `var _a; class X {…} _a = X;` emit in a CommonJS module ── +{ + const lib = cjs as any; + const client = lib.Client.create("a"); + show("cjs static via _a:", client.tagged()); + show("cjs extends _a:", client instanceof lib.Client); + lib.bumpHits(10); + show("cjs hits (member write + outer write):", [lib.Client.hits(), lib.readHits()]); +} + +// ── 2. reads after a later assignment, every member kind ── +function readsAfterAssignment(param: number) { + var withInit = 1; + var noInit: any; + let lexical = 1; + class K { + snapshot = [withInit, noInit, lexical, param]; + live = () => [withInit, noInit, lexical, param]; + static s() { return [withInit, noInit, lexical, param]; } + i() { return [withInit, noInit, lexical, param]; } + get g() { return [withInit, noInit, lexical, param]; } + static get sg() { return [withInit, noInit, lexical, param]; } + nested() { return new (class { r() { return [withInit, noInit, lexical, param]; } })().r(); } + } + withInit = 2; noInit = "set"; lexical = 2; param = 20; + const k = new K(); + show("after 1st: static", K.s()); + show("after 1st: static getter", K.sg); + show("after 1st: instance", k.i()); + show("after 1st: getter", k.g); + show("after 1st: field init", k.snapshot); + show("after 1st: field arrow", k.live()); + show("after 1st: nested class", k.nested()); + withInit = 3; noInit = "again"; lexical = 3; param = 30; + show("after 2nd: static", K.s()); + show("after 2nd: old instance", [k.i(), k.snapshot, k.live()]); + // a `var` re-declaration with an initializer writes the SAME binding + var withInit = 4; + show("after var redeclaration:", [K.s(), k.i()]); +} +readsAfterAssignment(10); + +// a `var` declared after the class (a `let`/`const` declared after a class that +// reads it is a separate, still-open gap — it stays undefined in Perry today) +function declaredAfterClass() { + class K { + static v() { return late; } + } + var late = "late var"; + return [K.v()]; +} +show("declared after class:", declaredAfterClass()); + +// ── 3. writes from class members ── +function ctorCounter() { + var count = 0; + class A { id: number; constructor() { this.id = ++count; } } + const ids = [new A().id, new A().id, new A().id]; + return [ids, count]; +} +show("ctor counter:", ctorCounter()); + +function staticAndInstanceWrites() { + var total = 0; + var log = ""; + class B { + static add(n: number) { total += n; } + push(s: string) { log = log + s; return log.length; } + set value(v: number) { total = v; } + get value() { return total; } + } + B.add(1); B.add(2); + const b1 = new B(), b2 = new B(); + b1.push("x"); b2.push("y"); + const afterStatic = total; + b1.value = 100; + return [afterStatic, total, b2.value, log]; +} +show("static/instance/setter writes:", staticAndInstanceWrites()); + +function fieldInitWrite() { + var next = 1; + class F { id = next++; } + new F(); new F(); + return [new F().id, next]; +} +show("field initializer write:", fieldInitWrite()); + +function postfixValue() { + var n = 0; + let m = 0; + class P { a() { return n++; } b() { return m++; } } + const p = new P(); + p.a(); p.a(); p.b(); p.b(); + return [p.a(), n, p.b(), m]; +} +show("postfix result:", postfixValue()); + +function nestedClassWrites() { + var made = 0; + class Outer { + make() { return class Inner { constructor() { made++; } }; } + } + const Inner = new Outer().make(); + new Inner(); new Inner(); + return made; +} +show("nested class write:", nestedClassWrites()); + +function fieldInitNestedClass() { + var seen = 0; + class Holder { + Tracked = class { constructor() { seen++; } }; + count() { return seen; } + } + const h = new Holder(); + new h.Tracked(); new h.Tracked(); + return [h.count(), seen]; +} +show("field-init nested class:", fieldInitNestedClass()); + +// a `var` in a loop body is ONE function-scoped binding +function loopVar() { + const classes: any[] = []; + for (let i = 0; i < 3; i++) { + var shared = i * 10; + class C { static get() { return shared; } } + classes.push(C); + } + return classes.map((c) => c.get()); +} +show("var in loop body:", loopVar()); + +// ── 4. emscripten FS shape: hoisted function constructs a later class expression ── +function hoistedFactory() { + function createNode() { return new FSNode(); } + var nextInode = 1, FSNode = class { id: number; constructor() { this.id = nextInode++; } }; + createNode(); createNode(); + return createNode().id + "/" + nextInode; +} +show("hoisted factory:", hoistedFactory()); + +// ── 5. same-named bindings and classes in sibling functions ── +function twinOne() { var n = 0; const K = class { static tag = "one"; constructor() { n++; } }; new K(); new K(); new K(); return [n, K.tag, new K() instanceof K]; } +function twinTwo() { var n = 0; const K = class { static tag = "two"; constructor() { n += 10; } }; new K(); new K(); new K(); return [n, K.tag, new K() instanceof K]; } +function twinDeclOne() { var n = 0; class D { constructor() { n++; } } new D(); new D(); return n; } +function twinDeclTwo() { var n = 0; class D { constructor() { n += 100; } } new D(); new D(); return n; } +function notAClass() { + var K: any = function (this: any) { this.kind = "function"; }; + return new K().kind; +} +show("twin expr one:", twinOne()); +show("twin expr two:", twinTwo()); +show("twin decl:", [twinDeclOne(), twinDeclTwo()]); +show("same-named non-class local:", notAClass()); + +// ── 6. module top level (always worked; must keep working) ── +var _top: any; +class Top { static viaAlias() { return _top === Top; } } +_top = Top; +show("esm top-level alias:", Top.viaAlias()); diff --git a/test-files/test_gap_10589_new_cast_builtin_shadow.ts b/test-files/test_gap_10589_new_cast_builtin_shadow.ts new file mode 100644 index 0000000000..76bd2dc891 --- /dev/null +++ b/test-files/test_gap_10589_new_cast_builtin_shadow.ts @@ -0,0 +1,54 @@ +// #10589: `new X()` / `new (X as any)()` on an imported binding whose NAME +// collides with a Perry builtin constructor (Headers, EventEmitter, ...) +// constructed the BUILTIN instead of the user's own function/class of the +// same name. `peel_new_callee` strips a `(X as any)` cast before `new`'s +// lowering ever branches on the callee's shape, so the cast form and the +// bare-identifier form take the identical codegen path — the cast in the +// issue's title is not itself the trigger. The real gap: `lower_new_impl_inner` +// (crates/perry-codegen/src/lower_call/new.rs) called into the unconditional +// builtin-constructor table for any `class_name` absent from `ctx.classes` +// BEFORE checking whether that name is a user-imported PLAIN FUNCTION +// constructor (`ctx.import_function_prefixes`). Imported CLASSES already land +// in `ctx.classes` and always skipped the builtin table (see the `EventEmitter +// class` / `Stream class` controls below, which passed even before the fix) — +// only the function-declaration form was exposed. +// +// Each driver module below imports its constructor under the exact reserved +// name (`Headers`/`EventEmitter`/`Stream`) in its OWN module scope — a single +// module can only bind one top-level identifier per name, so each +// function-decl/class-decl x named/default-import combination needs its own +// tiny module. Every driver probes three shapes: `new X(...)` (plain), +// `new (X as any)(...)` (cast) and `new alias(...)` for a local variable +// holding the same value (the control that already worked — a regression +// there must be caught same as the other two). +// +// Note: this deliberately avoids `instanceof` as a discriminator (`#10477`, +// imported non-class constructors folding `x instanceof F` to `false`, is a +// separate bug not yet fixed on this base) — each library constructor stamps +// a `__mark: "user"` own-property instead, which a real Perry builtin +// Headers/EventEmitter never has. +import { run as headersFnNamed } from "./_helpers/new_cast_builtin_shadow_10589/d_headers_fn_named.ts"; +import { run as headersFnDefault } from "./_helpers/new_cast_builtin_shadow_10589/d_headers_fn_default.ts"; +import { run as headersClassNamed } from "./_helpers/new_cast_builtin_shadow_10589/d_headers_class_named.ts"; +import { run as headersClassDefault } from "./_helpers/new_cast_builtin_shadow_10589/d_headers_class_default.ts"; +import { run as emitterFnNamed } from "./_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_named.ts"; +import { run as emitterFnDefault } from "./_helpers/new_cast_builtin_shadow_10589/d_emitter_fn_default.ts"; +import { run as emitterClassNamed } from "./_helpers/new_cast_builtin_shadow_10589/d_emitter_class_named.ts"; +import { run as emitterClassDefault } from "./_helpers/new_cast_builtin_shadow_10589/d_emitter_class_default.ts"; +import { run as streamFnNamed } from "./_helpers/new_cast_builtin_shadow_10589/d_stream_fn_named.ts"; +import { run as streamFnDefault } from "./_helpers/new_cast_builtin_shadow_10589/d_stream_fn_default.ts"; +import { run as streamClassNamed } from "./_helpers/new_cast_builtin_shadow_10589/d_stream_class_named.ts"; +import { run as streamClassDefault } from "./_helpers/new_cast_builtin_shadow_10589/d_stream_class_default.ts"; + +console.log("headers fn named:", headersFnNamed()); +console.log("headers fn default:", headersFnDefault()); +console.log("headers class named:", headersClassNamed()); +console.log("headers class default:", headersClassDefault()); +console.log("emitter fn named:", emitterFnNamed()); +console.log("emitter fn default:", emitterFnDefault()); +console.log("emitter class named:", emitterClassNamed()); +console.log("emitter class default:", emitterClassDefault()); +console.log("stream fn named:", streamFnNamed()); +console.log("stream fn default:", streamFnDefault()); +console.log("stream class named:", streamClassNamed()); +console.log("stream class default:", streamClassDefault()); diff --git a/test-files/test_gap_10595_inherited_accessor_field_shape.ts b/test-files/test_gap_10595_inherited_accessor_field_shape.ts new file mode 100644 index 0000000000..06e4fc8c68 --- /dev/null +++ b/test-files/test_gap_10595_inherited_accessor_field_shape.ts @@ -0,0 +1,81 @@ +// #10595: a field a subclass overrides must read as the SUBCLASS's value +// from every read path, not just a direct `obj.field` access — including +// from inside an inherited accessor (`Object.defineProperty` getter/setter), +// a dynamic `obj[computedKey]` read, and across a two-level `extends` chain. +// +// Root cause: a subclass field declaration that shares a name with an +// ancestor field is not deduplicated in the class's packed inline-slot +// layout (ancestor fields first, then the class's own), so the object ends +// up with two inline slots for the same logical property. The compile-time +// typed path (`obj.field` on a statically-known class) already resolves to +// the most-derived slot. Every DYNAMIC name-based lookup (an inherited +// accessor's `this.field`, a computed `obj[key]`) used to return the FIRST +// (ancestor's, uninitialized) slot instead. + +class Base { + tag = "base-tag"; +} + +Object.defineProperty(Base.prototype, "tagViaGetter", { + get() { + return (this as any).tag; + }, +}); + +const tagSym = Symbol("tagSym"); +Object.defineProperty(Base.prototype, tagSym, { + get() { + return (this as any).tag; + }, +}); + +let setterLog = ""; +Object.defineProperty(Base.prototype, "tagViaAccessor", { + get() { + return (this as any).tag; + }, + set(v: string) { + setterLog = (this as any).tag + ":" + v; + }, +}); + +class Sub extends Base { + tag = "sub-tag"; +} + +class SubSub extends Sub { + tag = "subsub-tag"; +} + +// A field overridden with a different runtime type than its ancestor. +class SubTyped extends Base { + tag: any = 42; +} + +const base = new Base(); +console.log("base.tag", base.tag); +console.log("base.tagViaGetter", (base as any).tagViaGetter); +console.log("base[tagSym]", (base as any)[tagSym]); + +const sub = new Sub(); +// Direct-read control: the non-accessor path already saw the override +// correctly before this fix, and must keep doing so. +console.log("sub.tag", sub.tag); +console.log("sub.tagViaGetter", (sub as any).tagViaGetter); +console.log("sub[tagSym]", (sub as any)[tagSym]); +const key = "tag"; +console.log("sub[computedKey]", (sub as any)[key]); + +const subsub = new SubSub(); +console.log("subsub.tag", subsub.tag); +console.log("subsub.tagViaGetter", (subsub as any).tagViaGetter); +console.log("subsub[tagSym]", (subsub as any)[tagSym]); + +const typed = new SubTyped(); +console.log("typed.tag", typed.tag); +console.log("typed.tagViaGetter", (typed as any).tagViaGetter); +console.log("typed[tagSym]", (typed as any)[tagSym]); + +(sub as any).tagViaAccessor = "written"; +console.log("setterLog", setterLog); +console.log("sub.tag after setter", sub.tag); diff --git a/test-files/test_gap_10599_eventemitter_prototype_identity.ts b/test-files/test_gap_10599_eventemitter_prototype_identity.ts new file mode 100644 index 0000000000..f3af58e1f6 --- /dev/null +++ b/test-files/test_gap_10599_eventemitter_prototype_identity.ts @@ -0,0 +1,170 @@ +// #10599: `class Sub extends EventEmitter {}` left +// `Object.getPrototypeOf(Sub.prototype) !== EventEmitter.prototype`. Split out +// from #10556 (PR #10592 fixed `new Sub() instanceof EventEmitter`, a +// different mechanism — the class-chain parent edge — that does not depend on +// prototype-OBJECT identity). Root cause: `class_decl_prototype_value` +// recurses on a registered parent class id via itself, which bails for a +// RESERVED native-builtin id (no `js_register_class_name` registration), so +// the link silently fell through to `Object.prototype`. +import { EventEmitter, EventEmitterAsyncResource } from "node:events"; + +// --- direct subclass, with a field + constructor ----------------------------------- +class Sub extends EventEmitter { + tag = "sub"; + constructor() { + super(); + this.tag = "sub-ctor"; + } +} + +console.log( + "getPrototypeOf(Sub.prototype) === EventEmitter.prototype:", + Object.getPrototypeOf(Sub.prototype) === EventEmitter.prototype, +); +console.log("typeof EventEmitter.prototype:", typeof EventEmitter.prototype); +console.log("Sub.prototype instanceof EventEmitter:", Sub.prototype instanceof EventEmitter); +console.log("new Sub() instanceof EventEmitter:", new Sub() instanceof EventEmitter); +console.log( + "getPrototypeOf(EventEmitter.prototype) === Object.prototype:", + Object.getPrototypeOf(EventEmitter.prototype) === Object.prototype, +); +console.log( + "getPrototypeOf(new Sub()) === Sub.prototype:", + Object.getPrototypeOf(new Sub()) === Sub.prototype, +); + +// --- fieldless subclass, no constructor -------------------------------------------- +class Fieldless extends EventEmitter {} +console.log( + "fieldless getPrototypeOf(Fieldless.prototype) === EventEmitter.prototype:", + Object.getPrototypeOf(Fieldless.prototype) === EventEmitter.prototype, +); +console.log("new Fieldless() instanceof EventEmitter:", new Fieldless() instanceof EventEmitter); + +// --- two-level subclass -------------------------------------------------------------- +class Mid extends EventEmitter { + mid = true; +} +class Grandchild extends Mid { + gc = true; +} +console.log( + "getPrototypeOf(Mid.prototype) === EventEmitter.prototype:", + Object.getPrototypeOf(Mid.prototype) === EventEmitter.prototype, +); +console.log( + "getPrototypeOf(Grandchild.prototype) === Mid.prototype:", + Object.getPrototypeOf(Grandchild.prototype) === Mid.prototype, +); +console.log( + "getPrototypeOf(getPrototypeOf(Grandchild.prototype)) === EventEmitter.prototype:", + Object.getPrototypeOf(Object.getPrototypeOf(Grandchild.prototype)) === EventEmitter.prototype, +); +const gc = new Grandchild(); +console.log("grandchild instanceof EventEmitter:", gc instanceof EventEmitter); +console.log("grandchild instanceof Mid:", gc instanceof Mid); +console.log( + "getPrototypeOf(new Grandchild()) === Grandchild.prototype:", + Object.getPrototypeOf(new Grandchild()) === Grandchild.prototype, +); + +// --- class expression ----------------------------------------------------------------- +const ExprSub = class extends EventEmitter { + expr = true; +}; +console.log( + "expr getPrototypeOf(ExprSub.prototype) === EventEmitter.prototype:", + Object.getPrototypeOf(ExprSub.prototype) === EventEmitter.prototype, +); +console.log("new ExprSub() instanceof EventEmitter:", new ExprSub() instanceof EventEmitter); + +// An UNNAMED, fieldless class expression — the exact minimal shape (no +// literal top-level `class Foo extends EventEmitter {}` declaration to key +// off of, no fields, no constructor). +const Sub3 = class extends EventEmitter {}; +console.log( + "unnamed fieldless getPrototypeOf(Sub3.prototype) === EventEmitter.prototype:", + Object.getPrototypeOf(Sub3.prototype) === EventEmitter.prototype, +); +console.log("new Sub3() instanceof EventEmitter:", new Sub3() instanceof EventEmitter); + +// A named class expression, assigned through an extra indirection (so codegen +// cannot special-case a literal top-level `class Foo extends EventEmitter {}` +// declaration shape). +function makeSubclass() { + return class NamedExpr extends EventEmitter {}; +} +const IndirectSub = makeSubclass(); +console.log( + "indirect getPrototypeOf(IndirectSub.prototype) === EventEmitter.prototype:", + Object.getPrototypeOf(IndirectSub.prototype) === EventEmitter.prototype, +); + +// --- EventEmitterAsyncResource variant -------------------------------------------------- +class SubAsync extends EventEmitterAsyncResource {} +console.log( + "getPrototypeOf(SubAsync.prototype) === EventEmitterAsyncResource.prototype:", + Object.getPrototypeOf(SubAsync.prototype) === EventEmitterAsyncResource.prototype, +); +// NOT covered here: `Object.getPrototypeOf(EventEmitterAsyncResource.prototype) +// === EventEmitter.prototype` -- that is EventEmitterAsyncResource's OWN +// internal chain (a native-builtin-to-native-builtin link set up wherever its +// `.prototype` is first materialized), not a user `extends` subclass. It is a +// separate, pre-existing gap (Perry answers `false`, Node `true`) outside +// this fix's scope -- `class_decl_prototype_value` never runs for it at all, +// since EventEmitterAsyncResource itself has no declared-class registration. +console.log( + "SubAsync.prototype instanceof EventEmitterAsyncResource:", + SubAsync.prototype instanceof EventEmitterAsyncResource, +); + +// --- control: a builtin whose subclass instance/prototype modeling is NOT this +// fallback (Array has its own dedicated ArrayHeader-based path) — guards the +// "Array/Map/Set/Error/typed-array subclasses don't reach this fallback" +// claim in the fix's own reasoning so a future change that breaks it fails +// loudly here instead of silently. +class ArraySub extends Array {} +console.log( + "getPrototypeOf(ArraySub.prototype) === Array.prototype:", + Object.getPrototypeOf(ArraySub.prototype) === Array.prototype, +); + +// --- `in` / `for...in` — the two-prototype-path weakness CLAUDE.md calls out ---------- +// (CLASS_PROTOTYPE_OBJECTS vs CLASS_DECL_PROTOTYPE_OBJECTS disagreeing about +// the same chain). `in` and `for...in` walk the exact same +// `[[Prototype]]` link `Object.getPrototypeOf` does, so fixing the identity +// above must also fix these without a separate code path. +console.log("'on' in Sub.prototype:", "on" in Sub.prototype); +console.log("'on' in new Sub():", "on" in new Sub()); +console.log("'emit' in new Sub():", "emit" in new Sub()); +console.log("'addListener' in new Sub():", "addListener" in new Sub()); + +const forInKeys: string[] = []; +for (const k in new Sub()) forInKeys.push(k); +console.log("for...in includes 'on':", forInKeys.includes("on")); +console.log("for...in includes 'emit':", forInKeys.includes("emit")); +console.log("for...in includes own field 'tag':", forInKeys.includes("tag")); + +// NOT covered here: `Object.keys(new Sub())`. It diverges from Node +// regardless of this fix (verified identically wrong with the fix reverted): +// Perry's native-base `super()` handling installs EventEmitter's methods +// (`on`, `emit`, ...) as literal OWN enumerable properties on the instance +// (CLAUDE.md "Known-weak areas: Native base-class subclassing -- a native +// base's surface is installed at super() time"), and never sets the +// `_events`/`_eventsCount`/`_maxListeners` own fields Node's real +// EventEmitter constructor does. That is an own-property-enumeration defect, +// orthogonal to the [[Prototype]] CHAIN identity this fix corrects. + +// --- the emitter still works (identity link must not disturb dispatch) --------------- +const s = new Sub(); +let fired = 0; +s.on("ping", (n: number) => (fired += n)); +s.emit("ping", 4); +console.log("sub fired:", fired, "listeners:", s.listenerCount("ping")); +console.log("sub.tag:", s.tag); + +// --- own-key enumeration on EventEmitter.prototype survives identity too ------------- +console.log( + "EventEmitter.prototype.constructor === EventEmitter:", + (EventEmitter.prototype as any).constructor === EventEmitter, +);