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. diff --git a/crates/perry-runtime/src/hot_diag/receiver_repr.rs b/crates/perry-runtime/src/hot_diag/receiver_repr.rs index cd978e85bb..2cc614f715 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); } @@ -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!( @@ -481,17 +497,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/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" + ); +} 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",