Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ jobs:
- name: File size limit
run: ./scripts/check_file_size.sh

# GC write-barrier store-site inventory: every raw heap-slot store in
# perry-codegen / perry-runtime / perry-stdlib must be barriered or
# carry a justified GC_STORE_AUDIT(...) marker (or a justified entry
# in scripts/gc_store_site_allowlist.txt). Catches new unbarriered
# old->young store paths before they become nondeterministic segfaults.
- name: GC store-site inventory
run: |
python3 scripts/gc_store_site_inventory.py --self-test
python3 scripts/gc_store_site_inventory.py

# ---------------------------------------------------------------------------
# API docs drift gate (#465)
#
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/expr/native_memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ fn emit_u32_fill_loop(ctx: &mut FnCtx<'_>, data_ptr: &str, len_i32: &str, value_

ctx.current_block = body_idx;
let elem_ptr = ctx.block().gep(I32, data_ptr, &[(I64, &i)]);
// GC_STORE_AUDIT(POINTER_FREE): i32 fill of a native-memory buffer, never a heap edge.
ctx.block().store(I32, value_i32, &elem_ptr);
let next = ctx.block().add(I64, &i, "1");
ctx.block().store(I64, &next, &index_slot);
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/expr/pod_record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ pub(crate) fn store_pod_field_native(
let ptr = pod_field_ptr(ctx, data_slot, field.offset);
let llvm_ty =
llvm_type_for_native_rep(&field.native_rep).expect("pod field reps have scalar LLVM types");
// GC_STORE_AUDIT(POINTER_FREE): POD record fields are native scalars, never heap edges.
ctx.block()
.store_aligned(llvm_ty, &lowered.value, &ptr, field.alignment);
ctx.record_lowered_value(
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/lower_call/extern_func.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,7 @@ fn build_pod_temp_from_object_value(
let ptr = pod_field_ptr(ctx, &data_slot, field.offset);
let llvm_ty = llvm_type_for_native_rep(&field.native_rep)
.expect("manifest POD field reps have scalar LLVM types");
// GC_STORE_AUDIT(POINTER_FREE): POD record fields are native scalars, never heap edges.
ctx.block()
.store_aligned(llvm_ty, &lowered.value, &ptr, field.alignment);
}
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-runtime/src/array/generic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ pub extern "C" fn js_arraylike_map(recv: f64, cb: f64, this_arg: f64) -> f64 {
let v = al_get(recv, k);
let mapped = js_closure_call3(cb, v, k as f64, recv);
unsafe {
// GC_STORE_AUDIT(BARRIERED): note_array_slot below re-stores this slot with the barrier.
ptr::write(elems.add(k as usize), mapped);
note_array_slot(result, k as usize, mapped.to_bits());
}
Expand Down Expand Up @@ -813,6 +814,7 @@ fn materialize(recv: f64) -> *mut ArrayHeader {
}
let v = al_get(recv, k);
unsafe {
// GC_STORE_AUDIT(BARRIERED): note_array_slot below re-stores this slot with the barrier.
ptr::write(elems.add(k as usize), v);
note_array_slot(arr, k as usize, v.to_bits());
}
Expand Down Expand Up @@ -1127,6 +1129,7 @@ fn object_splice(recv: f64, args_ptr: *const f64, args_len: usize) -> f64 {
if al_has(recv, from) {
let v = al_get(recv, from);
unsafe {
// GC_STORE_AUDIT(BARRIERED): note_array_slot below re-stores this slot with the barrier.
ptr::write(removed_elems.add(k as usize), v);
note_array_slot(removed, k as usize, v.to_bits());
}
Expand Down
8 changes: 8 additions & 0 deletions crates/perry-runtime/src/array/sort.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ pub extern "C" fn js_array_sort_with_comparator(
let mut l = left;
let mut r = mid;
let mut k = left;
// GC_STORE_AUDIT(STACK): merge writes go to the `defined`/`buf` Vec temporaries.
while l < mid && r < right {
let cmp = cmp_with(comparator, direct_call, *src.add(l), *src.add(r));
if cmp <= 0.0 {
Expand All @@ -245,11 +246,13 @@ pub extern "C" fn js_array_sort_with_comparator(
}
k += 1;
}
// GC_STORE_AUDIT(STACK): tail copies also target the Vec temporaries.
while l < mid {
*dst.add(k) = *src.add(l);
l += 1;
k += 1;
}
// GC_STORE_AUDIT(STACK): right-tail copy targets the Vec temporaries.
while r < right {
*dst.add(k) = *src.add(r);
r += 1;
Expand All @@ -262,20 +265,25 @@ pub extern "C" fn js_array_sort_with_comparator(
}
// Make sure the sorted run lives back in `defined`.
if src != defined.as_mut_ptr() {
// GC_STORE_AUDIT(STACK): copy between the two Vec temporaries.
ptr::copy_nonoverlapping(src, defined.as_mut_ptr(), n);
}
}
// Write back: sorted defined values, then `undefined` ×N, then
// holes ×N — restoring the array's exotic sparseness.
let mut idx = 0usize;
// GC_STORE_AUDIT(BARRIERED): write-back is included in the rebuild below.
for &v in &defined {
*elements_ptr.add(idx) = v;
idx += 1;
}
// GC_STORE_AUDIT(POINTER_FREE): undefined/hole suffix has no child pointer;
// covered by the rebuild below anyway.
for _ in 0..undef_count {
*elements_ptr.add(idx) = f64::from_bits(crate::value::TAG_UNDEFINED);
idx += 1;
}
// GC_STORE_AUDIT(POINTER_FREE): hole suffix has no child pointer.
for _ in 0..hole_count {
*elements_ptr.add(idx) = f64::from_bits(crate::value::TAG_HOLE);
idx += 1;
Expand Down
7 changes: 6 additions & 1 deletion crates/perry-runtime/src/builtins/globals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,7 @@ fn js_structured_clone_inner(value: f64) -> f64 {
for i in 0..len as usize {
let elem = *elements.add(i);
let cloned = js_structured_clone(elem);
// GC_STORE_AUDIT(BARRIERED): note_array_slot below re-stores this slot with the barrier.
*elements.add(i) = cloned;
crate::array::note_array_slot(new_arr, i, cloned.to_bits());
}
Expand Down Expand Up @@ -751,7 +752,11 @@ fn js_structured_clone_inner(value: f64) -> f64 {
as *mut f64;
for i in 0..field_count as usize {
let field = *fields.add(i);
*fields.add(i) = js_structured_clone(field);
let cloned = js_structured_clone(field);
// GC_STORE_AUDIT(BARRIERED): cloned field uses the shared object slot-store helper.
// The recursive clone above can run minor GCs that tenure `cloned_obj`
// mid-loop, so this store must be barriered like the array branch.
crate::object::store_object_field_slot(cloned_obj, i, cloned.to_bits());
}
}
// NaN-box with POINTER_TAG
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-runtime/src/json_tape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1274,6 +1274,7 @@ pub unsafe fn alloc_lazy_array(
}
let hdr = hdr_handle.get_raw_mut_ptr::<LazyArrayHeader>();
let tape_dst = (hdr as *mut u8).add(std::mem::size_of::<LazyArrayHeader>()) as *mut TapeEntry;
// GC_STORE_AUDIT(POINTER_FREE): TapeEntry is offset/kind/link numerics, no heap edges.
std::ptr::copy_nonoverlapping(tape_entries.as_ptr(), tape_dst, tape_entries.len());
hdr_handle.get_raw_mut_ptr::<LazyArrayHeader>()
}
Expand Down Expand Up @@ -1557,6 +1558,7 @@ pub unsafe fn force_materialize_lazy(hdr: *mut LazyArrayHeader) -> *mut crate::a
.add(std::mem::size_of::<crate::array::ArrayHeader>())
as *mut u64;
let value_bits = value_handle.get_nanbox_u64();
// GC_STORE_AUDIT(BARRIERED): note_array_slot below re-stores this slot with the barrier.
*elements_ptr.add(i) = value_bits;
(*arr_ptr).length = (i + 1) as u32;
crate::array::note_array_slot(arr_ptr, i, value_bits);
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-runtime/src/object/arguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ unsafe fn build_data_descriptor(
let packed = b"value\0writable\0enumerable\0configurable";
let desc = js_object_alloc_with_shape(0x0D_A6_50, 4, packed.as_ptr(), packed.len() as u32);
let fields = (desc as *mut u8).add(std::mem::size_of::<ObjectHeader>()) as *mut f64;
// GC_STORE_AUDIT(BARRIERED): fresh descriptor fields are replayed by the rebuild below.
*fields = value;
*fields.add(1) = bool_value(writable);
*fields.add(2) = bool_value(enumerable);
Expand All @@ -583,6 +584,7 @@ unsafe fn build_accessor_descriptor(
let packed = b"get\0set\0enumerable\0configurable";
let desc = js_object_alloc_with_shape(0x0D_A6_51, 4, packed.as_ptr(), packed.len() as u32);
let fields = (desc as *mut u8).add(std::mem::size_of::<ObjectHeader>()) as *mut f64;
// GC_STORE_AUDIT(BARRIERED): fresh descriptor fields are replayed by the rebuild below.
*fields = get;
*fields.add(1) = set;
*fields.add(2) = bool_value(enumerable);
Expand Down
1 change: 1 addition & 0 deletions crates/perry-runtime/src/object/namespace_create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ mod sso_tests_1781 {
// (b) aligned real object, but its keys_array points at misaligned
// garbage — the exact Express crash shape.
let obj = super::super::alloc::js_object_alloc(0, 4);
// GC_STORE_AUDIT(POINTER_FREE): deliberately-misaligned unit-test sentinel, not a heap edge.
(*obj).keys_array = 0x2800_0203usize as *mut _;
assert!(
!own_key_present(obj, key),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-runtime/src/temporal/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ pub fn alloc_temporal_cell(value: TemporalValue) -> f64 {
) as *mut TemporalCell;
// `arena_alloc_gc` hands back uninitialized memory; `write` moves the
// box pointer in without dropping the (garbage) prior contents.
// GC_STORE_AUDIT(INIT): fresh cell; the Box payload is malloc-owned, not a GC edge.
std::ptr::write(ptr, TemporalCell { value: boxed });
f64::from_bits(JSValue::pointer(ptr as *const u8).bits())
}
Expand Down
201 changes: 201 additions & 0 deletions crates/perry/tests/gc_write_barrier_stress.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
//! Write-barrier coverage stress tests for the generational GC.
//!
//! Failure mode under test: an old-gen (tenured or malloc-backed) object is
//! mutated to point at a freshly allocated nursery value without a write
//! barrier. Minor GC then either never sees the edge (child swept while
//! live) or never rewrites the slot when evacuation moves the child —
//! both end in nondeterministic garbage reads or segfaults.
//!
//! Both tests run the compiled binary with the detection-maximizing knobs:
//! `PERRY_GC_FORCE_EVACUATE=1` (stress-copy every movable nursery survivor)
//! and `PERRY_GC_VERIFY_EVACUATION=1` (panic if a live slot still points at
//! a forwarded object after an evacuation/rewrite cycle).
//!
//! NOTE: the churn helpers use object/array *literals* and explicit `gc()`
//! only on programs without wide dynamic objects — building thousands of
//! dynamic properties on one object and then calling `gc()` deadlocks on a
//! pre-existing (unrelated) bug (#4878), and structuredClone of dynamic
//! overflow properties is a separate pre-existing gap (#4879). Wide object
//! literals are avoided too (compile-time blowup, #4880).

use std::path::PathBuf;
use std::process::Command;

fn perry_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
}

fn compile_and_run(source: &str) -> std::process::Output {
let dir = tempfile::tempdir().expect("tempdir");
let entry = dir.path().join("main.ts");
let output = dir.path().join("main_bin");
std::fs::write(&entry, source).expect("write entry");

let compile = Command::new(perry_bin())
.current_dir(dir.path())
.arg("compile")
.arg(&entry)
.arg("-o")
.arg(&output)
.output()
.expect("run perry compile");
assert!(
compile.status.success(),
"perry compile failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&compile.stdout),
String::from_utf8_lossy(&compile.stderr)
);

Command::new(&output)
.env("PERRY_GC_FORCE_EVACUATE", "1")
.env("PERRY_GC_VERIFY_EVACUATION", "1")
.output()
.expect("run compiled binary")
}

fn assert_ok_output(run: &std::process::Output, expected: &str) {
assert!(
run.status.success(),
"compiled binary failed (signal/segfault = missed write barrier)\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}",
run.status,
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr)
);
let stdout = String::from_utf8_lossy(&run.stdout);
assert_eq!(
stdout,
expected,
"stderr:\n{}",
String::from_utf8_lossy(&run.stderr)
);
}

/// Tenure a population of objects across several GC cycles, then mutate the
/// tenured objects (object fields, array elements, closure captures, module
/// globals) to point at freshly allocated nursery values, GC again, and
/// verify every value survived. Any missed barrier on those store paths
/// shows up as corrupt reads, an evacuation-verify panic, or a crash.
#[test]
fn tenured_mutation_stress() {
let run = compile_and_run(
r#"
let moduleRef: any = null;

function makeCounter() {
let state: any = { n: 0, tag: "init" };
return () => {
state = { n: state.n + 1, tag: "c" + state.n };
return state.tag;
};
}

function churn(rounds: number) {
for (let c = 0; c < rounds; c++) {
let garbage: any[] = [];
for (let j = 0; j < 30000; j++) {
garbage.push({ x: j, s: "pad" + j });
}
gc();
}
}

// Phase 1: allocate keepers and tenure them (survive several GC cycles).
const keepers: any[] = [];
for (let i = 0; i < 300; i++) {
keepers.push({ id: i, payload: null, arr: [i], tag: "k" + i });
}
const counter = makeCounter();
churn(5);

// Phase 2: mutate tenured objects to point at fresh nursery values.
for (let i = 0; i < 300; i++) {
keepers[i].payload = { value: i * 3 + 1, text: "p" + i, inner: [i, i + 1, "s" + i] };
keepers[i].arr.push({ deep: i });
}
moduleRef = { mark: "module-root", list: [1, 2, 3] };
counter(); // closure capture slot now points at a fresh nursery object
counter();

// Phase 3: GC again so any unbarriered old->young edge gets swept or
// left stale by evacuation.
churn(4);

// Phase 4: verify.
let bad = 0;
for (let i = 0; i < 300; i++) {
const k = keepers[i];
if (k.id !== i) bad++;
if (k.payload.value !== i * 3 + 1) bad++;
if (k.payload.text !== "p" + i) bad++;
if (k.payload.inner[2] !== "s" + i) bad++;
const last = k.arr[k.arr.length - 1];
if (last.deep !== i) bad++;
}
if (moduleRef.mark !== "module-root" || moduleRef.list[2] !== 3) bad++;
// tag records the pre-increment n, so the 3rd call yields "c2".
if (counter() !== "c2") bad++;
console.log(bad === 0 ? "BARRIER_STRESS_OK" : "BARRIER_STRESS_CORRUPT " + bad);
"#,
);
assert_ok_output(&run, "BARRIER_STRESS_OK\n");
}

/// structuredClone integrity under GC churn + forced evacuation. Covers the
/// runtime-helper barrier path hardened in this change (the object-field
/// deep-clone loop in `js_structured_clone` now routes through the shared
/// barriered store). Uses a 300-key literal (all fields inline) plus a deep
/// nested chain so the clone itself allocates enough to run GCs mid-clone.
#[test]
fn structured_clone_gc_churn_stress() {
let mut fields = String::new();
for i in 0..300 {
fields.push_str(&format!(
" f{i}: {{ v: {i}, pad: \"x{i}\" + \"y\".repeat(96) }},\n"
));
}
let source = format!(
r#"
function nest(depth: number): any {{
let o: any = {{ leaf: true, n: depth }};
for (let d = 0; d < depth; d++) {{
o = {{ child: o, mark: "d" + d, arr: [d, "s" + d] }};
}}
return o;
}}

const src: any = {{
{fields}
deep: nest(200),
tail: "end"
}};

const cl = structuredClone(src);

for (let c = 0; c < 4; c++) {{
let garbage: any[] = [];
for (let j = 0; j < 30000; j++) {{
garbage.push({{ z: j, s: "g" + j }});
}}
gc();
}}

let bad = 0;
for (let i = 0; i < 300; i++) {{
const f = cl["f" + i];
if (f === undefined || f === null) {{ bad++; continue; }}
if (f.v !== i) bad++;
else if (f.pad !== "x" + i + "y".repeat(96)) bad++;
}}
let cur = cl.deep;
for (let d = 199; d >= 0; d--) {{
if (cur.mark !== "d" + d || cur.arr[1] !== "s" + d) {{ bad++; break; }}
cur = cur.child;
}}
if (!cur.leaf || cur.n !== 200) bad++;
if (cl.tail !== "end") bad++;
console.log(bad === 0 ? "CLONE_STRESS_OK" : "CLONE_STRESS_CORRUPT " + bad);
"#
);
let run = compile_and_run(&source);
assert_ok_output(&run, "CLONE_STRESS_OK\n");
}
12 changes: 12 additions & 0 deletions scripts/gc_store_site_allowlist.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# GC store-site inventory allowlist.
#
# Format: path-prefix | line-substring-or-* | justification
# Matched findings are suppressed. Every entry MUST have a justification;
# malformed lines fail the run (exit 2). Prefer inline GC_STORE_AUDIT(...)
# markers next to the store — use this file only for whole-module policy
# decisions where per-line markers would be churn.
#
# Audit classes for inline markers: BARRIERED, EXTERNAL_BARRIERED, ROOT,
# INIT, POINTER_FREE, STACK. See scripts/gc_store_site_inventory.py.

crates/perry-runtime/src/gc/ | * | collector-internal: the barrier implementation and GC unit tests perform raw stores by design (helpers in gc/barrier.rs ARE the barriers; gc/tests/ deliberately writes unbarriered slots to exercise detection)
Loading
Loading