Skip to content

Commit 16d44c0

Browse files
proggeramlugRalph Küpper
andauthored
fix(runtime): share symbol-keyed metadata by reference across wrappers of one native handle (#5715)
* fix(runtime): #5437 — share native-handle symbol meta across request wrappers (Next.js resolvedPathname) A native handle (small-id NaN-boxed POINTER, e.g. a node:http IncomingMessage) carries per-request metadata in the symbol side table keyed by its handle id. Node shares ONE metadata object by reference across every NodeNextRequest wrapper that re-news around the same IncomingMessage, so: - a wrapper read `req[NEXT_REQUEST_META]` resolves to the shared object, and - a write-back `this._req[SYM] = this[SYM]` is harmless when `this[SYM]` is the shared object. In Perry the sharing broke in two ways for a late SSR-bundled wrapper: 1. WIPE: the bundled wrapper reached the write-back with an *undefined* `this[SYM]`, clobbering the handle's existing metadata to undefined and losing `resolvedPathname` → Next's `resolvedPathname must be set` invariant. 2. UNSEEDED READ: the bundled wrapper's own `[SYM]` was never set, so the render's `getRequestMeta` read undefined off it even though its `_req` handle held the shared meta. Fix (both gated tightly to native handle-band receivers): - set_symbol_property: an `undefined` write onto a handle-band receiver that already holds a non-undefined entry is a no-op (never adds information; the by-reference object the handle still points at is what Node keeps). - js_object_get_symbol_property: on a heap-wrapper side-table miss, fall through to the wrapper's `_req` native handle's symbol meta. Regression tests cover the share, the wipe-guard, and that a plain heap object setting a symbol prop to undefined still clears it. * test(symbol): suppress GC in handle-meta-share tests (parallel cargo-test flake) The two #5437 handle-meta tests compared a heap `meta` pointer that the GC-rooted SYMBOL_PROPERTIES side table rewrites on a move while the local stays stale — deterministic serially (how it was validated), but under parallel cargo-test a sibling allocation triggers a GC mid-test → spurious `got != meta`. Wrap each body in gc_suppress()/gc_unsuppress() so the objects can't move; unsuppress before the assert so a panic can't leak suppression. The symbol-meta fix itself is unchanged. * fix(runtime): #5437 — GC-invariant handle-meta tests + CodeRabbit fixes (A) Make the handle-meta-share tests deterministic under parallel cargo-test without relying on gc_suppress (which only gates gc_check_trigger, not the 1MB block-alloc trigger). Use an immovable NaN-boxed NUMBER for the metadata value: numbers are never pointers, so the SYMBOL_PROPERTIES side-table scanner never rewrites them on a GC move -> got.to_bits() == meta.to_bits() holds across any collection. wrapper_reads_share_underlying_handle_meta also forces a full gc() up front to compact the arena so its few allocations can't trip a mid-test block-alloc GC. The metadata tests now use the exact Symbol.for("NextInternalRequestMeta") symbol (required by the narrowed guard below). (B1) req_handle_symbol_fallback (get.rs): drop the is_small_handle pre-check. is_valid_obj_ptr already rejects handles and accepts a valid heap object even at a low address; the pre-check wrongly rejected a real low-address wrapper. (B2) set_symbol_property (properties.rs): narrow the undefined-write no-op to ONLY Symbol.for("NextInternalRequestMeta") on a handle-band receiver (resolved+cached once). Any other handle symbol — including clearing it with undefined — now stores normally. Added a regression test (undefined_write_clears_non_metadata_symbol_on_handle). * test(symbol): handle-meta test ids must be < 0x1000 (Linux is_valid_obj_ptr HEAP_MIN) The handle-meta tests failed ONLY on x86_64-LINUX CI (pass on arm64 + x86_64 macOS — proven via Rosetta). Root: is_valid_obj_ptr uses HEAP_MIN=0x1000 on Linux but 0x200_0000_0000 on macOS, so synthetic handle ids 0x4321/0x5678/0x6789 (all >= 0x1000) were classified as valid heap objects on Linux, breaking the handle-band gate. On macOS they're far below HEAP_MIN so the gate worked. Fix: ids < 0x1000 (matching real tiny native handles), rejected as non-objects on every platform. Not a GC race (corrects the prior gc_suppress/number-meta theory). --------- Co-authored-by: Ralph Küpper <ralph2@skelpo.com>
1 parent 393af3c commit 16d44c0

2 files changed

Lines changed: 292 additions & 0 deletions

File tree

crates/perry-runtime/src/symbol/get.rs

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,48 @@ pub(crate) unsafe fn own_symbol_property(obj_f64: f64, sym_f64: f64) -> Option<f
6161
None
6262
}
6363

64+
/// #5437: resolve a symbol-keyed read against the underlying native handle a
65+
/// request wrapper aliases via its `_req` field. Returns `None` unless the
66+
/// receiver is a heap object whose `_req` is a small handle (POINTER-tagged,
67+
/// not a heap object) that holds the requested symbol in the side table.
68+
unsafe fn req_handle_symbol_fallback(obj_f64: f64, sym_f64: f64) -> Option<f64> {
69+
let bits = obj_f64.to_bits();
70+
if (bits >> 48) != 0x7FFD {
71+
return None;
72+
}
73+
let raw = (bits & POINTER_MASK) as usize;
74+
// Only heap-object wrappers carry a `_req` field; skip handle receivers.
75+
// `is_valid_obj_ptr` already rejects small native handles (it validates the
76+
// GcHeader) and accepts a genuine heap object even at a low address, so the
77+
// extra `is_small_handle` pre-check would have wrongly rejected a real heap
78+
// wrapper that happens to live in the low band.
79+
if !crate::object::is_valid_obj_ptr(raw as *const u8) {
80+
return None;
81+
}
82+
let key = b"_req";
83+
let kh = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32);
84+
let req = crate::object::js_object_get_field_by_name_f64(
85+
raw as *const crate::object::ObjectHeader,
86+
kh as *const crate::StringHeader,
87+
);
88+
let rbits = req.to_bits();
89+
if (rbits >> 48) != 0x7FFD {
90+
return None;
91+
}
92+
let rraw = (rbits & POINTER_MASK) as usize;
93+
// The `_req` must be a small native handle, never another heap object — a
94+
// heap `_req` would be served by its own normal symbol path, and recursing
95+
// into it risks loops.
96+
if !crate::value::addr_class::is_small_handle(rraw)
97+
|| crate::object::is_valid_obj_ptr(rraw as *const u8)
98+
{
99+
return None;
100+
}
101+
// Read only what the handle actually holds in the side table (no deref of
102+
// the handle id as a heap object).
103+
own_symbol_property(req, sym_f64)
104+
}
105+
64106
unsafe fn object_header_ptr_from_value_bits(bits: u64) -> Option<usize> {
65107
let top16 = bits >> 48;
66108
let raw = if top16 == 0x7FFD {
@@ -371,6 +413,21 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6
371413
if let Some(v) = own_symbol_property(obj_f64, sym_f64) {
372414
return v;
373415
}
416+
// #5437 (Next.js): a heap request *wrapper* whose own symbol entry misses
417+
// may be one of several `NodeNextRequest`s wrapping the SAME underlying
418+
// native IncomingMessage handle (stored in its `_req` field). Node shares
419+
// one per-request metadata object by reference across every such wrapper —
420+
// the wrapper's ctor does `this[SYM] = this._req[SYM] || {}`. When that
421+
// share didn't land on this particular wrapper (a late SSR-bundled copy
422+
// never had its `[SYM]` seeded), fall through to the underlying handle's
423+
// symbol meta so the read still observes the shared object, matching Node.
424+
// Gated tightly: only fires on a side-table MISS, only when `_req` resolves
425+
// to a small native handle (POINTER-tagged, below HANDLE_BAND_MAX, not a
426+
// real heap object), and only returns a value the handle actually holds —
427+
// so ordinary objects (no `_req`, or a heap `_req`) are unaffected.
428+
if let Some(v) = req_handle_symbol_fallback(obj_f64, sym_f64) {
429+
return v;
430+
}
374431
let sym_key = sym_key_from_f64(sym_f64);
375432
if sym_key != 0 {
376433
let jsval = crate::value::JSValue::from_bits(bits);
@@ -696,3 +753,170 @@ fn class_chain_has_method(class_id: u32, name: &str) -> bool {
696753
}
697754
false
698755
}
756+
757+
#[cfg(test)]
758+
mod handle_meta_share_tests {
759+
//! #5437: a native handle's symbol-keyed metadata must be shared by
760+
//! reference across heap wrappers that alias it (Next.js NodeNextRequest
761+
//! over an IncomingMessage), and must survive an `undefined` write-back.
762+
use super::*;
763+
764+
const POINTER_TAG_BITS: u64 = 0x7FFD_0000_0000_0000;
765+
766+
// A registered `Symbol.for(key)` as a NaN-boxed f64.
767+
unsafe fn registered_symbol(key: &str) -> f64 {
768+
let kh = js_string_from_bytes(key.as_ptr(), key.len() as u32);
769+
let key_f64 = crate::value::js_nanbox_string(kh as i64);
770+
super::constructors::js_symbol_for(key_f64)
771+
}
772+
773+
// The exact registered symbol Next.js uses; the undefined-write wipe guard
774+
// is gated to THIS symbol, so the metadata tests must use it (not a
775+
// test-suffixed variant) for the no-op behaviour to fire.
776+
unsafe fn next_request_meta_symbol() -> f64 {
777+
registered_symbol("NextInternalRequestMeta")
778+
}
779+
780+
// A small native handle id NaN-boxed as POINTER (e.g. an IncomingMessage).
781+
// MUST stay below 0x1000: `is_valid_obj_ptr` uses HEAP_MIN=0x1000 on Linux
782+
// (0x200_0000_0000 on macOS), so an id >= 0x1000 is misclassified as a valid
783+
// heap object ON LINUX — which breaks the handle-band gate and fails these
784+
// tests in CI (they pass on macOS, where the id is far below HEAP_MIN). Real
785+
// native handles are tiny, so a sub-0x1000 id faithfully models them.
786+
fn handle_value(id: u64) -> f64 {
787+
f64::from_bits(POINTER_TAG_BITS | id)
788+
}
789+
790+
// An IMMOVABLE metadata value: a plain NaN-boxed number. Unlike a heap
791+
// object, a number is never a pointer, so the SYMBOL_PROPERTIES side-table
792+
// scanner never rewrites it on a GC move — its bits are invariant across any
793+
// collection. The #5437 fix logic (no-op on undefined, `_req` fallthrough)
794+
// is value-agnostic, so a number tests it faithfully without depending on
795+
// GC suppression to keep a heap `meta` from moving.
796+
fn immovable_meta() -> f64 {
797+
// A normal finite double whose bits are stable and unambiguous.
798+
1234.5_f64
799+
}
800+
801+
#[test]
802+
fn wrapper_reads_share_underlying_handle_meta() {
803+
unsafe {
804+
// Compact the arena up front with a full GC so the few small
805+
// allocations this test makes can't trip a mid-test block-alloc GC
806+
// (which bypasses `gc_suppress`). `gc_suppress` is kept as belt-and-
807+
// braces; the immovable number `meta` makes the assertion itself
808+
// GC-invariant regardless.
809+
crate::gc::js_gc_collect();
810+
crate::gc::gc_suppress();
811+
let sym = next_request_meta_symbol();
812+
// Pick a handle id well inside the small-handle band but unlikely to
813+
// collide with another test's side-table entry.
814+
let handle = handle_value(0x321);
815+
816+
// The per-request metadata value lives on the handle. Use an
817+
// immovable number so a GC can't invalidate the comparison.
818+
let meta = immovable_meta();
819+
super::properties::js_object_set_symbol_property(handle, sym, meta);
820+
821+
// A heap wrapper that aliases the handle via `_req` but never had
822+
// its own `[sym]` seeded.
823+
let wrapper_obj = crate::object::js_object_alloc(0, 1);
824+
assert!(!wrapper_obj.is_null());
825+
let req_key = js_string_from_bytes(b"_req".as_ptr(), 4);
826+
crate::object::js_object_set_field_by_name(wrapper_obj, req_key, handle);
827+
let wrapper = crate::value::js_nanbox_pointer(wrapper_obj as i64);
828+
829+
// Reading the symbol off the wrapper falls through to the handle's
830+
// shared meta — the exact Node-by-reference semantics.
831+
let got = js_object_get_symbol_property(wrapper, sym);
832+
// Unsuppress before asserting so a panic can't leave GC suppressed
833+
// for sibling tests on this thread.
834+
crate::gc::gc_unsuppress();
835+
assert_eq!(
836+
got.to_bits(),
837+
meta.to_bits(),
838+
"wrapper symbol read should share the handle's meta value"
839+
);
840+
}
841+
}
842+
843+
#[test]
844+
fn undefined_write_does_not_clobber_handle_meta() {
845+
unsafe {
846+
// Immovable number `meta` → no heap object to move → assertion is
847+
// GC-invariant; `gc_suppress` is defensive only.
848+
crate::gc::gc_suppress();
849+
let sym = next_request_meta_symbol();
850+
let handle = handle_value(0x654);
851+
let meta = immovable_meta();
852+
super::properties::js_object_set_symbol_property(handle, sym, meta);
853+
854+
// The `this._req[SYM] = this[SYM]` write-back where `this[SYM]` is
855+
// undefined must NOT erase the handle's existing meta.
856+
let undef = f64::from_bits(TAG_UNDEFINED);
857+
super::properties::js_object_set_symbol_property(handle, sym, undef);
858+
859+
let got = js_object_get_symbol_property(handle, sym);
860+
crate::gc::gc_unsuppress();
861+
assert_eq!(
862+
got.to_bits(),
863+
meta.to_bits(),
864+
"an undefined write must not clobber a handle's existing meta"
865+
);
866+
}
867+
}
868+
869+
#[test]
870+
fn undefined_write_to_plain_heap_object_still_clears() {
871+
unsafe {
872+
// The wipe-guard is gated to handle-band receivers; a normal heap
873+
// object setting a symbol prop to undefined must still take effect.
874+
let sym = registered_symbol("plainObjSym@@test_clear");
875+
let obj_ptr = crate::object::js_object_alloc(0, 0);
876+
let obj = crate::value::js_nanbox_pointer(obj_ptr as i64);
877+
let v = immovable_meta();
878+
super::properties::js_object_set_symbol_property(obj, sym, v);
879+
let undef = f64::from_bits(TAG_UNDEFINED);
880+
super::properties::js_object_set_symbol_property(obj, sym, undef);
881+
let got = js_object_get_symbol_property(obj, sym);
882+
assert_eq!(
883+
got.to_bits(),
884+
TAG_UNDEFINED,
885+
"heap-object symbol prop set to undefined must read undefined"
886+
);
887+
}
888+
}
889+
890+
#[test]
891+
fn undefined_write_clears_non_metadata_symbol_on_handle() {
892+
unsafe {
893+
// The wipe-guard is narrowed to `Symbol.for("NextInternalRequestMeta")`.
894+
// Any OTHER symbol on a handle must clear normally with `undefined`.
895+
crate::gc::gc_suppress();
896+
let sym = registered_symbol("someOtherHandleSym@@test_clear");
897+
// Distinct handle id so this doesn't alias the metadata tests.
898+
let handle = handle_value(0x789);
899+
let v = immovable_meta();
900+
super::properties::js_object_set_symbol_property(handle, sym, v);
901+
902+
// Sanity: the value is observable before the clear.
903+
let before = js_object_get_symbol_property(handle, sym);
904+
assert_eq!(
905+
before.to_bits(),
906+
v.to_bits(),
907+
"non-metadata handle symbol should be set before clearing"
908+
);
909+
910+
// Writing undefined to a NON-metadata symbol on a handle MUST clear it.
911+
let undef = f64::from_bits(TAG_UNDEFINED);
912+
super::properties::js_object_set_symbol_property(handle, sym, undef);
913+
let got = js_object_get_symbol_property(handle, sym);
914+
crate::gc::gc_unsuppress();
915+
assert_eq!(
916+
got.to_bits(),
917+
TAG_UNDEFINED,
918+
"a non-metadata symbol on a handle must be clearable with undefined"
919+
);
920+
}
921+
}
922+
}

crates/perry-runtime/src/symbol/properties.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,34 @@ unsafe fn infer_symbol_function_name(sym_key: usize, val_bits: u64) {
216216
register_closure_name_if_absent(val_bits, &inferred);
217217
}
218218

219+
/// Resolve (and cache) the registered `Symbol.for("NextInternalRequestMeta")`
220+
/// pointer used by Next.js to stash per-request metadata on the underlying
221+
/// IncomingMessage handle. Returns the symbol's stable `sym_key` (its leaked
222+
/// `SymbolHeader*`), or 0 if it can't be resolved. The undefined-write wipe
223+
/// guard below is narrowed to THIS symbol so ordinary handle symbol writes —
224+
/// including clearing a non-metadata symbol with `undefined` — behave normally.
225+
fn next_request_meta_sym_key() -> usize {
226+
use std::sync::atomic::{AtomicUsize, Ordering};
227+
// 0 = "not resolved yet", usize::MAX would be a sentinel we never need
228+
// because the registered symbol pointer is always a real, non-zero address.
229+
static CACHED: AtomicUsize = AtomicUsize::new(0);
230+
let cached = CACHED.load(Ordering::Relaxed);
231+
if cached != 0 {
232+
return cached;
233+
}
234+
const KEY: &[u8] = b"NextInternalRequestMeta";
235+
let sym_key = unsafe {
236+
let kh = js_string_from_bytes(KEY.as_ptr(), KEY.len() as u32);
237+
let key_f64 = crate::value::js_nanbox_string(kh as i64);
238+
let sym = super::constructors::js_symbol_for(key_f64);
239+
sym_key_from_f64(sym)
240+
};
241+
if sym_key != 0 {
242+
CACHED.store(sym_key, Ordering::Relaxed);
243+
}
244+
sym_key
245+
}
246+
219247
unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 {
220248
if let Some(acc) = accessors::symbol_accessor_property(obj_f64, sym_f64) {
221249
if acc.set != 0 {
@@ -232,6 +260,46 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64
232260
if obj_key == 0 || sym_key == 0 {
233261
return value_f64;
234262
}
263+
// #5437 (Next.js): a native HANDLE (small-id NaN-boxed POINTER, e.g. the
264+
// node:http IncomingMessage) carries per-request metadata in the symbol
265+
// side table keyed by its handle id. Node shares one metadata object by
266+
// reference across every wrapper that re-`new`s around the same
267+
// IncomingMessage, so a wrapper write-back like
268+
// `this._req[NEXT_REQUEST_META] = this[NEXT_REQUEST_META]` is harmless when
269+
// `this[...]` is the shared object. In Perry a late-bundled wrapper can
270+
// reach that write-back with an *undefined* `this[...]`, which would CLOBBER
271+
// the handle's existing (non-undefined) metadata — wiping
272+
// `resolvedPathname` and tripping Next's `resolvedPathname must be set`
273+
// invariant. Treat an `undefined` write onto a handle-band receiver that
274+
// already holds a non-undefined entry as a no-op: it never *adds*
275+
// information, and the by-reference object the handle still points at is
276+
// exactly what Node would keep.
277+
//
278+
// Gated narrowly so it only protects the Next request-metadata flow:
279+
// (1) the symbol is `Symbol.for("NextInternalRequestMeta")`,
280+
// (2) the receiver is a handle-band value (id below HANDLE_BAND_MAX and
281+
// not a real heap object),
282+
// (3) the write value is `undefined`, and
283+
// (4) a non-undefined entry already exists.
284+
// Any OTHER symbol on a handle — including legitimately clearing it by
285+
// writing `undefined` — falls through to the normal store path below.
286+
{
287+
let raw = (obj_f64.to_bits() & crate::value::POINTER_MASK) as usize;
288+
let meta_key = next_request_meta_sym_key();
289+
if meta_key != 0
290+
&& sym_key == meta_key
291+
&& (obj_f64.to_bits() >> 48) == 0x7FFD
292+
&& crate::value::addr_class::is_small_handle(raw)
293+
&& !crate::object::is_valid_obj_ptr(raw as *const u8)
294+
&& value_f64.to_bits() == TAG_UNDEFINED
295+
{
296+
if let Some(existing) = symbol_property_root_bits(obj_key, sym_key) {
297+
if existing != TAG_UNDEFINED {
298+
return value_f64;
299+
}
300+
}
301+
}
302+
}
235303
// `Array.prototype[Symbol.iterator] = fn` disables the array fast path in
236304
// `js_get_iterator` so destructuring / GetIterator see the patched method.
237305
crate::array::note_array_proto_iterator_write(obj_key, sym_key);

0 commit comments

Comments
 (0)