Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions changelog.d/honest-handle-tag-null-stub.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 28 additions & 9 deletions crates/perry-runtime/src/hot_diag/receiver_repr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
3 changes: 1 addition & 2 deletions crates/perry-runtime/src/object/field_get_set/ic_miss.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
6 changes: 5 additions & 1 deletion crates/perry-runtime/src/object/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
36 changes: 20 additions & 16 deletions crates/perry-runtime/src/object/native_call_method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
};

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -2162,26 +2160,33 @@ 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;

if !keys.is_null() {
// 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
Expand All @@ -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
Expand Down
Loading
Loading