js_proxy_new mints proxy ids straight out of PROXIES.len() with no bound check against the band it encodes into:
// crates/perry-runtime/src/proxy.rs
const PROXY_TAG_BASE: u64 = addr_class::PROXY_ID_BAND_START as u64; // 0xF0000
fn encode_proxy_id(id: u64) -> i64 { (PROXY_TAG_BASE + id) as i64 }
pub extern "C" fn js_proxy_new(target: f64, handler: f64) -> f64 {
...
let id = v.len() as u64; // <- unbounded
v.push(Some(Box::new(ProxyEntry { ... })));
let encoded = encode_proxy_id(id) as u64;
f64::from_bits(POINTER_TAG | (encoded & POINTER_MASK))
}
The revocable-Proxy band is [PROXY_ID_BAND_START, HANDLE_BAND_MAX) = [0xF0000, 0x100000) — exactly 65,536 ids. Id 65,536 encodes to 0x100000, which is HANDLE_BAND_MAX itself: addr_class::is_proxy_id_band rejects it and addr_class::is_above_handle_band accepts it, i.e. every classifier in the tree reads that payload as a dereferenceable heap address. The next property read dereferences it.
This is not a lost proxy. It is a wild pointer handed to user code.
Reproduction
const keep: any[] = [];
for (let i = 0; i < 65600; i++) {
const t: any = { i: i };
keep.push(new Proxy(t, { get(o: any, k: any) { return o[k]; } }));
}
console.log("created", keep.length);
for (const idx of [0, 60000, 65530, 65534, 65535, 65536, 65540, 65599]) {
const p: any = keep[idx];
console.log(idx, typeof p, p.i);
}
Node prints all eight rows. Perry:
created 65600
0 object 0
60000 object 60000
65530 object 65530
65534 object 65534
<- SIGSEGV, exit 139
keep[65534] is registry id 65,535 (index 0 is reserved), the last payload inside the band. keep[65535] is id 65,536 — the first one outside it.
Why it matters beyond a synthetic loop
PROXIES is append-only: js_proxy_new pushes, and the only slot.take() in the file is #[cfg(test)]. Nothing reclaims a slot, so the count is cumulative over process lifetime, not a live-proxy count. #8213 measured a warm Next.js App Route creating ~4 proxies per request (headers / cookies / mutableCookies / searchParams adapters), 10,037 entries after 2,478 requests — 15% of the band already consumed. At that rate the ceiling lands after roughly 16k requests, i.e. this is reachable by a server that merely stays up, not by a program that does anything unusual.
Every other handle band already guards its end — common/handle.rs (panic!("common native handle id range exhausted ...")) and fetch/mod.rs (panic!("Web Fetch handle id range exhausted")). The Proxy band is the one without a guard, and it is also the only band whose ids are minted directly by user code (new Proxy) with no matching free/close call.
A second, smaller inconsistency in the same area: lookup() accepts any payload below 0x1_0000_0000, so an out-of-band id is simultaneously "a live proxy" (per decode_proxy_id) and "a heap address" (per every addr_class consumer).
Fix
Refuse to mint past the band edge — throw a catchable RangeError rather than return a wild pointer, the same trade error::throw_allocation_failed makes for #5067 — and tighten decode_proxy_id so the two classifications cannot drift apart again.
That makes the failure safe and named; it does not make it go away. The ceiling exists because the registry never reclaims, which is #8213's mechanism (b). Raising the ceiling is not a cheap alternative: the band is boxed in by zlib below and by HANDLE_BAND_MAX above, and HANDLE_BAND_MAX is the "payloads below this are never dereferenceable" contract that every is_handle_band caller reads.
Refs #8213.
js_proxy_newmints proxy ids straight out ofPROXIES.len()with no bound check against the band it encodes into:The revocable-Proxy band is
[PROXY_ID_BAND_START, HANDLE_BAND_MAX)=[0xF0000, 0x100000)— exactly 65,536 ids. Id 65,536 encodes to0x100000, which isHANDLE_BAND_MAXitself:addr_class::is_proxy_id_bandrejects it andaddr_class::is_above_handle_bandaccepts it, i.e. every classifier in the tree reads that payload as a dereferenceable heap address. The next property read dereferences it.This is not a lost proxy. It is a wild pointer handed to user code.
Reproduction
Node prints all eight rows. Perry:
keep[65534]is registry id 65,535 (index 0 is reserved), the last payload inside the band.keep[65535]is id 65,536 — the first one outside it.Why it matters beyond a synthetic loop
PROXIESis append-only:js_proxy_newpushes, and the onlyslot.take()in the file is#[cfg(test)]. Nothing reclaims a slot, so the count is cumulative over process lifetime, not a live-proxy count. #8213 measured a warm Next.js App Route creating ~4 proxies per request (headers / cookies / mutableCookies / searchParams adapters), 10,037 entries after 2,478 requests — 15% of the band already consumed. At that rate the ceiling lands after roughly 16k requests, i.e. this is reachable by a server that merely stays up, not by a program that does anything unusual.Every other handle band already guards its end —
common/handle.rs(panic!("common native handle id range exhausted ...")) andfetch/mod.rs(panic!("Web Fetch handle id range exhausted")). The Proxy band is the one without a guard, and it is also the only band whose ids are minted directly by user code (new Proxy) with no matching free/close call.A second, smaller inconsistency in the same area:
lookup()accepts any payload below0x1_0000_0000, so an out-of-band id is simultaneously "a live proxy" (perdecode_proxy_id) and "a heap address" (per everyaddr_classconsumer).Fix
Refuse to mint past the band edge — throw a catchable
RangeErrorrather than return a wild pointer, the same tradeerror::throw_allocation_failedmakes for #5067 — and tightendecode_proxy_idso the two classifications cannot drift apart again.That makes the failure safe and named; it does not make it go away. The ceiling exists because the registry never reclaims, which is #8213's mechanism (b). Raising the ceiling is not a cheap alternative: the band is boxed in by zlib below and by
HANDLE_BAND_MAXabove, andHANDLE_BAND_MAXis the "payloads below this are never dereferenceable" contract that everyis_handle_bandcaller reads.Refs #8213.