From f61da93d2dfb4d753a2247efeabbee9755c6e9e3 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 21 Sep 2026 17:54:32 +0000 Subject: [PATCH 1/4] fix(runtime): the unresolved-namespace stub is an ordinary object, not a header-less static (#10821) `js_unresolved_namespace_stub()` and ten dispatch catch-alls handed JS the address of `NULL_OBJECT_BYTES` under `POINTER_TAG` -- a `.rodata` byte array laid out like an `ObjectHeader`. It looked like an object to everything that reads an `ObjectHeader`, and it is not one: it has NO `GcHeader`. `addr_class::try_read_gc_header` accepts any heap-plausible address and returns `&*((addr - 8) as *const GcHeader)`, so every brand probe on the stub read whatever the linker placed before the static. In the v0.5.1631 binary those eight bytes are `6e 74 73 5d 00 00 00 00` -- the tail of a string literal, "nts]" -- so the stub reported `obj_type == 110`, a kind that does not exist. That is observable from a compiled program today, through a value any common-registry handle hands out: const c: any = crypto.createHash("sha256").constructor; // the stub JSON.stringify(c) // "" -- an empty object answers "{}" String(c) // TypeError: Cannot convert object to primitive value // -- an empty object answers "[object Object]" and the answer is BUILD-dependent: a different literal before the static is a different fake kind. This is the hazard the honest-tag invariant (a `POINTER_TAG` value is always a dereferenceable GC cell) exists to forbid, and `native_call_method.rs` already documents the same shape for a `Box`-allocated `SymbolHeader`. The stub is now an ordinary `GC_TYPE_OBJECT` with class id 0 and zero own keys -- exactly what `{}` allocates -- so the header at `addr - 8` is real and the object answers as the empty object it always claimed to be. * ONE object per realm, as before: every stub was the same address, so every stub was `===` every other, and that is kept. Per realm rather than per process because a GC object belongs to the thread whose arena allocated it; the static was shared across threads, which a heap object must not be. * Lazy, so a program that never reaches it pays nothing. All eleven sites return the stub immediately with no raw receiver live across the call, which is what makes allocating from inside the property-read funnels safe here. They now go through one funnel, `object::null_stub_value()`. * Rooted from `object::scan_object_cache_roots_mut`, with a researched `covered_elsewhere` verdict in the root-holder manifest (the gate reddens with the entry removed). * Class id 0, deliberately not a family id: the stub carries no native state, so it stays an ordinary object a worker can deep-copy like any `{}`. Collapsed on the way: an `is_valid_obj_ptr(obj)` branch in `js_native_call_method` whose two arms both returned the stub -- a test that could not change the answer. Its premise was the static's address lying outside the macOS heap window; a re-entrant `stub.raw().all(...)` now takes the ordinary-object path, finds a zero-key shape and reaches the same catch-all. Deleted: `NullObjectBytes`, `NULL_OBJECT_BYTES`, and `is_null_stub_address`, whose only production caller was the receiver-repr ledger arm gate A removes. Gate A: the `null_stub` arm of the receiver-repr fixture is inverted to `assert_fixture_migrated`, and the rendered sink line now witnesses `null_stub=0` in the `observed_old` section (it read 1). Gate B: `the_stub_is_an_ordinary_object_with_a_real_header` -- outside the handle band, a real `GcHeader` with `GC_TYPE_OBJECT`, class id 0, zero own keys. This retires no probe from the receiver-kind cascade: nothing asked about the stub by name. It removes one of the six header-less addresses that keep `try_read_gc_header`'s caller-side screens (`is_plausible_heap_addr`, `try_read_tracked_gc_header`) load-bearing. --- .../src/hot_diag/receiver_repr.rs | 21 ++- .../object/field_get_set/get_field_by_name.rs | 3 +- .../field_get_set/get_field_by_name_tail.rs | 4 +- .../src/object/field_get_set/ic_miss.rs | 3 +- crates/perry-runtime/src/object/mod.rs | 6 +- .../src/object/native_call_method.rs | 36 ++-- crates/perry-runtime/src/object/null_stub.rs | 155 ++++++++++++++---- scripts/gc_runtime_root_holders.json | 7 + 8 files changed, 173 insertions(+), 62 deletions(-) diff --git a/crates/perry-runtime/src/hot_diag/receiver_repr.rs b/crates/perry-runtime/src/hot_diag/receiver_repr.rs index cd978e85bb..f9aea92b76 100644 --- a/crates/perry-runtime/src/hot_diag/receiver_repr.rs +++ b/crates/perry-runtime/src/hot_diag/receiver_repr.rs @@ -219,9 +219,9 @@ fn observe_pointer(addr: usize) { if crate::async_hooks::is_async_resource_handle(addr as i64) { mark_old(ReceiverReprFamily::AsyncResource); } - if crate::object::is_null_stub_address(addr) { - mark_old(ReceiverReprFamily::NullStub); - } + // #340/#341 GATE A: `null_stub` has migrated to an ordinary object, so + // its arm is gone from here, and `is_null_stub_address` with it: it could + // only ever have answered for a `.data` static no longer handed to JS. if crate::shared_sab::is_shared_sab(addr) { mark_old(ReceiverReprFamily::Sab); } @@ -481,17 +481,20 @@ mod tests { assert_fixture(ReceiverReprFamily::Sab, || { (crate::shared_sab::alloc_shared_sab(1) as usize, false) }); - assert_fixture(ReceiverReprFamily::NullStub, || { - ( - crate::object::js_unresolved_namespace_stub().to_bits() as usize, - true, - ) + // #340/#341: `null_stub` is migrated — gate A, inverted (see `text`). + assert_fixture_migrated(ReceiverReprFamily::NullStub, || { + (crate::object::js_unresolved_namespace_stub().to_bits() + & crate::value::POINTER_MASK) as usize }); let line = render(); assert!(line.starts_with("[receiver-repr-diag] constructed common=0")); assert!(line.contains("null_stub=1; observed_old")); - assert!(line.contains("null_stub=1; observed_wrapped")); + // #340/#341 row 4: the rendered sink line is the last place gate A is + // visible. `null_stub` is the final bucket of the `observed_old` + // section, so this segment IS its observed_old count, and it must read + // 0 now that the stub is an ordinary object (it read 1 before). + assert!(line.contains("null_stub=0; observed_wrapped")); assert!(line.ends_with("bare_managed=0; invalid_pointer_zero=0; direct_mismatch=0\n")); receiver_repr_test_arm(false); } diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 7478b27e48..0164e6bfbe 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -1024,8 +1024,7 @@ pub(crate) fn get_field_by_name_past_inherited_cache( let key_len = (*key).byte_len as usize; let key_bytes = std::slice::from_raw_parts(key_ptr, key_len); if key_bytes == b"constructor" { - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return JSValue::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return JSValue::from_bits(crate::object::null_stub_value().to_bits()); } if let Some(dispatch) = handle_property_dispatch() { let bits = dispatch(raw as i64, key_ptr, key_len); diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index 436c0db52d..181dfd4a6a 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -78,9 +78,7 @@ pub(crate) fn get_field_by_name_object_tail( return value; } } - let null_obj_ptr = - &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return JSValue::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return JSValue::from_bits(crate::object::null_stub_value().to_bits()); } } if let Some(dispatch) = handle_property_dispatch() { diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index 51ea8de506..f6917d0a03 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -877,8 +877,7 @@ pub(super) fn get_field_ic_miss_impl( return bits; } } - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return crate::object::null_stub_value(); } } if let Some(dispatch) = handle_property_dispatch() { diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 944cd73d50..c556f49801 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -123,7 +123,7 @@ pub(crate) use live_slots::set_object_live_slot_count; pub use live_slots::{ js_object_live_slot_count, object_live_slot_count, perry_object_header_abi_revision, }; -pub(crate) use null_stub::{is_null_stub_address, NullObjectBytes, NULL_OBJECT_BYTES}; +pub(crate) use null_stub::null_stub_value; pub use null_stub::{js_unresolved_default_call, js_unresolved_namespace_stub}; #[cfg(test)] pub(crate) use side_table_roots::test_transition_cache_insert; @@ -1345,6 +1345,10 @@ pub fn scan_object_cache_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<' // the SAME object on every call, so the object lives here rather than // being re-minted. crate::tui::handle_object::scan_tui_handle_roots_mut(visitor); + // #340/#341 row 4: the unresolved-namespace stub. It was a `.data` + // static with no `GcHeader`; it is an ordinary object now, so the slot + // holding it is a real GC root that a moving collection must rewrite. + null_stub::scan_null_stub_roots_mut(visitor); #[cfg(feature = "regex-engine")] regex_proto_thunks::scan_canonical_test_site_roots_mut(visitor); } diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index c640618750..2504c109d3 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1388,8 +1388,7 @@ pub unsafe extern "C-unwind" fn js_native_call_method( method_name, "empty object", ); - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return crate::object::null_stub_value(); } }; @@ -2092,8 +2091,7 @@ pub unsafe extern "C-unwind" fn js_native_call_method( IMPLICIT_THIS.with(|c| c.set(prev_this_h.get_nanbox_u64())); return result; } - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return crate::object::null_stub_value(); } if let Some(r) = crate::builtins::try_console_instance_method_dispatch( @@ -2162,17 +2160,25 @@ pub unsafe extern "C-unwind" fn js_native_call_method( // numeric arithmetic on bit patterns. Truly garbage pointers // benefit too — chained calls hit a stable null stub instead // of mysterious numeric values. - if !is_valid_obj_ptr(obj as *const u8) { - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); - } - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); + // + // #340/#341 row 4 collapsed an `is_valid_obj_ptr(obj)` branch that + // used to sit here: BOTH of its arms already returned the stub, so + // it could not change the answer -- a test that cannot fail. Its + // premise is gone too. It was written when the stub was a `.data` + // static, deliberately OUTSIDE the macOS heap window + // (`HEAP_MIN == 0x200_0000_0000`) that `is_valid_obj_ptr` requires, + // so a re-entrant `stub.raw().all(...)` reached this arm with + // `gc_type` read out of whatever bytes preceded the static. The + // stub is a real `GC_TYPE_OBJECT` now, so that re-entry takes the + // ordinary-object path below, finds a zero-key shape, matches no + // method and reaches the same catch-all at the end of this + // function. Same answer, decided by the object model rather than by + // the linker's layout. + return crate::object::null_stub_value(); } let Some(descriptor) = crate::object::shapes::object_shape_descriptor(obj) else { - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return crate::object::null_stub_value(); }; let keys = descriptor.keys as usize as *mut ArrayHeader; @@ -2180,8 +2186,7 @@ pub unsafe extern "C-unwind" fn js_native_call_method( // Validate keys_array pointer before dereferencing let keys_ptr = keys as usize; if (keys_ptr as u64) >> 48 != 0 || keys_ptr < 0x10000 { - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return crate::object::null_stub_value(); } // Issue #62 phase B: removed macOS "ASCII-like pointer" heuristic — // mimalloc + arena strings produce valid heap pointers with bytes @@ -2193,8 +2198,7 @@ pub unsafe extern "C-unwind" fn js_native_call_method( let key_count = descriptor.logical_key_count as usize; // Sanity check key_count if key_count > 65536 { - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - return f64::from_bits(JSValue::pointer(null_obj_ptr).bits()); + return crate::object::null_stub_value(); } // Compare method_name bytes directly against each stored key // instead of allocating a transient StringHeader via diff --git a/crates/perry-runtime/src/object/null_stub.rs b/crates/perry-runtime/src/object/null_stub.rs index ce8ef3476c..c8de016eae 100644 --- a/crates/perry-runtime/src/object/null_stub.rs +++ b/crates/perry-runtime/src/object/null_stub.rs @@ -1,28 +1,89 @@ -//! The unresolved-module namespace stub — a static, GcHeader-less "empty -//! object" handed to user code when a module import or a method dispatch has -//! nowhere to go. +//! The unresolved-module namespace stub — the "empty object" handed to user +//! code when a module import or a method dispatch has nowhere to go. //! //! Split out of `object/mod.rs` (2000-line cap) by #8113. +//! +//! # Honest tags (#340/#341, #10821 row 4) +//! +//! This used to be a `static NullObjectBytes` — a `.data` byte array laid out +//! like an `ObjectHeader`, whose ADDRESS was handed to JS under `POINTER_TAG`. +//! It looked like an object to everything that reads an `ObjectHeader`, and it +//! is not one: **it has no `GcHeader`**. `addr_class::try_read_gc_header` +//! accepts any heap-plausible address and returns `&*((addr - 8) as *const +//! GcHeader)`, so every brand probe on this value read whatever `.data` bytes +//! happened to precede the static and dispatched on them as an `obj_type`. +//! That is the same hazard `native_call_method.rs` already documents for a +//! `Box`-allocated `SymbolHeader`, and it is what the honest-tag invariant — +//! a `POINTER_TAG` value is always a dereferenceable GC cell — exists to +//! forbid. It worked only because the preceding bytes happened to be benign. +//! +//! The stub is now an ordinary `GC_TYPE_OBJECT` with class id 0 and zero own +//! keys: exactly what `{}` allocates, so `typeof`, `Object.keys`, +//! `JSON.stringify` and property reads are unchanged, and the header at +//! `addr - 8` is real. +//! +//! It stays ONE object per realm, because that is what it was: every stub was +//! the same static address, so every stub was `===` every other. Per realm +//! rather than per process because a GC object belongs to the thread whose +//! arena allocated it — a static was shared across threads, which a heap +//! object must not be. -/// Static "null object" used as a safe return value when the depth guard triggers. -/// Instead of returning undefined (which callers may dereference as a null pointer), -/// we return a pointer to this valid-but-empty object so downstream code doesn't crash. -/// -/// Uses a raw byte array with matching layout to avoid Sync issues with raw pointers. +use std::sync::atomic::{AtomicI64, Ordering}; + +crate::perry_thread_local! { + static NULL_STUB_SLOT: AtomicI64 = const { AtomicI64::new(0) }; +} + +/// The realm's stub object. A GC pointer in a static, so it is scanned from +/// `object::scan_object_cache_roots_mut` beside the iterator tower — both to +/// keep it alive and to rewrite the slot when a moving collection relocates +/// it. Without the rewrite every later stub would be a stale address, which is +/// strictly worse than the static it replaces. +pub(crate) static NULL_STUB_PTR: crate::object::RealmAtomicI64 = + crate::object::RealmAtomicI64::new(&NULL_STUB_SLOT); + +/// GC root for the stub singleton. +pub(crate) fn scan_null_stub_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + NULL_STUB_PTR.with_slot(|slot| { + visitor.visit_atomic_i64_slot(slot, Ordering::Acquire, Ordering::Release); + }); +} + +/// The realm's unresolved-namespace stub, allocating it on first use. /// -/// #8047: mirrors the 16-byte header on both LP64 and ILP32. The trailing zero -/// word is `meta` on LP64 and `{alignment padding, meta}` on ILP32. -#[repr(C, align(8))] -pub(crate) struct NullObjectBytes { - class_id: u32, // 0 - parent_class_id: u32, // 0 (never a ShapeId: the stub has no descriptor) - meta_and_padding: u64, // 0 +/// Lazy, so a program that never hits an unresolved import pays nothing — and +/// every one of the eleven call sites returns this value immediately, with no +/// raw receiver pointer live across it, which is what makes allocating from +/// inside the property-read funnels safe here. +pub(crate) fn null_stub_object() -> *mut super::ObjectHeader { + let existing = NULL_STUB_PTR.load(Ordering::Acquire); + if existing != 0 { + return existing as *mut super::ObjectHeader; + } + // Class id 0 and zero keys: an ordinary `{}`. Deliberately NOT a family + // class id — the stub carries no native state, so it must stay an + // ordinary object that a worker can deep-copy like any other `{}`. + let obj = super::js_object_alloc(0, 0); + if obj.is_null() { + return std::ptr::null_mut(); + } + NULL_STUB_PTR.store(obj as i64, Ordering::Release); + obj } -// Safety: this is a read-only zero-initialized struct with no interior mutability -unsafe impl Sync for NullObjectBytes {} -const _: () = - assert!(std::mem::size_of::() == std::mem::size_of::()); +/// The stub as a JS value. The single funnel every fallback returns through. +/// +/// Answers `undefined` if the allocation fails, which is the honest behaviour +/// under memory exhaustion: a caller then sees "cannot read property of +/// undefined" rather than dereferencing a null pointer, and the static this +/// replaces could not report failure at all. +pub(crate) fn null_stub_value() -> f64 { + let obj = null_stub_object(); + if obj.is_null() { + return f64::from_bits(0x7FFC_0000_0000_0001); // TAG_UNDEFINED + } + f64::from_bits(crate::JSValue::pointer(obj as *mut u8).bits()) +} /// Issue #629: namespace imports for unresolved modules /// (`import * as fsp from "node:fs/promises"` when the module isn't @@ -35,13 +96,12 @@ const _: () = /// undefined via the existing object-field path. #[no_mangle] pub extern "C" fn js_unresolved_namespace_stub() -> f64 { - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; if crate::hot_diag::receiver_repr_on() { crate::hot_diag::receiver_repr_note_constructed( crate::hot_diag::ReceiverReprFamily::NullStub, ); } - f64::from_bits(crate::JSValue::pointer(null_obj_ptr).bits()) + null_stub_value() } /// Issue #692: default-import calls against unresolved modules @@ -69,13 +129,50 @@ pub extern "C" fn js_unresolved_default_call() -> f64 { f64::from_bits(0x7FFC_0000_0000_0001) // TAG_UNDEFINED } -pub(crate) static NULL_OBJECT_BYTES: NullObjectBytes = NullObjectBytes { - class_id: 0, - parent_class_id: 0, - meta_and_padding: 0, -}; +// #340/#341 row 4 deleted `is_null_stub_address`. Its only production caller +// was the receiver-repr ledger's `observe_pointer` arm, which asked whether a +// decoded pointer was the `.data` static — gate A inverts that arm away, and a +// heap object needs no address-equality probe to be recognised. -#[inline] -pub(crate) fn is_null_stub_address(addr: usize) -> bool { - addr == &NULL_OBJECT_BYTES as *const NullObjectBytes as usize +#[cfg(test)] +mod tests { + use super::*; + + /// GATE B for this family, and the invariant it exists for: the value JS + /// receives is a real heap object with a `GcHeader`, not a `.data` static + /// whose `addr - 8` is whatever the linker put there. + #[test] + fn the_stub_is_an_ordinary_object_with_a_real_header() { + let value = js_unresolved_namespace_stub(); + let bits = value.to_bits(); + assert_eq!(bits & crate::value::TAG_MASK, crate::value::POINTER_TAG); + let addr = (bits & crate::value::POINTER_MASK) as usize; + assert!( + !crate::value::addr_class::is_handle_band(addr), + "gate B: the stub is in the small-handle band ({addr:#x})" + ); + let header = unsafe { crate::value::addr_class::try_read_gc_header(addr) } + .expect("the stub carries a GcHeader"); + assert_eq!(header.obj_type, crate::gc::GC_TYPE_OBJECT); + let obj = addr as *mut super::super::ObjectHeader; + assert_eq!(unsafe { (*obj).class_id }, 0, "an ordinary object, not a family"); + let keys = unsafe { crate::object::object_keys_array(obj) }; + let key_count = if keys.is_null() { + 0 + } else { + unsafe { (*keys).length } + }; + assert_eq!(key_count, 0, "the stub must have no own keys"); + assert_eq!(NULL_STUB_PTR.load(Ordering::Acquire) as usize, addr, "the realm slot holds it"); + } + + /// One object per realm, as the static was: every stub was the same + /// address, so every stub was `===` every other, and a program that + /// compares two unresolved namespaces must keep seeing that. + #[test] + fn every_stub_in_a_realm_is_the_same_object() { + let a = js_unresolved_namespace_stub(); + let b = js_unresolved_namespace_stub(); + assert_eq!(a.to_bits(), b.to_bits()); + } } diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 507ecb35d9..7fa1d6f176 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -872,6 +872,13 @@ "verdict": "test_only", "why": "#[cfg(test)] diagnostic trace for the bound-method moving-GC regression: records the (before, after) addresses a test-forced minor produced so the test can assert the relocation happened. The addresses are compared as integers, never dereferenced, and the cell is dead in a shipped binary." }, + { + "file": "crates/perry-runtime/src/object/null_stub.rs", + "name": "NULL_STUB_SLOT", + "verdict": "covered_elsewhere", + "scanner": "object::scan_object_cache_roots_mut -> object::null_stub::scan_null_stub_roots_mut", + "why": "#340/#341 row 4: the per-realm unresolved-namespace stub OBJECT. It used to be a `.data` static with no GcHeader; it is an ordinary GC_TYPE_OBJECT now, visited by `null_stub::scan_null_stub_roots_mut` from the registered `object::scan_object_cache_roots_mut`, which keeps it alive and rewrites the slot when it moves. Uncovered here only because that registered scanner is in another file." + }, { "file": "crates/perry-runtime/src/object/read_stub.rs", "name": "READ_STUB", From a339acc95b13fc8b7d94e549f3f48faa35265d2b Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 21 Sep 2026 18:08:36 +0000 Subject: [PATCH 2/4] test(runtime): compiled-program test for the unresolved-namespace stub (#10917) --- crates/perry/tests/null_stub_is_an_object.rs | 90 ++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 crates/perry/tests/null_stub_is_an_object.rs diff --git a/crates/perry/tests/null_stub_is_an_object.rs b/crates/perry/tests/null_stub_is_an_object.rs new file mode 100644 index 0000000000..035af3e3b6 --- /dev/null +++ b/crates/perry/tests/null_stub_is_an_object.rs @@ -0,0 +1,90 @@ +//! #10917 / #10821 row 4 -- the unresolved-namespace stub is an ordinary +//! object. +//! +//! The stub was `NULL_OBJECT_BYTES`, a `.rodata` byte array laid out like an +//! `ObjectHeader` and handed to JS under `POINTER_TAG`, with no `GcHeader`. +//! `try_read_gc_header` read the 8 bytes the linker placed before it as the +//! header -- in the v0.5.1631 binary the tail of a string literal, `"nts]"`, +//! i.e. `obj_type == 110`, a kind that does not exist. So the stub, which is +//! meant to be an empty object, answered `JSON.stringify` with `""` and threw +//! on `String()`, and did so differently on a build whose literal layout +//! differed. +//! +//! The expected strings are what an empty object gives -- node's answer for +//! `JSON.stringify({})` and `String({})` -- because that is what the stub has +//! always claimed to be. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(dir: &std::path::Path, source: &str) -> String { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + let run = Command::new(&output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status.code(), + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// `handle.constructor` on a common-registry handle falls through to the stub +/// (`ic_miss.rs` / `get_field_by_name*.rs`), which makes it reachable from an +/// ordinary program. Pre-fix: `json ""` and `String()` threw +/// `TypeError: Cannot convert object to primitive value`. +#[test] +fn the_unresolved_namespace_stub_answers_as_an_empty_object() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +import * as crypto from "node:crypto"; +const c: any = crypto.createHash("sha256").constructor; +console.log("typeof", typeof c); +console.log("json", JSON.stringify(c)); +console.log("nested", JSON.stringify({ a: c })); +console.log("keys", JSON.stringify(Object.keys(c))); +console.log("brand", Object.prototype.toString.call(c)); +console.log("string", String(c)); +console.log("stable", c === crypto.createHash("md5").constructor); +const m = new Map([[c, 1]]); +console.log("map", m.get(crypto.createHash("sha1").constructor)); +"#, + ); + assert_eq!( + stdout, + "typeof object\n\ + json {}\n\ + nested {\"a\":{}}\n\ + keys []\n\ + brand [object Object]\n\ + string [object Object]\n\ + stable true\n\ + map 1\n" + ); +} From ded606b65d507ee6859e172e0ec446f7e0395c5b Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 21 Sep 2026 19:09:41 +0000 Subject: [PATCH 3/4] test(runtime): gate A covers the header-less class too, not only small band ids (#10821) assert_fixture_migrated checked one thing about a migrated producer value: not in the small-handle band. That is one of the TWO dishonest classes (plan 1.1). The other is a pointer-tagged address with NO GcHeader -- the null stub, a Box-allocated SymbolHeader, a SAB or external buffer backing -- and it is not in the band, so for those families gate A could not fail. Measured: with the stub sabotaged back to a header-less block, gate A stayed green while gate B went red. The gate now also requires try_read_tracked_gc_header(value).is_some(), which proves allocator ownership instead of trusting addr - 8. Under the same sabotage it reports: NullStub producer returned 0x39ead543390, which is not an allocator-owned GC cell. Clean, it stays green for every migrated family (text, timer, tui, null_stub: 99 passed). --- .../perry-runtime/src/hot_diag/receiver_repr.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/perry-runtime/src/hot_diag/receiver_repr.rs b/crates/perry-runtime/src/hot_diag/receiver_repr.rs index f9aea92b76..2cc614f715 100644 --- a/crates/perry-runtime/src/hot_diag/receiver_repr.rs +++ b/crates/perry-runtime/src/hot_diag/receiver_repr.rs @@ -390,6 +390,22 @@ mod tests { !crate::value::addr_class::is_handle_band(value), "{family:?} producer still returns a small band id ({value:#x})" ); + // The band check above covers only ONE of the two dishonest classes + // (plan section 1.1): small registry ids. The other class is a + // pointer-tagged address with NO `GcHeader` -- the `.data` null stub, + // a `Box`-allocated SymbolHeader, a SAB or external buffer backing -- + // and it is NOT in the band, so for those families the band check + // alone cannot fail (measured: #10821 row 4's gate A stayed green with + // the stub sabotaged back to a header-less block). What every migrated + // family's value has in common is that the ALLOCATOR owns it: + // `try_read_tracked_gc_header` proves ownership rather than trusting + // `addr - 8`, so it refuses both old classes and accepts exactly the + // ordinary object the migration produces. + assert!( + unsafe { crate::value::addr_class::try_read_tracked_gc_header(value) }.is_some(), + "{family:?} producer returned {value:#x}, which is not an allocator-owned GC \ + cell -- the header-less class of the old representation" + ); receiver_repr_note_decoded_pointer(value); let (constructed, observed, wrapped) = receiver_repr_test_snapshot(family); assert!( From dffb1e32692dab5b3812191fc543a9d12ce96d69 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 21 Sep 2026 19:10:09 +0000 Subject: [PATCH 4/4] changelog: the unresolved-namespace stub is a real empty object (#10917) --- changelog.d/honest-handle-tag-null-stub.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 changelog.d/honest-handle-tag-null-stub.md diff --git a/changelog.d/honest-handle-tag-null-stub.md b/changelog.d/honest-handle-tag-null-stub.md new file mode 100644 index 0000000000..7f490bc54c --- /dev/null +++ b/changelog.d/honest-handle-tag-null-stub.md @@ -0,0 +1,16 @@ +The value perry hands back when an import or a method dispatch has nowhere to go +(the "unresolved-namespace stub") is now a real empty object. It used to be the +address of a `.rodata` byte array laid out like an object header but with no GC +header in front of it, so every type probe read whatever bytes the linker had +placed before it. In a v0.5.1631 build those bytes were the tail of a string +literal, and the stub reported itself as a heap kind that does not exist: +`JSON.stringify` of it answered `""` and `String()` of it threw `TypeError: +Cannot convert object to primitive value`. Both now answer as `{}` does +(`"{}"`, `"[object Object]"`), and the answers no longer depend on how the +binary happened to be linked (#10917). + +One behaviour change follows from the stub now being the empty object it always +claimed to be: calling a method on it (`stub.raw()`) throws `TypeError: raw is +not a function`, exactly as it does on any `{}` and as node does. It used to +return the stub again, but only because the fake header routed the call into a +fallback arm; perry had already stopped doing that for real empty objects.