diff --git a/CLAUDE.md b/CLAUDE.md index 7e693c8873..a3a2dccc99 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.1590 +**Current Version:** 0.5.1591 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 7b8a02ac45..33f676ed1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "fc61f41aef38c94e922057977bcb33bf185ab42242188719991ecfdc0fa1fe6b" [[package]] name = "perry" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1590" +version = "0.5.1591" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6039,7 +6039,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "lru", "perry-ffi", @@ -6048,7 +6048,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "chrono", "perry-ffi", @@ -6056,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "bson", "futures-util", @@ -6068,7 +6068,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "chrono", "perry-ffi", @@ -6080,7 +6080,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "nanoid", "perry-ffi", @@ -6089,7 +6089,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "bytes", "perry-ffi", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6123,7 +6123,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "lettre", "perry-ffi", @@ -6133,7 +6133,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "notify", "perry-ffi", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "printpdf", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "sqlx", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "perry-runtime", @@ -6171,7 +6171,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "governor", "perry-ffi", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "fast_image_resize", "image", @@ -6190,7 +6190,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "lazy_static", "perry-ffi", @@ -6199,7 +6199,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-ffi", @@ -6219,7 +6219,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "perry-runtime", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "uuid", @@ -6236,7 +6236,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-ffi", "perry-validation", @@ -6245,7 +6245,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "futures-util", "lazy_static", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "brotli", "flate2", @@ -6268,7 +6268,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6278,7 +6278,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-api-manifest", @@ -6298,11 +6298,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1590" +version = "0.5.1591" [[package]] name = "perry-parser" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "perry-diagnostics", @@ -6315,7 +6315,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perex", "regex", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "ahash", "base64 0.22.1", @@ -6381,14 +6381,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6477,21 +6477,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "dirs", "perry-ffi", @@ -6501,7 +6501,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "jni", @@ -6516,7 +6516,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "rand 0.10.2", "serde", @@ -6526,7 +6526,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6549,7 +6549,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "block2", @@ -6566,7 +6566,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "block2", @@ -6583,7 +6583,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1590" +version = "0.5.1591" [[package]] name = "perry-ui-test" @@ -6594,11 +6594,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1590" +version = "0.5.1591" [[package]] name = "perry-ui-tvos" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "block2", @@ -6615,7 +6615,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "block2", @@ -6632,7 +6632,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "block2", "libc", @@ -6646,7 +6646,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "libc", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "base64 0.22.1", "libc", @@ -6678,7 +6678,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "anyhow", "base64 0.22.1", @@ -6693,7 +6693,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "idna", "regex", @@ -6703,7 +6703,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1590" +version = "0.5.1591" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 3161d1ec2d..b14b82b1b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1590" +version = "0.5.1591" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10414-array-push-layout-note.md b/changelog.d/10414-array-push-layout-note.md new file mode 100644 index 0000000000..fe5e490af4 --- /dev/null +++ b/changelog.d/10414-array-push-layout-note.md @@ -0,0 +1,14 @@ +Drop the provably-no-op layout note from the raw-f64 array push. +`layout_note_slot` was 15.0% of a push/pop loop, 96 of its 109 samples from the +single call in `array_numeric_raw_f64_push_inbounds`, where the caller has +already proved the value is a plain number. The mask work is then a no-op in +every layout state the receiver can be in — the same argument already written +out for the codegen-side elision on `array_store_needs_layout_note`'s object +twin. The #7480 element-shape invariant is kept, through the resolved-flags +entry so it reads the header the caller already holds. + +Validated on the shape that would expose a mistake: array slots filled with +POINTERS, popped, then refilled with plain numbers, with a retained live graph. +Three seeds under from-space protection, evacuation verification and +PERRY_GC_FROMSPACE_SCAN_ABORT=1 each ran ~270,000 copying minors and ~33,700 +from-space scans with dangling=0 and missing_rewrites=0, byte-identical to node. diff --git a/changelog.d/10414-array-push-receiver-requeries.md b/changelog.d/10414-array-push-receiver-requeries.md new file mode 100644 index 0000000000..7c518f00b8 --- /dev/null +++ b/changelog.d/10414-array-push-receiver-requeries.md @@ -0,0 +1,30 @@ +Stop re-deriving the receiver on every `Array.prototype.push`. A hot +`for (…) { a.push(v); a.pop(); }` loop cost 1,350 instructions per push+pop +pair, and a symbol-resolved profile showed more than half of that was not the +append but the append re-asking questions about a receiver it had already +resolved, once per helper in the chain. 1,350.6 -> 432.0, **-68.0%**, with +nothing inlined and nothing cached. + +Four removals. `typed_feedback::numeric_array_push_guard` probed the property +descriptor of `"length"` on every push — the heaviest frame in the loop at +16.5% of samples — although the flag test three lines above had already +rejected every receiver that could make it answer true (a non-writable +`length` always marks `OBJ_FLAG_ARRAY_DESCRIPTORS`). +`js_array_numeric_push_f64_unboxed` re-ran `clean_arr_ptr` three more times by +asking `array_is_sealed_or_no_extend`, `array_is_frozen` and +`guard_writable_length` in sequence, each of which goes through the +non-resolved `array_object_flags`; that function alone was 24.0% of the loop. +The append chain resolved twice more, and the exotic and numeric-layout checks +once each. `js_array_pop_f64` already carried this exact fix — `push` never got +it. + +Also fixes a parity bug the new fixture found: +`Object.preventExtensions(a); a.push(1)` silently kept the old length where +node throws. The dense append answers `SEALED | NO_EXTEND` with a bare +`return arr`, which is correct for the internal CreateDataProperty-style append +that builds fresh result arrays and wrong for user `push`; the observable entry +now throws, with node's wording. `Object.seal` had masked it by routing down +the exotic path for an unrelated reason. + +Known divergence left: frozen `pop` still reports "Cannot mutate a frozen +array" rather than node's "Cannot delete property 'N' of [object Array]". diff --git a/changelog.d/10491-copying-minor-single-decode.md b/changelog.d/10491-copying-minor-single-decode.md new file mode 100644 index 0000000000..ee5939e024 --- /dev/null +++ b/changelog.d/10491-copying-minor-single-decode.md @@ -0,0 +1,35 @@ +Decode each word the copying minor visits once. A raw (untagged) word was +classified twice: `CopyingPointerSet::decode_bits` classified it only to +validate it, and `mark_addr` classified it again. Every traced shaped object +visits its shape record's `keys` word, a raw address, so that was a second +page-table probe and header read per traced object. The slot visit's +remembering arm then re-decoded the slot it had just decoded. The validating +classification is now the one the mark uses (the memo is still consulted after +it, as before), and the remembering arm reuses the child the visit decoded; +only a raw word that moved is validated again, which is all the re-decode could +still reject. + +Two codegen facts are load-bearing and pinned by comment. The decode is +`#[inline(always)]`: out of line, its call frame and the by-memory return of +its result cost as much as the classification it saves (the first cut measured +flat to +1.05%). And `barrier_parent_needs_remembering` is asked before the +visit rather than after. It reads only the parent and the slot's address, so +the order does not change the answer, but asked after, the optimizer +duplicated the call into both decode arms and stopped inlining it, which gave +back a third of the win on gc3 (-1.20% instead of -1.80%) and more than a third +on w20000 (-0.84% instead of -1.44%). + +Measured on six GC fixtures, instructions:u min-of-5: gc3 -1.83%, w5000 -1.77%, +w20000 -1.52%, oldyoung -1.46%, w1000 -0.84%, alloc flat (-951 instructions). +Exact instruction counts under callgrind agree: gc3 -1.79%, with +`classify_arena` calls down from 6.09M to 4.20M. On the pointer-slot control +(60k records whose K fields all point at one shared object, against the same +records holding doubles) the per-slot term falls from 379.2 to 349.6 +instructions at K=16, counted exactly under callgrind: a memo hit no longer pays a call to +`mark_addr`, and the re-decode's classification is gone. + +The page-generation cache was read before any of this was attempted. It runs +the direct-mapped table arm with a 93.4-97.3% hit rate, and at most 0.02% of +lookups are capacity misses. Nearly every miss is an address in no registered +block: the shape record's `keys` slot, which lives outside the heap. So the +cache's size was not the problem, and nothing here changes it. diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index 41672ffddb..4b0187379e 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -1340,6 +1340,18 @@ pub(crate) unsafe fn array_numeric_layout(arr: *const ArrayHeader) -> Option Option { array_has_raw_f64_layout_flag(arr).then_some(NumericArrayLayout::RawF64) } @@ -1375,6 +1387,17 @@ pub(crate) unsafe fn ensure_array_numeric_raw_f64(arr: *mut ArrayHeader) -> bool if arr.is_null() { return false; } + unsafe { ensure_array_numeric_raw_f64_resolved(arr) } +} + +/// [`ensure_array_numeric_raw_f64`] for a receiver the caller has already put +/// through [`clean_arr_ptr_mut`]. +/// +/// # Safety +/// `arr` is that non-null resolved head, with no intervening allocation or +/// safepoint — the same contract [`array_object_flags_resolved`] carries. +#[inline] +pub(crate) unsafe fn ensure_array_numeric_raw_f64_resolved(arr: *mut ArrayHeader) -> bool { let length = (*arr).length as usize; let capacity = (*arr).capacity as usize; if length > capacity || length > 16_000_000 { @@ -1437,12 +1460,23 @@ pub(crate) unsafe fn array_numeric_raw_f64_set_inbounds( } #[inline] -pub(crate) unsafe fn array_numeric_raw_f64_push_inbounds( + +/// [`array_numeric_raw_f64_push_inbounds`] for an already-resolved receiver. +/// +/// The append chain re-entered `clean_arr_ptr` — allocator-ownership plus +/// forwarding classification — once per helper: the unboxed push entry +/// resolved, then this did, then `ensure_array_numeric_raw_f64` did again, all +/// on the one pointer the entry had already proved live. Threading the resolved +/// head through removes the repeats instead of caching their answer. +/// +/// # Safety +/// `arr` is a non-null [`clean_arr_ptr_mut`] result with no intervening +/// allocation or safepoint. +pub(crate) unsafe fn array_numeric_raw_f64_push_inbounds_resolved( arr: *mut ArrayHeader, value: f64, ) -> bool { - let arr = clean_arr_ptr_mut(arr); - if arr.is_null() || !ensure_array_numeric_raw_f64(arr) { + if !ensure_array_numeric_raw_f64_resolved(arr) { return false; } let length = (*arr).length; @@ -1458,7 +1492,27 @@ pub(crate) unsafe fn array_numeric_raw_f64_push_inbounds( let elements_ptr = array_elements_ptr(arr) as *mut f64; // GC_STORE_AUDIT(POINTER_FREE): raw-f64 push stores numeric payloads only. std::ptr::write(elements_ptr.add(length as usize), number); - crate::gc::layout_note_slot(arr as usize, length as usize, number.to_bits()); + // `layout_note_slot`'s MASK work is a provable no-op for a value that is a + // plain number, and `value_bits_to_number` just proved that above. The + // argument is the one already written out for the codegen-side elision on + // `array_store_needs_layout_note`'s object twin, and it holds in every + // layout state the receiver can be in: `GC_LAYOUT_UNKNOWN` returns at the + // note's own state check; an intact typed descriptor lets a non-pointer + // fall through the pointer-mask arm untouched; `GC_LAYOUT_POINTER_FREE` + // hits the note's `!pointer && POINTER_FREE` early return; and under + // `GC_LAYOUT_SIDE_MASK` the note could only ever CLEAR this slot's bit, so + // skipping it leaves at worst a stale set bit over a non-pointer word — + // which costs one extra visit and nothing else, because + // `gc::trace::mark_field_into_worklist` re-validates every slot word and + // rejects f64 bit patterns as out-of-range addresses. + // + // That leaves the #7480 element-shape invariant, which is NOT part of that + // argument and is kept — through the resolved-flags entry, so it reads the + // header this function already holds instead of classifying the parent a + // second time. Measured: `layout_note_slot` was 15.0% of a push/pop loop, + // 96 of its 109 samples from this one call. + let flags = array_object_flags_resolved(arr); + crate::array::note_element_store_resolved_flags(arr, length as usize, number.to_bits(), flags); (*arr).length = length + 1; true } @@ -1617,8 +1671,22 @@ pub extern "C" fn js_array_is_numeric_f64_layout(arr: *const ArrayHeader) -> i32 if arr.is_null() { return 0; } + unsafe { js_array_is_numeric_f64_layout_resolved(arr) } +} + +/// [`js_array_is_numeric_f64_layout`] for a caller holding a resolved head. +/// +/// The typed-feedback push guard reaches this with a pointer it has already +/// normalized, header-checked and proved non-forwarded, so the entry's own +/// `clean_arr_ptr` — and the second one `array_numeric_layout` used to perform +/// inside it — were both re-deriving that proof once per push. +/// +/// # Safety +/// `arr` is a non-null resolved head with no intervening allocation or +/// safepoint. +pub(crate) unsafe fn js_array_is_numeric_f64_layout_resolved(arr: *const ArrayHeader) -> i32 { unsafe { - if array_numeric_layout(arr) == Some(NumericArrayLayout::RawF64) { + if array_numeric_layout_resolved(arr) == Some(NumericArrayLayout::RawF64) { return 1; } // #6011 follow-up: a holes-flagged array (`new Array(n)` mid-fill) diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 1199056c5c..69d15f0ace 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -98,6 +98,35 @@ pub(crate) fn array_iteration_is_exotic(arr: *const ArrayHeader) -> bool { unsafe { array_iteration_is_exotic_resolved(arr, flags) } } +/// [`array_iteration_is_exotic`] for a caller holding a resolved head and its +/// flag word, but which has NOT excluded Buffer / TypedArray receivers. +/// +/// This is the shape the hot append wants: `js_array_numeric_push_f64_unboxed` +/// resolved the receiver and read the header once, so the only thing it still +/// needs from `array_iteration_is_exotic` is the registry probe plus the policy +/// tests — not a second `clean_arr_ptr`, which re-runs allocator-ownership and +/// forwarding classification on a pointer already proved live one call up. +/// +/// The registry probes stay: a Buffer or typed array must still be routed to +/// the spec path, and the flag word cannot answer that (their headers are not +/// `GC_TYPE_ARRAY`, so `array_object_flags_from_tag` reads them as `0`, which +/// on its own would let a typed array reach the raw-f64 append). +/// +/// # Safety +/// +/// `arr` and `flags` must satisfy [`array_object_flags_resolved`]'s contract. +pub(crate) unsafe fn array_iteration_is_exotic_cleaned( + arr: *const ArrayHeader, + flags: u16, +) -> bool { + if crate::buffer::is_registered_buffer(arr as usize) + || crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + { + return true; + } + unsafe { array_iteration_is_exotic_resolved(arr, flags) } +} + /// [`array_iteration_is_exotic`] for a caller that already resolved the live /// plain-array head, excluded Buffer/TypedArray receivers, and owns the header /// word: the policy tests without a second receiver resolution and registry diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index efdfb88608..f085ceffce 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -102,7 +102,7 @@ pub use self::concat_reverse::{ }; pub(crate) use self::element_shape::{ forget_element_shape, invalidate_all_element_shapes, note_element_store, - prune_dead_element_shape_owners, transfer_element_shape, + note_element_store_resolved_flags, prune_dead_element_shape_owners, transfer_element_shape, }; pub use self::element_shape::{ js_array_element_shape_check, js_array_element_shape_class, js_array_element_shape_epoch, @@ -142,9 +142,9 @@ pub(crate) use self::generic_object::{ object_splice, }; pub(crate) use self::header::{ - array_has_arguments_object_flag, mark_array_as_arguments_object, - rebuild_array_numeric_raw_f64_allow_holes, rebuild_array_numeric_raw_f64_dense_window, - rebuild_array_numeric_raw_f64_dense_window_i32, + array_has_arguments_object_flag, js_array_is_numeric_f64_layout_resolved, + mark_array_as_arguments_object, rebuild_array_numeric_raw_f64_allow_holes, + rebuild_array_numeric_raw_f64_dense_window, rebuild_array_numeric_raw_f64_dense_window_i32, }; pub use self::header::{ js_array_clear_numeric_layout, js_array_declare_all_pointer_elements, @@ -160,8 +160,8 @@ pub use self::immutable::{ }; pub(crate) use self::indexing::{ array_custom_prototype, array_has_own_index, array_iteration_is_exotic, - array_iteration_is_exotic_resolved, array_prototype_has_index_flag, array_spec_get, - array_spec_has_index, array_spec_set, + array_iteration_is_exotic_cleaned, array_iteration_is_exotic_resolved, + array_prototype_has_index_flag, array_spec_get, array_spec_has_index, array_spec_set, }; pub use self::indexing::{ js_array_get_element, js_array_get_element_f64, js_array_get_f64, js_array_get_f64_unchecked, @@ -221,6 +221,7 @@ pub(crate) use self::prototype_addr::{ test_memoized_prototype_addr, test_prototype_addr_cache_wiring, test_prototype_addr_cell_count, test_rewrite_prototype_addr_slot, }; +pub(crate) use self::push_pop::throw_non_extensible_array_push; pub(crate) use self::sort::object_prototype_has_index_prop; pub(crate) use self::sort::object_prototype_index_get as sort_object_prototype_index_get; pub(crate) use self::sort::object_prototype_index_get_with_receiver as sort_object_prototype_index_get_with_receiver; @@ -281,18 +282,18 @@ pub(crate) use self::alloc::{js_array_from_arraylike, js_array_from_string_codep pub(crate) use self::flat_clone::{dense_spread_copy, dense_spread_source, flattenable_array_ptr}; pub(crate) use self::header::{ array_byte_size, array_is_frozen, array_is_sealed_or_no_extend, array_numeric_raw_f64_get, - array_numeric_raw_f64_push_inbounds, array_numeric_raw_f64_set_inbounds, array_object_flags, - array_object_flags_from_tag, array_object_flags_resolved, array_ptr_as_proxy, - array_receiver_addr, array_receiver_gc_tag, buffer_receiver_as_uint8_typed_array, - canonicalize_array_numeric_store_value_from_flags, clean_arr_ptr, clean_arr_ptr_mut, - clear_array_numeric_layout, clear_array_numeric_layout_ptr, finish_array_dense_move_layout, - gc_element_slot_range, mark_array_layout_unknown, mark_array_raw_f64_holes_fresh, - normalize_array_receiver, note_array_slot, note_array_slot_layout_only, - note_array_slot_resolved_flags, rebuild_array_layout, rebuild_array_layout_exact, - reclassify_array_numeric_layout_from_slots, refresh_array_numeric_layout, - replay_array_growth_write_barriers, set_array_numeric_layout, store_array_slot, - store_array_slot_resolved, typed_array_receiver, value_bits_to_number, NumericArrayLayout, - MIN_ARRAY_CAPACITY, + array_numeric_raw_f64_push_inbounds_resolved, array_numeric_raw_f64_set_inbounds, + array_object_flags, array_object_flags_from_tag, array_object_flags_resolved, + array_ptr_as_proxy, array_receiver_addr, array_receiver_gc_tag, + buffer_receiver_as_uint8_typed_array, canonicalize_array_numeric_store_value_from_flags, + clean_arr_ptr, clean_arr_ptr_mut, clear_array_numeric_layout, clear_array_numeric_layout_ptr, + finish_array_dense_move_layout, gc_element_slot_range, mark_array_layout_unknown, + mark_array_raw_f64_holes_fresh, normalize_array_receiver, note_array_slot, + note_array_slot_layout_only, note_array_slot_resolved_flags, rebuild_array_layout, + rebuild_array_layout_exact, reclassify_array_numeric_layout_from_slots, + refresh_array_numeric_layout, replay_array_growth_write_barriers, set_array_numeric_layout, + store_array_slot, store_array_slot_resolved, typed_array_receiver, value_bits_to_number, + NumericArrayLayout, MIN_ARRAY_CAPACITY, }; pub(crate) use self::named_props::{ array_has_named_properties_resolved, array_has_sparse_index_properties_resolved, diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index de88a8949a..54cab10a38 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -33,6 +33,17 @@ fn array_length_is_non_writable_with_flags(arr: *const ArrayHeader, flags: u16) .unwrap_or(false) } +/// §23.1.3.21 push performs `Set(O, len, value, true)`; on a non-extensible +/// receiver `CreateDataProperty` for the new index fails, and `Throw=true` +/// makes that a TypeError. Node words it exactly this way for +/// `preventExtensions`, `seal` AND `freeze`. +#[cold] +pub(crate) fn throw_non_extensible_array_push(index: u32) -> ! { + crate::collection_iter::throw_type_error(&format!( + "Cannot add property {index}, object is not extensible" + )); +} + #[cold] fn throw_non_writable_length() -> ! { crate::collection_iter::throw_type_error( @@ -892,6 +903,25 @@ static KEEP_JS_ARRAY_PUSH_U31_WITH_LENGTH: extern "C" fn( #[no_mangle] pub extern "C" fn js_array_push_f64_spec(arr: *mut ArrayHeader, value: f64) -> *mut ArrayHeader { if let Some(plain) = direct_plain_push_receiver(arr) { + // A non-extensible receiver must THROW here, not decline silently. + // `js_array_push_f64_resolved` answers `SEALED | NO_EXTEND` with a bare + // `return arr`, which is right for its other caller — `js_array_push_f64` + // is the INTERNAL CreateDataProperty-style append that runtime code uses + // to build fresh result arrays, and those must not throw. It is wrong for + // user `push`: `Object.preventExtensions(a); a.push(1)` silently kept the + // old length where Node raises TypeError. `Object.seal` happened to throw + // only because sealing also marks the receiver's element descriptors, + // which sends it down the exotic route instead of this one. + // + // FROZEN is left to the resolved append below, which throws its own + // frozen message; only the extensibility bits are answered here. + // SAFETY: `direct_plain_push_receiver` just proved the resolved head. + let flags = unsafe { array_object_flags_resolved(plain) }; + if flags & crate::gc::OBJ_FLAG_FROZEN == 0 + && flags & (crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND) != 0 + { + throw_non_extensible_array_push(unsafe { (*plain).length }); + } crate::string::js_string_addref_if_heap_string(value); return unsafe { js_array_push_f64_resolved(plain, value) }; } @@ -982,15 +1012,34 @@ pub extern "C" fn js_array_numeric_push_f64_unboxed( if arr.is_null() { return js_array_alloc(0); } - if array_is_sealed_or_no_extend(arr) || array_is_frozen(arr) { + // ONE read of the already-resolved header answers all three integrity + // questions. Each of `array_is_sealed_or_no_extend`, `array_is_frozen` and + // `guard_writable_length` went through the non-resolved + // `array::header::array_object_flags`, which re-runs `clean_arr_ptr` — the + // allocator-ownership and forwarding classification — before every single + // bit test. On a pointer `clean_arr_ptr_mut` resolved on the line above, + // that is three further resolutions to re-derive a fact already proved. + // + // `array_object_flags_from_tag` keeps the exact semantics of the helpers it + // replaces: a receiver whose header is not `GC_TYPE_ARRAY` (a Buffer, a + // typed array) reads as flags `0`, just as `array_object_flags` returned 0 + // for it, so the exotic check below still owns those receivers. + // + // Measured on `for (…) { a.push(v); a.pop(); }`: `array_object_flags` was + // 24.0% of all samples in the loop, every one of them from this function. + let flags = crate::array::array_object_flags_from_tag(crate::array::array_receiver_gc_tag(arr)); + if flags + & (crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND | crate::gc::OBJ_FLAG_FROZEN) + != 0 + { return arr; } - guard_writable_length(arr); + guard_writable_length_with_flags(arr, flags); unsafe { - if crate::array::array_iteration_is_exotic(arr) { + if crate::array::array_iteration_is_exotic_cleaned(arr, flags) { return js_array_push_f64_spec(arr, value); } - if array_numeric_raw_f64_push_inbounds(arr, value) { + if crate::array::array_numeric_raw_f64_push_inbounds_resolved(arr, value) { return arr; } } diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index 52442f4f6f..7a510d1d64 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -313,19 +313,6 @@ impl CopyingNurseryCollector { } } - pub(super) fn visit_value_bits(&mut self, bits: u64) -> Option { - let (addr, is_nanbox, tag) = self.ptrs.decode_bits(bits)?; - let new_addr = self.mark_addr(addr)?; - if new_addr == addr { - return None; - } - Some(if is_nanbox { - tag | (new_addr as u64 & POINTER_MASK) - } else { - new_addr as u64 - }) - } - pub(super) fn visit_raw_addr(&mut self, addr: usize) -> Option { let new_addr = self.mark_addr(addr)?; (new_addr != addr).then_some(new_addr) @@ -430,6 +417,20 @@ impl CopyingNurseryCollector { return Some(self.memo_result); } let ptr = self.ptrs.classify(addr)?; + Some(self.mark_classified(addr, ptr)) + } + + /// [`mark_addr`](Self::mark_addr) for an address the caller has already + /// classified: the memo, then the mark, without classifying again. + #[inline] + pub(super) fn mark_classified_addr(&mut self, addr: usize, ptr: CopyingPointer) -> usize { + if addr == self.memo_addr { + return self.memo_result; + } + self.mark_classified(addr, ptr) + } + + fn mark_classified(&mut self, addr: usize, ptr: CopyingPointer) -> usize { let result = match ptr.kind { CopyingPointerKind::Eden | CopyingPointerKind::FromSurvivor => unsafe { self.move_young(ptr) @@ -457,7 +458,7 @@ impl CopyingNurseryCollector { }; self.memo_addr = addr; self.memo_result = result; - Some(result) + result } /// #7742: the object's block is being promoted whole, in place. It does not diff --git a/crates/perry-runtime/src/gc/copying_parent_facts.rs b/crates/perry-runtime/src/gc/copying_parent_facts.rs index 2cecd82327..19b6d06833 100644 --- a/crates/perry-runtime/src/gc/copying_parent_facts.rs +++ b/crates/perry-runtime/src/gc/copying_parent_facts.rs @@ -1,5 +1,6 @@ -//! The per-parent weak-holder fact the copying minor's slot visit reads, and -//! the slot visit itself. Split out of `gc/copying.rs` for the 2000-line lint. +//! The per-parent weak-holder fact the copying minor's slot visit reads, the +//! slot visit itself, and its single decode of the visited word. Split out of +//! `gc/copying.rs` for the 2000-line lint. use super::*; @@ -58,7 +59,107 @@ pub(crate) mod copy_hoist_sabotage { } } +/// Test-only sabotage for the single decode per slot visit +/// (`visit_value_bits_child`). Witness: `gc::tests::copy_slot_decode`. +#[cfg(test)] +pub(crate) mod copy_decode_sabotage { + use std::cell::Cell; + + /// The validated raw word is dropped instead of marked. + pub(crate) const RAW_MARK: u8 = 1; + /// The remembering arm loses the child the visit decoded. + pub(crate) const CHILD: u8 = 2; + + thread_local! { + static FORGET: Cell = const { Cell::new(0) }; + } + + pub(crate) fn forgetting(what: u8) -> bool { + FORGET.with(|f| f.get() & what != 0) + } + + pub(crate) struct Guard(u8); + + impl Guard { + pub(crate) fn arm(what: u8) -> Self { + Self(FORGET.with(|f| f.replace(f.get() | what))) + } + } + + impl Drop for Guard { + fn drop(&mut self) { + FORGET.with(|f| f.set(self.0)); + } + } +} + impl CopyingNurseryCollector { + /// The root visitors' form of [`Self::visit_value_bits_child`]. Always + /// inlined too: out of line it added a call frame per root word. + #[inline(always)] + pub(super) fn visit_value_bits(&mut self, bits: u64) -> Option { + self.visit_value_bits_child(bits)?.1 + } + + /// Decode, classify and mark one value word ONCE: the child's address as + /// the word reads after this visit, the word's new bits if the child + /// moved, and whether the word was raw. `None` is exactly + /// `CopyingPointerSet::decode_bits`'s `None`: not a heap reference. + /// + /// A raw word used to be classified twice — by `decode_bits`, only to + /// validate it, and again by `mark_addr`. Every traced shaped object visits + /// its shape record's `keys` word, a raw address, so that was a second + /// page-table probe and header read per traced object. The validating + /// classification is now the one the mark uses; the memo is still + /// consulted after it, as before. + /// + /// Always inlined: out of line, the extra frame and the by-memory return + /// of the triple cost as much as the classification it saves. + #[inline(always)] + pub(super) fn visit_value_bits_child( + &mut self, + bits: u64, + ) -> Option<(usize, Option, bool)> { + let tag = bits & TAG_MASK; + if tag == POINTER_TAG || tag == STRING_TAG || tag == BIGINT_TAG { + let addr = (bits & POINTER_MASK) as usize; + if addr == 0 { + return None; + } + // An unclassifiable NaN-boxed word is still a reference to the + // remembering arm, which never classified NaN-boxed words. + return Some(match self.mark_addr(addr) { + Some(new_addr) if new_addr != addr => { + let new_bits = tag | (new_addr as u64 & POINTER_MASK); + ((new_bits & POINTER_MASK) as usize, Some(new_bits), false) + } + _ => (addr, None, false), + }); + } + if tag >= 0x7FF8_0000_0000_0000 || !CopyingPointerSet::raw_pointer_candidate(bits) { + return None; + } + let addr = bits as usize; + let ptr = self.ptrs.classify(addr)?; + #[cfg(test)] + if copy_decode_sabotage::forgetting(copy_decode_sabotage::RAW_MARK) { + return None; + } + let new_addr = self.mark_classified_addr(addr, ptr); + Some(( + new_addr, + (new_addr != addr).then_some(new_addr as u64), + true, + )) + } + + /// The remembering arm's one remaining re-decode: a raw word that moved. + /// Out of line so the rare case does not grow the slot visit it sits in. + #[inline(never)] + fn revalidate_moved_raw(&self, bits: u64) -> Option { + self.ptrs.decode_bits(bits).map(|(addr, _, _)| addr) + } + pub(super) unsafe fn visit_slot_with_parent( &mut self, slot: *mut u64, @@ -100,22 +201,39 @@ impl CopyingNurseryCollector { self.weak_slots.push(slot); return; } - let bits = *slot; - if let Some(new_bits) = self.visit_value_bits(bits) { + // Asked BEFORE the visit: it reads only the parent and the slot's own + // address, never the child. Asked after, the optimizer duplicated the + // call into both decode arms and then stopped inlining it. + let remembering = !parent_header.is_null() + && !self.skip_remembering + && barrier_parent_needs_remembering( + (parent_header as *mut u8).add(GC_HEADER_SIZE) as usize, + external, + ); + let visited = self.visit_value_bits_child(*slot); + if let Some((_, Some(new_bits), _)) = visited { *slot = new_bits; } - if !parent_header.is_null() && !self.skip_remembering { - let parent_user = (parent_header as *mut u8).add(GC_HEADER_SIZE) as usize; - if barrier_parent_needs_remembering(parent_user, external) { - if let Some((child_addr, _, _)) = self.ptrs.decode_bits(*slot) { - // Keep old→malloc pages dirty alongside old→nursery: - // the malloc child is spared by this cycle's mark - // (mark_addr handles CopyingPointerKind::Malloc) but - // the NEXT minor's malloc sweep needs the edge again. - if crate::gc::barrier::remembered_child_needs_tracking(child_addr) { - self.sticky.remember_slot(parent_header, slot, external); - } - } + if !remembering { + return; + } + // The visit above already decoded this word; re-decoding `*slot` + // repeated it. Only a raw word that MOVED is validated again, which is + // all the re-decode could still reject. + let child = match visited { + Some((_, Some(new_bits), true)) => self.revalidate_moved_raw(new_bits), + other => other.map(|(addr, _, _)| addr).filter(|&addr| addr != 0), + }; + #[cfg(test)] + let child = + child.filter(|_| !copy_decode_sabotage::forgetting(copy_decode_sabotage::CHILD)); + if let Some(child_addr) = child { + // Keep old→malloc pages dirty alongside old→nursery: the malloc + // child is spared by this cycle's mark (mark_addr handles + // CopyingPointerKind::Malloc) but the NEXT minor's malloc sweep + // needs the edge again. + if crate::gc::barrier::remembered_child_needs_tracking(child_addr) { + self.sticky.remember_slot(parent_header, slot, external); } } } diff --git a/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs b/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs new file mode 100644 index 0000000000..bed7c41126 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/copy_slot_decode.rs @@ -0,0 +1,140 @@ +//! The copying minor decodes each visited word ONCE (`visit_value_bits_child`): +//! a raw word's validating classification is the one the mark uses, and the +//! remembering arm reuses the child the visit decoded instead of re-decoding +//! the slot. Both halves are pinned by collections and what they leave in the +//! heap, and each has a sabotaged twin that must fail. + +use super::super::*; +use super::support::*; +use crate::gc::copying_parent_facts::copy_decode_sabotage::{Guard, CHILD, RAW_MARK}; + +fn string_bytes(addr: usize) -> Vec { + unsafe { + let s = addr as *const crate::StringHeader; + std::slice::from_raw_parts(crate::string::string_data(s), (*s).byte_len as usize).to_vec() + } +} + +/// A young string reachable ONLY through a RAW (untagged) word in a rooted +/// young object. Returns whether the minor evacuated it through that word. +fn raw_child_evacuated(sabotaged: bool) -> bool { + std::thread::spawn(move || { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _scan = ConservativeScanDisabledGuard::new(); + let _roots = ShadowAndGlobalRootResetGuard; + let (parent, fields) = unsafe { alloc_nursery_test_object(1) }; + let child = young_leaf(); + let expected = string_bytes(child); + unsafe { *fields = child as u64 }; + js_shadow_slot_set(0, ptr_bits(parent as usize)); + assert!( + crate::arena::pointer_in_nursery(child), + "premise: the child must be young, or there is nothing to evacuate" + ); + { + let _sabotage = sabotaged.then(|| Guard::arm(RAW_MARK)); + let _ = gc_collect_minor(); + } + let parent_after = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_ne!( + parent_after, parent as usize, + "premise: the rooted parent moved" + ); + let word = unsafe { + *((parent_after as *const u8).add(std::mem::size_of::()) + as *const u64) + }; + // Checked before any read through `word`: a stale word names from-space. + word != child as u64 && string_bytes(word as usize) == expected + }) + .join() + .expect("raw-word decode test thread must not panic") +} + +#[test] +fn a_raw_word_is_marked_through_its_validating_classification() { + assert!( + raw_child_evacuated(false), + "the young child behind a raw word must be evacuated and the word rewritten" + ); +} + +#[test] +fn sabotaged_raw_mark_leaves_the_raw_word_stale() { + assert!( + !raw_child_evacuated(true), + "with the validated raw word dropped instead of marked, the child is not evacuated" + ); +} + +/// An OLD parent whose NaN-boxed slot holds a young child, handed to the minor +/// through the write barrier, then two minors: the second finds the edge only +/// if the first re-remembered it from the child its visit decoded. `Err` is +/// the collection thread's panic message. +fn old_edge_across_two_minors(sabotaged: bool) -> Result { + std::thread::spawn(move || { + let _guard = CopyingNurseryTestGuard::new(1); + let _tenuring = crate::gc::tenuring::set_survivals_for_test( + crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX, + ); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _scan = ConservativeScanDisabledGuard::new(); + let _roots = ShadowAndGlobalRootResetGuard; + let (parent, fields) = unsafe { alloc_old_test_object(1) }; + let child = young_leaf(); + let expected = string_bytes(child); + unsafe { *fields = ptr_bits(child) }; + js_write_barrier_slot(ptr_bits(parent as usize), fields as u64, ptr_bits(child)); + assert!( + crate::arena::pointer_in_old_gen(parent as usize) + && crate::arena::pointer_in_nursery(child), + "premise: an old parent and a young child" + ); + let read = || unsafe { (*fields & POINTER_MASK) as usize }; + { + let _sabotage = sabotaged.then(|| Guard::arm(CHILD)); + let _ = gc_collect_minor(); + } + let first = read(); + assert!( + first != child && crate::arena::pointer_in_nursery(first), + "premise: the first minor copied the child within the nursery" + ); + let _ = gc_collect_minor(); + let second = read(); + second != first && string_bytes(second) == expected + }) + .join() + .map_err(|payload| { + payload + .downcast_ref::() + .cloned() + .or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_default() + }) +} + +#[test] +fn an_old_parents_edge_is_remembered_from_the_child_the_visit_decoded() { + assert_eq!( + old_edge_across_two_minors(false), + Ok(true), + "the second minor must find and move the child through the remembered edge" + ); +} + +/// In a release build `restore_surviving_dirty_coverage` would re-add the page +/// the arm failed to remember, which is why a forgotten remembered-set entry +/// is invisible to a survival check alone. In the debug build `cargo test` +/// runs, the same walk cross-checks the dirty scan's per-slot re-remembering +/// and refuses the disagreement — that refusal is this twin's observable. +#[test] +fn sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check() { + let outcome = old_edge_across_two_minors(true); + assert!( + matches!(&outcome, Err(message) if message.contains("restore_surviving_dirty_coverage")), + "with the decoded child forgotten, the coverage walk must report the \ + unremembered page; got {outcome:?}" + ); +} diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 1ffdcb9893..3307fdea2b 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -17,6 +17,7 @@ mod census_block_windows; mod census_whole_block; mod concat_site; mod contract; +mod copy_slot_decode; mod copy_slot_hoists; mod copying; mod copying_side_tables; diff --git a/crates/perry-runtime/src/object/native_call_method/common_methods.rs b/crates/perry-runtime/src/object/native_call_method/common_methods.rs index 12a9e001eb..88bd5bdb7c 100644 --- a/crates/perry-runtime/src/object/native_call_method/common_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/common_methods.rs @@ -897,7 +897,10 @@ pub(super) unsafe fn dispatch_common( jsval.as_pointer::() as *mut crate::array::ArrayHeader; // Spec §23.1.3.21: length is Set even with 0 args, so guards fire regardless if crate::array::array_is_frozen(arr_ptr) { - crate::collection_iter::throw_type_error("Cannot mutate a frozen array"); + // What fails is CreateDataProperty for the NEW index, not a + // write to an existing read-only one, so node words this the + // same for freeze / seal / preventExtensions. + crate::array::throw_non_extensible_array_push(unsafe { (*arr_ptr).length }); } crate::array::guard_writable_length(arr_ptr); let mut arr = arr_ptr; diff --git a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs index d4f306c499..2d47c3c7a0 100644 --- a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs @@ -424,9 +424,7 @@ pub(super) unsafe fn dispatch_handle( // args, so frozen / non-writable-length must throw. let arr = raw_ptr as *mut crate::array::ArrayHeader; if crate::array::array_is_frozen(arr) { - crate::collection_iter::throw_type_error( - "Cannot mutate a frozen array", - ); + crate::array::throw_non_extensible_array_push(unsafe { (*arr).length }); } crate::array::guard_writable_length(arr); let mut a = arr; diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index 38bc1e4d9f..ffb9b526d6 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -1801,17 +1801,27 @@ fn numeric_array_push_guard(arr: *const ArrayHeader, value: f64) -> bool { { return false; } - if crate::object::get_property_attrs(raw_addr, "length") - .map(|attrs| !attrs.writable()) - .unwrap_or(false) - { - return false; - } + // A non-writable `length` is reachable only through a descriptor + // write, and every one of those marks the receiver + // `OBJ_FLAG_ARRAY_DESCRIPTORS` (`array::named_props::mark_array_descriptors` + // — the bit is documented there as the shared "index-accessor / + // non-writable-length / sparse-index" gate). The flag test above has + // already returned `false` for any receiver carrying it, so by this + // point the array provably has no descriptors and its `length` is + // provably writable. `array::push_pop::array_length_is_non_writable_with_flags` + // encodes the same implication the other way round, short-circuiting on + // the flag before it will look a descriptor up at all. + // + // The lookup that used to sit here was therefore unreachable-true, and + // it was not cheap: a string-keyed descriptor probe on EVERY push. In a + // `for (…) { a.push(v); a.pop(); }` loop it was the single heaviest + // frame in the profile at 16.5% of all samples, more than the append it + // was guarding. len <= 16_000_000 && cap <= 16_000_000 && len < cap && is_numeric_value_bits(value.to_bits()) - && crate::array::js_array_is_numeric_f64_layout(arr) != 0 + && crate::array::js_array_is_numeric_f64_layout_resolved(arr) != 0 } } diff --git a/test-files/test_gap_array_push_integrity.ts b/test-files/test_gap_array_push_integrity.ts new file mode 100644 index 0000000000..bfad8e4526 --- /dev/null +++ b/test-files/test_gap_array_push_integrity.ts @@ -0,0 +1,57 @@ +// `Array.prototype.push` on a receiver whose integrity has been locked. +// +// §23.1.3.21 performs `Set(O, len, value, true)` and then +// `Set(O, "length", len+1, true)`. Both carry Throw=true, which is the +// mutator's OWN throw and does not depend on the caller's strictness — so +// every case below throws in this file whether node treats it as a module or +// as CommonJS. +// +// `preventExtensions` is the case that regressed: the dense append answered a +// non-extensible receiver with a bare `return arr`, which is correct for the +// INTERNAL CreateDataProperty-style append used to build fresh result arrays +// and wrong for user `push`. `seal` masked it, because sealing also marks the +// element descriptors and therefore routes the receiver down the exotic path, +// which threw for a different reason. +function push1(label: string, a: any): void { + try { + a.push(99); + console.log(label, "no-throw len=" + a.length); + } catch (e: any) { + console.log(label, e.constructor.name + ": " + e.message); + } +} + +const ext: number[] = [1, 2]; +Object.preventExtensions(ext); +push1("preventExtensions", ext); + +const sealed: number[] = [1, 2]; +Object.seal(sealed); +push1("seal", sealed); + +const frozen: number[] = [1, 2]; +Object.freeze(frozen); +push1("freeze", frozen); + +// Zero-argument push only performs the `length` Set, which succeeds on a +// non-extensible receiver: no throw, length unchanged. +const zero: number[] = [1, 2]; +Object.preventExtensions(zero); +zero.push(); +console.log("zero-arg push len", zero.length); + +// A non-writable `length` must still throw, and must keep throwing after the +// typed-feedback fast tier has been warmed by a hot loop — the re-check the +// guard performs per push is what this pins. +const warm: number[] = []; +for (let i = 0; i < 200; i++) { + warm.push(i); + warm.pop(); +} +Object.defineProperty(warm, "length", { writable: false }); +push1("warm-then-non-writable-length", warm); + +// A plain array is unaffected by all of the above. +const plain: number[] = [1, 2]; +plain.push(3); +console.log("plain push len", plain.length, "last", plain[plain.length - 1]); diff --git a/test-files/test_gap_array_push_numeric_over_pointer_slot.ts b/test-files/test_gap_array_push_numeric_over_pointer_slot.ts new file mode 100644 index 0000000000..2fe0c4094a --- /dev/null +++ b/test-files/test_gap_array_push_numeric_over_pointer_slot.ts @@ -0,0 +1,24 @@ +// The case the elision has to survive: a slot that HELD A POINTER, was popped, +// and is then re-filled with a plain number. Skipping the layout note leaves +// the slot's pointer-mask bit set over a numeric word, so the collector must +// re-validate slot words rather than trust the mask. +class Node2 { v: number; tag: string; constructor(v: number) { this.v = v; this.tag = "n" + v; } } +const retained: any[] = []; +let checksum = 0; +for (let round = 0; round < 400; round++) { + const a: any[] = []; + // fill with pointers, then drain + for (let i = 0; i < 24; i++) a.push(new Node2(round * 100 + i)); + while (a.length > 0) { const n = a.pop(); checksum += n.v % 7; } + // refill the SAME slots with plain numbers + for (let i = 0; i < 24; i++) a.push(i * 1.5); + while (a.length > 4) a.pop(); + // interleave: numbers and pointers into one array + for (let i = 0; i < 12; i++) { a.push(i); a.push(new Node2(i)); } + for (const e of a) { if (typeof e === "number") checksum += e; else checksum += e.v % 5; } + if (round % 40 === 0) retained.push(a); +} +console.log("checksum", checksum, "retained", retained.length); +let live = 0; +for (const a of retained) for (const e of a) if (typeof e !== "number" && e && e.tag) live++; +console.log("live tags", live);