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.
Summary
"" + errand"" + arrevaluate to the string"undefined"in release builds. The ToPrimitive-for-+path bit-casts a non-ObjectHeaderreceiver and reads itsvalueOf/toStringout of garbage field slots.Repro (release build)
test-files/test_gap_string_coercion_tostring.tsalready covers the array case (array: undefinedvs node'sarray: 1,2,3) and is triaged inknown_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 anObjectHeaderbefore falling through toordinary_to_primitive_number_for_add, which doesptr as *mut ObjectHeaderand reads field slots off it. There are guards for Proxy, Buffer, TypedArray, closures, Date and URL — but not forArrayHeaderorErrorHeader, so those two fall into the bit-cast and the garbagevalueOf/toStringread yieldsundefined.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--releaseprintsundefined. Anything that only ever tests with a dev-profile runtime will not see it.RegExpHeaderhad exactly this bug and is fixed in #6370's PR by adding the missing guard next to the Date one:Array and Error want the same treatment (
Array.prototype.join(",")/ thename: messageform both already exist insidejs_jsvalue_to_string), plus an own-valueOffirst step for the "default" hint. Left out of the #6370 PR to keep that diff scoped to the own-toStringoverride bug.