From ab1c8b81cd697c38f63b584e03f6daf6a5f963eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 09:59:00 +0200 Subject: [PATCH] perf(runtime): reuse stable one-key for-in snapshots Lands #8709. Routes compiled `ForInKeys` through `js_for_in_keys_stable_value` and reuses the immutable shape-owned key snapshot when the receiver, key, descriptors and %Object.prototype% generation prove the result exact, keeping the complete generic enumerator for every proof miss. The reuse guard was checked rather than assumed. `PrototypeSignature` carries a raw `prototype_addr`, which is the shape that went stale in #8393 -- but this is not that shape. The signature is recomputed live on every call from `object_prototype_addr()`, with `try_read_gc_header` validation and an explicit `GC_FLAG_FORWARDED` rejection, and the cached verdict is consulted only when the freshly-read signature compares equal in all three fields. The cached address is never dereferenced, so a moved prototype produces a mismatch and a cold recompute rather than a false hit. Descriptor, key and prototype mutations mint a new ShapeId, and class-level changes move `vtable_generation`. `PROTOTYPE_VERDICT` is a new rule-T holder, pinned on the inventory frontier alongside the other identity-ratcheted thread-locals. No version bump. --- .../fixtures/for_in_stable_keys.ts | 16 ++ benchmarks/compiler_output/workloads.toml | 52 ++++ changelog.d/8709-for-in-stable-keys.md | 6 + .../src/expr/logical_collections.rs | 7 +- .../src/runtime_decls/strings.rs | 1 + .../perry-runtime/src/object/field_get_set.rs | 2 + .../src/object/field_get_set/for_in_stable.rs | 247 ++++++++++++++++++ .../perry/tests/issue_8694_stable_for_in.rs | 200 ++++++++++++++ scripts/gc_runtime_root_holders.json | 4 + 9 files changed, 532 insertions(+), 3 deletions(-) create mode 100644 benchmarks/compiler_output/fixtures/for_in_stable_keys.ts create mode 100644 changelog.d/8709-for-in-stable-keys.md create mode 100644 crates/perry-runtime/src/object/field_get_set/for_in_stable.rs create mode 100644 crates/perry/tests/issue_8694_stable_for_in.rs diff --git a/benchmarks/compiler_output/fixtures/for_in_stable_keys.ts b/benchmarks/compiler_output/fixtures/for_in_stable_keys.ts new file mode 100644 index 0000000000..c13d092b65 --- /dev/null +++ b/benchmarks/compiler_output/fixtures/for_in_stable_keys.ts @@ -0,0 +1,16 @@ +// #8694: stable monomorphic registry enumeration must lower through the +// guarded, allocation-free helper rather than materializing generic key lists +// at every call. Keep this intentionally close to perform-ecs' one-key +// ComponentGroupRegistry hot path. +const groups: any = {}; +groups[3] = 1; + +function sumRegistry(): number { + let total = 0; + for (const groupHash in groups) total += groups[groupHash]; + return total; +} + +let checksum = 0; +for (let i = 0; i < 200_000; i++) checksum += sumRegistry(); +console.log(`for_in_stable_keys:${checksum}`); diff --git a/benchmarks/compiler_output/workloads.toml b/benchmarks/compiler_output/workloads.toml index 763813cc81..349b82acf2 100644 --- a/benchmarks/compiler_output/workloads.toml +++ b/benchmarks/compiler_output/workloads.toml @@ -2062,3 +2062,55 @@ allow_materialization_reasons = [ "unknown_bounds", "dynamic_escape", ] + +[workloads.for_in_stable_keys] +source = "benchmarks/compiler_output/fixtures/for_in_stable_keys.ts" +kind = "for_in_stable_keys" +allow_dynamic_property_runtime = true +allow_hot_loop_conversions = true +allowed_hot_loop_runtime_calls = [ + "js_array_get", + "js_array_length", + "js_dyn_index_get", + "js_dynamic_string_or_number_add", + "js_for_in_keys_stable_value", + "js_gc_loop_safepoint", + "js_in_operator", +] + +[workloads.for_in_stable_keys.vectorization] +min_vectorized_loops = 0 +scalar_baseline = "allowed: dynamic for-in semantics require guarded runtime enumeration" +allowed_missed_reason_kinds = [ + "call_instruction", + "control_flow", + "generic_not_vectorized", + "not_beneficial", + "uncountable_loop", + "unknown_trip_count", + "unsupported_instruction", + "unsupported_reduction", +] + +[[workloads.for_in_stable_keys.stdout_checks]] +name = "for_in_stable_keys_checksum" +equals = "for_in_stable_keys:200000\n" +detail = "the stable registry fixture retains exact for-in semantics" + +[[workloads.for_in_stable_keys.ir_checks]] +name = "stable_registry_uses_guarded_for_in_helper" +section = "llvm_before" +function_contains = "sumRegistry" +contains = "call i64 @js_for_in_keys_stable_value" +detail = "the registry loop enters the guarded stable-key helper" + +[[workloads.for_in_stable_keys.ir_checks]] +name = "stable_registry_avoids_generic_enumeration_helpers" +section = "llvm_before" +function_contains = "sumRegistry" +regex_none = [ + "call i64 @js_for_in_keys_value", + "call i64 @js_object_keys", + "call i64 @js_object_get_own_property_names", +] +detail = "the stable arm does not directly allocate or rebuild generic key lists" diff --git a/changelog.d/8709-for-in-stable-keys.md b/changelog.d/8709-for-in-stable-keys.md new file mode 100644 index 0000000000..8456a0d073 --- /dev/null +++ b/changelog.d/8709-for-in-stable-keys.md @@ -0,0 +1,6 @@ +--- +category: Performance +title: Reuse stable one-key for-in snapshots +--- + +Compiled `for...in` loops now reuse an ordinary object's immutable one-key shape snapshot when its own descriptors and prototype chain are stable. Other receivers keep the complete generic enumerator, including inherited keys, mutations, and Proxy behavior. diff --git a/crates/perry-codegen/src/expr/logical_collections.rs b/crates/perry-codegen/src/expr/logical_collections.rs index 5a278fb05d..1ba49da27a 100644 --- a/crates/perry-codegen/src/expr/logical_collections.rs +++ b/crates/perry-codegen/src/expr/logical_collections.rs @@ -649,12 +649,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } // -------- for (key in obj) enumeration keys -> string[] -------- - // Like ObjectKeys but nullish-safe (no throw) and walks the prototype - // chain for inherited enumerable keys. Backs the for-in desugar. + // The guarded runtime entry reuses a stable one-key shape's immutable + // key array without allocation, then falls back to the complete + // nullish/prototype-aware enumerator for every other receiver. Expr::ForInKeys(obj) => { let obj_box = lower_expr(ctx, obj)?; let blk = ctx.block(); - let arr_handle = blk.call(I64, "js_for_in_keys_value", &[(DOUBLE, &obj_box)]); + let arr_handle = blk.call(I64, "js_for_in_keys_stable_value", &[(DOUBLE, &obj_box)]); Ok(nanbox_pointer_inline(blk, &arr_handle)) } diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index e9367bd00d..6653757bc3 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -401,6 +401,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_object_keys", I64, &[I64]); module.declare_function("js_object_keys_value", I64, &[DOUBLE]); module.declare_function("js_for_in_keys_value", I64, &[DOUBLE]); + module.declare_function("js_for_in_keys_stable_value", I64, &[DOUBLE]); module.declare_function("js_is_finite", DOUBLE, &[DOUBLE]); module.declare_function("js_is_undefined_or_bare_nan", I32, &[DOUBLE]); module.declare_function("js_math_min_array", DOUBLE, &[I64]); diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 9e77c94d52..64ae285ea0 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -202,6 +202,7 @@ mod class_object_props; mod crypto_key; pub(crate) mod enumeration; mod field_ops; +mod for_in_stable; mod get_field_by_name; #[cfg(test)] mod get_field_by_name_probe_tests; @@ -266,6 +267,7 @@ pub use field_ops::{ js_object_set_field_by_index, js_object_set_field_f64, js_object_set_keys, js_object_to_value, js_value_to_object, }; +pub use for_in_stable::js_for_in_keys_stable_value; pub use get_field_by_name::js_object_get_field_by_name; pub(crate) use get_field_by_name_tail::get_field_by_name_object_tail; pub(super) use has_property::native_module_own_field_by_key; diff --git a/crates/perry-runtime/src/object/field_get_set/for_in_stable.rs b/crates/perry-runtime/src/object/field_get_set/for_in_stable.rs new file mode 100644 index 0000000000..644a574e12 --- /dev/null +++ b/crates/perry-runtime/src/object/field_get_set/for_in_stable.rs @@ -0,0 +1,247 @@ +//! Allocation-free stable-shape arm for compiled `for...in` loops (#8694). + +use super::*; + +/// The semantic identity of the canonical `%Object.prototype%` chain used by +/// the one-key proof. Addresses are comparison tokens only, never +/// dereferenced from the cache, so this record is not a GC root. A moving +/// collection re-derives a different live address and causes a conservative +/// miss; a descriptor, key, or prototype mutation mints a new ShapeId. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct PrototypeSignature { + prototype_addr: usize, + shape_id: u32, + vtable_generation: u64, +} + +#[derive(Clone, Copy)] +struct PrototypeVerdict { + signature: PrototypeSignature, + no_enumerable_chain_keys: bool, +} + +crate::perry_thread_local! { + static PROTOTYPE_VERDICT: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +fn for_in_diag_enabled() -> bool { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ENABLED.get_or_init(|| crate::gc::env_flag_enabled("PERRY_FOR_IN_DIAG")) +} + +fn stable_miss(reason: &'static str) -> Option { + if for_in_diag_enabled() { + static REPORTED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + if REPORTED.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < 8 { + eprintln!("FOR-IN-DIAG miss={reason}"); + } + } + None +} + +unsafe fn prototype_signature(prototype_addr: usize) -> Option { + let header = crate::value::addr_class::try_read_gc_header(prototype_addr)?; + if header.obj_type != crate::gc::GC_TYPE_OBJECT + || header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + { + return None; + } + let prototype = prototype_addr as *const ObjectHeader; + let shape_id = super::super::shapes::object_shape_id(prototype); + if shape_id == 0 { + return None; + } + Some(PrototypeSignature { + prototype_addr, + shape_id, + vtable_generation: super::super::class_registry::vtable_generation(), + }) +} + +/// Whether the ordinary prototype contributes no enumerable key and itself +/// has no prototype. The cold recompute walks its authoritative shape keys +/// and descriptor metadata without allocating in Perry's heap; the hot path +/// compares only the prototype's immutable semantic ShapeId. +fn canonical_prototype_has_no_enumerable_chain_keys(receiver_addr: usize) -> bool { + let prototype_addr = crate::array::object_prototype_addr(); + if prototype_addr == 0 + || prototype_addr == receiver_addr + || super::super::prototype_chain::object_static_prototype(prototype_addr).is_some() + { + return false; + } + let Some(now) = (unsafe { prototype_signature(prototype_addr) }) else { + return false; + }; + if let Some(verdict) = PROTOTYPE_VERDICT.with(|cell| cell.get()) { + if verdict.signature == now { + return verdict.no_enumerable_chain_keys; + } + } + + // Cold once per prototype generation. Built-in Object.prototype methods + // are physical but non-enumerable, so testing raw key-count would make the + // optimization permanently vacuous. Do not call `js_object_keys` here: + // that allocates and could move the unrooted receiver passed to this native + // helper before its shape pointer is returned. + let prototype = prototype_addr as *const ObjectHeader; + let keys = unsafe { crate::object::object_keys_array(prototype) }; + let key_count = if keys.is_null() { + 0 + } else { + crate::array::js_array_length(keys) + }; + let mut no_enumerable_chain_keys = true; + for index in 0..key_count { + let key = crate::array::js_array_get(keys, index); + if !unsafe { super::enumeration::descriptor_marks_non_enumerable(prototype, key) } { + no_enumerable_chain_keys = false; + break; + } + } + if no_enumerable_chain_keys { + no_enumerable_chain_keys = !super::super::accessor_descriptor_keys_for_obj(prototype_addr) + .iter() + .any(|key| { + super::super::get_property_attrs(prototype_addr, key) + .is_some_and(|attrs| attrs.enumerable()) + }); + } + let after_addr = crate::array::object_prototype_addr(); + let after = unsafe { prototype_signature(after_addr) }; + if after == Some(now) + && super::super::prototype_chain::object_static_prototype(after_addr).is_none() + { + PROTOTYPE_VERDICT.with(|cell| { + cell.set(Some(PrototypeVerdict { + signature: now, + no_enumerable_chain_keys, + })) + }); + } + no_enumerable_chain_keys +} + +/// Return the receiver's immutable shape-owned one-key snapshot when every +/// JavaScript enumeration input is stable and exact. +fn stable_single_own_for_in_keys(value: f64) -> Option<*mut ArrayHeader> { + let receiver = JSValue::from_bits(value.to_bits()); + if !receiver.is_pointer() { + return stable_miss("non_pointer"); + } + let receiver_addr = (receiver.bits() & crate::value::POINTER_MASK) as usize; + if !crate::value::addr_class::is_above_handle_band(receiver_addr) { + return stable_miss("handle_band"); + } + let Some(receiver_gc) = + (unsafe { crate::value::addr_class::try_read_gc_header(receiver_addr) }) + else { + return stable_miss("invalid_header"); + }; + if receiver_gc.obj_type != crate::gc::GC_TYPE_OBJECT + || receiver_gc.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + || receiver_gc._reserved & crate::gc::OBJ_FLAG_NULL_PROTO != 0 + { + return stable_miss("receiver_kind"); + } + + let object = receiver_addr as *const ObjectHeader; + let descriptor = unsafe { + let class_id = (*object).class_id; + if class_id != 0 && !super::super::is_anon_shape_class_id(class_id) { + return stable_miss("receiver_class"); + } + if !(*object).meta.is_null() { + return stable_miss("receiver_meta"); + } + if !object_is_regular(object) { + return stable_miss("receiver_shape_kind"); + } + if super::super::prototype_chain::object_static_prototype(receiver_addr).is_some() { + return stable_miss("receiver_custom_prototype"); + } + let Some(descriptor) = super::super::shapes::object_shape_descriptor(object) else { + return stable_miss("missing_shape"); + }; + descriptor + }; + if descriptor.logical_key_count != 1 || descriptor.keys == 0 { + return stable_miss("key_count"); + } + + let keys = descriptor.keys as usize as *mut ArrayHeader; + let Some(keys_gc) = (unsafe { crate::value::addr_class::try_read_gc_header(keys as usize) }) + else { + return stable_miss("invalid_keys_header"); + }; + if keys_gc.obj_type != crate::gc::GC_TYPE_ARRAY + || keys_gc.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + || unsafe { crate::array::keys_array_len_capped_to_capacity(keys) } != 1 + { + return stable_miss("keys_array"); + } + + // Ordinary literal shapes normally cannot carry Perry-private fields, but + // bind the proof to the actual observable key. This also excludes virtual + // WASI state without an address-keyed registry probe on every invocation. + let key = crate::array::js_array_get(keys, 0); + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let Some(key_bytes) = (unsafe { crate::string::js_string_key_bytes(key, &mut scratch) }) else { + return stable_miss("non_string_key"); + }; + if unsafe { super::enumeration::descriptor_marks_non_enumerable(object, key) } { + return stable_miss("non_enumerable_key"); + } + if super::enumeration::is_internal_runtime_key_bytes(key_bytes) + || key_bytes.starts_with(b"__wasi") + { + return stable_miss("internal_key"); + } + if !canonical_prototype_has_no_enumerable_chain_keys(receiver_addr) { + return stable_miss("object_prototype"); + } + + // Freeze this key-list version as the loop snapshot. Any body addition + // now forks the receiver to a successor key array rather than growing the + // active snapshot in place; deletion is filtered by the HIR's per-key `in` + // recheck. + unsafe { + let keys_gc = + (keys as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + (*keys_gc).gc_flags |= crate::gc::GC_FLAG_SHAPE_SHARED; + } + Some(keys) +} + +fn note_for_in_stable_path(hit: bool) { + if !for_in_diag_enabled() { + return; + } + static CHECKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + static HITS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let checks = CHECKS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + let hits = HITS.fetch_add(hit as u64, std::sync::atomic::Ordering::Relaxed) + hit as u64; + let fallbacks = checks - hits; + if checks <= 8 || (hit && hits == 1) || (!hit && fallbacks == 1) || checks % 100_000 == 0 { + eprintln!( + "FOR-IN-DIAG checks={} stable_single={} fallback={}", + checks, hits, fallbacks + ); + } +} + +/// Guarded entry point used by compiled `for...in` loops. +/// +/// The generated program names this helper rather than the generic helper so +/// retained LLVM makes the optimization selection auditable. Every proof +/// miss reaches [`super::enumeration::js_for_in_keys_value`]. +#[no_mangle] +pub extern "C" fn js_for_in_keys_stable_value(value: f64) -> *mut ArrayHeader { + if let Some(keys) = stable_single_own_for_in_keys(value) { + note_for_in_stable_path(true); + return keys; + } + note_for_in_stable_path(false); + super::enumeration::js_for_in_keys_value(value) +} diff --git a/crates/perry/tests/issue_8694_stable_for_in.rs b/crates/perry/tests/issue_8694_stable_for_in.rs new file mode 100644 index 0000000000..ccdcf06cb1 --- /dev/null +++ b/crates/perry/tests/issue_8694_stable_for_in.rs @@ -0,0 +1,200 @@ +//! Semantic and observability ratchet for #8694's stable one-key `for...in` +//! snapshot. The executable exercises the allocation-free arm first, then +//! cases that must remain on the complete generic enumerator. It is run again +//! with forced evacuation so the returned shape-owned key array is proven to +//! stay valid across moving collections. + +use std::path::PathBuf; +use std::process::{Command, Output}; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn run_fixture(binary: &std::path::Path, force_evacuation: bool) -> Output { + let mut command = Command::new(binary); + command.env("PERRY_FOR_IN_DIAG", "1"); + if force_evacuation { + command.env("PERRY_GC_FORCE_EVACUATE", "1"); + } else { + command.env_remove("PERRY_GC_FORCE_EVACUATE"); + } + command.output().expect("run compiled #8694 fixture") +} + +fn diagnostic_counts(stderr: &str) -> (u64, u64) { + let mut stable = 0; + let mut fallback = 0; + for line in stderr + .lines() + .filter(|line| line.starts_with("FOR-IN-DIAG ")) + { + for field in line.split_whitespace() { + let Some((name, value)) = field.split_once('=') else { + continue; + }; + match name { + "stable_single" => stable = stable.max(value.parse().unwrap_or(0)), + "fallback" => fallback = fallback.max(value.parse().unwrap_or(0)), + _ => {} + } + } + } + (stable, fallback) +} + +#[test] +fn stable_one_key_for_in_reuses_shape_snapshot_with_generic_fallback() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let binary = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#" +const groups: any = {}; +groups[3] = []; + +function pushEntity(entity: any) { + for (const groupHash in groups) groups[groupHash].push(entity); +} +function removeEntity(entity: any) { + for (const groupHash in groups) { + const entities = groups[groupHash]; + const index = entities.indexOf(entity); + if (index !== -1) entities.splice(index, 1); + } +} + +for (let i = 0; i < 2000; i++) { + const entity = { id: i }; + pushEntity(entity); + removeEntity(entity); +} +console.log("stable:", groups[3].length); + +// The fast path returns the receiver's shape-owned key array. Adding a key +// in the body must fork it: additions after enumeration starts are not part of +// this snapshot, but the next invocation sees the successor shape. +const mutating: any = { only: 1 }; +const first: string[] = []; +for (const key in mutating) { + first.push(key); + mutating.added = 2; + const gc = (globalThis as any).gc; + if (gc) gc(); +} +const second: string[] = []; +for (const key in mutating) second.push(key); +console.log("snapshot:", first.join(","), "next:", second.join(",")); + +// Multi-key ordering, inherited enumerables, non-enumerable shadowing, and a +// deletion before visitation all require and exercise the generic fallback. +const proto: any = { inherited: 1, shadowed: 2 }; +Object.defineProperty(proto, "hidden", { value: 3, enumerable: false }); +const ordered: any = Object.create(proto); +ordered[10] = 10; +ordered[2] = 2; +ordered.word = 1; +Object.defineProperty(ordered, "shadowed", { value: 4, enumerable: false }); +const orderedKeys: string[] = []; +for (const key in ordered) orderedKeys.push(key); +console.log("ordered:", orderedKeys.join(",")); + +// Even a one-key receiver must fall back when it has a custom prototype. +const customOne: any = Object.create({ base: 1 }); +customOne.own = 2; +const customOneKeys: string[] = []; +for (const key in customOne) customOneKeys.push(key); +console.log("custom-one:", customOneKeys.join(",")); + +// The cached Object.prototype verdict is shape-generation keyed. Installing +// an enumerable property after the hot arm was warmed must invalidate it. +(Object.prototype as any).lateEnumerable = 3; +const latePrototypeKeys: string[] = []; +for (const key in { own: 1 }) latePrototypeKeys.push(key); +delete (Object.prototype as any).lateEnumerable; +console.log("late-prototype:", latePrototypeKeys.join(",")); + +const deleting: any = { a: 1, b: 2 }; +const deletionKeys: string[] = []; +for (const key in deleting) { + deletionKeys.push(key); + if (key === "a") { + delete deleting.b; + deleting.c = 3; + } +} +console.log("mutation:", deletionKeys.join(",")); + +const proxyKeys: string[] = []; +const proxy = new Proxy({ target: 1 }, { + ownKeys() { return ["target"]; }, + getOwnPropertyDescriptor(_target: any, key: string) { + if (key === "target") return { enumerable: true, configurable: true }; + return undefined; + } +}); +for (const key in proxy) proxyKeys.push(key); +console.log("proxy:", proxyKeys.join(",")); + +let caught = ""; +try { + for (const key in { x: 1, y: 2 }) { + caught = key; + throw new Error("stop"); + } +} catch (_error) { + console.log("exception:", caught); +} +"#, + ) + .expect("write #8694 fixture"); + + let mut compile_command = Command::new(perry_bin()); + compile_command + .current_dir(dir.path()) + // RS4GC's Windows EH limitation is unrelated to this test's moving-GC + // coverage and rejects the fixture's deliberate try/catch before + // codegen, so select the shadow-root backend explicitly (#7354). + .env("PERRY_RS4GC", "0") + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&binary) + .arg("--no-cache") + .arg("--no-auto-optimize"); + let compile = compile_command.output().expect("compile #8694 fixture"); + assert!( + compile.status.success(), + "compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + const EXPECTED: &str = "stable: 0\n\ +snapshot: only next: only,added\n\ +ordered: 2,10,word,inherited\n\ +custom-one: own,base\n\ +late-prototype: own,lateEnumerable\n\ +mutation: a\n\ +proxy: target\n\ +exception: x\n"; + + for force_evacuation in [false, true] { + let run = run_fixture(&binary, force_evacuation); + let stderr = String::from_utf8_lossy(&run.stderr); + assert!( + run.status.success(), + "fixture failed (force_evacuation={force_evacuation})\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + stderr + ); + assert_eq!(String::from_utf8_lossy(&run.stdout), EXPECTED); + let (stable, fallback) = diagnostic_counts(&stderr); + assert!( + stable > 0 && fallback > 0, + "both the stable arm and generic fallback must be observable; got \ + stable={stable}, fallback={fallback}\nstderr:\n{stderr}" + ); + } +} diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index b143e80c50..7ab5462921 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -790,6 +790,10 @@ "file": "crates/perry-runtime/src/object/field_get_set/field_ops.rs", "name": "WARN_NULL_PTR_STATE" }, + { + "file": "crates/perry-runtime/src/object/field_get_set/for_in_stable.rs", + "name": "PROTOTYPE_VERDICT" + }, { "file": "crates/perry-runtime/src/object/field_get_set/ic_miss/private_member_access.rs", "name": "PRIVATE_MEMBER_ACCESS_HINTS",