diff --git a/CLAUDE.md b/CLAUDE.md index c0163e3043..769a806eec 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.1310 +**Current Version:** 0.5.1311 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 3e9d9864c2..d2195b1f14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1310" +version = "0.5.1311" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1310" +version = "0.5.1311" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1310" +version = "0.5.1311" [[package]] name = "perry-ui-tvos" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1310" +version = "0.5.1311" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 32f8478d57..74cd009fc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1310" +version = "0.5.1311" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7551-array-forwarding-stub-object-ops.md b/changelog.d/7551-array-forwarding-stub-object-ops.md new file mode 100644 index 0000000000..2ae074570f --- /dev/null +++ b/changelog.d/7551-array-forwarding-stub-object-ops.md @@ -0,0 +1,20 @@ +### Fixed + +- **`Object.freeze`/`Object.seal` and `Object.defineProperty(arr, "length", …)` hung on any array that had outgrown its dense capacity (#7548).** `js_array_grow` reallocates an array's header+elements as one allocation and leaves a #233 forwarding stub at the old address — and the stub's first 8 bytes are exactly where `length` and `capacity` live, so they read back as the two halves of the forwarding *pointer*. The array branches of `Object.*` reinterpreted the caller's pointer with a bare `obj as *ArrayHeader` cast, so a JS binding still holding the pre-grow address made `(*arr).length` return a heap address: `615098568` instead of `6` in the observed case. `is_array_object` cannot tell a stub apart (it keeps `obj_type == GC_TYPE_ARRAY`; only `GC_FLAG_FORWARDED` plus the clobbered payload distinguish it), so the bad pointer passed every guard. + + Two loops are driven by that length and became bounded-but-unreachable walks — one `to_string()` plus an attrs side-table probe per index, ~6·10^8 iterations: + + - `mark_all_array_props`, i.e. `Object.freeze` / `Object.seal`. `const t = [1, 2]; t.push(3); Object.freeze(t)` never returned. + - `array_set_length_from_descriptor`, i.e. ArraySetLength's shrink walk — reached by the `Set(receiver, "length", n)` tail of an `Array.prototype.splice` that grows a **Proxy** receiver. That is the reported symptom: `test-files/test_gap_6908_proxy_array_mutators.ts` stopped after `sort-cmp` and was killed at the harness's 10 s budget. The mutator's element writes all completed; only the final length write walked. + + **The hang was bounded, not infinite** — a 10 s budget simply cannot tell the two apart, and the distinction matters because the fixes differ. + + Fix: one `array_header` / `array_header_mut` helper in `crates/perry-runtime/src/object/array_object_ops.rs` that follows the forwarding chain (`clean_arr_ptr`) before the cast, applied at all four header casts there. It falls back to the raw cast when the chain does not resolve, so no caller loses a pointer it previously accepted. + + Deliberately unchanged: the `obj as usize` side-table keys. The array attrs table is keyed inconsistently across the runtime (`getOwnPropertyDescriptor` reads at the caller's address; the element-write rejection path resolves through `clean_arr_ptr` first), and re-keying only these writers was measured to regress `getOwnPropertyDescriptor` on a grown frozen array without gaining the write rejection. Unifying the readers is a separate change. + + Affected mutators on a Proxy receiver were exactly those that write an index at/beyond the dense capacity and then write `length`: `push`, `unshift`, `splice` with inserts > deletes, and `splice(len, 0, x)`. `pop`, `shift`, `reverse`, `sort`, `fill` and `copyWithin` never grow and were never affected, in direct or `.call` form. + + There is no bisectable regression commit — the test never passed. Building `perry` at `d255ae604` (#7424, the PR that *added* `test_gap_6908_proxy_array_mutators.ts`) and running the test that commit ships reproduces the timeout exactly, same five lines, same last line `sort-cmp: 2,4,10,33`. It was broken on arrival and invisible because `parity` is gated to tag pushes. The bare casts themselves date to #4709 (ArraySetLength) and #5025 (freeze/seal on arrays), so the `Object.freeze`-on-a-grown-array hang had been shipping for two months independent of any Proxy work. + + Coverage: new gap test `test-files/test_gap_7548_grown_array_object_ops.ts` (byte-identical to node 26.5.1; the pre-fix build hangs on it with zero output) and a sabotage-tested unit test `stale_pre_grow_array_pointer_reads_the_real_length_in_object_ops`, which asserts the header read *before* the walks so a regression fails in 0.00 s rather than hanging the suite, and asserts non-vacuity (the stub's length word must actually be clobbered). diff --git a/crates/perry-runtime/src/object/array_object_ops.rs b/crates/perry-runtime/src/object/array_object_ops.rs index bd383ff220..ec59543c3d 100644 --- a/crates/perry-runtime/src/object/array_object_ops.rs +++ b/crates/perry-runtime/src/object/array_object_ops.rs @@ -13,6 +13,52 @@ unsafe fn is_array_object(obj: *const ObjectHeader) -> bool { (*gc_header).obj_type == crate::gc::GC_TYPE_ARRAY } +/// Reinterpret an array's `ObjectHeader` pointer as an `ArrayHeader`, following +/// the growth/GC forwarding chain (#233) to the array's CURRENT home first. +/// +/// `js_array_grow` reallocates header+elements as ONE allocation and leaves a +/// forwarding stub at the old address — and the stub's first 8 bytes are +/// exactly where `length` and `capacity` live, so they read back as the two +/// halves of the forwarding POINTER. A JS binding still holding the pre-grow +/// pointer (`const t = [1,2,3,4,5]; t.push(6)`) hands that stub to the +/// `Object.*` entry points below, where `(*arr).length` then returns a heap +/// address: ~6·10^8 instead of 6. +/// +/// Two loops in this file are driven by that length and become bounded-but- +/// unreachable walks — hundreds of millions of `to_string()` + side-table +/// probes apiece, which present as a hang (#7548): +/// * `mark_all_array_props` — every `Object.freeze` / `Object.seal` of an +/// array that has ever grown past its dense capacity. +/// * `array_set_length_from_descriptor` — ArraySetLength's shrink walk, +/// reached by the `Set(receiver, "length", …)` tail of every +/// `Array.prototype.splice` that grows a Proxy receiver. +/// +/// `is_array_object` cannot tell a stub apart: it keeps +/// `obj_type == GC_TYPE_ARRAY`, and only the `GC_FLAG_FORWARDED` bit plus the +/// clobbered payload distinguish it — so only the chain walk can. Falls back to +/// the raw cast when the chain does not resolve, so no caller loses a pointer +/// it would previously have accepted. +/// +/// Deliberately NOT applied to the `obj as usize` side-table keys in this file: +/// those are written and read at whatever address the caller holds, so re-keying +/// one half alone would strand the entry. +#[inline] +pub(super) unsafe fn array_header(obj: *const ObjectHeader) -> *const crate::array::ArrayHeader { + let raw = obj as *const crate::array::ArrayHeader; + let cleaned = crate::array::clean_arr_ptr(raw); + if cleaned.is_null() { + raw + } else { + cleaned + } +} + +/// `*mut` sibling of [`array_header`]. +#[inline] +unsafe fn array_header_mut(obj: *mut ObjectHeader) -> *mut crate::array::ArrayHeader { + array_header(obj as *const ObjectHeader) as *mut crate::array::ArrayHeader +} + /// Apply `Object.freeze` / `Object.seal` to an array's OWN index + named data /// properties. The generic `mark_all_keys` walks `(*obj).keys_array`, but an /// array's indices live in the dense element store and its named props in the @@ -35,7 +81,16 @@ pub(crate) unsafe fn mark_all_array_props( let gc = gc_header_for(obj); (*gc)._reserved |= crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS; } - let arr = obj as *const crate::array::ArrayHeader; + let arr = array_header(obj); + // NOTE: `addr` deliberately stays the CALLER's address, not `arr`'s. The + // attrs side table for arrays is keyed inconsistently across the runtime — + // `Object.getOwnPropertyDescriptor` reads at the caller's (possibly + // pre-grow) address while the element-write rejection path resolves through + // `clean_arr_ptr` first — so re-keying only this writer strands the entry + // for the reader that today finds it. Measured both ways under #7548; + // canonical keying regressed `getOwnPropertyDescriptor` on a grown frozen + // array without gaining the write rejection. Unifying the two is a separate + // change to the readers. let addr = obj as usize; let apply = |key: String| { let mut attrs = @@ -71,7 +126,7 @@ pub(crate) unsafe fn array_property_is_enumerable( if key_name == "length" { return Some(f64::from_bits(TAG_FALSE)); } - let arr = obj as *const crate::array::ArrayHeader; + let arr = array_header(obj); if !super::has_own_helpers::array_own_key_present(arr, key_str) { return Some(f64::from_bits(TAG_FALSE)); } @@ -121,7 +176,9 @@ pub(crate) unsafe fn array_set_length_from_descriptor( let gc = gc_header_for(obj); (*gc)._reserved |= crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS; } - let arr = obj as *mut crate::array::ArrayHeader; + // #7548: `obj` may be a pre-grow forwarding stub. `old_len` below drives + // the shrink walk, so it must come from the array's current home. + let arr = array_header_mut(obj); let read_present = |name: &[u8]| -> bool { let k = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); @@ -330,7 +387,10 @@ pub(crate) unsafe fn define_array_property( f64::from_bits(crate::value::TAG_UNDEFINED) }; - let arr = obj as *mut crate::array::ArrayHeader; + // #7548: `obj` may be a pre-grow forwarding stub whose `length`/`capacity` + // words hold the forwarding pointer — the `index >= (*arr).length` gate + // below and every element read/extend need the array's current home. + let arr = array_header_mut(obj); let read_bool = |name: &[u8]| -> Option { if !super::desc_has_field(descriptor_value, name) { diff --git a/crates/perry-runtime/src/object/tests.rs b/crates/perry-runtime/src/object/tests.rs index 3a58bd5570..0b2a424b1c 100644 --- a/crates/perry-runtime/src/object/tests.rs +++ b/crates/perry-runtime/src/object/tests.rs @@ -1310,3 +1310,72 @@ fn global_builtin_constructor_values_are_not_redispatched_by_name() { "#7518: EventTarget must keep its spec `.length`" ); } + +/// #7548: the array branches of `Object.*` reinterpreted the caller's +/// `ObjectHeader` pointer as an `ArrayHeader` with a bare cast. When a JS +/// binding still holds an array's PRE-GROW address, that pointer is a #233 +/// forwarding stub whose first 8 bytes — exactly `length` and `capacity` — +/// have been overwritten with the forwarding POINTER, so `(*arr).length` read +/// back a heap address (~6·10^8 in the wild) instead of the real length. +/// +/// Two loops are driven by that value and became bounded-but-unreachable +/// walks — one `to_string()` plus a side-table probe per index, hundreds of +/// millions of iterations, which presents as a hang: +/// * `mark_all_array_props` — `Object.freeze` / `Object.seal`. +/// * `array_set_length_from_descriptor` — ArraySetLength's shrink walk, the +/// `Set(receiver, "length", …)` tail of a Proxy-receiver `splice` that grows. +/// +/// The header read is asserted FIRST and directly: a regression must fail fast +/// here rather than hang the suite in one of the walks below. +#[test] +fn stale_pre_grow_array_pointer_reads_the_real_length_in_object_ops() { + let mut arr = crate::array::js_array_alloc(0); + let stale = arr; + let capacity = unsafe { (*arr).capacity }; + for i in 0..capacity { + arr = crate::array::js_array_push_f64(arr, i as f64); + } + // One more push exceeds the dense capacity: the array is reallocated and a + // forwarding stub is left behind at `stale`. + let grown = crate::array::js_array_push_f64(arr, capacity as f64); + assert_ne!( + grown as usize, stale as usize, + "pushing past capacity must reallocate — otherwise no stub exists and \ + this test proves nothing" + ); + let real_len = crate::array::js_array_length(grown); + assert_eq!(real_len, capacity + 1); + + // Non-vacuity: the stub's payload must really be clobbered, or the bare + // cast below would have been harmless all along. + let raw_len = unsafe { (*(stale as *const crate::array::ArrayHeader)).length }; + assert_ne!( + raw_len, real_len, + "the forwarding stub's length word must be clobbered for this test to \ + exercise #7548" + ); + + // The fix: resolve the chain before reading the header. + let resolved = unsafe { super::array_object_ops::array_header(stale as *const ObjectHeader) }; + assert_eq!( + unsafe { (*resolved).length }, + real_len, + "#7548: a stale pre-grow array pointer must resolve to the array's \ + current home before its length is read" + ); + + // `Object.freeze`'s index walk now terminates at the real length: it must + // record attrs for every real index and none beyond it. + unsafe { + super::array_object_ops::mark_all_array_props(stale as *mut ObjectHeader, true, true); + } + let addr = stale as usize; + assert!( + crate::object::get_property_attrs(addr, &(real_len - 1).to_string()).is_some(), + "the freeze walk must reach the array's last real index" + ); + assert!( + crate::object::get_property_attrs(addr, &real_len.to_string()).is_none(), + "the freeze walk must not run past the array's real length" + ); +} diff --git a/test-files/test_gap_7548_grown_array_object_ops.ts b/test-files/test_gap_7548_grown_array_object_ops.ts new file mode 100644 index 0000000000..add639333f --- /dev/null +++ b/test-files/test_gap_7548_grown_array_object_ops.ts @@ -0,0 +1,122 @@ +// Gap test for #7548 — `Object.*` operations on an array whose dense storage +// has been REALLOCATED by a capacity-extending write. +// +// `js_array_grow` reallocates header+elements as one allocation and leaves a +// forwarding stub at the old address (issue #233), and the stub's first 8 bytes +// are exactly where `length` and `capacity` live — so they read back as the two +// halves of the forwarding POINTER. The array branches of `Object.*` cast the +// caller's pointer to an `ArrayHeader` without following that chain, so +// `(*arr).length` returned a heap address (~6·10^8) instead of the real length. +// +// Two loops are driven by that length and became bounded-but-unreachable walks +// — one `to_string()` plus a side-table probe per index — which present as a +// hang rather than a wrong answer: +// * `Object.freeze` / `Object.seal` of any array that has ever grown. +// * ArraySetLength's shrink walk, reached by the `Set(receiver, "length", …)` +// tail of an `Array.prototype.splice` that grows a Proxy receiver (the +// original #7548 report, via test_gap_6908_proxy_array_mutators.ts). +// +// A JS binding keeps the pre-grow address, so every case below hands the stub +// to the entry point under test. +// Compared byte-for-byte against `node --experimental-strip-types`. + +// ---- freeze / seal after a capacity-extending push ---- +{ + const t: any = [1, 2, 3, 4, 5]; + t.push(6); + Object.freeze(t); + console.log("freeze-after-push:", t.length, Object.isFrozen(t), t.join(",")); +} +{ + const t: any = [1, 2, 3]; + t.push(4, 5, 6, 7); + Object.seal(t); + console.log("seal-after-push:", t.length, Object.isSealed(t), t.join(",")); +} +{ + // The freeze walk must record attrs for the array's REAL indices — the whole + // point of the walk that used to run past 6·10^8. (Whether the element-WRITE + // path then honors them on a grown array is a separate, pre-existing + // address-keying inconsistency in the attrs side table, not covered here.) + const t: any = [1, 2]; + t.push(3); + Object.freeze(t); + console.log( + "freeze-descriptors:", + JSON.stringify(Object.getOwnPropertyDescriptor(t, "0")), + JSON.stringify(Object.getOwnPropertyDescriptor(t, "2")), + Object.getOwnPropertyDescriptor(t, "3") === undefined, + ); +} + +// ---- defineProperty("length") after a capacity-extending index define ---- +{ + const t: any = [1, 2, 3, 4, 5]; + Object.defineProperty(t, "5", { value: 6, writable: true, enumerable: true, configurable: true }); + Object.defineProperty(t, "length", { value: 6 }); + console.log("define-length-same:", t.length, t.join(",")); +} +{ + const t: any = [1, 2, 3]; + Object.defineProperty(t, "3", { value: 4, writable: true, enumerable: true, configurable: true }); + Object.defineProperty(t, "length", { value: 7 }); + console.log("define-length-grow:", t.length); +} +{ + const t: any = [1, 2, 3]; + Object.defineProperty(t, "3", { value: 4, writable: true, enumerable: true, configurable: true }); + Object.defineProperty(t, "length", { value: 2 }); + console.log("define-length-shrink:", t.length, t.join(",")); +} +{ + const t: any = [1, 2, 3]; + Object.defineProperty(t, "3", { value: 4, writable: true, enumerable: true, configurable: true }); + const ok = Reflect.defineProperty(t, "length", { value: 4 }); + console.log("reflect-define-length:", ok, t.length); +} + +// ---- `length` write through a Proxy receiver after the array grew ---- +// This is the exact tail of the #7548 report: `Set(receiver, "length", n)` with +// a Proxy receiver reaches ArraySetLength through [[DefineOwnProperty]]. +{ + const t: any = [1, 2, 3, 4, 5]; + const p: any = new Proxy(t, {}); + Reflect.set(t, "5", 5, p); + const ok = Reflect.set(t, "length", 6, p); + console.log("proxy-receiver-length:", ok, t.length, t.join(",")); +} +{ + const t: any = [1, 2, 3, 4, 5]; + const p: any = new Proxy(t, {}); + const removed = p.splice(1, 2, "a", "b", "c"); + console.log("proxy-splice-grow:", t.join(","), t.length, removed.join(",")); +} +{ + const t: any = [1, 2, 3]; + const p: any = new Proxy(t, {}); + const n = p.push(4, 5, 6, 7); + console.log("proxy-push-grow:", t.join(","), n); +} +{ + const t: any = [1, 2, 3]; + const p: any = new Proxy(t, {}); + const n = p.unshift(0); + console.log("proxy-unshift-grow:", t.join(","), n); +} + +// ---- propertyIsEnumerable / descriptor reads survive the reallocation ---- +{ + const t: any = [1, 2, 3]; + t.push(4, 5); + console.log( + "enumerable-after-grow:", + t.propertyIsEnumerable("0"), + t.propertyIsEnumerable("4"), + t.propertyIsEnumerable("5"), + ); +} +{ + const t: any = [1, 2, 3]; + t.push(4); + console.log("keys-after-grow:", Object.keys(t).join(","), JSON.stringify(t)); +}