Repository navigation
runtime: a fourth unrooted-operand family — js_string_coerce as a plain ToString argument coercion (string built-ins, constructors) #6949
Description
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: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 and
flags coercions sit at :68/:79 andreis mutated far below ((*re).regex_ptr,
.fancy_ptr,.pattern_ptr,.flags_ptr, … around :152–186). A stalerewrites the compiled
regex into a forwarding stub.object/native_call_method/typed_array.rs:253—ta(the view) andpatched(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 receivermessaging.rs:609js_broadcast_channel_new—objallocated at :602, coerced at :609,
dereferenced at :611–618disposable.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–317object/class_registry/construct.rs:646—pattern(the result of the coercion at :641) spans the
flags coercion at :646 and is passed tojs_regexp_newat :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_arrayandjs_object_set_field_by_nameobject/object_ops/define_properties.rs—keys: Vec<f64>held across every
js_object_define_propertycall 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.
Activity
fix(runtime): root string/regex receivers across the ToString argumen…
fix(runtime): root constructor receivers across a later ToString coer…
fix(runtime): root string/regex receivers across the ToString argumen…
fix(runtime): root constructor receivers across a later ToString coer…
fix(runtime): convert #7811/#7815's new bare raw-handle reads to acro…
proggeramlug commentedon Aug 12, 2026
Current-main audit complete. #7811 and #7815 covered the six replace/replaceAll entries plus three constructor sites, but four primary #6949 sites remained: String split, RegExp compile, patched typed-array toLocaleString, and the rebound RegExp constructor deferred by #7815's file-size constraint.
PR #7950 fixes those four sites (and two adjacent windows exposed while ordering the handle re-reads). The distinct Rust-container retention shape from this issue is now tracked separately in #7949 so it is not lost when #7950 closes this issue.