Skip to content

runtime: a fourth unrooted-operand family — js_string_coerce as a plain ToString argument coercion (string built-ins, constructors) #6949

Description

@proggeramlug

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_dyns, pattern
  • regex/replace_fn.rs:371 js_string_replace_all_string_dyns, pattern
  • regex/replace_fn.rs:413 js_string_replace_search_dyns
  • regex/replace_fn.rs:427 js_string_replace_all_search_dyns
  • regex/replace_fn.rs:444 js_string_replace_regex_dyns, re
  • regex/replace_fn.rs:461 js_string_replace_all_regex_dyns, re
  • string/split.rs:611 js_string_split_values 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_valuere 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:253ta (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_newobj allocated at :602, coerced at :609,
    dereferenced at :611–618
  • disposable.rs:482 js_suppressed_error_newobj 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_newobj allocated at :301,
    dereferenced at :313–317
  • object/class_registry/construct.rs:646pattern (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.rsgroups: 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.rskeys: 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions