While closing #6655 (operand rooting across GC-capable coercions in the dynamic operator helpers) I swept the rest of the runtime for the same bug class. The operator family is fixed in that PR. A second, larger family turned up that is out of scope there and wants its own change: the property-key / receiver-and-stored-value shape.
The shape
ToPropertyKey(key) runs a user Symbol.toPrimitive / toString / valueOf, so it can allocate, collect and evacuate. These call sites hold the receiver object pointer — and often the value being stored — as a raw local across that coercion:
let key = js_to_property_key(key_value); // user JS -> allocate -> GC -> evacuation
...
let obj = extract_obj_ptr(obj_value); // receiver was raw across the coercion
js_object_set_field_by_name(obj, key_str, value); // stale receiver AND stale value
This is strictly worse than the operator family: on the set paths the stale value is written into the object, so the corruption outlives the call instead of just producing one wrong answer.
Sites found (all missing a RuntimeHandleScope)
| File |
Function |
Note |
crates/perry-runtime/src/object/property_key.rs |
js_object_set_property_key, js_object_get_property_key, js_object_set_property_key_method |
obj_value (and value, closure) raw across js_to_property_key |
crates/perry-runtime/src/object/object_literal_ops.rs |
js_object_literal_set_computed, js_object_define_accessor |
receiver is extracted to a raw *mut ObjectHeader before the coercion — already-dereferenced form, not a NaN-box |
crates/perry-runtime/src/value/dyn_index.rs |
js_dyn_index_set |
receiver validated at entry, then held across js_jsvalue_to_string(index); stale value is stored |
crates/perry-runtime/src/object/polymorphic_index.rs |
js_object_set_index_polymorphic |
non-canonical-key arms only (an object key is exactly what lands there) |
crates/perry-runtime/src/object/delete_rest.rs |
js_object_delete_dynamic |
receiver across js_to_property_key; string-key case already returned, so key is a number or object |
crates/perry-runtime/src/array/indexing.rs |
js_array_set_index_or_string |
arr + value across js_jsvalue_to_string(idx); the doc comment names a[new Number(1)] as intended input |
crates/perry-runtime/src/object/native_call_method.rs |
js_native_call_method_by_key |
object across js_to_property_key (rarer object-key path) |
Fix
Same established idiom as #6655 — crate::gc::RuntimeHandleScope + root_nanbox_f64 / root_raw_mut_ptr, re-reading receiver and value through their handles after the coercion. Note that where the receiver has already been lowered to a raw *mut ObjectHeader, it must be rooted as a raw pointer (root_raw_mut_ptr) rather than re-derived from a stale NaN-box.
Verification
PERRY_GC_FORCE_EVACUATE=1 + PERRY_GC_VERIFY_EVACUATION=1, with the coercion driven by a toString/valueOf that churns the nursery and calls gc(), and the receiver kept reachable from a root so it is genuinely evacuated (moved + rewritten) rather than merely swept — a dead receiver may leave intact bytes behind and mask the bug. See crates/perry/tests/gc_dynamic_arith_operand_rooting_6655.rs for the harness shape.
Also worth recording
crates/perry-runtime/src/value/equality.rs (js_jsvalue_loose_equals, js_jsvalue_compare) is clean w.r.t. this bug class because it never invokes ToPrimitive at all — every arm is a tag test. That is a separate spec-conformance gap (object operands should coerce), and whoever closes it must add the rooting at the same time or they will introduce exactly this bug.
While closing #6655 (operand rooting across GC-capable coercions in the dynamic operator helpers) I swept the rest of the runtime for the same bug class. The operator family is fixed in that PR. A second, larger family turned up that is out of scope there and wants its own change: the property-key / receiver-and-stored-value shape.
The shape
ToPropertyKey(key)runs a userSymbol.toPrimitive/toString/valueOf, so it can allocate, collect and evacuate. These call sites hold the receiver object pointer — and often the value being stored — as a raw local across that coercion:This is strictly worse than the operator family: on the set paths the stale
valueis written into the object, so the corruption outlives the call instead of just producing one wrong answer.Sites found (all missing a
RuntimeHandleScope)crates/perry-runtime/src/object/property_key.rsjs_object_set_property_key,js_object_get_property_key,js_object_set_property_key_methodobj_value(andvalue,closure) raw acrossjs_to_property_keycrates/perry-runtime/src/object/object_literal_ops.rsjs_object_literal_set_computed,js_object_define_accessor*mut ObjectHeaderbefore the coercion — already-dereferenced form, not a NaN-boxcrates/perry-runtime/src/value/dyn_index.rsjs_dyn_index_setjs_jsvalue_to_string(index); stalevalueis storedcrates/perry-runtime/src/object/polymorphic_index.rsjs_object_set_index_polymorphiccrates/perry-runtime/src/object/delete_rest.rsjs_object_delete_dynamicjs_to_property_key; string-key case already returned, so key is a number or objectcrates/perry-runtime/src/array/indexing.rsjs_array_set_index_or_stringarr+valueacrossjs_jsvalue_to_string(idx); the doc comment namesa[new Number(1)]as intended inputcrates/perry-runtime/src/object/native_call_method.rsjs_native_call_method_by_keyobjectacrossjs_to_property_key(rarer object-key path)Fix
Same established idiom as #6655 —
crate::gc::RuntimeHandleScope+root_nanbox_f64/root_raw_mut_ptr, re-reading receiver and value through their handles after the coercion. Note that where the receiver has already been lowered to a raw*mut ObjectHeader, it must be rooted as a raw pointer (root_raw_mut_ptr) rather than re-derived from a stale NaN-box.Verification
PERRY_GC_FORCE_EVACUATE=1+PERRY_GC_VERIFY_EVACUATION=1, with the coercion driven by atoString/valueOfthat churns the nursery and callsgc(), and the receiver kept reachable from a root so it is genuinely evacuated (moved + rewritten) rather than merely swept — a dead receiver may leave intact bytes behind and mask the bug. Seecrates/perry/tests/gc_dynamic_arith_operand_rooting_6655.rsfor the harness shape.Also worth recording
crates/perry-runtime/src/value/equality.rs(js_jsvalue_loose_equals,js_jsvalue_compare) is clean w.r.t. this bug class because it never invokesToPrimitiveat all — every arm is a tag test. That is a separate spec-conformance gap (object operands should coerce), and whoever closes it must add the rooting at the same time or they will introduce exactly this bug.