diff --git a/changelog.d/6934-dynamic-arith-operand-rooting.md b/changelog.d/6934-dynamic-arith-operand-rooting.md new file mode 100644 index 0000000000..b4a1dd00ae --- /dev/null +++ b/changelog.d/6934-dynamic-arith-operand-rooting.md @@ -0,0 +1,15 @@ +**Root operands across GC-capable coercions in the dynamic operator helpers (#6934, closes #6655)** + +`to_numeric` on an object operand runs a user `Symbol.toPrimitive` / `valueOf` / `toString`, which can allocate, trigger a GC and **evacuate** live objects. The dynamic operator helpers held the *other* operand as a raw NaN-boxed `f64` in a Rust local across exactly that call — neither a GC root nor a codegen shadow slot — so after the first coercion it could name a forwarded address. The mirror hazard hits the *coerced* `a` when it resolves to a BigInt pointer and the **second** operand is the allocating one, so both operand orders are affected. The correct discipline already existed in-tree (`dynamic_bigint_binary_op`; `js_dynamic_ushr` / `throw_mix_bigint` in #6650); this applies it file-wide and to the siblings sharing the shape. + +- **`value/dynamic_arith.rs`** — the `to_numeric(a); to_numeric(b)` prelude in `js_dynamic_{mul,sub,div,mod,pow,shr,shl,bitand,bitor,bitxor}`, factored into one `to_numeric_pair` helper that returns handles and feeds the existing `dynamic_bigint_binary_op_from_handles` without re-rooting. `js_dynamic_ushr` folded onto the same helper so the file has a single mechanism. Also `js_numeric_step` (`++`/`--`), which allocates `1n` via `js_bigint_from_i64` while the incoming BigInt operand sits raw in a local — the old comment there reasoned only about the *new* `one_ptr` surviving and missed that the pre-existing operand is the one at risk. Unaffected and unchanged: `js_dynamic_add`, `js_dynamic_neg`, `js_dynamic_bitnot`, `js_to_numeric`, `js_dynamic_string_or_number_add` (already rooted). +- **`builtins/arithmetic.rs`** — `abstract_relational` (behind `js_rel_{lt,gt,le,ge}`) had the identical prelude and additionally held `px` (frequently a freshly allocated heap string from the `DefaultString` arm) across the second coercion; its `vx`/`vy` snapshots also had `as_bigint_ptr()` payloads dereferenced *after* allocating `string_to_bigint` / `js_number_coerce` steps, so those pointers are now re-derived from handles at the point of use. `js_loose_eq` — not named in the issue, found while reading the file — coerces the object side via `rel_to_primitive` and then recurses with the other, raw operand. +- **`string/concat.rs`** — `js_string_concat_value` / `js_value_concat_string` hold the raw `*const StringHeader` operand across **two** GC-capable operations: `string_storage_alloc` (→ `arena_alloc_gc`) on the fast path and `js_jsvalue_to_string`'s user `toString` on the slow path. `js_string_concat` already roots its arguments, but that is one frame too late. + +Because the fix puts a `RuntimeHandleScope` on every dynamic binary operator, each also gains the plain-double fast path that skips the scope — the same `0x7FF9` tag-band predicate `js_number_coerce` already short-circuits on and that #5525 added to `js_dynamic_string_or_number_add`. For two plain IEEE-754 doubles `ToNumeric` is the identity and there are no pointers to root; the issue explicitly anticipates this escape. A unit test feeds every operator the same numbers as plain doubles and as int32-tagged values — forcing the fast and rooted paths respectively — and requires them to agree, plus a non-finite case pinning NaN/±Inf and `-1 % -1 == -0`. + +**The pre-fix state did not reproduce, and that is recorded rather than papered over.** Reverting only the three runtime files to the merge base (keeping the identical tests) still passed, as did a stronger variant that re-fills the nursery *after* the collection. `PERRY_GC_DIAG=1` confirms the stress arm is not inert (`retained_forwarded_stub_objects=6` — evacuation fires) and shows why it stays latent: `gc/oldgen.rs` deliberately retains a forwarding stub at the old address because "a minor sweep cannot prove a stub unreferenced", so a stale read lands on the stub and silently gets the right answer. Stubs are reclaimed only once outside the recent-block safety window. `PERRY_GC_VERIFY_EVACUATION=1` also cannot catch this class structurally — it checks mutable live *slots*, and a raw operand in a Rust local is not a tracked slot. This lands as soundness hardening, not as a fix for an observed miscompile. + +New `crates/perry/tests/gc_dynamic_arith_operand_rooting_6655.rs`: 4 tests covering every affected operator in **both** operand orders under `PERRY_GC_FORCE_EVACUATE=1` + `PERRY_GC_VERIFY_EVACUATION=1`, with operands kept reachable from a root so they are genuinely evacuated (moved + rewritten) rather than merely swept, and `valueOf` reading an instance *field* so a stale receiver yields a wrong value rather than a coincidentally-correct constant. 4/4 pass; the standalone probe is also clean under default, `GEN_GC=0`, `WRITE_BARRIERS=0` and `FORCE_EVACUATE+VERIFY+GEN_GC=0`. `cargo test -p perry-runtime --lib` is 1478/0 with `--test-threads=1` (the 5 failures in the default parallel run — `gc::tests::teardown::*`, `global_this_webassembly`, `native_module_stream` — pass in isolation and are a pre-existing shared-global parallelism artifact). Gap coverage was **scoped, not full**: 29 gap tests over the touched surfaces are byte-exact vs pinned Node v26.5.0 (pass=29 fail=0 skip=0), but the full 430-file sweep was skipped because the box sat at 13–16 GB free against the 25 GB gate with three GC agents building concurrently. + +The new harness sets `PERRY_EXTRA_LINK_ARGS="-framework CoreFoundation"`: on this host the runtime-only macOS link path omits CoreFoundation while `perry-runtime` pulls `iana_time_zone` (`_CFRelease` & co.), and the pre-existing `gc_side_table_roots_evacuation` test fails identically on an untouched checkout in both `perry-dev` and `release` — likely fallout from #6923, worth its own issue. The larger sibling family (receiver + stored value held across `ToPropertyKey`) is deliberately out of scope and filed separately. diff --git a/crates/perry-runtime/src/builtins/arithmetic.rs b/crates/perry-runtime/src/builtins/arithmetic.rs index a451db9fb3..aaf6103d76 100644 --- a/crates/perry-runtime/src/builtins/arithmetic.rs +++ b/crates/perry-runtime/src/builtins/arithmetic.rs @@ -124,13 +124,30 @@ pub extern "C" fn js_loose_eq(a: JSValue, b: JSValue) -> JSValue { // steps 10-11). Object-vs-object was settled above; symbols are primitives // (`eq_is_object` excludes them) and correctly fall through to not-equal. // Done before the BigInt block so `0n == { valueOf() { return 0n } }` works. + // #6655: `rel_to_primitive` runs a user `valueOf`/`toString`, so it can + // allocate, collect and evacuate. The *other* operand is a raw NaN-boxed + // local here — not a GC root — so it must be rooted across the coercion and + // re-read through its handle before the recursive call, or `==` compares + // against a forwarded address. if eq_is_object(a) { + let scope = crate::gc::RuntimeHandleScope::new(); + let b_handle = scope.root_nanbox_f64(f64::from_bits(b.bits())); let pa = unsafe { rel_to_primitive(f64::from_bits(a.bits())) }; - return js_loose_eq(JSValue::from_bits(pa.to_bits()), b); + let pa_handle = scope.root_nanbox_f64(pa); + return js_loose_eq( + JSValue::from_bits(pa_handle.get_nanbox_u64()), + JSValue::from_bits(b_handle.get_nanbox_u64()), + ); } if eq_is_object(b) { + let scope = crate::gc::RuntimeHandleScope::new(); + let a_handle = scope.root_nanbox_f64(f64::from_bits(a.bits())); let pb = unsafe { rel_to_primitive(f64::from_bits(b.bits())) }; - return js_loose_eq(a, JSValue::from_bits(pb.to_bits())); + let pb_handle = scope.root_nanbox_f64(pb); + return js_loose_eq( + JSValue::from_bits(a_handle.get_nanbox_u64()), + JSValue::from_bits(pb_handle.get_nanbox_u64()), + ); } // BigInt abstract equality (ES2024 §7.2.15). Neither side is // null/undefined here and boxed wrappers (incl. `Object(0n)`) have already @@ -273,22 +290,40 @@ unsafe fn rel_string_compare(a: f64, b: f64) -> i32 { /// runs on the two operands (observable when a `valueOf`/`toString` has side /// effects). Returns [`REL_TRUE`], [`REL_FALSE`], or [`REL_UNDEFINED`]. unsafe fn abstract_relational(x: f64, y: f64, x_first: bool) -> i32 { - let (px, py) = if x_first { - let px = rel_to_primitive(x); - let py = rel_to_primitive(y); + // #6655: `rel_to_primitive` runs a user `Symbol.toPrimitive` / `valueOf` / + // `toString`, which can allocate, trigger a GC and *evacuate* live objects. + // Every raw NaN-boxed `f64` in a local here is invisible to the collector, + // so pre-fix the second operand was held unrooted across the first + // coercion, and `px` — frequently a *freshly allocated* heap string from + // the `DefaultString` arm — was held unrooted across the second. Root both + // inputs before the first coercion and both primitives as they are + // produced, then read every value back through its handle. Same discipline + // as `dynamic_bigint_binary_op` / `js_dynamic_ushr` in `value/dynamic_arith.rs`. + let scope = crate::gc::RuntimeHandleScope::new(); + let x_in = scope.root_nanbox_f64(x); + let y_in = scope.root_nanbox_f64(y); + let (px_handle, py_handle) = if x_first { + let px = scope.root_nanbox_f64(rel_to_primitive(x_in.get_nanbox_f64())); + let py = scope.root_nanbox_f64(rel_to_primitive(y_in.get_nanbox_f64())); (px, py) } else { - let py = rel_to_primitive(y); - let px = rel_to_primitive(x); + let py = scope.root_nanbox_f64(rel_to_primitive(y_in.get_nanbox_f64())); + let px = scope.root_nanbox_f64(rel_to_primitive(x_in.get_nanbox_f64())); (px, py) }; + let px = px_handle.get_nanbox_f64(); + let py = py_handle.get_nanbox_f64(); + // NOTE: `vx` / `vy` are *snapshots*. Tag predicates (`is_any_string`, + // `is_bigint`, …) stay valid across a GC because evacuation preserves the + // tag, but any pointer payload read out of them (`as_bigint_ptr`) must be + // re-derived from the handle at the point of use — see the BigInt arms below. let vx = JSValue::from_bits(px.to_bits()); let vy = JSValue::from_bits(py.to_bits()); // Both String → code-unit (byte) compare; never `undefined`. if vx.is_any_string() && vy.is_any_string() { - return if rel_string_compare(px, py) < 0 { + return if rel_string_compare(px_handle.get_nanbox_f64(), py_handle.get_nanbox_f64()) < 0 { REL_TRUE } else { REL_FALSE @@ -301,11 +336,16 @@ unsafe fn abstract_relational(x: f64, y: f64, x_first: bool) -> i32 { // BigInt vs String / String vs BigInt: parse the string as a BigInt // (StringToBigInt); a non-numeric string makes the comparison `undefined`. if x_big && vy.is_any_string() { - let s = string_content_for_bigint(py); + let s = string_content_for_bigint(py_handle.get_nanbox_f64()); + // `string_to_bigint` allocates the parsed BigInt, so re-derive the `x` + // pointer from its handle *after* that call — the snapshot in `vx` may + // name a forwarded address by now (#6655). return match crate::bigint::string_to_bigint(&s) { None => REL_UNDEFINED, Some(ny) => { - if crate::bigint::js_bigint_cmp(vx.as_bigint_ptr(), ny) < 0 { + let px_ptr = + JSValue::from_bits(px_handle.get_nanbox_f64().to_bits()).as_bigint_ptr(); + if crate::bigint::js_bigint_cmp(px_ptr, ny) < 0 { REL_TRUE } else { REL_FALSE @@ -314,11 +354,13 @@ unsafe fn abstract_relational(x: f64, y: f64, x_first: bool) -> i32 { }; } if vx.is_any_string() && y_big { - let s = string_content_for_bigint(px); + let s = string_content_for_bigint(px_handle.get_nanbox_f64()); return match crate::bigint::string_to_bigint(&s) { None => REL_UNDEFINED, Some(nx) => { - if crate::bigint::js_bigint_cmp(nx, vy.as_bigint_ptr()) < 0 { + let py_ptr = + JSValue::from_bits(py_handle.get_nanbox_f64().to_bits()).as_bigint_ptr(); + if crate::bigint::js_bigint_cmp(nx, py_ptr) < 0 { REL_TRUE } else { REL_FALSE @@ -327,9 +369,13 @@ unsafe fn abstract_relational(x: f64, y: f64, x_first: bool) -> i32 { }; } - // Both BigInt → exact integer compare. + // Both BigInt → exact integer compare. `js_bigint_cmp` does not allocate, + // but re-read both pointers through the handles anyway so this arm stays + // correct if it ever grows an allocating step. if x_big && y_big { - return if crate::bigint::js_bigint_cmp(vx.as_bigint_ptr(), vy.as_bigint_ptr()) < 0 { + let px_ptr = JSValue::from_bits(px_handle.get_nanbox_f64().to_bits()).as_bigint_ptr(); + let py_ptr = JSValue::from_bits(py_handle.get_nanbox_f64().to_bits()).as_bigint_ptr(); + return if crate::bigint::js_bigint_cmp(px_ptr, py_ptr) < 0 { REL_TRUE } else { REL_FALSE @@ -339,17 +385,21 @@ unsafe fn abstract_relational(x: f64, y: f64, x_first: bool) -> i32 { // BigInt vs Number (mixed): exact mathematical compare. `js_number_coerce` // is `ToNumber` and throws on a Symbol operand, as the spec requires. if x_big { - let yn = js_number_coerce(py); - return match crate::bigint::bigint_cmp_f64(vx.as_bigint_ptr(), yn) { + // `js_number_coerce` on a string primitive can allocate; re-derive the + // BigInt pointer from its handle after the coercion (#6655). + let yn = js_number_coerce(py_handle.get_nanbox_f64()); + let px_ptr = JSValue::from_bits(px_handle.get_nanbox_f64().to_bits()).as_bigint_ptr(); + return match crate::bigint::bigint_cmp_f64(px_ptr, yn) { 2 => REL_UNDEFINED, c if c < 0 => REL_TRUE, _ => REL_FALSE, }; } if y_big { - let xn = js_number_coerce(px); + let xn = js_number_coerce(px_handle.get_nanbox_f64()); + let py_ptr = JSValue::from_bits(py_handle.get_nanbox_f64().to_bits()).as_bigint_ptr(); // `bigint_cmp_f64(y, xn)` is the sign of (y − x); x < y ⇔ that is positive. - return match crate::bigint::bigint_cmp_f64(vy.as_bigint_ptr(), xn) { + return match crate::bigint::bigint_cmp_f64(py_ptr, xn) { 2 => REL_UNDEFINED, c if c > 0 => REL_TRUE, _ => REL_FALSE, @@ -357,8 +407,8 @@ unsafe fn abstract_relational(x: f64, y: f64, x_first: bool) -> i32 { } // Both Number (after ToNumber). NaN on either side → undefined. - let xn = js_number_coerce(px); - let yn = js_number_coerce(py); + let xn = js_number_coerce(px_handle.get_nanbox_f64()); + let yn = js_number_coerce(py_handle.get_nanbox_f64()); if xn.is_nan() || yn.is_nan() { return REL_UNDEFINED; } diff --git a/crates/perry-runtime/src/string/concat.rs b/crates/perry-runtime/src/string/concat.rs index 006a0c65a1..c4608f0d15 100644 --- a/crates/perry-runtime/src/string/concat.rs +++ b/crates/perry-runtime/src/string/concat.rs @@ -310,6 +310,17 @@ pub extern "C" fn js_string_concat_value( prefix: *const StringHeader, value: f64, ) -> *mut StringHeader { + // #6655: `prefix` is a raw movable heap pointer held across two different + // GC-capable operations — `string_storage_alloc` on the fast path below, + // and `js_jsvalue_to_string(value)` (an arbitrary user `toString`) on the + // slow path. Neither is a GC root, so an evacuating collection during + // either would leave the subsequent `(*prefix)` reads and `string_data` + // copy pointing at a forwarded address. Root it for the whole body and + // re-read it through the handle after anything that can allocate. + // (`js_string_concat` already roots its own arguments — that is one frame + // too late for this one.) + let scope = crate::gc::RuntimeHandleScope::new(); + let prefix_handle = scope.root_string_ptr(prefix); let prefix_blen = if is_valid_string_ptr(prefix) { unsafe { (*prefix).byte_len } } else { @@ -370,6 +381,10 @@ pub extern "C" fn js_string_concat_value( // Single allocation for prefix + number string let total_blen = prefix_blen as usize + num_len; let (ptr, data_ptr) = string_storage_alloc(total_blen as u32); + // `string_storage_alloc` → `arena_alloc_gc` can collect and evacuate, so + // the incoming `prefix` may have moved. Re-read it from its handle + // before touching the header or copying the payload (#6655). + let prefix = prefix_handle.get_raw_const_ptr::(); unsafe { // Both prefix and number digits are ASCII, so utf16_len == byte_len for the number part @@ -400,9 +415,11 @@ pub extern "C" fn js_string_concat_value( return ptr; } - // Slow path: non-number value — fall back to js_jsvalue_to_string + js_string_concat + // Slow path: non-number value — fall back to js_jsvalue_to_string + js_string_concat. + // `js_jsvalue_to_string` can run a user `toString` and collect, so reload + // `prefix` from its handle afterwards (#6655). let value_str = crate::value::js_jsvalue_to_string(value); - js_string_concat(prefix, value_str) + js_string_concat(prefix_handle.get_raw_const_ptr::(), value_str) } /// N-way string concatenation (v0.5.771). @@ -629,6 +646,11 @@ pub extern "C" fn js_value_concat_string( value: f64, suffix: *const StringHeader, ) -> *mut StringHeader { + // #6655: mirror of `js_string_concat_value` — `suffix` is a raw movable + // heap pointer held across `string_storage_alloc` (fast path) and across + // `js_jsvalue_to_string(value)`'s user `toString` (slow path). + let scope = crate::gc::RuntimeHandleScope::new(); + let suffix_handle = scope.root_string_ptr(suffix); let suffix_blen = if is_valid_string_ptr(suffix) { unsafe { (*suffix).byte_len } } else { @@ -683,6 +705,8 @@ pub extern "C" fn js_value_concat_string( let total_blen = num_len + suffix_blen as usize; let (ptr, data_ptr) = string_storage_alloc(total_blen as u32); + // Re-read after the allocation: it can collect and evacuate (#6655). + let suffix = suffix_handle.get_raw_const_ptr::(); unsafe { let flags = if is_valid_string_ptr(suffix) { @@ -712,8 +736,9 @@ pub extern "C" fn js_value_concat_string( return ptr; } + // Reload `suffix` after the user `toString` (#6655). let value_str = crate::value::js_jsvalue_to_string(value); - js_string_concat(value_str, suffix) + js_string_concat(value_str, suffix_handle.get_raw_const_ptr::()) } /// Fast integer-to-ASCII formatting into a provided buffer. diff --git a/crates/perry-runtime/src/value/dynamic_arith.rs b/crates/perry-runtime/src/value/dynamic_arith.rs index ae502b1732..90cde483be 100644 --- a/crates/perry-runtime/src/value/dynamic_arith.rs +++ b/crates/perry-runtime/src/value/dynamic_arith.rs @@ -356,13 +356,80 @@ fn numify_arith_operand(v: f64) -> f64 { } } +/// True when a NaN-boxed operand is a plain IEEE-754 double — its top 16 bits +/// (sign stripped) sit below the `0x7FF9` Perry tag band, so it is not a +/// string / pointer / bigint / int32 / singleton. +/// +/// For such an operand `ToNumeric` is the identity — [`js_number_coerce`] +/// short-circuits on this exact predicate — and there is no heap pointer to +/// root, so the binary operators below can skip the [`RuntimeHandleScope`] +/// entirely. Canonical-NaN (`0x7FF8`), negative-NaN payloads from real +/// arithmetic, and the infinities all stay on this path. Same predicate and +/// same reasoning as the #5525 fast path in `js_dynamic_string_or_number_add`. +/// +/// [`js_number_coerce`]: crate::builtins::js_number_coerce +/// [`RuntimeHandleScope`]: crate::gc::RuntimeHandleScope +#[inline] +fn is_plain_double(v: f64) -> bool { + const TAG_BAND_FLOOR: u64 = 0x7FF9_0000_0000_0000; + (v.to_bits() & 0x7FFF_0000_0000_0000) < TAG_BAND_FLOOR +} + +/// `ToNumeric` both operands of a dynamic binary operator while keeping each +/// one rooted across the *other's* coercion (#6655). +/// +/// `to_numeric` on an object operand runs a user `Symbol.toPrimitive` / +/// `valueOf` / `toString`, which can allocate, trigger a GC and *evacuate* live +/// objects. A raw NaN-boxed `f64` held in a Rust local is not a GC root and not +/// in a shadow slot, so the pre-fix prelude +/// +/// ```ignore +/// let a = to_numeric(a); +/// let b = to_numeric(b); // raw `b` was held UNROOTED across the line above +/// ``` +/// +/// left `b` — and the freshly coerced `a`, when it is a BigInt pointer — +/// pointing at a forwarded (stale) address. Root both operands *before* the +/// first coercion and read every subsequent value back through its handle. +/// Same discipline as `dynamic_bigint_binary_op` and `js_dynamic_ushr` (#6650). +/// +/// Returns the coerced operands as handles so the caller can hand them +/// straight to [`dynamic_bigint_binary_op_from_handles`] without re-rooting. +#[inline] +unsafe fn to_numeric_pair<'scope>( + scope: &'scope crate::gc::RuntimeHandleScope, + a: f64, + b: f64, +) -> ( + crate::gc::RuntimeHandle<'scope>, + crate::gc::RuntimeHandle<'scope>, +) { + let a_in = scope.root_nanbox_f64(a); + let b_in = scope.root_nanbox_f64(b); + let a_num = to_numeric(a_in.get_nanbox_f64()); + let a_handle = scope.root_nanbox_f64(a_num); + let b_num = to_numeric(b_in.get_nanbox_f64()); + let b_handle = scope.root_nanbox_f64(b_num); + (a_handle, b_handle) +} + /// Dynamic multiply: BigInt * BigInt if either operand is BigInt, else f64 * f64. #[no_mangle] pub unsafe extern "C" fn js_dynamic_mul(a: f64, b: f64) -> f64 { - let a = to_numeric(a); - let b = to_numeric(b); + if is_plain_double(a) && is_plain_double(b) { + return a * b; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let (a_handle, b_handle) = to_numeric_pair(&scope, a, b); + let a = a_handle.get_nanbox_f64(); + let b = b_handle.get_nanbox_f64(); if both_bigint_or_throw(a, b) { - return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_mul); + return dynamic_bigint_binary_op_from_handles( + &scope, + &a_handle, + &b_handle, + crate::bigint::js_bigint_mul, + ); } numify_arith_operand(a) * numify_arith_operand(b) } @@ -399,16 +466,22 @@ pub unsafe extern "C" fn js_to_numeric(value: f64) -> f64 { #[no_mangle] pub unsafe extern "C" fn js_numeric_step(numeric: f64, is_increment: i32) -> f64 { if JSValue::from_bits(numeric.to_bits()).is_bigint() { + // `js_bigint_from_i64` ALLOCATES, so it can trigger a GC that evacuates + // the BigInt `numeric` points at — and `numeric` is a raw NaN-boxed + // local, not a root. Root the incoming operand *before* that allocation + // and read it back through its handle afterwards (#6655). The old + // comment here only reasoned about `one_ptr` surviving, and missed that + // the pre-existing operand is the one at risk. + let scope = crate::gc::RuntimeHandleScope::new(); + let numeric_handle = scope.root_nanbox_f64(numeric); let one_ptr = crate::bigint::js_bigint_from_i64(1); - // `js_nanbox_bigint` is pure and `dynamic_bigint_binary_op` roots both - // operands before any further allocation, so `one_ptr` survives. - let one_val = js_nanbox_bigint(one_ptr as i64); + let one_handle = scope.root_nanbox_f64(js_nanbox_bigint(one_ptr as i64)); let op = if is_increment != 0 { crate::bigint::js_bigint_add } else { crate::bigint::js_bigint_sub }; - dynamic_bigint_binary_op(numeric, one_val, op) + dynamic_bigint_binary_op_from_handles(&scope, &numeric_handle, &one_handle, op) } else if is_increment != 0 { numeric + 1.0 } else { @@ -539,10 +612,20 @@ pub unsafe extern "C" fn js_dynamic_string_or_number_add(a: f64, b: f64) -> f64 /// Dynamic subtract: BigInt - BigInt if either operand is BigInt, else f64 - f64. #[no_mangle] pub unsafe extern "C" fn js_dynamic_sub(a: f64, b: f64) -> f64 { - let a = to_numeric(a); - let b = to_numeric(b); + if is_plain_double(a) && is_plain_double(b) { + return a - b; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let (a_handle, b_handle) = to_numeric_pair(&scope, a, b); + let a = a_handle.get_nanbox_f64(); + let b = b_handle.get_nanbox_f64(); if both_bigint_or_throw(a, b) { - return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_sub); + return dynamic_bigint_binary_op_from_handles( + &scope, + &a_handle, + &b_handle, + crate::bigint::js_bigint_sub, + ); } numify_arith_operand(a) - numify_arith_operand(b) } @@ -550,10 +633,20 @@ pub unsafe extern "C" fn js_dynamic_sub(a: f64, b: f64) -> f64 { /// Dynamic divide: BigInt / BigInt if either operand is BigInt, else f64 / f64. #[no_mangle] pub unsafe extern "C" fn js_dynamic_div(a: f64, b: f64) -> f64 { - let a = to_numeric(a); - let b = to_numeric(b); + if is_plain_double(a) && is_plain_double(b) { + return a / b; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let (a_handle, b_handle) = to_numeric_pair(&scope, a, b); + let a = a_handle.get_nanbox_f64(); + let b = b_handle.get_nanbox_f64(); if both_bigint_or_throw(a, b) { - return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_div); + return dynamic_bigint_binary_op_from_handles( + &scope, + &a_handle, + &b_handle, + crate::bigint::js_bigint_div, + ); } numify_arith_operand(a) / numify_arith_operand(b) } @@ -561,10 +654,20 @@ pub unsafe extern "C" fn js_dynamic_div(a: f64, b: f64) -> f64 { /// Dynamic modulo: BigInt % BigInt if either operand is BigInt, else f64 % f64. #[no_mangle] pub unsafe extern "C" fn js_dynamic_mod(a: f64, b: f64) -> f64 { - let a = to_numeric(a); - let b = to_numeric(b); + if is_plain_double(a) && is_plain_double(b) { + return a % b; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let (a_handle, b_handle) = to_numeric_pair(&scope, a, b); + let a = a_handle.get_nanbox_f64(); + let b = b_handle.get_nanbox_f64(); if both_bigint_or_throw(a, b) { - return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_mod); + return dynamic_bigint_binary_op_from_handles( + &scope, + &a_handle, + &b_handle, + crate::bigint::js_bigint_mod, + ); } let a = numify_arith_operand(a); let b = numify_arith_operand(b); @@ -640,10 +743,20 @@ fn dyn_to_uint32(v: f64) -> u32 { /// Dynamic right shift: BigInt >> if either operand is BigInt, else i32 >> for numbers. #[no_mangle] pub unsafe extern "C" fn js_dynamic_shr(a: f64, b: f64) -> f64 { - let a = to_numeric(a); - let b = to_numeric(b); + if is_plain_double(a) && is_plain_double(b) { + return (dyn_to_int32(a) >> (dyn_to_uint32(b) & 0x1f)) as f64; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let (a_handle, b_handle) = to_numeric_pair(&scope, a, b); + let a = a_handle.get_nanbox_f64(); + let b = b_handle.get_nanbox_f64(); if both_bigint_or_throw(a, b) { - return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_shr); + return dynamic_bigint_binary_op_from_handles( + &scope, + &a_handle, + &b_handle, + crate::bigint::js_bigint_shr, + ); } // JS ToInt32(a); ToUint32(b) & 0x1F for the shift count (#6079). let ai = dyn_to_int32(a); @@ -654,10 +767,20 @@ pub unsafe extern "C" fn js_dynamic_shr(a: f64, b: f64) -> f64 { /// Dynamic left shift: BigInt << if either operand is BigInt, else i32 << for numbers. #[no_mangle] pub unsafe extern "C" fn js_dynamic_shl(a: f64, b: f64) -> f64 { - let a = to_numeric(a); - let b = to_numeric(b); + if is_plain_double(a) && is_plain_double(b) { + return (dyn_to_int32(a) << (dyn_to_uint32(b) & 0x1f)) as f64; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let (a_handle, b_handle) = to_numeric_pair(&scope, a, b); + let a = a_handle.get_nanbox_f64(); + let b = b_handle.get_nanbox_f64(); if both_bigint_or_throw(a, b) { - return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_shl); + return dynamic_bigint_binary_op_from_handles( + &scope, + &a_handle, + &b_handle, + crate::bigint::js_bigint_shl, + ); } // JS ToInt32(a); ToUint32(b) & 0x1F for the shift count (#6079). let ai = dyn_to_int32(a); @@ -668,12 +791,22 @@ pub unsafe extern "C" fn js_dynamic_shl(a: f64, b: f64) -> f64 { /// Dynamic bitwise AND: BigInt & if either operand is BigInt, else i32 & for numbers. #[no_mangle] pub unsafe extern "C" fn js_dynamic_bitand(a: f64, b: f64) -> f64 { + if is_plain_double(a) && is_plain_double(b) { + return (dyn_to_int32(a) & dyn_to_int32(b)) as f64; + } // ToNumeric both operands first so a boxed BigInt/Number (`Object(1n)`) // resolves to its primitive type before the both-BigInt check. - let a = to_numeric(a); - let b = to_numeric(b); + let scope = crate::gc::RuntimeHandleScope::new(); + let (a_handle, b_handle) = to_numeric_pair(&scope, a, b); + let a = a_handle.get_nanbox_f64(); + let b = b_handle.get_nanbox_f64(); if both_bigint_or_throw(a, b) { - return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_and); + return dynamic_bigint_binary_op_from_handles( + &scope, + &a_handle, + &b_handle, + crate::bigint::js_bigint_and, + ); } // JS ToInt32 both operands (#6079). (dyn_to_int32(a) & dyn_to_int32(b)) as f64 @@ -682,10 +815,20 @@ pub unsafe extern "C" fn js_dynamic_bitand(a: f64, b: f64) -> f64 { /// Dynamic bitwise OR: BigInt | if either operand is BigInt, else i32 | for numbers. #[no_mangle] pub unsafe extern "C" fn js_dynamic_bitor(a: f64, b: f64) -> f64 { - let a = to_numeric(a); - let b = to_numeric(b); + if is_plain_double(a) && is_plain_double(b) { + return (dyn_to_int32(a) | dyn_to_int32(b)) as f64; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let (a_handle, b_handle) = to_numeric_pair(&scope, a, b); + let a = a_handle.get_nanbox_f64(); + let b = b_handle.get_nanbox_f64(); if both_bigint_or_throw(a, b) { - return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_or); + return dynamic_bigint_binary_op_from_handles( + &scope, + &a_handle, + &b_handle, + crate::bigint::js_bigint_or, + ); } // JS ToInt32 both operands (#6079). (dyn_to_int32(a) | dyn_to_int32(b)) as f64 @@ -694,10 +837,20 @@ pub unsafe extern "C" fn js_dynamic_bitor(a: f64, b: f64) -> f64 { /// Dynamic bitwise XOR: BigInt ^ if either operand is BigInt, else i32 ^ for numbers. #[no_mangle] pub unsafe extern "C" fn js_dynamic_bitxor(a: f64, b: f64) -> f64 { - let a = to_numeric(a); - let b = to_numeric(b); + if is_plain_double(a) && is_plain_double(b) { + return (dyn_to_int32(a) ^ dyn_to_int32(b)) as f64; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let (a_handle, b_handle) = to_numeric_pair(&scope, a, b); + let a = a_handle.get_nanbox_f64(); + let b = b_handle.get_nanbox_f64(); if both_bigint_or_throw(a, b) { - return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_xor); + return dynamic_bigint_binary_op_from_handles( + &scope, + &a_handle, + &b_handle, + crate::bigint::js_bigint_xor, + ); } // JS ToInt32 both operands (#6079). (dyn_to_int32(a) ^ dyn_to_int32(b)) as f64 @@ -709,10 +862,20 @@ pub unsafe extern "C" fn js_dynamic_bitxor(a: f64, b: f64) -> f64 { /// `js_bigint_pow`). #[no_mangle] pub unsafe extern "C" fn js_dynamic_pow(a: f64, b: f64) -> f64 { - let a = to_numeric(a); - let b = to_numeric(b); + if is_plain_double(a) && is_plain_double(b) { + return crate::math::js_math_pow(a, b); + } + let scope = crate::gc::RuntimeHandleScope::new(); + let (a_handle, b_handle) = to_numeric_pair(&scope, a, b); + let a = a_handle.get_nanbox_f64(); + let b = b_handle.get_nanbox_f64(); if both_bigint_or_throw(a, b) { - return dynamic_bigint_binary_op(a, b, crate::bigint::js_bigint_pow); + return dynamic_bigint_binary_op_from_handles( + &scope, + &a_handle, + &b_handle, + crate::bigint::js_bigint_pow, + ); } crate::math::js_math_pow(a, b) } @@ -726,18 +889,18 @@ pub unsafe extern "C" fn js_dynamic_pow(a: f64, b: f64) -> f64 { /// ToUint32 `>>>`. #[no_mangle] pub unsafe extern "C" fn js_dynamic_ushr(a: f64, b: f64) -> f64 { + if is_plain_double(a) && is_plain_double(b) { + return (dyn_to_uint32(a) >> (dyn_to_uint32(b) & 0x1f)) as f64; + } // Root both operands across the coercions: to_numeric(a) can invoke a // user ToPrimitive (allocate → GC → evacuation), which would leave the // raw NaN-boxed `b` — and the freshly coerced `a`, if it is a BigInt - // pointer — dangling. Reload through the handles after each GC-capable - // call (same discipline as dynamic_bigint_binary_op above). + // pointer — dangling. `to_numeric_pair` reloads through the handles after + // each GC-capable call (same discipline as dynamic_bigint_binary_op above). let scope = crate::gc::RuntimeHandleScope::new(); - let a_in = scope.root_nanbox_f64(a); - let b_in = scope.root_nanbox_f64(b); - let a_num = to_numeric(a_in.get_nanbox_f64()); - let a_handle = scope.root_nanbox_f64(a_num); - let b = to_numeric(b_in.get_nanbox_f64()); + let (a_handle, b_handle) = to_numeric_pair(&scope, a, b); let a = a_handle.get_nanbox_f64(); + let b = b_handle.get_nanbox_f64(); if both_bigint_or_throw(a, b) { let msg = b"BigInts have no unsigned right shift, use >> instead"; let s = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); @@ -793,4 +956,102 @@ mod tests { assert_eq!(js_dynamic_string_or_number_add(int32(2), int32(3)), 5.0); } } + + // The #6655 rooting fix put a `RuntimeHandleScope` on every dynamic binary + // operator, so each one also grew the plain-double fast path that skips the + // scope (same predicate `js_number_coerce` already short-circuits on). + // Feed each operator the SAME numbers twice — once as plain doubles (fast + // path) and once int32-tagged (rooted slow path, since a tagged operand + // fails `is_plain_double`) — and require both to agree. + #[test] + fn plain_double_fast_path_agrees_with_rooted_slow_path() { + unsafe { + let cases: &[(i32, i32)] = &[ + (12, 3), + (13, 5), + (1024, 3), + (-16, 1), + (0, 7), + (-7, 2), + (255, 16), + ]; + for &(x, y) in cases { + let (xf, yf) = (x as f64, y as f64); + assert_eq!( + js_dynamic_mul(xf, yf), + js_dynamic_mul(int32(x), int32(y)), + "mul {x} {y}" + ); + assert_eq!( + js_dynamic_sub(xf, yf), + js_dynamic_sub(int32(x), int32(y)), + "sub {x} {y}" + ); + assert_eq!( + js_dynamic_div(xf, yf), + js_dynamic_div(int32(x), int32(y)), + "div {x} {y}" + ); + assert_eq!( + js_dynamic_pow(xf, yf), + js_dynamic_pow(int32(x), int32(y)), + "pow {x} {y}" + ); + assert_eq!( + js_dynamic_shr(xf, yf), + js_dynamic_shr(int32(x), int32(y)), + "shr {x} {y}" + ); + assert_eq!( + js_dynamic_shl(xf, yf), + js_dynamic_shl(int32(x), int32(y)), + "shl {x} {y}" + ); + assert_eq!( + js_dynamic_bitand(xf, yf), + js_dynamic_bitand(int32(x), int32(y)), + "bitand {x} {y}" + ); + assert_eq!( + js_dynamic_bitor(xf, yf), + js_dynamic_bitor(int32(x), int32(y)), + "bitor {x} {y}" + ); + assert_eq!( + js_dynamic_bitxor(xf, yf), + js_dynamic_bitxor(int32(x), int32(y)), + "bitxor {x} {y}" + ); + assert_eq!( + js_dynamic_ushr(xf, yf), + js_dynamic_ushr(int32(x), int32(y)), + "ushr {x} {y}" + ); + // `%` needs a NaN-aware compare: `js_dynamic_mod(0, 7)` and its + // int32 twin are both `0`, but a NaN case must match as NaN. + let (m_fast, m_slow) = (js_dynamic_mod(xf, yf), js_dynamic_mod(int32(x), int32(y))); + assert!( + m_fast == m_slow || (m_fast.is_nan() && m_slow.is_nan()), + "mod {x} {y}: {m_fast} vs {m_slow}" + ); + } + } + } + + // Non-finite operands must stay on the fast path and keep IEEE semantics + // (canonical NaN is 0x7FF8, below the 0x7FF9 tag band floor). + #[test] + fn plain_double_fast_path_handles_non_finite() { + unsafe { + assert!(js_dynamic_mul(f64::NAN, 2.0).is_nan()); + assert!(js_dynamic_div(0.0, 0.0).is_nan()); + assert_eq!(js_dynamic_div(1.0, 0.0), f64::INFINITY); + assert_eq!(js_dynamic_mul(f64::INFINITY, 2.0), f64::INFINITY); + // ToInt32/ToUint32 map non-finite to 0. + assert_eq!(js_dynamic_bitor(f64::NAN, 5.0), 5.0); + assert_eq!(js_dynamic_shl(f64::INFINITY, 1.0), 0.0); + // `%` keeps the sign of the dividend: -1 % -1 is -0. + assert!(js_dynamic_mod(-1.0, -1.0).is_sign_negative()); + } + } } diff --git a/crates/perry/tests/gc_dynamic_arith_operand_rooting_6655.rs b/crates/perry/tests/gc_dynamic_arith_operand_rooting_6655.rs new file mode 100644 index 0000000000..449359f4c0 --- /dev/null +++ b/crates/perry/tests/gc_dynamic_arith_operand_rooting_6655.rs @@ -0,0 +1,336 @@ +//! Regression tests for #6655 — raw NaN-boxed operands held across GC-capable +//! coercions in the dynamic operator helpers. +//! +//! `to_numeric` / `rel_to_primitive` / `js_jsvalue_to_string` on an object +//! operand run a user `Symbol.toPrimitive` / `valueOf` / `toString`, which can +//! allocate, trigger a GC and *evacuate* (move) live objects. Pre-fix the +//! operator helpers held the other operand as a raw `f64` in a Rust local: +//! +//! ```ignore +//! let a = to_numeric(a); // user valueOf -> allocate -> GC -> evacuation +//! let b = to_numeric(b); // raw `b` was held UNROOTED across the line above +//! ``` +//! +//! A Rust local is neither a GC root nor a shadow slot, so after the first +//! coercion `b` could name a forwarded address. The same shape hit the coerced +//! `a` when it is a BigInt pointer and the *second* operand is the allocating +//! one — hence every operator below is exercised in BOTH operand orders. +//! +//! The programs run with `PERRY_GC_FORCE_EVACUATE=1` (stress-copies every +//! marked non-pinned nursery object, so a stale pointer becomes a deterministic +//! wrong answer / crash instead of a rare one) and `PERRY_GC_VERIFY_EVACUATION=1` +//! (panics if any mutable live slot still points at a forwarded object). +//! +//! Coverage: `js_dynamic_{mul,sub,div,mod,pow,shr,shl,bitand,bitor,bitxor,ushr}`, +//! `js_numeric_step`, `abstract_relational` behind `js_rel_{lt,gt,le,ge}`, and +//! `js_string_concat_value` / `js_value_concat_string`. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run_forced_evacuation(dir: &std::path::Path, source: &str) -> String { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + // The runtime-only macOS link path does not pass `-framework CoreFoundation`, + // but `perry-runtime` pulls `iana_time_zone`, which references `_CFRelease` + // & co. On this host that leaves the link with undefined symbols for any + // "runtime-only" test program (the pre-existing + // `gc_side_table_roots_evacuation` fails identically). Append the framework + // through the supported escape hatch so this suite links regardless. + let mut compile_cmd = Command::new(perry_bin()); + compile_cmd + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache"); + if cfg!(target_os = "macos") { + let extra = match std::env::var("PERRY_EXTRA_LINK_ARGS") { + Ok(existing) if !existing.trim().is_empty() => { + format!("{existing} -framework CoreFoundation") + } + _ => "-framework CoreFoundation".to_string(), + }; + compile_cmd.env("PERRY_EXTRA_LINK_ARGS", extra); + } + let compile = compile_cmd.output().expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir) + .env("PERRY_GC_FORCE_EVACUATE", "1") + .env("PERRY_GC_VERIFY_EVACUATION", "1") + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed under forced evacuation (exit {:?})\nstdout:\n{}\nstderr:\n{}", + run.status.code(), + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// Shared prelude: an operand whose `valueOf` churns the nursery and forces an +/// explicit collection, and a plain movable object operand that (pre-fix) was +/// held unrooted across the other operand's coercion. +const PRELUDE: &str = r#" +// Allocate a lot of short-lived nursery objects, then force a collection so a +// GC + evacuation happens *inside* the operand coercion rather than by luck. +// 20000 matches the churn the existing side-table evacuation regression uses: +// enough live nursery traffic to cross the 1 MB arena-block GC trigger even if +// the explicit `gc()` hook is unavailable in this build. +function churnAndCollect(): void { + let sink = 0; + for (let i = 0; i < 20000; i++) { + const tmp = { i, s: "pad" + i }; + sink += tmp.s.length > 0 ? 1 : 0; + } + if (sink !== 20000) throw new Error("churn miscounted"); + (globalThis as any).gc?.(); + // Allocate AGAIN after the collection. Evacuation leaves the vacated nursery + // region intact-but-dead, so a stale pointer read immediately after a copy + // usually still finds the original bytes and silently returns the right + // answer. Re-filling the nursery here is what gives the vacated region a + // chance to be handed out and overwritten before the *second* operand + // coercion dereferences it. + for (let i = 0; i < 20000; i++) { + const tmp2 = { a: i, b: "fill" + i, c: [i, i + 1] }; + sink += tmp2.c[0] >= 0 ? 1 : 0; + } + if (sink !== 40000) throw new Error("refill miscounted"); +} + +// Keeps operands reachable from a real GC root. This matters: an object that +// is only referenced by the (unrooted) raw operand register is *dead* at the +// collection, so it would merely be swept and the stale read might happen to +// find intact bytes. Reachable objects are instead EVACUATED — the address +// genuinely changes and every rooted holder is rewritten, while the raw +// operand local is not. That is the state that turns the bug into a wrong +// answer or a crash. +const keepalive: any[] = []; + +// GC-capable operand: its valueOf runs user JS that allocates and collects. +function heavy(v: any): any { + const o: any = { + v: v, + valueOf() { + churnAndCollect(); + return this.v; + }, + }; + keepalive.push(o); + return o; +} + +// Plain heap-object operand. It is a movable pointer, so pre-fix it went stale +// whenever it sat on the far side of the other operand's coercion. `valueOf` +// reads an instance FIELD, so a stale receiver yields a wrong value (or +// faults) rather than coincidentally returning the right constant. +function plain(v: any): any { + const o: any = { + v: v, + valueOf() { + return this.v; + }, + }; + keepalive.push(o); + return o; +} + +let failures = 0; +function check(name: string, got: any, want: any): void { + if (got !== want) { + failures++; + console.log("FAIL " + name + " got=" + String(got) + " want=" + String(want)); + } +} +"#; + +/// Every `to_numeric(a); to_numeric(b)` operator, in both operand orders. +/// +/// `heavy-first` exercises the primary hazard (raw second operand held across +/// the first coercion). `heavy-second` exercises the coerced-`a` hazard, which +/// is the one that bites when `a` resolves to a BigInt pointer. +#[test] +fn dynamic_arith_operands_survive_forced_evacuation() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = format!( + "{PRELUDE}{}", + r#" +// --- heavy operand FIRST: raw `b` held across to_numeric(a) --- +check("mul", heavy(12) * plain(3), 36); +check("sub", heavy(12) - plain(3), 9); +check("div", heavy(12) / plain(3), 4); +check("mod", heavy(13) % plain(5), 3); +check("pow", heavy(2) ** plain(10), 1024); +check("shr", heavy(1024) >> plain(3), 128); +check("shl", heavy(3) << plain(4), 48); +check("bitand", heavy(12) & plain(10), 8); +check("bitor", heavy(12) | plain(10), 14); +check("bitxor", heavy(12) ^ plain(10), 6); +check("ushr", heavy(-16) >>> plain(1), 2147483640); + +// --- heavy operand SECOND: coerced `a` held across to_numeric(b) --- +check("mul-rev", plain(12) * heavy(3), 36); +check("sub-rev", plain(12) - heavy(3), 9); +check("div-rev", plain(12) / heavy(3), 4); +check("mod-rev", plain(13) % heavy(5), 3); +check("pow-rev", plain(2) ** heavy(10), 1024); +check("shr-rev", plain(1024) >> heavy(3), 128); +check("shl-rev", plain(3) << heavy(4), 48); +check("bitand-rev", plain(12) & heavy(10), 8); +check("bitor-rev", plain(12) | heavy(10), 14); +check("bitxor-rev", plain(12) ^ heavy(10), 6); +check("ushr-rev", plain(-16) >>> heavy(1), 2147483640); + +console.log("failures:", failures); +"# + ); + let stdout = compile_and_run_forced_evacuation(dir.path(), &source); + assert_eq!( + stdout, "failures: 0\n", + "dynamic arithmetic produced a wrong result under forced evacuation" + ); +} + +/// BigInt operands are heap pointers, so they are the sharpest probe for the +/// "coerced `a` goes stale across `to_numeric(b)`" half of the bug: `a` +/// resolves to a BigInt pointer and then the heavy second operand collects. +/// Also covers `js_numeric_step` (`++`/`--`), which allocates `1n` while the +/// incoming BigInt operand is live. +#[test] +fn dynamic_bigint_operands_survive_forced_evacuation() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = format!( + "{PRELUDE}{}", + r#" +// `a` coerces to a BigInt pointer, then the heavy `b` collects underneath it. +check("big-mul-rev", plain(11n) * heavy(3n), 33n); +check("big-sub-rev", plain(20n) - heavy(3n), 17n); +check("big-div-rev", plain(36n) / heavy(3n), 12n); +check("big-mod-rev", plain(13n) % heavy(5n), 3n); +check("big-pow-rev", plain(2n) ** heavy(10n), 1024n); +check("big-and-rev", plain(12n) & heavy(10n), 8n); +check("big-or-rev", plain(12n) | heavy(10n), 14n); +check("big-xor-rev", plain(12n) ^ heavy(10n), 6n); +check("big-shl-rev", plain(3n) << heavy(4n), 48n); +check("big-shr-rev", plain(1024n) >> heavy(3n), 128n); + +// heavy FIRST, raw BigInt `b` held across the coercion. +check("big-mul", heavy(11n) * plain(3n), 33n); +check("big-add", heavy(11n) + plain(3n), 14n); + +// js_numeric_step: `1n` is allocated while the operand BigInt is live. +let step: any = 9007199254740993n; +for (let i = 0; i < 20; i++) { + churnAndCollect(); + step++; +} +check("bigint-step", step, 9007199254741013n); + +console.log("failures:", failures); +"# + ); + let stdout = compile_and_run_forced_evacuation(dir.path(), &source); + assert_eq!( + stdout, "failures: 0\n", + "dynamic BigInt arithmetic produced a wrong result under forced evacuation" + ); +} + +/// `abstract_relational` (behind `js_rel_lt/gt/le/ge`) has the identical +/// `rel_to_primitive(x); rel_to_primitive(y)` prelude, and additionally held +/// `px` — often a freshly allocated heap string from the `DefaultString` arm — +/// across the second coercion. +#[test] +fn relational_operands_survive_forced_evacuation() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = format!( + "{PRELUDE}{}", + r#" +check("lt", heavy(3) < plain(10), true); +check("gt", heavy(30) > plain(10), true); +check("le", heavy(10) <= plain(10), true); +check("ge", heavy(10) >= plain(10), true); + +check("lt-rev", plain(3) < heavy(10), true); +check("gt-rev", plain(30) > heavy(10), true); +check("le-rev", plain(10) <= heavy(10), true); +check("ge-rev", plain(10) >= heavy(10), true); + +// String operands take the lexicographic arm, where the ToPrimitive result is +// itself a fresh heap string that must survive the other side's coercion. +check("lt-str", heavy("apple") < plain("banana"), true); +check("gt-str", plain("cherry") > heavy("banana"), true); + +// Mixed BigInt/string and BigInt/number arms re-derive the BigInt pointer +// after an allocating StringToBigInt / ToNumber step. +check("big-lt-str", heavy(5n) < plain("10"), true); +check("big-gt-num", plain(10n) > heavy(5), true); + +// Loose equality takes the same `rel_to_primitive` path: `js_loose_eq` coerces +// the object side and then recurses with the OTHER operand, which pre-fix was +// a raw local held across that coercion. +check("looseeq-num", heavy(5) == 5, true); +check("looseeq-rev", 5 == heavy(5), true); +check("looseeq-str", heavy("7") == 7, true); +check("looseeq-big", heavy(5n) == 5, true); +check("looseeq-neq", heavy(5) == 6, false); + +console.log("failures:", failures); +"# + ); + let stdout = compile_and_run_forced_evacuation(dir.path(), &source); + assert_eq!( + stdout, "failures: 0\n", + "relational comparison produced a wrong result under forced evacuation" + ); +} + +/// `js_string_concat_value` / `js_value_concat_string` hold the raw +/// `*const StringHeader` operand across `string_storage_alloc` (fast path) and +/// across `js_jsvalue_to_string`'s user `toString` (slow path). +#[test] +fn string_concat_operands_survive_forced_evacuation() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = format!( + "{PRELUDE}{}", + r#" +// Build the fixed operand dynamically so it is a movable nursery string +// rather than a pinned static literal. +const prefix: string = "pre-" + String(7); +const suffix: string = "-post" + String(9); + +check("concat-value", prefix + heavy(5), "pre-75"); +check("value-concat", heavy(5) + suffix, "5-post9"); + +// Repeat under sustained churn so the allocation inside the concat itself has +// a live nursery to evacuate. +for (let i = 0; i < 10; i++) { + const p: string = "p" + String(i); + check("concat-loop", p + heavy(i), "p" + String(i) + String(i)); +} + +console.log("failures:", failures); +"# + ); + let stdout = compile_and_run_forced_evacuation(dir.path(), &source); + assert_eq!( + stdout, "failures: 0\n", + "string concatenation produced a wrong result under forced evacuation" + ); +}