Skip to content
Merged
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
93 changes: 93 additions & 0 deletions changelog.d/7953-weakref-receiver-shapes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
Gave `WeakRef` and `FinalizationRegistry` an actual runtime method surface, and
made every folded weak intrinsic brand-check its receiver.

`WeakRef.prototype.deref` and `FinalizationRegistry.prototype.register` /
`.unregister` had **no runtime existence at all**. They were purely an HIR fold:
`pre_scan_weakref_locals` records bare local NAMES bound to
`let/const x = new WeakRef(…)` — walking module statements and the bodies of
function *declarations* only — and `expr_call/url_date_instance.rs` folds
`<tracked-name>.deref()` to `Expr::WeakRefDeref`. Anything the fold could not
name fell through to ordinary dynamic dispatch, where nothing resolved:
`try_weak_method_dispatch` early-returned unless the receiver carried
`CLASS_ID_WEAKMAP`/`CLASS_ID_WEAKSET`, `install_collection_proto_methods` had no
arm for either wrapper (so `WeakRef.prototype` carried no `deref` property at
all), and the by-name value read in `get_field_by_name.rs` had a WeakMap/WeakSet
arm but no wrapper arm.

Measured over twenty receiver shapes, **two worked**: a `const x = new WeakRef(…)`
at module top level, and the same inside a function *declaration*. Everything
else threw `TypeError: deref is not a function` — an array element (#7947's
report), a local copied from one, an object property, a call result, a `for…of`
binding, a function parameter, a `.map` callback, `new WeakRef(x).deref()`
inline, a `Map` value, and any binding inside an arrow function, function
expression or class method. The reflective path was equally dead:
`WeakRef.prototype.deref.call(wr)` threw "was called on a value that is not a
function", `wr.deref.bind(wr)` threw "Bind must be called on a function",
`typeof wr.deref` was `undefined`, and `wr.deref?.()` silently produced
`undefined`. `WeakMap`/`WeakSet` passed every one of those shapes both before and
after, because they have all three routes — that asymmetry is the whole bug, and
`weakref_locals.rs` already named it in a comment justifying why those sets are
exempt from the ambiguity poison pass ("have no runtime method-dispatch
fallback — they rely on the codegen fast path").

Three additions close it. `try_weak_method_dispatch` gains
`("deref", CLASS_ID_WEAKREF)` and
`("register" | "unregister", CLASS_ID_FINALIZATION_REGISTRY)` arms, so a *call*
on any receiver shape reaches the runtime helper. Brand-checking prototype
thunks are installed on both prototypes via
`populate_builtin_prototype_methods`, which fixes `.call`/`.apply`, method
extraction, the spec `.length` values, and makes
`WeakRef.prototype.deref.call({})` throw the `TypeError` the spec requires. And
`get_field_by_name.rs` resolves those same thunk values for an instance read, so
`typeof wr.deref === "function"` and `wr.deref === WeakRef.prototype.deref`.
`Object.prototype.toString` gained the two missing arms as well
(`[object Object]` → `[object WeakRef]` / `[object FinalizationRegistry]`).

The same investigation turned up the silent sibling, filed as #7948 and closed
here. The fold is name-keyed and **scope-blind**, and its helpers did not
brand-check, so one genuine `const r = new WeakRef(x)` anywhere in a module
folded *every* `r.deref()` in that module onto `js_weakref_deref` — which read
`__perry_wr_target` by name off whatever it was handed and answered `undefined`.
A user class instance, an object literal, an array with an attached `deref`, and
a **function parameter** all silently returned `undefined` instead of their own
method's result, with exit code 0. `weakmap_locals`/`weakset_locals`/
`proxy_locals` *are* subtracted by the ambiguity poison pass, but that pass only
recognises `new <OtherClass>()` and call/await initializers — it cannot see an
object literal, an array, or a parameter, so the identical hijack went through on
the far more common `get`/`set`/`has`/`add`/`delete`. Name poisoning can only
ever be a partial patch, because the pre-scan cannot enumerate every way a name
acquires a non-intrinsic value; parameters and destructuring bindings are not
even declarations it visits. So the fix went on the other side:
`js_weakref_deref`, `js_finreg_register`, `js_finreg_unregister`,
`js_weakmap_{set,get,has,delete}` and `js_weakset_add` now verify the receiver's
reserved `class_id` before trusting it and hand a foreign one to
`dispatch_foreign_weak_receiver`, which re-enters `js_native_call_method`. A
mis-fold degrades to the correct slow path instead of a wrong answer. Recursion
is impossible: `js_native_call_method` routes back into these helpers only via
`try_weak_method_dispatch`, which requires exactly the reserved `class_id` the
brand check just rejected.

`try_weak_method_dispatch` and `weak_class_id_from_receiver` moved out of
`weakref.rs` (at 1988 of the 2000-line gate) into a new
`crates/perry-runtime/src/object/weakref_proto_thunks.rs` as a pure move, then
extended there.

Deliberately left: weak-wrapper **subclassing**. `class M extends WeakMap {}` and
the WeakSet/WeakRef/FinalizationRegistry equivalents throw before *and* after this
change (`value is not a function`; `Constructor WeakRef requires 'new'`),
verified identical against a pristine `origin/main` binary so the new brand
checks cannot be blamed for it. It is a different mechanism —
constructor/prototype reification, of which `map_set_subclass` exists only for
Map/Set — and the gap test's header pins it as an explicit non-boundary so a
green run is not misread as coverage. The HIR pre-scan is also still name-keyed
and scope-blind and still does not descend into arrow bodies; that is now a
*performance* property rather than a correctness one, since an unnamed receiver
takes the dynamic path and a mis-named one brand-checks its way back to the right
method.

`test-files/test_gap_weakref_receiver_shapes_7947.ts` pins all of it: the 14
previously-throwing receiver shapes, the reflective and value-read paths, both
`toString` tags, the brand check, `FinalizationRegistry` through three shapes,
the six WeakRef/FinReg name-collision cells and the five WeakMap/WeakSet ones,
and the WeakMap/WeakSet shapes that already worked — so a future refactor of the
shared dispatch cannot silently drop them.
4 changes: 2 additions & 2 deletions crates/perry-runtime/src/object/collection_proto_thunks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ fn render_incompatible_receiver(bits: u64) -> String {
if crate::map::is_registered_map(ptr) {
return "#<Map>".to_string();
}
match crate::weakref::weak_class_id_from_receiver(value) {
match crate::object::weak_class_id_from_receiver(value) {
Some(crate::weakref::CLASS_ID_WEAKSET) => return "#<WeakSet>".to_string(),
Some(crate::weakref::CLASS_ID_WEAKMAP) => return "#<WeakMap>".to_string(),
_ => {}
Expand Down Expand Up @@ -417,7 +417,7 @@ fn map_receiver_or_throw(method: &str) -> *mut crate::map::MapHeader {
#[inline]
fn weak_receiver_or_throw(expected: u32, proto: &str, method: &str) -> f64 {
let receiver = f64::from_bits(IMPLICIT_THIS.with(|c| c.get()));
match crate::weakref::weak_class_id_from_receiver(receiver) {
match super::weak_class_id_from_receiver(receiver) {
Some(cid) if cid == expected => receiver,
_ => throw_incompatible_receiver(proto, method, receiver.to_bits()),
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ pub extern "C" fn js_object_get_field_by_name(
{
unsafe {
let boxed = f64::from_bits(JSValue::pointer(obj as *const u8).bits());
if let Some(cid) = crate::weakref::weak_class_id_from_receiver(boxed) {
if let Some(cid) = crate::object::weak_class_id_from_receiver(boxed) {
let name_ptr = (key as *const u8).add(std::mem::size_of::<crate::StringHeader>());
let name_len = (*key).byte_len as usize;
let name = std::slice::from_raw_parts(name_ptr, name_len);
Expand All @@ -349,6 +349,37 @@ pub extern "C" fn js_object_get_field_by_name(
}
}
}
// #7947: the same VALUE read for a `WeakRef` / `FinalizationRegistry`
// instance — `typeof wr.deref`, `const d = wr.deref`, `wr.deref.bind(wr)`.
// These wrappers had no prototype thunks at all before #7947, so every such
// read answered `undefined` (and `.bind` threw "Bind must be called on a
// function"). Own keys keep precedence — fresh instances carry only the
// `__perry_wr_target` / `__perry_fr_*` sentinels.
if !key.is_null()
&& ((obj as u64) >> 48) == 0
&& crate::value::addr_class::is_above_handle_band(obj as usize)
{
unsafe {
let boxed = f64::from_bits(JSValue::pointer(obj as *const u8).bits());
if let Some(cid) = crate::object::weak_wrapper_class_id(boxed) {
let name_ptr = (key as *const u8).add(std::mem::size_of::<crate::StringHeader>());
let name_len = (*key).byte_len as usize;
let name = std::slice::from_raw_parts(name_ptr, name_len);
if let Ok(method_name) = std::str::from_utf8(name) {
if !super::super::own_key_present(obj as *mut ObjectHeader, key) {
if let Some(v) =
super::super::weakref_proto_thunks::weakref_proto_method_value_for(
cid,
method_name,
)
{
return JSValue::from_bits(v.to_bits());
}
}
}
}
}
}
// `class X extends Promise` instance — a value read of `then`/`catch`/
// `finally` (`p.then` / `typeof p.finally`, and codegen's `p.finally(cb)`
// which reads the property first) must resolve the reified Promise prototype
Expand Down
7 changes: 7 additions & 0 deletions crates/perry-runtime/src/object/global_this/proto_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ pub(crate) fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj:
install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS);
return;
}
// #7947: WeakRef / FinalizationRegistry prototypes get brand-checking
// thunks, so `WeakRef.prototype.deref.call(wr)`, `wr.deref.bind(wr)` and
// `typeof wr.deref` resolve instead of answering `undefined`.
if super::super::weakref_proto_thunks::install_weakref_proto_methods(builtin_name, proto_obj) {
install_noop_proto_methods(proto_obj, OBJECT_PROTO_METHODS);
return;
}
// #4795: TC39 explicit-resource-management stacks.
if super::super::disposable_proto_thunks::install_disposable_proto_methods(
builtin_name,
Expand Down
4 changes: 2 additions & 2 deletions crates/perry-runtime/src/object/instanceof.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1369,7 +1369,7 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 {
const CLASS_ID_WEAKMAP_RESERVED: u32 = 0xFFFF002C;
const CLASS_ID_WEAKSET_RESERVED: u32 = 0xFFFF002D;
if class_id == CLASS_ID_WEAKMAP_RESERVED {
return if crate::weakref::weak_class_id_from_receiver(value)
return if crate::object::weak_class_id_from_receiver(value)
== Some(crate::weakref::CLASS_ID_WEAKMAP)
{
true_val
Expand All @@ -1378,7 +1378,7 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 {
};
}
if class_id == CLASS_ID_WEAKSET_RESERVED {
return if crate::weakref::weak_class_id_from_receiver(value)
return if crate::object::weak_class_id_from_receiver(value)
== Some(crate::weakref::CLASS_ID_WEAKSET)
{
true_val
Expand Down
7 changes: 7 additions & 0 deletions crates/perry-runtime/src/object/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ mod temporal_proto;
mod typed_array_define;
mod typed_array_proto_thunks;
mod util_types;
mod weakref_proto_thunks;
mod websocket_global;
mod with_env;
// Issue #1103 follow-up: behavior-preserving split of the residual top-level
Expand Down Expand Up @@ -197,6 +198,12 @@ pub(crate) use typed_array_define::{
TypedArrayOwnIndex,
};
pub use util_types::*;
// #7947: weak-wrapper method dispatch (moved out of `weakref.rs`, which is at
// the 2000-line gate) plus the WeakRef/FinalizationRegistry arms and thunks.
pub use weakref_proto_thunks::{
delegate_if_not_weak_collection, dispatch_foreign_weak_receiver, is_weak_wrapper,
try_weak_method_dispatch, weak_class_id_from_receiver, weak_wrapper_class_id,
};
pub use with_env::*;
// Re-exports for the residual-helper split (issue #1103 follow-up). Explicit
// named re-exports keep existing `crate::object::X` / bare-name call sites in
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/object/native_call_method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1774,7 +1774,7 @@ pub unsafe extern "C" fn js_native_call_method(
// add to the js_weak* helpers instead of throwing "has is not a
// function". The class_id guard + routing live in weakref.rs.
if let Some(r) =
crate::weakref::try_weak_method_dispatch(obj, object(), method_name, args_ptr, args_len)
crate::object::try_weak_method_dispatch(obj, object(), method_name, args_ptr, args_len)
{
return r;
}
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/object/object_ops/prototype.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ pub extern "C" fn js_object_get_prototype_of(obj_value: f64) -> f64 {
// comparing `class_id`, matching the `is_registered_map`/
// `is_registered_set` safety bar above.
let receiver = crate::value::js_nanbox_pointer(addr as i64);
if let Some(class_id) = crate::weakref::weak_class_id_from_receiver(receiver) {
if let Some(class_id) = crate::object::weak_class_id_from_receiver(receiver) {
let name = if class_id == crate::weakref::CLASS_ID_WEAKMAP {
"WeakMap"
} else {
Expand Down
15 changes: 10 additions & 5 deletions crates/perry-runtime/src/object/to_string_tag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,16 @@ pub unsafe extern "C" fn js_object_to_string(value: f64) -> f64 {
return f64::from_bits(STRING_TAG | (str_ptr as u64 & POINTER_MASK));
}
}
if let Some(cid) = crate::weakref::weak_class_id_from_receiver(value) {
let tag = if cid == crate::weakref::CLASS_ID_WEAKSET {
"WeakSet"
} else {
"WeakMap"
// #7947: `WeakRef` / `FinalizationRegistry` were missing here, so
// `Object.prototype.toString.call(new WeakRef(x))` answered
// `[object Object]` instead of `[object WeakRef]`. Same reserved-class_id
// question as WeakMap/WeakSet, just two more arms.
if let Some(cid) = crate::object::weak_wrapper_class_id(value) {
let tag = match cid {
crate::weakref::CLASS_ID_WEAKSET => "WeakSet",
crate::weakref::CLASS_ID_WEAKREF => "WeakRef",
crate::weakref::CLASS_ID_FINALIZATION_REGISTRY => "FinalizationRegistry",
_ => "WeakMap",
};
let formatted = format!("[object {}]", tag);
let str_ptr =
Expand Down
Loading
Loading