Found while sweeping #6943 (the js_string_coerce-as-property-key family). #6943 fixed every site
where js_string_coerce is being used as the property-key coercion. The sweep read all 102
js_string_coerce callsites in perry-runtime and turned up a second, structurally identical set
where the coercion is a plain ToString argument coercion — not a key at all — and a raw
StringHeader / ObjectHeader operand still spans it.
The mechanism is the same and needs no re-arguing: js_string_coerce returns early with no
allocation only for an already-heap STRING_TAG value (builtins::string_coerce_is_inert, added in
#6943). Every other shape allocates — SSO short strings materialize onto the heap, numbers/bools/
null/BigInt build their stringification, and a POINTER_TAG object runs a user toString /
valueOf — and an allocation can trigger a GC that evacuates. A Rust local is neither a GC root
nor a shadow slot.
Two sub-shapes, both on main @ 83a6767.
(a) String built-ins: the receiver string spans the argument coercion
pub extern "C" fn js_string_replace_string_dyn(
s: *const StringHeader, // receiver — raw fn param
pattern: *const StringHeader, // also raw
replacement: f64,
) -> *mut StringHeader {
...
js_string_replace_string(s, pattern, crate::builtins::js_string_coerce(replacement))
// ^^^^^^^^^^ stale after the coercion evacuates them
}
Rust evaluates arguments left to right, so s / pattern are copied before the coercion runs —
but the pointees move, and the callee dereferences the stale copies (string_as_str(s) in
replace_string.rs).
regex/replace_fn.rs:359 js_string_replace_string_dyn — s, pattern
regex/replace_fn.rs:371 js_string_replace_all_string_dyn — s, pattern
regex/replace_fn.rs:413 js_string_replace_search_dyn — s
regex/replace_fn.rs:427 js_string_replace_all_search_dyn — s
regex/replace_fn.rs:444 js_string_replace_regex_dyn — s, re
regex/replace_fn.rs:461 js_string_replace_all_regex_dyn — s, re
string/split.rs:611 js_string_split_value — s is the fn param, coerced separator at :611,
s dereferenced at :620 (split_single_element) and :629 (js_string_split_n)
regex/compile.rs:68 and :79 js_regexp_compile_value — re is the fn param; the pattern and
flags coercions sit at :68/:79 and re is mutated far below ((*re).regex_ptr,
.fancy_ptr, .pattern_ptr, .flags_ptr, … around :152–186). A stale re writes the compiled
regex into a forwarding stub.
object/native_call_method/typed_array.rs:253 — ta (the view) and patched (a closure ptr) are
established before a per-element loop whose body coerces, then re-dereferenced on the next
iteration.
(b) Constructors: a freshly allocated object spans a later coercion
let obj = js_object_alloc(CLASS_ID_..., n); // raw local
...
let coerced = crate::builtins::js_string_coerce(message); // allocate -> GC -> evacuation
set_nonenum(obj, "message", ...); // stale receiver
messaging.rs:609 js_broadcast_channel_new — obj allocated at :602, coerced at :609,
dereferenced at :611–618
disposable.rs:482 js_suppressed_error_new — obj allocated at :456, dereferenced at :485 and
:492 (object_set_static_prototype(obj as usize, …) — the address is also a side-table key)
builtins/formatting/boxed_primitives.rs:310 js_boxed_string_new — obj allocated at :301,
dereferenced at :313–317
object/class_registry/construct.rs:646 — pattern (the result of the coercion at :641) spans the
flags coercion at :646 and is passed to js_regexp_new at :648
A third, related shape worth its own decision
Raw JSValues parked in a Rust container across allocations, which no GC scanner can see:
object/groupby.rs — groups: Vec<Vec<f64>> / order: Vec<Key> hold JS values across
js_string_coerce, group_by_make_array and js_object_set_field_by_name
object/object_ops/define_properties.rs — keys: Vec<f64> held across every
js_object_define_property call in the loop
descriptors.rs's js_object_get_own_property_descriptors carried an explicit comment saying "the
intermediate allocations aren't rooted — Perry's builder helpers follow this convention", which
suggests this is a documented convention rather than an oversight. If so it should be either
justified in writing (why is it safe?) or fixed as a class; #6943 rooted that one function because
its receiver and stored value straddled a key coercion, but the convention itself is untouched.
Scope note
#6943 deliberately closed only the coercion window and did not chase the wider window in the same
helpers — e.g. js_object_define_property also holds obj / descriptor_value / the six raw
JSValues inside DescView across its own later js_string_from_bytes calls, and
obj_value_has_own_key holds keys / key_str across a js_array_get walk that can materialize a
lazy array. That "cold arms never root anything" shape is bigger than any of these three families and
probably wants a systematic answer (an audit lint? a RuntimeHandleScope convention for
#[no_mangle] runtime entry points?) rather than another site-by-site sweep.
Verification caveat (unchanged)
Same as #6942 / #6946: none of this is reproducible from compiled code today, because gc() runs a
full mark-sweep and engages the conservative stack scan that pins exactly these raw locals, and
perry/gc's minor() engages the same scan. Any fix here will be latent hardening backed by an
audit plus a forced-evacuation behavioral guard, exactly like #6934 / #6941 / #6948 — until #6942
gives us a real harness.
Found while sweeping #6943 (the
js_string_coerce-as-property-key family). #6943 fixed every sitewhere
js_string_coerceis being used as the property-key coercion. The sweep read all 102js_string_coercecallsites inperry-runtimeand turned up a second, structurally identical setwhere the coercion is a plain
ToStringargument coercion — not a key at all — and a rawStringHeader/ObjectHeaderoperand still spans it.The mechanism is the same and needs no re-arguing:
js_string_coercereturns early with noallocation only for an already-heap
STRING_TAGvalue (builtins::string_coerce_is_inert, added in#6943). Every other shape allocates — SSO short strings materialize onto the heap, numbers/bools/
null/BigInt build their stringification, and aPOINTER_TAGobject runs a usertoString/valueOf— and an allocation can trigger a GC that evacuates. A Rust local is neither a GC rootnor a shadow slot.
Two sub-shapes, both on
main@ 83a6767.(a) String built-ins: the receiver string spans the argument coercion
Rust evaluates arguments left to right, so
s/patternare copied before the coercion runs —but the pointees move, and the callee dereferences the stale copies (
string_as_str(s)inreplace_string.rs).regex/replace_fn.rs:359js_string_replace_string_dyn—s,patternregex/replace_fn.rs:371js_string_replace_all_string_dyn—s,patternregex/replace_fn.rs:413js_string_replace_search_dyn—sregex/replace_fn.rs:427js_string_replace_all_search_dyn—sregex/replace_fn.rs:444js_string_replace_regex_dyn—s,reregex/replace_fn.rs:461js_string_replace_all_regex_dyn—s,restring/split.rs:611js_string_split_value—sis the fn param, coerced separator at :611,sdereferenced at :620 (split_single_element) and :629 (js_string_split_n)regex/compile.rs:68and:79js_regexp_compile_value—reis the fn param; the pattern andflags coercions sit at :68/:79 and
reis mutated far below ((*re).regex_ptr,.fancy_ptr,.pattern_ptr,.flags_ptr, … around :152–186). A stalerewrites the compiledregex into a forwarding stub.
object/native_call_method/typed_array.rs:253—ta(the view) andpatched(a closure ptr) areestablished before a per-element loop whose body coerces, then re-dereferenced on the next
iteration.
(b) Constructors: a freshly allocated object spans a later coercion
messaging.rs:609js_broadcast_channel_new—objallocated at :602, coerced at :609,dereferenced at :611–618
disposable.rs:482js_suppressed_error_new—objallocated at :456, dereferenced at :485 and:492 (
object_set_static_prototype(obj as usize, …)— the address is also a side-table key)builtins/formatting/boxed_primitives.rs:310js_boxed_string_new—objallocated at :301,dereferenced at :313–317
object/class_registry/construct.rs:646—pattern(the result of the coercion at :641) spans theflags coercion at :646 and is passed to
js_regexp_newat :648A third, related shape worth its own decision
Raw
JSValues parked in a Rust container across allocations, which no GC scanner can see:object/groupby.rs—groups: Vec<Vec<f64>>/order: Vec<Key>hold JS values acrossjs_string_coerce,group_by_make_arrayandjs_object_set_field_by_nameobject/object_ops/define_properties.rs—keys: Vec<f64>held across everyjs_object_define_propertycall in the loopdescriptors.rs'sjs_object_get_own_property_descriptorscarried an explicit comment saying "theintermediate allocations aren't rooted — Perry's builder helpers follow this convention", which
suggests this is a documented convention rather than an oversight. If so it should be either
justified in writing (why is it safe?) or fixed as a class; #6943 rooted that one function because
its receiver and stored value straddled a key coercion, but the convention itself is untouched.
Scope note
#6943 deliberately closed only the coercion window and did not chase the wider window in the same
helpers — e.g.
js_object_define_propertyalso holdsobj/descriptor_value/ the six rawJSValues insideDescViewacross its own laterjs_string_from_bytescalls, andobj_value_has_own_keyholdskeys/key_stracross ajs_array_getwalk that can materialize alazy array. That "cold arms never root anything" shape is bigger than any of these three families and
probably wants a systematic answer (an audit lint? a
RuntimeHandleScopeconvention for#[no_mangle]runtime entry points?) rather than another site-by-site sweep.Verification caveat (unchanged)
Same as #6942 / #6946: none of this is reproducible from compiled code today, because
gc()runs afull mark-sweep and engages the conservative stack scan that pins exactly these raw locals, and
perry/gc'sminor()engages the same scan. Any fix here will be latent hardening backed by anaudit plus a forced-evacuation behavioral guard, exactly like #6934 / #6941 / #6948 — until #6942
gives us a real harness.