diff --git a/CLAUDE.md b/CLAUDE.md index f05b2be5e6..3b5a1dac6c 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.1304 +**Current Version:** 0.5.1305 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index a1eb766c4a..9e3cd8548c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1304" +version = "0.5.1305" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1304" +version = "0.5.1305" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1304" +version = "0.5.1305" [[package]] name = "perry-ui-tvos" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1304" +version = "0.5.1305" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 927440284c..e0b6a27f02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1304" +version = "0.5.1305" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7532-typed-shape-declared-at-allocation.md b/changelog.d/7532-typed-shape-declared-at-allocation.md new file mode 100644 index 0000000000..fe96542392 --- /dev/null +++ b/changelog.d/7532-typed-shape-declared-at-allocation.md @@ -0,0 +1,88 @@ +**A class's typed-shape layout is now declared at the allocation site instead +of validated after the constructor, so the constructor's own field stores can +see it (#7510 item 1, closing the residual half of #7512).** + +`js_gc_init_typed_shape_layout` was emitted *after* the constructor call. The +consequence was that no raw-f64 class-field store **inside** a constructor could +pass its `GC_OBJ_TYPED_LAYOUT_INTACT` guard — neither the inline +`and i16 %r, 4096` test nor `class_field_fast_contract`'s +`layout_typed_raw_f64_slot_for_user` — so every one fell back to +`js_put_value_set`. Stated plainly, because it inverts the usual assumption: + +> **Declaring the fields `number` was what made the class slower.** More type +> information selected a representation whose guard the construction path had +> made unsatisfiable. + +The one-line reorder does not work, and that is the whole design problem. +`init_typed_shape_layout` validates that each raw-f64 slot already holds a plain +double; a fresh slot holds `TAG_UNDEFINED`, whose `0x7FFC` tag is inside +`layout_raw_f64_bits`' reject range, so an early call downgrades every instance +it touches. + +A second entry point, `js_gc_declare_typed_shape_layout`, **skips that +validation** — which moves the burden of proof to codegen, where two conditions +now discharge it (`typed_shape::class_layout_declarable_at_allocation`): + +1. **Every** raw-f64 field is assigned by the constructor prologue from a plain + parameter — #7486's `ctor_prologue_param_assigned_fields`, which is non-empty + only for a class with no heritage, no field initializers or computed keys, no + decorators, plain parameters, and no setter shadowing an assigned field. A + `LocalGet` of a plain parameter cannot throw, allocate, or observe `this`, so + nothing can read a raw-f64 slot between the declaration and its first write. + *Every*, not *some*: one field assigned later would still be exposed. +2. **The pointer mask is empty**, so the declared state is `POINTER_FREE` — + byte-identical to what `layout_init_pointer_free` already sets on every fresh + instance. The only delta emitted is the intact bit and the shape-shared + descriptor install; the collector's view at birth is unchanged. Classes with + pointer fields would install `SIDE_MASK` over the allocator's fill, which is + sound on the pre-filling allocation path but would rest on that pre-fill, so + they are out of scope. + +**Nothing rests on the values actually being numbers.** A constructor that +stores a string into a `number`-declared field is rejected by the store guard +(`is_plain_number_bits`, and the inline path's finite-exponent test), falls back +to the boxed setter, and downgrades the descriptor through `layout_note_slot` — +the same path any post-install contradiction has always taken. There is a +witness for exactly this: 20,000 instances constructed with heap strings in a +`number` field, collected hard, all 20,000 still readable and `typeof` `string`, +under `PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1`. + +Interleaved A/B (arms alternating per round, best-of-9 user CPU): + +| bench | speedup | +|---|--:| +| `push_cls` (`new Node(v, w)` ×20M) | **1.54×** | +| `push_cls_read` (same, plus reading both fields back) | **1.53×** | + +The collector result is strictly better, not merely equal: `push_cls` promotion +falls **210,488 → 64 bytes**, bytes copied 0.0042 → 0.0036 GB, peak RSS 29.6 → +24.1 MB, cycle count unchanged at 105 — i.e. the class instance now behaves +exactly like the equivalent object literal (`churn`: 0.0036 GB, 64 B, 24.2 MB), +which is #7512's anomaly closed on the memory axis too. + +Every other benchmark compiles to **byte-identical generated objects** across +the two arms (`churn`, `churn_alloc`, `push_num`, `tree`, `deeplist`, +`churn_read` — compared as object bytes, since the object *cache key* hashes +codegen env and compiler identity and so always differs). Their ±3% A/B spread +is host noise, not a regression. + +**Object literals do not qualify, and the reason is worth recording.** HIR +rewrites a closed-shape literal to `new __AnonShape_(…)`, whose +synthesized constructor *is* a qualifying prologue — but the minted class's +field types come out as `Any`, not `Number`. `Any` is pointer-bearing, so the +gate refuses, and there is no raw-f64 store path to unlock in the first place. +That also means `{v: number, w: number}` is currently declared to the collector +as **two pointer slots**. The literal path's remaining cost is therefore a type +propagation gap, not an ordering one — a separate lever from this ticket's. + +New tests: `perry-codegen/tests/typed_shape_declared_at_allocation.rs` (the +declaration dominates the constructor call, replaces the post-constructor +install, carries a raw-f64 mask with a null pointer mask, and is refused for a +non-prologue number field, a pointer field, an untyped field, an all-boolean +class, and a class with no constructor) and +`perry-runtime/src/gc/tests/layout_trace/declared_at_allocation.rs` (the +validating install still refuses a fresh instance — executable documentation for +why the split exists — the declaring one accepts it, both still reject a +slot-count mismatch and overlapping masks, and a contradicting store both evicts +the descriptor and leaves the object conservatively scanned so its string child +is still traced). diff --git a/crates/perry-codegen/src/gc_call_effects.rs b/crates/perry-codegen/src/gc_call_effects.rs index bc7acd7139..fb0742e6e4 100644 --- a/crates/perry-codegen/src/gc_call_effects.rs +++ b/crates/perry-codegen/src/gc_call_effects.rs @@ -82,6 +82,7 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect { | "js_gc_note_slot_layout" | "js_gc_note_slot_layout_aware" | "js_gc_init_typed_shape_layout" + | "js_gc_declare_typed_shape_layout" // `typed_feedback.rs`: counters/registries only. This intentionally // does not include feedback wrappers that perform the actual object // get/set operation. diff --git a/crates/perry-codegen/src/lower_call/field_init.rs b/crates/perry-codegen/src/lower_call/field_init.rs index 32ffad47dd..d2eae38b6a 100644 --- a/crates/perry-codegen/src/lower_call/field_init.rs +++ b/crates/perry-codegen/src/lower_call/field_init.rs @@ -146,7 +146,7 @@ fn prologue_assigned_field<'a>( /// so every field it assigns is written before ANY other effect of the /// constructor — which is exactly the guarantee that makes the earlier /// `undefined` write dead. -fn ctor_prologue_param_assigned_fields( +pub(crate) fn ctor_prologue_param_assigned_fields( class: &perry_hir::Class, ) -> std::collections::HashSet { let empty = std::collections::HashSet::new(); diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index aec7d6230b..a8bb8793df 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -58,6 +58,9 @@ mod omitted_native_params; mod options; mod property_get; mod scalar_method; +/// #7510: which of the two typed-shape layout entry points a `new` site emits, +/// and where. Split out of `new.rs` to keep it under the 2000-line cap. +mod typed_shape_init; mod ui_styling; mod ui_tables; mod web_storage; diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 6e24912b8f..20f2d8ef07 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -85,60 +85,8 @@ fn reload_instance( (handle, boxed) } -/// Emit the `js_gc_init_typed_shape_layout` call that registers the freshly -/// constructed instance's raw-f64 / pointer slot masks with the GC so the -/// typed-feedback class-field fast path engages. Must run AFTER the constructor -/// body has set the declared fields to their numeric values (the runtime -/// validates each raw-f64 slot currently holds a plain double before promoting). -/// No-op for classes without an inline-keys shape global. Refs the standalone -/// `_constructor` symbol path, which previously returned before reaching -/// this — leaving every numeric class field permanently on the by-name hashmap -/// fallback (10M `counter.increment()` ran ~640ns/call instead of slot-direct). -fn emit_typed_shape_layout_init(ctx: &mut FnCtx<'_>, class_name: &str, obj_handle: &str) { - let Some(keys_global_name) = ctx.class_keys_globals.get(class_name).cloned() else { - return; - }; - // Refs #5094: prefer the prefix-disambiguated chain so slot/word counts - // agree with the mask globals emitted in compile_module (same-named - // cross-module parents mis-resolve in the name-keyed walk). - let typed_layout = ctx - .class_init_chains - .get(class_name) - .map(|chain| crate::typed_shape::class_typed_layout_from_chain(chain)) - .unwrap_or_else(|| crate::typed_shape::class_typed_layout(ctx.classes, class_name)); - let slot_count_str = typed_layout.slot_count.to_string(); - let raw_mask_word_count_str = typed_layout.raw_f64_mask_words.len().to_string(); - let pointer_mask_word_count_str = typed_layout.pointer_mask_words.len().to_string(); - let raw_mask_ref = if typed_layout.raw_f64_mask_words.is_empty() { - "null".to_string() - } else { - format!( - "@{}", - crate::typed_shape::raw_f64_mask_global_name_from_keys_global(&keys_global_name) - ) - }; - let pointer_mask_ref = if typed_layout.pointer_mask_words.is_empty() { - "null".to_string() - } else { - format!( - "@{}", - crate::typed_shape::mask_global_name_from_keys_global(&keys_global_name) - ) - }; - ctx.block().call_void( - "js_gc_init_typed_shape_layout", - &[ - (I64, obj_handle), - (I32, &slot_count_str), - (PTR, &raw_mask_ref), - (I32, &raw_mask_word_count_str), - (PTR, &pointer_mask_ref), - (I32, &pointer_mask_word_count_str), - ], - ); -} - pub(crate) use super::capture_writeback::emit_class_capture_writeback; +use super::typed_shape_init::{emit_typed_shape_layout_declare, emit_typed_shape_layout_init}; /// Lower `new ClassName(args…)` — Phase C.1. /// @@ -931,6 +879,17 @@ fn lower_new_impl_inner( // // The slot is released by the scope cut in `lower_new_impl`, which covers // all ~20 return paths below. + // #7510: declare the canonical layout HERE — the instance is allocated, its + // slots still hold the allocator's `undefined` fill, and the constructor + // has not run. That ordering is the whole point: the post-constructor + // `emit_typed_shape_layout_init` arrives after the only stores that wanted + // the descriptor, so a `number`-declared class field could never pass its + // intact-bit guard (#7512). Gated and suppressed as one — see + // `layout_declared_at_allocation`. + // + // Before the temp-root push, so the handle this names is the one the + // allocator returned: nothing between here and there can collect. + emit_typed_shape_layout_declare(ctx, class_name, &obj_handle); let instance_root = construction_runs_user_code(ctx, class_name) .then(|| temp_root::temp_root_push_i64(ctx, &obj_handle)); let obj_box = nanbox_pointer_inline(ctx.block(), &obj_handle); diff --git a/crates/perry-codegen/src/lower_call/typed_shape_init.rs b/crates/perry-codegen/src/lower_call/typed_shape_init.rs new file mode 100644 index 0000000000..e30f822506 --- /dev/null +++ b/crates/perry-codegen/src/lower_call/typed_shape_init.rs @@ -0,0 +1,145 @@ +//! Where a `new ClassName(…)` site establishes the instance's canonical +//! typed-shape layout — and, since #7510, *when*. +//! +//! There are two runtime entry points and they differ in one thing: whether +//! the descriptor is **observed** from the instance's current slot contents or +//! **declared** over a fresh one. +//! +//! - [`emit_typed_shape_layout_init`] emits `js_gc_init_typed_shape_layout` +//! after the constructor has run. The runtime validates every slot before +//! promoting, so this form needs no proof from codegen at all. +//! - [`emit_typed_shape_layout_declare`] emits +//! `js_gc_declare_typed_shape_layout` at the allocation site, before the +//! constructor. The runtime skips validation — it has to, since a fresh slot +//! holds `TAG_UNDEFINED` and would fail it — so the proof moves here. +//! +//! The second form exists because the first arrives too late to matter: a +//! raw-f64 class-field store *inside* a constructor tests the +//! `GC_OBJ_TYPED_LAYOUT_INTACT` bit, which the post-constructor install has not +//! set yet, so every such store fell back to `js_put_value_set` (#7512). One +//! predicate, [`layout_declared_at_allocation`], chooses between them, and both +//! emitters consult it — the declaration is emitted **iff** the +//! post-constructor install is suppressed, so they cannot drift into +//! double-installing or into leaving an instance with no descriptor. +//! +//! Split out of `new.rs` to stay under the repo's 2000-line-per-file cap +//! (`scripts/check_file_size.sh`). + +use crate::expr::FnCtx; +use crate::types::{I32, I64, PTR}; + +/// #7510: may `class_name`'s layout be declared at allocation instead of +/// validated after the constructor? +/// +/// Resolves the class and hands both halves of the proof to +/// [`crate::typed_shape::class_layout_declarable_at_allocation`], which +/// documents what they are and why they are enough. +pub(super) fn layout_declared_at_allocation(ctx: &FnCtx<'_>, class_name: &str) -> bool { + if !ctx.class_keys_globals.contains_key(class_name) { + return false; + } + ctx.classes.get(class_name).is_some_and(|class| { + let prologue = super::field_init::ctor_prologue_param_assigned_fields(class); + crate::typed_shape::class_layout_declarable_at_allocation(class, &prologue) + }) +} + +/// Emit the `js_gc_declare_typed_shape_layout` call that registers a **freshly +/// allocated** instance's layout, before its constructor runs, so the +/// constructor's own field stores can pass the intact-bit guard (#7510/#7512). +/// +/// No-op unless [`layout_declared_at_allocation`] holds. **Must be emitted +/// while the instance's slots are still the allocator's fill** — that is the +/// runtime contract, and it is not checkable from the runtime side. +pub(super) fn emit_typed_shape_layout_declare( + ctx: &mut FnCtx<'_>, + class_name: &str, + obj_handle: &str, +) { + if !layout_declared_at_allocation(ctx, class_name) { + return; + } + emit_typed_shape_layout_call( + ctx, + class_name, + obj_handle, + "js_gc_declare_typed_shape_layout", + ); +} + +/// Emit the `js_gc_init_typed_shape_layout` call that registers the freshly +/// constructed instance's raw-f64 / pointer slot masks with the GC so the +/// typed-feedback class-field fast path engages. Must run AFTER the constructor +/// body has set the declared fields to their numeric values (the runtime +/// validates each raw-f64 slot currently holds a plain double before +/// promoting). No-op for classes without an inline-keys shape global. Refs the +/// standalone `_constructor` symbol path, which previously returned +/// before reaching this — leaving every numeric class field permanently on the +/// by-name hashmap fallback (10M `counter.increment()` ran ~640ns/call instead +/// of slot-direct). +/// +/// #7510: suppressed for a class already declared at its allocation site. Every +/// store since has maintained that descriptor — or downgraded it, which +/// re-validating here must not silently undo — and re-installing would cost +/// exactly the work this ticket removes. +pub(super) fn emit_typed_shape_layout_init( + ctx: &mut FnCtx<'_>, + class_name: &str, + obj_handle: &str, +) { + if layout_declared_at_allocation(ctx, class_name) { + return; + } + emit_typed_shape_layout_call(ctx, class_name, obj_handle, "js_gc_init_typed_shape_layout"); +} + +/// The shared operand build. Both entry points take the identical six-argument +/// signature, so the only thing that varies is the callee name. +fn emit_typed_shape_layout_call( + ctx: &mut FnCtx<'_>, + class_name: &str, + obj_handle: &str, + callee: &str, +) { + let Some(keys_global_name) = ctx.class_keys_globals.get(class_name).cloned() else { + return; + }; + // Refs #5094: prefer the prefix-disambiguated chain so slot/word counts + // agree with the mask globals emitted in compile_module (same-named + // cross-module parents mis-resolve in the name-keyed walk). + let typed_layout = ctx + .class_init_chains + .get(class_name) + .map(|chain| crate::typed_shape::class_typed_layout_from_chain(chain)) + .unwrap_or_else(|| crate::typed_shape::class_typed_layout(ctx.classes, class_name)); + let slot_count_str = typed_layout.slot_count.to_string(); + let raw_mask_word_count_str = typed_layout.raw_f64_mask_words.len().to_string(); + let pointer_mask_word_count_str = typed_layout.pointer_mask_words.len().to_string(); + let raw_mask_ref = if typed_layout.raw_f64_mask_words.is_empty() { + "null".to_string() + } else { + format!( + "@{}", + crate::typed_shape::raw_f64_mask_global_name_from_keys_global(&keys_global_name) + ) + }; + let pointer_mask_ref = if typed_layout.pointer_mask_words.is_empty() { + "null".to_string() + } else { + format!( + "@{}", + crate::typed_shape::mask_global_name_from_keys_global(&keys_global_name) + ) + }; + ctx.block().call_void( + callee, + &[ + (I64, obj_handle), + (I32, &slot_count_str), + (PTR, &raw_mask_ref), + (I32, &raw_mask_word_count_str), + (PTR, &pointer_mask_ref), + (I32, &pointer_mask_word_count_str), + ], + ); +} diff --git a/crates/perry-codegen/src/root_reload.rs b/crates/perry-codegen/src/root_reload.rs index da3150549f..d76a05a360 100644 --- a/crates/perry-codegen/src/root_reload.rs +++ b/crates/perry-codegen/src/root_reload.rs @@ -118,6 +118,7 @@ const NON_COLLECTING: &[&str] = &[ "js_gc_temp_root_truncate", // layout / barrier bookkeeping "js_gc_init_typed_shape_layout", + "js_gc_declare_typed_shape_layout", "js_gc_layout_note_slot", "js_write_barrier", "js_write_barrier_root_nanbox", diff --git a/crates/perry-codegen/src/runtime_decls/arrays.rs b/crates/perry-codegen/src/runtime_decls/arrays.rs index 3d67d07f67..f198a7aa93 100644 --- a/crates/perry-codegen/src/runtime_decls/arrays.rs +++ b/crates/perry-codegen/src/runtime_decls/arrays.rs @@ -159,6 +159,14 @@ pub fn declare_phase_b_arrays(module: &mut LlModule) { VOID, &[I64, I32, PTR, I32, PTR, I32], ); + // #7510: same signature, but for a FRESHLY ALLOCATED instance whose slots + // are still the allocator's fill — it declares the layout instead of + // validating it, so a constructor's own field stores can see it. + module.declare_function( + "js_gc_declare_typed_shape_layout", + VOID, + &[I64, I32, PTR, I32, PTR, I32], + ); // Array methods (Phase B.12). // - js_array_pop_f64(arr) -> f64 (last element, NaN if empty) // - js_array_join(arr, sep) -> *mut StringHeader (i64) diff --git a/crates/perry-codegen/src/typed_shape.rs b/crates/perry-codegen/src/typed_shape.rs index b6da04c634..3e21745d48 100644 --- a/crates/perry-codegen/src/typed_shape.rs +++ b/crates/perry-codegen/src/typed_shape.rs @@ -79,6 +79,80 @@ pub(crate) fn type_is_raw_f64_candidate(ty: &Type) -> bool { matches!(ty, Type::Number) } +/// #7510: may this class's canonical layout be declared at **allocation**, +/// before its constructor runs, rather than validated after it? +/// +/// The motivating defect is #7512: `js_gc_init_typed_shape_layout` is emitted +/// after the constructor call, so no raw-f64 class-field store *inside* a +/// constructor can pass its `GC_OBJ_TYPED_LAYOUT_INTACT` guard, and every one +/// falls back to `js_put_value_set`. Declaring the fields `number` is what +/// makes the class slower — more type information selects a representation +/// whose guard the construction path has made unsatisfiable. +/// +/// Moving the existing call earlier does not work: it validates that each +/// raw-f64 slot holds a plain double, and a fresh slot holds `TAG_UNDEFINED` +/// (tag `0x7FFC`, inside `layout_raw_f64_bits`' reject range), so an early call +/// downgrades every instance. `js_gc_declare_typed_shape_layout` skips that +/// validation, which shifts the burden of proof here. +/// +/// Two obligations, and both are discharged by conditions, not by hope: +/// +/// 1. **No read may observe a raw-f64 slot before its first write** — it would +/// read `undefined`'s NaN-box bits as a double and see a NaN. `prologue` +/// is #7486's `ctor_prologue_param_assigned_fields`: the maximal leading run +/// of `this. = ` statements, non-empty only for a class with +/// no heritage, no field initializers or computed keys, no decorators, plain +/// parameters, and no setter shadowing an assigned field. A `LocalGet` of a +/// plain parameter cannot throw, allocate, or observe `this`, so every field +/// it assigns is written before ANY other effect of the constructor. We +/// require **every** raw-f64 field to be in that set — one field assigned +/// later would still be exposed. +/// +/// 2. **The collector's view must be true at birth.** We require the pointer +/// mask to be EMPTY, which makes the declared state `GC_LAYOUT_POINTER_FREE` +/// — byte-identical to what `layout_init_pointer_free` already sets on every +/// fresh instance. So the only delta this emits is the intact bit and the +/// shape-shared descriptor install; the collector sees exactly what it saw +/// before. A class with pointer fields would install `SIDE_MASK` and hand the +/// collector slots holding the allocator's fill — sound on the +/// `js_object_alloc_class_inline_keys` path, which pre-fills with +/// `undefined`, but it would rest on that pre-fill rather than on nothing, +/// so it is out of scope here. +/// +/// Nothing rests on the *values* being numbers. A constructor that stores a +/// string into a `number`-declared field is rejected by the store guard +/// (`is_plain_number_bits`, and the inline path's finite-exponent test), falls +/// back to the boxed setter, and downgrades the descriptor through +/// `layout_note_slot` — the same path any post-install contradiction takes. +pub(crate) fn class_layout_declarable_at_allocation( + class: &perry_hir::Class, + prologue: &std::collections::HashSet, +) -> bool { + if prologue.is_empty() { + return false; + } + let mut has_raw_f64 = false; + for field in &class.fields { + if field.key_expr.is_some() { + continue; + } + // An untyped field lands on `Any`/`Unknown`, which + // `type_is_pointer_bearing` answers `true` for — so it is rejected + // here, by the same condition and for the same reason as a declared + // `string`. + if type_is_pointer_bearing(&field.ty) { + return false; + } + if type_is_raw_f64_candidate(&field.ty) { + has_raw_f64 = true; + if !prologue.contains(&field.name) { + return false; + } + } + } + has_raw_f64 +} + #[derive(Clone, Debug, Default)] pub(crate) struct TypedShapeLayout { pub(crate) slot_count: u32, diff --git a/crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs b/crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs new file mode 100644 index 0000000000..a516912a42 --- /dev/null +++ b/crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs @@ -0,0 +1,427 @@ +//! #7510 item 1 / #7512 residual: the typed-shape layout of a qualifying class +//! is DECLARED at the allocation site instead of validated after the +//! constructor. +//! +//! The defect these lock down is an ordering one. +//! `js_gc_init_typed_shape_layout` was emitted after the constructor call, so +//! no raw-f64 class-field store *inside* the constructor could pass its +//! `GC_OBJ_TYPED_LAYOUT_INTACT` guard, and every one fell back to +//! `js_put_value_set`. #7512's stated acceptance is an IR census showing the +//! two real field stores of `constructor(v, w) { this.v = v; this.w = w }` +//! with `v: number; w: number` no longer routing through it. Its static half +//! is `the_declaration_dominates_the_constructor_call`; the rest is a runtime +//! property of the guarded store diamond (whose fallback arm is emitted either +//! way — what changed is whether its guard can pass), measured as 1.55× on +//! `push_cls`. +//! +//! The negative tests matter as much as the positive one: the declaration +//! skips the runtime's slot validation, so its whole soundness rests on the +//! gate refusing every shape where a read could observe a raw-f64 slot before +//! its first write, or where the collector's view at birth would not be +//! `POINTER_FREE`. + +use perry_codegen::{compile_module, AppMetadata, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Class, ClassField, Expr, Function, Module, ModuleInitKind, Param, Stmt}; + +const DECLARE_CALL: &str = "call void @js_gc_declare_typed_shape_layout"; +const INIT_CALL: &str = "call void @js_gc_init_typed_shape_layout"; + +fn empty_opts() -> CompileOptions { + CompileOptions { + target: None, + is_entry_module: false, + non_entry_module_prefixes: Vec::new(), + import_function_prefixes: std::collections::HashMap::new(), + import_function_ffi_aliases: std::collections::HashMap::new(), + import_function_origin_names: std::collections::HashMap::new(), + import_function_v8_specifiers: std::collections::HashMap::new(), + import_function_node_submodule: std::collections::HashMap::new(), + namespace_node_submodules: std::collections::HashMap::new(), + namespace_v8_specifiers: std::collections::HashMap::new(), + namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), + emit_ir_only: true, + verify_native_regions: false, + disable_buffer_fast_path: false, + namespace_imports: Vec::new(), + imported_classes: Vec::new(), + imported_enums: Vec::new(), + imported_async_funcs: std::collections::HashSet::new(), + type_aliases: std::collections::HashMap::new(), + imported_func_param_counts: std::collections::HashMap::new(), + imported_func_has_rest: std::collections::HashSet::new(), + imported_func_synthetic_arguments: std::collections::HashSet::new(), + imported_func_return_types: std::collections::HashMap::new(), + imported_vars: std::collections::HashSet::new(), + output_type: "executable".to_string(), + needs_stdlib: false, + needs_ui: false, + needs_geisterhand: false, + geisterhand_port: 7676, + enabled_features: Vec::new(), + native_module_init_names: Vec::new(), + js_module_specifiers: Vec::new(), + bundled_extensions: Vec::new(), + native_library_functions: Vec::new(), + i18n_table: None, + fast_math: false, + fp_contract_mode: perry_codegen::FpContractMode::Off, + app_metadata: AppMetadata::default(), + namespace_entries: Vec::new(), + dynamic_import_path_to_prefix: std::collections::HashMap::new(), + nextjs_path_init_modules: Vec::new(), + deferred_module_prefixes: std::collections::HashSet::new(), + module_init_deps: Vec::new(), + is_dynamic_import_target: false, + debug_locations: false, + module_source: None, + debug_source_line_offset: 0, + } +} + +fn field(name: &str, ty: Type) -> ClassField { + ClassField { + name: name.to_string(), + key_expr: None, + ty, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + } +} + +fn param(id: u32, name: &str, ty: Type) -> Param { + Param { + id, + name: name.to_string(), + ty, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +/// `this. = ` in the shape user source lowers to +/// (`PutValueSet`, not the synthesized `PropertySet` — see #7512). +fn assign_field_from_param(name: &str, param_id: u32) -> Stmt { + Stmt::Expr(Expr::PutValueSet { + target: Box::new(Expr::This), + key: Box::new(Expr::String(name.to_string())), + value: Box::new(Expr::LocalGet(param_id)), + receiver: Box::new(Expr::This), + strict: false, + }) +} + +fn ctor(params: Vec, body: Vec) -> Function { + Function { + id: 900, + name: "constructor".to_string(), + type_params: Vec::new(), + params, + 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(name: &str, fields: Vec, constructor: Option) -> Class { + Class { + id: 1, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields, + constructor, + 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, + } +} + +fn module_with_new(class: Class, arg_count: usize) -> Module { + let class_name = class.name.clone(); + Module { + name: "typed_shape_declared_at_allocation.ts".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes: vec![class], + 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::Named(class_name.clone()), + body: vec![Stmt::Return(Some(Expr::New { + class_name, + args: (0..arg_count).map(|i| Expr::Number(i as f64)).collect(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }))], + 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: Vec::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(), + async_generator_funcs: std::collections::HashSet::new(), + gen_param_prologue_len: std::collections::HashMap::new(), + } +} + +fn compile_ir(module: &Module) -> String { + String::from_utf8(compile_module(module, empty_opts()).unwrap()).unwrap() +} + +/// The motivating shape: `class Node { v: number; w: number; +/// constructor(v, w) { this.v = v; this.w = w } }`. +fn declarable_module() -> Module { + module_with_new( + class( + "Node", + vec![field("v", Type::Number), field("w", Type::Number)], + Some(ctor( + vec![param(10, "v", Type::Number), param(11, "w", Type::Number)], + vec![ + assign_field_from_param("v", 10), + assign_field_from_param("w", 11), + ], + )), + ), + 2, + ) +} + +#[test] +fn declarable_class_declares_its_layout_at_the_allocation_site() { + let ir = compile_ir(&declarable_module()); + assert!( + ir.contains(DECLARE_CALL), + "a class whose constructor prologue assigns every number field from a \ + plain parameter must declare its layout at allocation:\n{ir}" + ); +} + +/// The declaration and the post-constructor install are keyed on ONE predicate, +/// so exactly one of them is emitted. Two installs would pay back the cost this +/// removes; zero would leave the instance with no descriptor at all. +#[test] +fn the_declaration_replaces_the_post_constructor_install() { + let ir = compile_ir(&declarable_module()); + assert!( + !ir.contains(INIT_CALL), + "the post-constructor install must be suppressed once the layout is \ + declared at allocation:\n{ir}" + ); +} + +/// #7512's stated acceptance criterion. The constructor's own field stores must +/// stop routing through the by-name fallback — which is only possible if the +/// descriptor exists before the constructor body runs. +/// #7512's stated acceptance is that the constructor's own field stores stop +/// routing through `js_put_value_set`. That is a *runtime* property of the +/// guarded store diamond — the diamond's fallback arm is emitted either way; +/// what changed is whether its guard can pass. The static half of the claim, +/// and the whole of the codegen change, is this ordering: the declaration must +/// dominate the constructor call, because a descriptor installed after the +/// constructor arrives after the only stores that wanted it. +#[test] +fn the_declaration_dominates_the_constructor_call() { + let ir = compile_ir(&declarable_module()); + let alloc_at = ir + .find("call i64 @js_object_alloc_class_inline_keys") + .unwrap_or_else(|| panic!("the allocation must be emitted:\n{ir}")); + let declare_at = ir[alloc_at..] + .find(DECLARE_CALL) + .map(|i| alloc_at + i) + .unwrap_or_else(|| panic!("the layout declaration must be emitted:\n{ir}")); + let ctor_call = ir[alloc_at..] + .find("_Node_constructor(") + .map(|i| alloc_at + i) + .unwrap_or_else(|| panic!("the constructor call must be emitted:\n{ir}")); + assert!( + alloc_at < declare_at && declare_at < ctor_call, + "expected alloc -> declare -> constructor; got alloc@{alloc_at} \ + declare@{declare_at} ctor@{ctor_call}:\n{ir}" + ); +} + +/// The declaration must name the freshly allocated instance and carry the +/// class's raw-f64 mask with a NULL pointer mask — the gate admits only +/// all-scalar shapes, and a non-null pointer mask here would mean the +/// collector is being handed slots that still hold the allocator's fill. +#[test] +fn the_declaration_carries_a_raw_f64_mask_and_no_pointer_mask() { + let ir = compile_ir(&declarable_module()); + let line = ir + .lines() + .find(|l| l.contains(DECLARE_CALL)) + .unwrap_or_else(|| panic!("no declaration in:\n{ir}")); + assert!( + line.contains("@perry_typed_shape_raw_f64_mask_"), + "the raw-f64 mask must be passed: {line}" + ); + assert!( + line.contains("ptr null, i32 0"), + "the pointer mask must be null/empty for a declarable class: {line}" + ); +} + +/// Negative: a `number` field the prologue does not assign could be READ before +/// its first write, and would then see `undefined`'s NaN-box bits as a double. +#[test] +fn a_number_field_outside_the_prologue_refuses_the_declaration() { + let ir = compile_ir(&module_with_new( + class( + "Partial", + vec![field("v", Type::Number), field("w", Type::Number)], + Some(ctor( + // `w` is declared `number` but never assigned in the prologue. + vec![param(10, "v", Type::Number), param(11, "w", Type::Number)], + vec![assign_field_from_param("v", 10)], + )), + ), + 2, + )); + assert!( + !ir.contains(DECLARE_CALL), + "every raw-f64 field must be prologue-assigned, not just some:\n{ir}" + ); +} + +/// Negative: a pointer field would install `SIDE_MASK` at birth and hand the +/// collector slots holding the allocator's fill. Out of scope — the declared +/// state must stay byte-identical to what `layout_init_pointer_free` sets. +#[test] +fn a_pointer_field_refuses_the_declaration() { + let ir = compile_ir(&module_with_new( + class( + "WithPointer", + vec![field("v", Type::Number), field("name", Type::String)], + Some(ctor( + vec![ + param(10, "v", Type::Number), + param(11, "name", Type::String), + ], + vec![ + assign_field_from_param("v", 10), + assign_field_from_param("name", 11), + ], + )), + ), + 2, + )); + assert!( + !ir.contains(DECLARE_CALL), + "a class with a pointer-bearing field must keep the post-constructor \ + install:\n{ir}" + ); +} + +/// Negative: an untyped field lands on `Any`, which is pointer-bearing — the +/// same condition, and the reason the synthesized anon-shape classes behind +/// object literals do not qualify (their inferred field types are `Any`). +#[test] +fn an_untyped_field_refuses_the_declaration() { + let ir = compile_ir(&module_with_new( + class( + "Untyped", + vec![field("v", Type::Number), field("other", Type::Any)], + Some(ctor( + vec![param(10, "v", Type::Number), param(11, "other", Type::Any)], + vec![ + assign_field_from_param("v", 10), + assign_field_from_param("other", 11), + ], + )), + ), + 2, + )); + assert!( + !ir.contains(DECLARE_CALL), + "`Any` is pointer-bearing:\n{ir}" + ); +} + +/// Negative: with no raw-f64 field there is nothing to unlock, so the extra +/// call would be pure cost. +#[test] +fn a_class_with_no_number_field_refuses_the_declaration() { + let ir = compile_ir(&module_with_new( + class( + "Flags", + vec![field("a", Type::Boolean), field("b", Type::Boolean)], + Some(ctor( + vec![param(10, "a", Type::Boolean), param(11, "b", Type::Boolean)], + vec![ + assign_field_from_param("a", 10), + assign_field_from_param("b", 11), + ], + )), + ), + 2, + )); + assert!(!ir.contains(DECLARE_CALL), "nothing to declare:\n{ir}"); +} + +/// Negative: a class with no constructor at all has no prologue, so nothing is +/// proven about when its fields are written. +#[test] +fn a_class_with_no_constructor_refuses_the_declaration() { + let ir = compile_ir(&module_with_new( + class("NoCtor", vec![field("v", Type::Number)], None), + 0, + )); + assert!(!ir.contains(DECLARE_CALL), "no prologue, no proof:\n{ir}"); +} diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index d9ef314466..c6be71b59c 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -935,11 +935,43 @@ pub extern "C" fn js_gc_note_slot_layout_aware( layout_note_slot(parent_user, slot_index as usize, value_bits); } +/// How `init_typed_shape_layout` establishes the descriptor's truth. +#[derive(Clone, Copy, PartialEq, Eq)] +enum TypedShapeProof { + /// **Observe.** The object's fields already hold their final values, so + /// check every one against the declared masks and refuse the descriptor if + /// any disagrees. This is the post-constructor call site. + ValidateSlots, + /// **Construct.** The object was allocated moments ago and its slots are + /// still the allocator's `TAG_UNDEFINED` fill, so there is nothing to + /// observe yet — validating would reject every raw-f64 slot (`undefined` + /// carries a `0x7FFC` tag, inside `layout_raw_f64_bits`' reject range) and + /// downgrade the object it was asked to describe. + /// + /// The caller carries the proof instead, and it is a codegen one: #7510 + /// emits this form only for a class whose constructor prologue provably + /// assigns **every** raw-f64 field from a plain parameter before any other + /// statement runs (`lower_call::field_init`'s #7486 predicate), so no read + /// can observe a raw-f64 slot between here and its first write. + /// + /// The *collector's* half needs no proof at all: `TAG_UNDEFINED` is a + /// non-pointer in every slot, which is consistent with both the + /// `POINTER_FREE` and the `SIDE_MASK` state this installs. + /// + /// And the descriptor stays honest afterwards without the loop: a store + /// that contradicts it is rejected by the guard's `is_plain_number_bits` / + /// the inline path's finite-exponent test, falls back to the boxed setter, + /// and downgrades through [`layout_note_slot`] exactly as a post-install + /// contradiction always has. + FreshlyAllocated, +} + unsafe fn init_typed_shape_layout( user_ptr: usize, slot_count: usize, raw_f64_words: &[u64], pointer_words: &[u64], + proof: TypedShapeProof, ) { let Some(header) = layout_header_for_user(user_ptr) else { return; @@ -965,7 +997,7 @@ unsafe fn init_typed_shape_layout( return; } - if slot_count != 0 { + if slot_count != 0 && proof == TypedShapeProof::ValidateSlots { let fields = (obj_header as *const u8) .add(std::mem::size_of::()) as *const u64; @@ -1054,14 +1086,15 @@ unsafe fn init_typed_shape_layout( } } -#[no_mangle] -pub extern "C" fn js_gc_init_typed_shape_layout( +#[inline] +fn typed_shape_layout_entry( obj: u64, slot_count: u32, raw_f64_mask_words: *const u64, raw_f64_mask_word_count: u32, pointer_mask_words: *const u64, pointer_mask_word_count: u32, + proof: TypedShapeProof, ) { let user_ptr = strip_nanbox_user_ptr(obj); let slot_count = slot_count as usize; @@ -1080,10 +1113,68 @@ pub extern "C" fn js_gc_init_typed_shape_layout( } else { std::slice::from_raw_parts(pointer_mask_words, pointer_mask_word_count as usize) }; - init_typed_shape_layout(user_ptr, slot_count, raw_words, pointer_words); + init_typed_shape_layout(user_ptr, slot_count, raw_words, pointer_words, proof); } } +/// Register a constructed instance's canonical layout **after** its fields hold +/// their final values. Validates every slot before promoting. +#[no_mangle] +pub extern "C" fn js_gc_init_typed_shape_layout( + obj: u64, + slot_count: u32, + raw_f64_mask_words: *const u64, + raw_f64_mask_word_count: u32, + pointer_mask_words: *const u64, + pointer_mask_word_count: u32, +) { + typed_shape_layout_entry( + obj, + slot_count, + raw_f64_mask_words, + raw_f64_mask_word_count, + pointer_mask_words, + pointer_mask_word_count, + TypedShapeProof::ValidateSlots, + ); +} + +/// #7510: register a **freshly allocated** instance's canonical layout, before +/// its constructor runs, so the constructor's own field stores can pass the +/// `GC_OBJ_TYPED_LAYOUT_INTACT` guard. +/// +/// `js_gc_init_typed_shape_layout` cannot be moved earlier: it validates that +/// each raw-f64 slot already holds a plain double, and a fresh slot holds +/// `TAG_UNDEFINED`, so an early call downgrades every instance it touches. That +/// is why a declared-`number` class field was *slower* than the equivalent +/// object literal (#7512) — the descriptor arrived after the only stores that +/// wanted it, so every one fell back to `js_put_value_set`. +/// +/// This form carries the proof on the codegen side instead; see +/// [`TypedShapeProof::FreshlyAllocated`] for what it rests on and why the +/// collector's half is unconditional. **Callers must invoke it only on an +/// instance whose slots are still the allocator's fill** — the whole contract +/// is "nothing has been written yet", and it is not checkable from here. +#[no_mangle] +pub extern "C" fn js_gc_declare_typed_shape_layout( + obj: u64, + slot_count: u32, + raw_f64_mask_words: *const u64, + raw_f64_mask_word_count: u32, + pointer_mask_words: *const u64, + pointer_mask_word_count: u32, +) { + typed_shape_layout_entry( + obj, + slot_count, + raw_f64_mask_words, + raw_f64_mask_word_count, + pointer_mask_words, + pointer_mask_word_count, + TypedShapeProof::FreshlyAllocated, + ); +} + pub(super) unsafe fn layout_rebuild_from_slots_with_policy( user_ptr: *mut u8, slots: *const u64, diff --git a/crates/perry-runtime/src/gc/tests/layout_trace.rs b/crates/perry-runtime/src/gc/tests/layout_trace.rs index f6e0310f81..d65bf5a1bd 100644 --- a/crates/perry-runtime/src/gc/tests/layout_trace.rs +++ b/crates/perry-runtime/src/gc/tests/layout_trace.rs @@ -1,6 +1,7 @@ use super::super::*; use super::support::*; mod array_layout; +mod declared_at_allocation; mod element_shape; mod object_closure_slots; mod object_layout_invalidation; diff --git a/crates/perry-runtime/src/gc/tests/layout_trace/declared_at_allocation.rs b/crates/perry-runtime/src/gc/tests/layout_trace/declared_at_allocation.rs new file mode 100644 index 0000000000..52348a97cd --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/layout_trace/declared_at_allocation.rs @@ -0,0 +1,192 @@ +//! #7510 item 1: `js_gc_declare_typed_shape_layout` — installing a canonical +//! typed layout on a FRESHLY ALLOCATED instance, before its constructor runs. +//! +//! The runtime half of the change is one thing: this entry point does not +//! validate slot contents. `js_gc_init_typed_shape_layout` does, which is +//! exactly why it cannot be moved earlier — a fresh slot holds `TAG_UNDEFINED` +//! (`0x7FFC`, inside `layout_raw_f64_bits`' reject range), so an early call +//! would downgrade every instance it touched. These tests pin both halves of +//! that claim: the validating form still refuses a fresh object, the declaring +//! form accepts it, and the descriptor the declaring form installs is +//! afterwards maintained — and downgraded — by exactly the same store paths. + +use super::*; + +/// The reason a second entry point had to exist. Left as executable +/// documentation: if `layout_raw_f64_bits` ever starts accepting `undefined`, +/// this test fails and the whole `TypedShapeProof` split becomes removable. +#[test] +fn test_validating_install_refuses_a_freshly_allocated_instance() { + clear_marks(); + clear_mark_seeds(); + + let obj = crate::object::js_object_alloc(0, 2); + let raw_mask = [0b11u64]; + js_gc_init_typed_shape_layout( + obj as u64, + 2, + raw_mask.as_ptr(), + raw_mask.len() as u32, + std::ptr::null(), + 0, + ); + + assert!( + !crate::gc::layout_typed_intact_for_user(obj as usize), + "a fresh instance's slots hold `undefined`, which is not raw-f64 bits — \ + the validating install must refuse it, which is why the constructor's \ + own stores could never see a descriptor (#7512)" + ); + + clear_marks(); + clear_mark_seeds(); +} + +/// The declaring form accepts the same object, and the descriptor it installs +/// is real: the slots read back as raw-f64 through the query the codegen guard +/// consults. +#[test] +fn test_declaring_install_accepts_a_freshly_allocated_instance() { + clear_marks(); + clear_mark_seeds(); + + let obj = crate::object::js_object_alloc(0, 2); + let raw_mask = [0b11u64]; + js_gc_declare_typed_shape_layout( + obj as u64, + 2, + raw_mask.as_ptr(), + raw_mask.len() as u32, + std::ptr::null(), + 0, + ); + + assert!( + crate::gc::layout_typed_intact_for_user(obj as usize), + "the declaring install must set the intact bit the class-field store \ + guard tests" + ); + assert!(crate::gc::layout_typed_raw_f64_slot_for_user( + obj as usize, + 0 + )); + assert!(crate::gc::layout_typed_raw_f64_slot_for_user( + obj as usize, + 1 + )); + // An empty pointer mask means POINTER_FREE — byte-identical to what + // `layout_init_pointer_free` already set at birth, so the collector's view + // is unchanged by the declaration. + assert_eq!(test_layout_pointer_slot_count(obj as usize, 2), Some(0)); + + clear_marks(); + clear_mark_seeds(); +} + +/// The load-bearing follow-through: skipping validation does NOT mean the +/// descriptor is trusted forever. A store that contradicts it must still evict +/// it — otherwise a string written into a slot the collector believes is +/// raw-f64 would never be traced. +#[test] +fn test_a_contradicting_store_downgrades_a_declared_layout() { + clear_marks(); + clear_mark_seeds(); + + let obj = crate::object::js_object_alloc(0, 2); + let raw_mask = [0b11u64]; + js_gc_declare_typed_shape_layout( + obj as u64, + 2, + raw_mask.as_ptr(), + raw_mask.len() as u32, + std::ptr::null(), + 0, + ); + assert!(crate::gc::layout_typed_intact_for_user(obj as usize)); + + let child = crate::string::js_string_from_bytes(b"contradiction".as_ptr(), 13); + let child_header = unsafe { header_from_user_ptr(child as *mut u8) }; + crate::object::js_object_set_field(obj, 0, crate::value::JSValue::string_ptr(child)); + + assert!( + !crate::gc::layout_typed_intact_for_user(obj as usize), + "a string stored into a slot declared raw-f64 must evict the descriptor" + ); + + // And the proof that the eviction is what keeps the collector honest: the + // string is reachable only through that slot, and it must be traced. + let valid_ptrs = build_valid_pointer_set(); + assert!(try_mark_value( + POINTER_TAG | (obj as u64 & POINTER_MASK), + &valid_ptrs + )); + trace_marked_objects(&valid_ptrs); + unsafe { + assert_ne!( + (*child_header).gc_flags & GC_FLAG_MARKED, + 0, + "the downgraded object must be scanned conservatively — a missed \ + trace here is a use-after-free, not a slow path" + ); + } + + clear_marks(); + clear_mark_seeds(); +} + +/// A declaration whose slot count disagrees with the object is still rejected: +/// the mismatch check runs ahead of the proof split, so a mis-derived mask +/// cannot ride in on the declaring path either. +#[test] +fn test_declaring_install_still_rejects_a_slot_count_mismatch() { + clear_marks(); + clear_mark_seeds(); + + let obj = crate::object::js_object_alloc(0, 2); + let raw_mask = [0b111u64]; + js_gc_declare_typed_shape_layout( + obj as u64, + 3, // the object has 2 fields + raw_mask.as_ptr(), + raw_mask.len() as u32, + std::ptr::null(), + 0, + ); + + assert!( + !crate::gc::layout_typed_intact_for_user(obj as usize), + "a slot-count mismatch must land in the conservative state on both \ + entry points" + ); + + clear_marks(); + clear_mark_seeds(); +} + +/// Raw-f64 and pointer masks that overlap are contradictory on their face; the +/// declaring path must reject them without needing to look at any slot. +#[test] +fn test_declaring_install_rejects_overlapping_masks() { + clear_marks(); + clear_mark_seeds(); + + let obj = crate::object::js_object_alloc(0, 2); + let raw_mask = [0b11u64]; + let pointer_mask = [0b10u64]; + js_gc_declare_typed_shape_layout( + obj as u64, + 2, + raw_mask.as_ptr(), + raw_mask.len() as u32, + pointer_mask.as_ptr(), + pointer_mask.len() as u32, + ); + + assert!( + !crate::gc::layout_typed_intact_for_user(obj as usize), + "a slot cannot be both raw-f64 and pointer-bearing" + ); + + clear_marks(); + clear_mark_seeds(); +}