Skip to content

Commit a8780e2

Browse files
proggeramlugRalphclaude
authored
fix(runtime): #5552 demote unique strings in remaining array insert paths (unshift / fill / with / from_jsvalue) (#5567)
* fix(runtime): #5552 demote unique strings in remaining array insert paths Follow-up to #5533 (object fields) and #5548 (the array store paths it enumerated). A uniquely-owned (refcount==1) heap string written into an array element aliases that slot, so a later in-place `s += x` on the source local (`js_string_append`'s refcount==1 fast path) rewrites the stored element and corrupts it. #5548 fixed the push / set / from_values / splice-insert paths; the sibling insert/replace paths below do the same raw element write without the demote. Apply the same tag-checked `js_string_addref_if_heap_string` (no-op for SSO / non-string, idempotent) before the element write at each: - `js_array_unshift_f64` (covers `js_array_unshift_jsvalue` transitively) and the per-item loop in `js_array_unshift_variadic`. - `js_array_fill` / `js_array_fill_range` (demote once before the fill loop — the source aliases every filled slot) and `js_array_fill_generic` (its object-receiver loop writes `value` into each index directly; the array receiver delegates to the two above, so the extra demote is idempotent). - `js_array_with` (the replacement value stored into the new array's slot; the cloned elements are already shared). - `js_array_from_jsvalue` (mixed-type literal construction — the JSValue sibling of the already-covered `js_array_from_values`). Internal reshuffles (sort, splice tail shift, slice copy, copyWithin) only move values already stored in an array — already shared — so no demote is needed. Tests: `string_append_heap_alias.rs` gains compile-run regressions for unshift / fill / with, each confirmed to fail without the demote. `js_array_from_jsvalue` is not emitted by codegen from any TypeScript source, so it gets a runtime unit test (`array/tests.rs`) instead, also confirmed to fail without the demote. Refs #5533, #5548. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(runtime): restore GC_STORE_AUDIT marker on unshift_variadic insert write The #5552 demote line pushed the ptr::write past the proximity window of the existing GC_STORE_AUDIT(BARRIERED) marker, failing the lint job's GC store-site inventory check. Re-annotate the insert write directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Ralph <ralph@skelpo.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ef48663 commit a8780e2

6 files changed

Lines changed: 159 additions & 2 deletions

File tree

crates/perry-runtime/src/array/concat_reverse.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,11 @@ fn reverse_throw_type_error(message: &[u8]) -> ! {
347347
/// `value`. Returns the same array pointer.
348348
#[no_mangle]
349349
pub extern "C" fn js_array_fill(arr: *mut ArrayHeader, value: f64) -> *mut ArrayHeader {
350+
// #5552: `fill` writes the same source into every slot — a uniquely-owned
351+
// (refcount==1) string would alias the source AND every filled slot to each
352+
// other, so a later `s += x` corrupts them all. Demote once to shared (no-op
353+
// for SSO / non-string; mirrors `js_array_push_f64`, #5548).
354+
crate::string::js_string_addref_if_heap_string(value);
350355
let arr = clean_arr_ptr_mut(arr);
351356
if arr.is_null() {
352357
return arr;
@@ -388,6 +393,9 @@ pub extern "C" fn js_array_fill_range(
388393
start: f64,
389394
end: f64,
390395
) -> *mut ArrayHeader {
396+
// #5552: demote a uniquely-owned source string once before it fills the
397+
// range (no-op for SSO / non-string). See `js_array_fill`.
398+
crate::string::js_string_addref_if_heap_string(value);
391399
let arr = clean_arr_ptr_mut(arr);
392400
if arr.is_null() {
393401
return arr;
@@ -472,6 +480,11 @@ pub extern "C" fn js_array_fill_generic(
472480
has_end: i32,
473481
end: f64,
474482
) -> f64 {
483+
// #5552: demote a uniquely-owned source string once before any slot write.
484+
// The array receiver delegates to `js_array_fill`/`_range` (which also
485+
// demote — idempotent), but the generic object-receiver loop below writes
486+
// `value` into every index directly, so the demote must happen here too.
487+
crate::string::js_string_addref_if_heap_string(value);
475488
let receiver_value = JSValue::from_bits(receiver.to_bits());
476489
if receiver_value.is_null() || receiver_value.is_undefined() {
477490
throw_fill_nullish_receiver();

crates/perry-runtime/src/array/immutable.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,11 @@ pub extern "C" fn js_array_with(
234234
index: f64,
235235
value: f64,
236236
) -> *mut ArrayHeader {
237+
// #5552: the replacement `value` is stored into the new array's slot — demote
238+
// a uniquely-owned (refcount==1) string to shared so a later `s += x` on the
239+
// source local can't corrupt it. No-op for SSO / non-string. The cloned
240+
// elements come from `arr` and are already shared. (Mirrors #5548.)
241+
crate::string::js_string_addref_if_heap_string(value);
237242
let arr = clean_arr_ptr(arr);
238243
if arr.is_null() {
239244
return js_array_alloc(0);

crates/perry-runtime/src/array/jsvalue_api.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,13 @@ pub extern "C" fn js_array_from_jsvalue(elements: *const u64, count: u32) -> *mu
3636
// Each u64 contains NaN-boxed JSValue bits, store as f64 bits
3737
for i in 0..count as usize {
3838
let bits = *elements.add(i);
39+
let value = f64::from_bits(bits);
40+
// #5552: demote a uniquely-owned source string before it aliases its
41+
// slot — the JSValue sibling of the already-covered
42+
// `js_array_from_values` (#5548). No-op for SSO / non-string.
43+
crate::string::js_string_addref_if_heap_string(value);
3944
// GC_STORE_AUDIT(BARRIERED): JSValue array initialization is followed by layout/barrier rebuild.
40-
ptr::write(arr_elements.add(i), f64::from_bits(bits));
45+
ptr::write(arr_elements.add(i), value);
4146
}
4247
rebuild_array_layout(arr);
4348
}

crates/perry-runtime/src/array/push_pop.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,11 @@ pub extern "C" fn js_array_shift_f64(arr: *mut ArrayHeader) -> f64 {
496496
/// Returns a pointer to the (possibly reallocated) array
497497
#[no_mangle]
498498
pub extern "C" fn js_array_unshift_f64(arr: *mut ArrayHeader, value: f64) -> *mut ArrayHeader {
499+
// #5552: a uniquely-owned (refcount==1) string unshifted to the front aliases
500+
// the new slot — demote it to shared so a later `s += x` on the source local
501+
// allocates fresh instead of mutating the stored element. No-op for SSO /
502+
// non-string (mirrors `js_array_push_f64`, #5548).
503+
crate::string::js_string_addref_if_heap_string(value);
499504
let arr = clean_arr_ptr_mut(arr);
500505
if arr.is_null() {
501506
return js_array_alloc(0);
@@ -590,8 +595,12 @@ pub extern "C" fn js_array_unshift_variadic(
590595
// Shift existing elements up by `n`.
591596
// GC_STORE_AUDIT(BARRIERED): memmove + new slots followed by layout/barrier rebuild.
592597
ptr::copy(elements_ptr, elements_ptr.add(n), length as usize);
593-
// Write items in source order at the front.
598+
// Write items in source order at the front. #5552: demote each
599+
// uniquely-owned string before it aliases its slot (no-op for SSO /
600+
// non-string).
594601
for (i, v) in item_vec.into_iter().enumerate() {
602+
crate::string::js_string_addref_if_heap_string(v);
603+
// GC_STORE_AUDIT(BARRIERED): inserted slots are followed by the layout/barrier rebuild below.
595604
ptr::write(elements_ptr.add(i), v);
596605
}
597606
(*arr).length = length + n as u32;

crates/perry-runtime/src/array/tests.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,45 @@ fn test_array_from_jsvalue_int32_rebuild_canonicalizes_raw_slots() {
739739
assert_ne!(js_array_get_f64_unchecked(arr, 1).to_bits(), elements[1]);
740740
}
741741

742+
/// #5552: `js_array_from_jsvalue` (the JSValue sibling of `js_array_from_values`,
743+
/// used for mixed-type array construction) must demote a uniquely-owned
744+
/// (refcount==1) heap string before it aliases an element slot — otherwise a
745+
/// later in-place `js_string_append` on the source rewrites the stored element.
746+
/// Codegen never emits this symbol today, so this can only be exercised at the
747+
/// runtime level (no compiled-TS regression test can reach it).
748+
#[test]
749+
fn test_array_from_jsvalue_demotes_unique_string_against_inplace_append() {
750+
// A uniquely-owned heap string with spare capacity, so `js_string_append`
751+
// takes its in-place (refcount==1) fast path.
752+
let init = b"prefix_init";
753+
let s = crate::string::js_string_from_bytes_with_capacity(init.as_ptr(), init.len() as u32, 64);
754+
unsafe {
755+
(*s).refcount = 1;
756+
}
757+
let s_bits = crate::value::js_nanbox_string(s as i64).to_bits();
758+
759+
// Construct a mixed-type array via the JSValue path.
760+
let elements = [s_bits, int32_jsvalue_bits(1)];
761+
let arr = js_array_from_jsvalue(elements.as_ptr(), elements.len() as u32);
762+
763+
// Grow the source in place. With the demote, `s` is now shared (refcount==0)
764+
// so this allocates fresh and leaves the stored element untouched; without
765+
// it, the in-place append corrupts arr[0].
766+
let more = crate::string::js_string_from_bytes(b"_more".as_ptr(), 5);
767+
let grown = crate::string::js_string_append(s, more);
768+
769+
let stored_bits = js_array_get_jsvalue(arr, 0);
770+
let stored_ptr = (stored_bits & crate::value::POINTER_MASK) as *const crate::StringHeader;
771+
let stored = crate::string::string_as_str(stored_ptr);
772+
assert_eq!(
773+
stored, "prefix_init",
774+
"string stored via js_array_from_jsvalue must not be corrupted by a later in-place append"
775+
);
776+
// Sanity: the source itself did grow (the append happened).
777+
let grown_str = crate::string::string_as_str(grown as *const crate::StringHeader);
778+
assert_eq!(grown_str, "prefix_init_more");
779+
}
780+
742781
#[test]
743782
fn test_nonnumeric_append_downgrades_raw_f64_and_preserves_payload() {
744783
let bool_bits = crate::value::JSValue::bool(true).bits();

crates/perry/tests/string_append_heap_alias.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,92 @@ console.log("a0=" + a[0] + " s=" + s);
265265
);
266266
}
267267

268+
// #5552: sibling array insert/replace paths that #5548 left uncovered —
269+
// `unshift`, `fill`, `with`, and mixed-type literal construction
270+
// (`js_array_from_jsvalue`). Each does a raw write of the source value into a
271+
// slot without the demote; the same snapshot-then-grow shape corrupts the
272+
// stored element. Each fails without the matching runtime demote.
273+
274+
/// `a.unshift(s)` — front insertion (`js_array_unshift_f64`).
275+
#[test]
276+
fn unique_string_unshifted_into_array_is_not_corrupted() {
277+
let dir = tempfile::tempdir().expect("tempdir");
278+
let entry = dir.path().join("main.ts");
279+
std::fs::write(
280+
&entry,
281+
r#"
282+
let s = "prefix"; // non-SSO, shared literal
283+
s += "_init"; // append on shared -> fresh heap string, refcount==1
284+
const a = ["x"];
285+
a.unshift(s); // front insert -> must demote s to shared
286+
s += "_more"; // refcount==1 in-place append -> must NOT corrupt a[0]
287+
console.log("a0=" + a[0] + " s=" + s);
288+
"#,
289+
)
290+
.expect("write entry");
291+
let out = compile_and_run(dir.path(), &entry);
292+
assert!(
293+
out.contains("a0=prefix_init s=prefix_init_more"),
294+
"string unshifted into an array must not be corrupted by a later += (got: {out:?})"
295+
);
296+
}
297+
298+
/// `a.fill(s)` — fills every slot with the same source (`js_array_fill`). The
299+
/// source aliases the source local AND every filled slot, so a later `+=` must
300+
/// not corrupt any of them.
301+
#[test]
302+
fn unique_string_filled_into_array_is_not_corrupted() {
303+
let dir = tempfile::tempdir().expect("tempdir");
304+
let entry = dir.path().join("main.ts");
305+
std::fs::write(
306+
&entry,
307+
r#"
308+
let s = "prefix";
309+
s += "_init";
310+
const a = ["x", "y", "z"];
311+
a.fill(s); // fill all slots -> must demote s to shared
312+
s += "_more";
313+
console.log("a0=" + a[0] + " a2=" + a[2] + " s=" + s);
314+
"#,
315+
)
316+
.expect("write entry");
317+
let out = compile_and_run(dir.path(), &entry);
318+
assert!(
319+
out.contains("a0=prefix_init a2=prefix_init s=prefix_init_more"),
320+
"string filled into an array must not be corrupted by a later += (got: {out:?})"
321+
);
322+
}
323+
324+
/// `a.with(i, s)` — immutable replace stores the source into the new array's
325+
/// slot (`js_array_with`).
326+
#[test]
327+
fn unique_string_stored_via_with_is_not_corrupted() {
328+
let dir = tempfile::tempdir().expect("tempdir");
329+
let entry = dir.path().join("main.ts");
330+
std::fs::write(
331+
&entry,
332+
r#"
333+
let s = "prefix";
334+
s += "_init";
335+
const a = ["placeholder"].with(0, s); // replace -> must demote s to shared
336+
s += "_more";
337+
console.log("a0=" + a[0] + " s=" + s);
338+
"#,
339+
)
340+
.expect("write entry");
341+
let out = compile_and_run(dir.path(), &entry);
342+
assert!(
343+
out.contains("a0=prefix_init s=prefix_init_more"),
344+
"string stored via arr.with() must not be corrupted by a later += (got: {out:?})"
345+
);
346+
}
347+
348+
// NOTE: the mixed-type construction path `js_array_from_jsvalue` (#5552) is not
349+
// emitted by codegen from any TypeScript source, so it has no compiled-TS
350+
// regression test here. Its demote is covered by the runtime unit test
351+
// `test_array_from_jsvalue_demotes_unique_string_against_inplace_append` in
352+
// crates/perry-runtime/src/array/tests.rs.
353+
268354
/// The snapshot-then-grow shape end to end: store the latest value into a heap
269355
/// field each step, then keep growing the source. Every stored snapshot must
270356
/// retain the value it had when stored (no in-place rewrite).

0 commit comments

Comments
 (0)