Skip to content

gc: js_headers_for_each/keys/values/entries hold heap pointers across allocations and a JS callback #8217

Description

@proggeramlug

Found by code-reading crates/perry-stdlib/src/fetch/headers.rs while answering an ownership question during the #8163 residual hunt. Reachability on the Next App Route path is NOT proven — filing because the shape is unambiguous and it is the #7154 family, not because it is known to fire. Four sites, all in that one file:

1. js_headers_for_each — a raw closure pointer live across user JS

let cb_ptr = (cb_bits & 0x0000_FFFF_FFFF_FFFF) as i64;
let closure = cb_ptr as *const perry_runtime::ClosureHeader;
for (k, v) in entries {
    let v_ptr = js_string_from_bytes(...);   // allocates
    let k_ptr = js_string_from_bytes(...);   // allocates
    let v_nan = JSValue::string_ptr(v_ptr).bits();   // <-- v_ptr may have MOVED
    ...
    perry_runtime::js_closure_call2(closure, ...);   // <-- runs user JS; `closure` may have MOVED
}

Two defects in one loop:

  • closure is a bare *const ClosureHeader in a Rust local, reused on every iteration, and js_closure_call2 runs user JS — so a collection during iteration n leaves every later iteration calling a pre-move address. A headers.forEach whose callback allocates is the trigger.
  • v_ptr is allocated, then k_ptr is allocated (a second collection point), and only afterwards is v_ptr read into v_nan. A collection inside the k_ptr allocation moves the value string, and the callback receives a dangling one.

2–4. js_headers_keys / js_headers_values / js_headers_entries — the array is live across allocations

let mut arr = perry_runtime::js_array_alloc(entries.len() as u32);
for (k, _) in entries {
    let k_ptr = js_string_from_bytes(...);        // allocates -> can move `arr`
    arr = perry_runtime::js_array_push_f64(arr, ...);  // stale `arr`
}

arr is held across js_string_from_bytes. The arr = push(arr, …) idiom handles the array growing, not the collector moving it. js_headers_entries is the worst of the three: k_ptr is live across the v_ptr allocation, pair is live across two js_array_push_f64 calls (which allocate), and arr is live across the whole pair construction.

Why the existing instruments miss it

  • scripts/gc_root_dominance_check.py reads emitted LLVM IR and is structurally blind — these are Rust runtime locals.
  • scripts/gc_runtime_root_holders.py enumerates static declarations; these are stack locals, not declarations.
  • The Next App Route fixture's cold-start loop runs zero copying minors in normal mode (measured, test(next): add the warm-process soak arm #8163's residual needs #8215), so it could not surface this even if the path were hot.

Fix shape

The same one #8131/#8211 applied elsewhere: RuntimeHandleScope + across_* around each allocation and each js_closure_call*, re-reading the closure pointer and the string/array pointers after every call that can collect. Note raw_handle_debt.py distinguishes reload shape (across_*) from argument position (with_*_ptr).

Coordination

crates/perry-stdlib/src/fetch/** was excluded from the #8163 residual hunt at my request; I have released it. If the hunt's own headers.rs change turns out to be a candidate fix that covers some of these, it should absorb them; if it is a throwaway instrument, these four still want fixing and I will take them.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions