Skip to content

runtime: "" + err / "" + arr yield "undefined" in release builds — the + ToPrimitive path lacks ArrayHeader/ErrorHeader guards (bit-casts them as ObjectHeader) #6381

Description

@proggeramlug

Summary

"" + err and "" + arr evaluate to the string "undefined" in release builds. The ToPrimitive-for-+ path bit-casts a non-ObjectHeader receiver and reads its valueOf/toString out of garbage field slots.

Repro (release build)

const err = new Error("boom");
console.log("" + err);        // node: "Error: boom"   perry: "undefined"

const arr = [1, 2, 3];
console.log("" + arr);        // node: "1,2,3"         perry: "undefined"

// contrast — these are all correct:
console.log(String(err), `${err}`, String(arr), `${arr}`);

test-files/test_gap_string_coercion_tostring.ts already covers the array case (array: undefined vs node's array: 1,2,3) and is triaged in known_failures.json; this issue is the root cause.

Why

to_primitive_default_for_add (crates/perry-runtime/src/value/dynamic_arith.rs) special-cases the heap types whose header is NOT an ObjectHeader before falling through to ordinary_to_primitive_number_for_add, which does ptr as *mut ObjectHeader and reads field slots off it. There are guards for Proxy, Buffer, TypedArray, closures, Date and URL — but not for ArrayHeader or ErrorHeader, so those two fall into the bit-cast and the garbage valueOf/toString read yields undefined.

The read is UB, which is why it is optimization-sensitive: a perry-dev (opt-level=1) runtime happens to produce the right answer, while --release prints undefined. Anything that only ever tests with a dev-profile runtime will not see it.

RegExpHeader had exactly this bug and is fixed in #6370's PR by adding the missing guard next to the Date one:

if crate::regex::is_regex_pointer(ptr as *const u8) {
    // OrdinaryToPrimitive(re, "default") = valueOf, then toString
    if let Some(primitive) = exotic_own_value_of_primitive(ptr, ExoticKind::RegExp, value) {
        return primitive;
    }
    let s = crate::value::js_jsvalue_to_string(value);
    return crate::value::js_nanbox_string(s as i64);
}

Array and Error want the same treatment (Array.prototype.join(",") / the name: message form both already exist inside js_jsvalue_to_string), plus an own-valueOf first step for the "default" hint. Left out of the #6370 PR to keep that diff scoped to the own-toString override bug.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regressiontriagedMaintainer reviewed; type, scope, and next step are clear

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions