Repository navigation
gc: js_headers_for_each/keys/values/entries hold heap pointers across allocations and a JS callback #8217
Description
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:
closureis a bare*const ClosureHeaderin a Rust local, reused on every iteration, andjs_closure_call2runs user JS — so a collection during iteration n leaves every later iteration calling a pre-move address. Aheaders.forEachwhose callback allocates is the trigger.v_ptris allocated, thenk_ptris allocated (a second collection point), and only afterwards isv_ptrread intov_nan. A collection inside thek_ptrallocation 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.pyreads emitted LLVM IR and is structurally blind — these are Rust runtime locals.scripts/gc_runtime_root_holders.pyenumeratesstaticdeclarations; 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.
Activity
fix(gc): root the FormData array accumulators the new shape test found
fix(gc): root the Headers/FormData iteration frame slots (#8163, #8217)
fix(gc): root the FormData array accumulators the new shape test found
fix(gc): root the Headers/FormData iteration frame slots (#8163, #8217)…