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
79 changes: 79 additions & 0 deletions changelog.d/7273-lint-gc-store-and-addr-class-gates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
**Cleared the two `lint` steps that had been failing on `main` for days —
`gc_store_site_inventory.py` (19 unaudited GC store sites) and
`addr_class_inventory.py` (2 ratchet regressions plus a stale allowlist
substring).** Both were invisible in CI: the `lint` job stops at its first
failing step and step 9 fails ahead of them, so neither had ever executed.

Of the 19 GC store-site findings, **5 were phantom**. `classify_rust_store`
searched the atomic-store regexes against `call_window` — a 6-line *forward*
window — without anchoring, so a single `cache.store(...)` was re-reported from
each of the five lines above it; `continue;` and a bare `}` were both listed as
"raw atomic cache/global pointer store". `anchored_search` now requires the
match to begin inside the head line, preserving the split-call coverage the
window exists for (`CACHE.store(` … `);`) while reporting each site once.

Three were genuinely new runtime sites and got a class each: the `TAG_HOLE`
capacity fills in `array/alloc.rs` and `array/push_pop.rs` (#7138) are `INIT` —
freshly allocated, never-published storage written with a non-pointer sentinel,
so there is no old value to remember and no edge to record; the `cache.store` in
`array/indexing.rs`'s `scan_prototype_addr_cache_roots_mut` (#7071) is `ROOT` —
it *is* the collector rewriting a registered side-table slot with the mutator
stopped, and `visit_usize_slot` returns true only when it relocated the object.

The remaining 11 were codegen stores. Four already carried a
`GC_STORE_AUDIT(POINTER_FREE)` marker that #6915's `value_is_canonical_raw_f64`
branch split had pushed 9–10 lines below it, outside the ±6-line window. The
other seven had prose rationale but not the canonical marker form: the
`instance_misc1.rs` / `property_set.rs` field stores are reached only after
`emit_plain_finite_number_check` proves the value's exponent is *not* all-ones
(every NaN-box tag shares an all-ones exponent, so the value is a genuine
unboxed double), and the five `proven_view_access.rs` arms write the typed
array's backing store, whose elements are raw numeric bytes — the codegen-side
counterpart of the `is_pointer_free_module` carve-out the script already makes
for `typedarray`/`typedarray_view`/`buffer`. That half of the change is
comments only; no generated code and no runtime behaviour moved.

The address-classification failures were **real defects, not bookkeeping**.
Three hand-rolled address floors landed on 2026-07-30 without going through
`value::addr_class`: `> 0x10000` / `<= 0x10000` in
`child_process/value_util.rs` and `< 0x1000` in `fs/dirent.rs`.
`js_get_string_pointer_unified` forwards a `POINTER_TAG` payload verbatim and
`JSValue::as_pointer` does the same, and those payloads carry registry handle
ids as well as heap pointers — so a fetch (`0x40000..0xE0000`), zlib
(`0xE0000..0xF0000`) or proxy (`0xF0000..0x100000`) id passed a floor an order
of magnitude too low and was dereferenced as a `StringHeader`/`ObjectHeader`.
That is the Linux-only fault class of #1843 / #4004 / #6271, which macOS's high
allocation base hides. Every handle-floor site in both files was converted to
`is_handle_band` / `is_above_handle_band` — including the two grandfathered
ones, same defect and same one-line fix — so both baseline entries drop to zero
instead of being re-pinned lower. `is_handle_band` also subsumes the redundant
null checks that followed, and `cp_raw_slot_is_heap_ptr` keeps an explicit
`raw > 0` so a negative `i64` slot is not turned into a huge "above the band"
value by `as usize`.

Separately, the `O_SYMLINK` allowlist entry was keyed on one of the two
spellings in `native_module/constants.rs`, so `("O_SYMLINK", (0x200000) as
f64),` failed the gate while `"O_SYMLINK" => Some(0x200000),` was suppressed.
Re-keyed on the constant name; it remains path-prefixed to that file, so any
other band literal there still fails. The pre-split `object/native_module.rs`
entry was dropped — that file now holds zero band literals.

The ratchet baseline was regenerated: 566 → 544 sites across 256 → 249 entries,
verified to contain **zero increases and zero new keys** by diffing the
`(rule, path) → count` maps, so it is purely the slack that accumulated while
`lint` was red. Only 3 of the 22 come from this change.

Both new regression tests were confirmed to fail *without* the fix rather than
merely pass with it: the `proto_cache_scan` case fails on the pre-anchoring
logic with exactly the five phantom lines, and the `O_SYMLINK` case fails
against the pre-fix allowlist with exactly the uncovered spelling.

One finding was deliberately left open. `addr_class_inventory.py`'s
`SCAN_ROOTS` covers only `perry-runtime` and `perry-stdlib`, while its sibling
`gc_store_site_inventory.py` also globs `crates/perry-ext-*`. When #6826 moved
`perry-stdlib/src/http.rs` into the HTTP extension its 11 `handle-floor` sites
moved out of the gate's field of view rather than being fixed — 18 ext-crate
sites are unaudited, 8 of them `band-literal`, which is allowlist-governed and
would hard-fail until each is individually justified. Filed as #7272 rather
than silently absorbed. This change also does not by itself turn `lint` green:
step 9 (#7257) still fails ahead of these two.
6 changes: 6 additions & 0 deletions crates/perry-codegen/src/expr/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@ pub(crate) fn lower_index_set_fast(
// arithmetic / Math.* / coerce chain) — store verbatim.
blk.store(DOUBLE, val_double, &element_ptr);
} else {
// GC_STORE_AUDIT(POINTER_FREE): js_array_numeric_value_to_raw_f64
// returns a plain unboxed f64 — no GC pointer, so no barrier.
let numeric_value = canonicalize_raw_f64_numeric_store_value(blk, val_double);
blk.store(DOUBLE, &numeric_value, &element_ptr);
}
Expand Down Expand Up @@ -484,6 +486,8 @@ pub(crate) fn lower_index_set_fast(
if value_is_canonical_raw_f64 {
blk.store(DOUBLE, val_double, &element_ptr);
} else {
// GC_STORE_AUDIT(POINTER_FREE): js_array_numeric_value_to_raw_f64
// returns a plain unboxed f64 — no GC pointer, so no barrier.
let numeric_value = canonicalize_raw_f64_numeric_store_value(blk, val_double);
blk.store(DOUBLE, &numeric_value, &element_ptr);
}
Expand Down Expand Up @@ -519,6 +523,8 @@ pub(crate) fn lower_index_set_fast(
if value_is_canonical_raw_f64 {
blk.store(DOUBLE, val_double, &element_ptr);
} else {
// GC_STORE_AUDIT(POINTER_FREE): js_array_numeric_value_to_raw_f64
// returns a plain unboxed f64 — no GC pointer, so no barrier.
let numeric_value = canonicalize_raw_f64_numeric_store_value(blk, val_double);
blk.store(DOUBLE, &numeric_value, &element_ptr);
}
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-codegen/src/expr/index_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1211,6 +1211,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
} else {
let numeric_value =
canonicalize_raw_f64_numeric_store_value(blk, &val_double);
// GC_STORE_AUDIT(POINTER_FREE): the
// canonicalizer returns a plain unboxed
// f64, never a GC pointer — no barrier.
blk.store(DOUBLE, &numeric_value, &element_ptr);
}
blk.br(&merge_label);
Expand Down
7 changes: 7 additions & 0 deletions crates/perry-codegen/src/expr/instance_misc1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1497,7 +1497,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
}
ctx.current_block = store_idx;
{
// Reached only when the finite check above
// proved `new`'s exponent is NOT all-ones;
// every NaN-box tag (INT32/STRING/POINTER/
// BIGINT) has an all-ones exponent.
let blk = ctx.block();
// GC_STORE_AUDIT(POINTER_FREE): a genuine
// unboxed double by the proof above, never
// a GC pointer — no edge, so no barrier.
blk.store(DOUBLE, &new, &field_ptr);
blk.br(&merge_label);
}
Expand Down
12 changes: 7 additions & 5 deletions crates/perry-codegen/src/expr/property_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -590,12 +590,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
}
ctx.current_block = store_idx;
{
// The finite check proved a genuine
// unboxed double (INT32-boxed and every
// NaN-box tag share the all-ones
// exponent) — no canonicalization call,
// no barrier (pointer-free by proof).
// The finite check proved a genuine unboxed
// double (INT32-boxed and every NaN-box tag
// share the all-ones exponent), so no
// canonicalization call is needed.
let blk = ctx.block();
// GC_STORE_AUDIT(POINTER_FREE): pointer-free
// by that proof — no GC pointer reaches the
// slot, so no write barrier.
blk.store(DOUBLE, &val_double, &field_ptr);
blk.br(&merge_label);
}
Expand Down
12 changes: 12 additions & 0 deletions crates/perry-codegen/src/expr/proven_view_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,23 +388,35 @@ pub(crate) fn try_lower_proven_view_checked_store(
idx_i64
};
let elem_ptr = blk.gep(I8, &data_ptr, &[(I64, &byte_off)]);
// Every arm below stores into `elem_ptr`, which addresses the view's
// BACKING STORE (`view.data_slot`). Typed-array elements are raw
// numeric bytes and can never hold a JSValue, so none of these stores
// creates a heap edge and none needs a write barrier. This is the
// codegen-side counterpart of the runtime carve-out for the
// `typedarray` / `typedarray_view` / `buffer` modules
// (`is_pointer_free_module` in scripts/gc_store_site_inventory.py).
match view.elem {
BufferElem::I8 | BufferElem::U8 => {
let byte = blk.trunc(I32, &value_native.value, I8);
// GC_STORE_AUDIT(POINTER_FREE): typed-array backing store.
blk.store(I8, &byte, &elem_ptr);
}
BufferElem::I16 | BufferElem::U16 => {
let half = blk.trunc(I32, &value_native.value, I16);
// GC_STORE_AUDIT(POINTER_FREE): typed-array backing store.
blk.store(I16, &half, &elem_ptr);
}
BufferElem::I32 | BufferElem::U32 => {
// GC_STORE_AUDIT(POINTER_FREE): typed-array backing store.
blk.store(I32, &value_native.value, &elem_ptr);
}
BufferElem::F32 => {
let narrow = blk.fptrunc(DOUBLE, &value_native.value, F32);
// GC_STORE_AUDIT(POINTER_FREE): typed-array backing store.
blk.store(F32, &narrow, &elem_ptr);
}
BufferElem::F64 => {
// GC_STORE_AUDIT(POINTER_FREE): typed-array backing store.
blk.store(DOUBLE, &value_native.value, &elem_ptr);
}
BufferElem::U8Clamped => unreachable!("gated above"),
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-runtime/src/array/alloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ pub extern "C" fn js_array_alloc(capacity: u32) -> *mut ArrayHeader {
// scan misreads as live from-space pointers.
let elements_ptr = (ptr as *mut u8).add(std::mem::size_of::<ArrayHeader>()) as *mut u64;
for i in 0..actual_capacity as usize {
// GC_STORE_AUDIT(INIT): initialization of a just-allocated array
// that is not yet reachable from any root, and TAG_HOLE is a
// non-pointer sentinel — there is no old value to remember and no
// new edge to record, so no write barrier.
std::ptr::write(elements_ptr.add(i), crate::value::TAG_HOLE);
}
set_array_numeric_layout(ptr, NumericArrayLayout::RawF64);
Expand Down
6 changes: 6 additions & 0 deletions crates/perry-runtime/src/array/indexing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ pub fn scan_prototype_addr_cache_roots_mut(visitor: &mut crate::gc::RuntimeRootV
}
let mut addr = cached;
if visitor.visit_usize_slot(&mut addr) {
// GC_STORE_AUDIT(ROOT): this IS the collector's root-rewrite of a
// registered side-table slot, running inside a root scan with the
// mutator stopped. `visit_usize_slot` returns true only when it
// relocated the object, and the value written is the visitor's own
// to-space address — barriering it would push an edge into the
// remembered set that this very cycle is rebuilding.
cache.store(addr, Ordering::Relaxed);
}
}
Expand Down
6 changes: 6 additions & 0 deletions crates/perry-runtime/src/array/push_pop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ pub extern "C" fn js_array_grow(arr: *mut ArrayHeader, min_capacity: u32) -> *mu
let new_elems =
(new_ptr as *mut u8).add(std::mem::size_of::<ArrayHeader>()) as *mut u64;
for i in old_capacity as usize..new_capacity as usize {
// GC_STORE_AUDIT(INIT): initialization of the freshly grown
// array's added [old_capacity, new_capacity) slack — storage
// this allocation has never published, written with the
// non-pointer TAG_HOLE sentinel. No edge is created, so no
// write barrier (the copied prefix replays its own barriers
// via replay_array_growth_write_barriers below).
ptr::write(new_elems.add(i), crate::value::TAG_HOLE);
}
}
Expand Down
26 changes: 23 additions & 3 deletions crates/perry-runtime/src/child_process/value_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,13 @@ pub(crate) fn cp_box_string(s: &str) -> f64 {
/// route through the unified accessor which materializes SSO bytes.
pub(crate) fn cp_value_to_string(value: f64) -> Option<String> {
let ptr = crate::value::js_get_string_pointer_unified(value) as *const StringHeader;
if ptr.is_null() || (ptr as usize) < 0x1000 {
// #7259: `js_get_string_pointer_unified` forwards a POINTER_TAG payload
// verbatim, and those payloads carry registry handle ids as well as heap
// pointers. The old `< 0x1000` floor sat an order of magnitude below
// `HANDLE_BAND_MAX`, so a fetch/zlib/proxy id sailed through into the
// `(*ptr).byte_len` read below — the Linux-only segfault class of #1843 /
// #4004 / #6271, which macOS's high allocation base hides.
if !crate::value::addr_class::is_above_handle_band(ptr as usize) {
return None;
}
unsafe {
Expand Down Expand Up @@ -215,14 +221,28 @@ pub(crate) fn cp_args_from_value(value: f64) -> Vec<String> {
}
}

/// True when a codegen-supplied raw argument slot holds a dereferenceable heap
/// pointer, rather than an absent argument or a small registry handle id.
///
/// #7259: the previous `> 0x10000` / `<= 0x10000` floors sat an order of
/// magnitude below `HANDLE_BAND_MAX`, so a fetch (0x40000..0xE0000), zlib
/// (0xE0000..0xF0000) or proxy (0xF0000..0x100000) id in the slot was boxed as
/// a pointer and then dereferenced by `cp_array_ptr` / `cp_object_ptr`. The
/// explicit `raw > 0` keeps the old signed comparison's rejection of negative
/// slots, which `as usize` alone would turn into a huge "above the band" value.
#[inline]
fn cp_raw_slot_is_heap_ptr(raw: i64) -> bool {
raw > 0 && crate::value::addr_class::is_above_handle_band(raw as usize)
}

/// Normalize `spawn*`/`fork`'s optional `(args, options)` slots after codegen
/// has unboxed them: when the third argument is absent, a plain object in the
/// second slot is the options object, not an argv list.
pub(crate) fn cp_options_from_raw_args(args_ptr: i64, opts_ptr: i64) -> f64 {
if opts_ptr > 0x10000 {
if cp_raw_slot_is_heap_ptr(opts_ptr) {
return cp_box_ptr(opts_ptr as *const u8);
}
if args_ptr <= 0x10000 {
if !cp_raw_slot_is_heap_ptr(args_ptr) {
return cp_undefined();
}
let args = cp_box_ptr(args_ptr as *const u8);
Expand Down
24 changes: 12 additions & 12 deletions crates/perry-runtime/src/fs/dirent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,15 @@ pub(crate) unsafe fn options_with_file_types(options_value: f64) -> bool {
} else {
return false;
};
if raw_ptr < 0x1000 {
// #7259: a POINTER_TAG payload can be a registry handle id rather than a
// heap address, and `< 0x1000` sits an order of magnitude below
// `HANDLE_BAND_MAX` — fetch/zlib/proxy ids passed it and were dereferenced
// as an ObjectHeader (the Linux-only fault class of #1843/#4004/#6271).
// `is_handle_band` also subsumes the null check that used to follow.
if crate::value::addr_class::is_handle_band(raw_ptr) {
return false;
}
let obj_ptr = raw_ptr as *const crate::object::ObjectHeader;
Comment on lines +132 to 140

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Root options_value before constructing the field key.

js_string_from_bytes at Line 141 can collect. obj_ptr can then be stale when Line 142 calls js_object_get_field_by_name.

Replace this duplicated read with options_bool_field(options_value, b"withFileTypes"). That helper roots and reloads options_value.

As per coding guidelines, GC-managed pointers must remain rooted across every operation that can collect. Based on learnings, options_field_value provides the required GC-safe filesystem option-field access.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/fs/dirent.rs` around lines 132 - 140, Update the
filesystem option-field access around options_value and
js_object_get_field_by_name to use options_bool_field(options_value,
b"withFileTypes") instead of constructing a field key and dereferencing obj_ptr
directly. Ensure the helper roots and reloads options_value across any
allocating operation, while preserving the existing withFileTypes behavior.

Sources: Coding guidelines, Learnings

if obj_ptr.is_null() {
return false;
}
let key = crate::string::js_string_from_bytes(b"withFileTypes".as_ptr(), 13);
let val = crate::object::js_object_get_field_by_name(obj_ptr, key);
crate::value::js_is_truthy(f64::from_bits(val.bits())) != 0
Expand Down Expand Up @@ -181,13 +183,13 @@ pub(crate) unsafe fn options_field_value(
} else {
return None;
};
if raw_ptr < 0x1000 {
// #7259: see `options_with_file_types` — a POINTER_TAG payload can be a
// registry handle id, and `is_handle_band` (not `< 0x1000`) is the floor
// that rejects the fetch/zlib/proxy bands. It subsumes the null check too.
if crate::value::addr_class::is_handle_band(raw_ptr) {
return None;
}
let obj_ptr = raw_ptr as *const crate::object::ObjectHeader;
if obj_ptr.is_null() {
return None;
}
let keys = (*obj_ptr).keys_array;
if !keys.is_null() {
let key_count = crate::array::js_array_length(keys) as usize;
Expand All @@ -214,13 +216,11 @@ pub(crate) unsafe fn options_field_value(
} else {
return None;
};
if refreshed_ptr < 0x1000 {
// #7259: same handle-band floor after the GC-safe re-read of the handle.
if crate::value::addr_class::is_handle_band(refreshed_ptr) {
return None;
}
let refreshed_obj_ptr = refreshed_ptr as *const crate::object::ObjectHeader;
if refreshed_obj_ptr.is_null() {
return None;
}
let val = crate::object::js_object_get_field_by_name(refreshed_obj_ptr, key);
if val.bits() == crate::value::TAG_UNDEFINED {
None
Expand Down
Loading
Loading