Skip to content

fix(runtime): give WeakRef/FinalizationRegistry a real method surface, and brand-check every folded weak intrinsic - #7953

Merged
proggeramlug merged 3 commits into
mainfrom
fix/7947-weakref-receiver-shapes
Aug 12, 2026
Merged

fix(runtime): give WeakRef/FinalizationRegistry a real method surface, and brand-check every folded weak intrinsic#7953
proggeramlug merged 3 commits into
mainfrom
fix/7947-weakref-receiver-shapes

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #7947. Closes #7948.

The measured blast radius

WeakRef.prototype.deref worked in 2 of 20 receiver shapes. The issue's
framing ("array element") was one symptom; its claim that const r = arr[0]; r.deref() works is wrong — that throws too.

receiver shape deref before FinReg.register before after
const x = new X(…) at module top level
const x = new X(…) in a function DECLARATION
const x = new X(…) in an ARROW function
const x = new X(…) in a function EXPRESSION
const x = new X(…) in a CLASS METHOD
array element arr[0].m()
local copied from an element
object property o.r.m()
local copied from a property
function return mk().m()
local from a call
for (const x of arr) binding
function PARAMETER
.map((x) => x.m()) callback
new X(…).m() inline
Map value m.get(k).m()
X.prototype.m.call(x) ❌ "not a function"
x.m.bind(x) ❌ "Bind must be called on a function"
typeof x.m undefined
x.m?.() ❌ silently undefined

WeakMap/WeakSet were ✅ in every one of those rows before and after — the
asymmetry is the whole story.

Root cause

deref/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 dynamic dispatch, where
nothing resolved:

  • try_weak_method_dispatch early-returned unless the receiver's class_id
    was CLASS_ID_WEAKMAP/CLASS_ID_WEAKSET;
  • install_collection_proto_methods had no WeakRef/FinalizationRegistry
    arm, so WeakRef.prototype carried no deref property at all;
  • the by-name VALUE read in get_field_by_name.rs had a WeakMap/WeakSet arm
    but no wrapper arm.

weakref_locals.rs names the asymmetry in a comment, as the reason those sets
are exempt from the ambiguity poison pass:

Restricted to weakmap/weakset only: WeakRef.deref / FinalizationRegistry
.register / Proxy use distinct method names … and (unlike WeakMap/WeakSet)
have no runtime method-dispatch fallback — they rely on the codegen fast
path

★ The silent half (#7948)

The fold is name-keyed and scope-blind, and its helpers did not brand-check.
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. Exit code 0, no diagnostic:

A objLiteral      expect 40 -> undefined     ({ deref: () => 40 })
B userClass       expect 40 -> undefined     (class Cache { deref() {…} })
C arrayWithDeref  expect 41 -> undefined     (arr.deref = …)
D paramNamed      expect 42 -> undefined     (function f(r) { return r.deref() })
E genuine         expect T  -> T
F control (name q)expect 40 -> 40

weakmap_locals/weakset_locals/proxy_locals are poisoned, but the poison
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: the pre-scan cannot enumerate
every way a name acquires a non-intrinsic value (parameters and destructuring
bindings are not even declarations it visits). The durable fix is on the other
side — the intrinsic brand-checks its receiver.

What changed

New module crates/perry-runtime/src/object/weakref_proto_thunks.rs.
try_weak_method_dispatch + weak_class_id_from_receiver moved there from
weakref.rs (which was at 1988/2000 lines) as a pure move, then extended.

  1. Dispatch armstry_weak_method_dispatch gains
    ("deref", CLASS_ID_WEAKREF) and
    ("register" | "unregister", CLASS_ID_FINALIZATION_REGISTRY).
  2. Prototype thunks — brand-checking WeakRef.prototype.deref (arity 0),
    FinalizationRegistry.prototype.register (2) / .unregister (1), hooked
    into populate_builtin_prototype_methods. Gives the reflective path,
    .length, and the spec TypeError for X.prototype.m.call({}).
  3. Value-read armget_field_by_name.rs resolves those thunk values for
    an instance read, so typeof wr.deref === "function" and
    wr.deref === WeakRef.prototype.deref.
  4. Brand check + delegatejs_weakref_deref, js_finreg_register,
    js_finreg_unregister, js_weakmap_{set,get,has,delete} and
    js_weakset_add verify the receiver's reserved class_id first and hand a
    foreign one to dispatch_foreign_weak_receiver, which re-enters
    js_native_call_method. A mis-fold now 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 the reserved class_id the brand check just rejected.
  5. Object.prototype.toStringto_string_tag.rs gained the two missing
    arms ([object Object][object WeakRef] /
    [object FinalizationRegistry]).

Deliberately NOT fixed

  • 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 are not the cause. Different mechanism (constructor/prototype
    reification — map_set_subclass exists only for Map/Set), out of scope. The
    gap test's header names this as the pinned boundary so nobody reads a green
    run as coverage.
  • The HIR pre-scan itself is still name-keyed, scope-blind, and still does
    not descend into arrow bodies. That is now a performance property rather
    than a correctness one: an unnamed receiver takes the dynamic path, and a
    mis-named one brand-checks its way back to the right method. Making the scan
    scope-aware is the real cleanup and belongs on its own.

Validation

  • test-files/test_gap_weakref_receiver_shapes_7947.ts — 48 output lines,
    byte-exact vs node 26.5.1, exit 0. All 14 previously-throwing receiver
    shapes, the reflective/value-read path, both toString tags, the brand
    check, FinalizationRegistry through three shapes, the six bug(hir): name-keyed weak/proxy intrinsic folds SILENTLY hijack same-named foreign receivers (deref/get/set/has → undefined) #7948
    WeakRef/FinReg collision cells and the five WeakMap/WeakSet ones, plus the
    WeakMap/WeakSet shapes that already worked (so a refactor of the shared
    dispatch cannot silently drop them).
  • Six ad-hoc probes byte-exact vs node except the subclass rows named above.
  • cargo test --release -p perry-runtime: 2211 passed, 0 failed.
  • 19/19 realistic-apps corpus byte-exact, exit 0.
  • Gap suite: no new failures vs a pristine origin/main build.
  • Sabotage-verified with the fix committed first (see comments).

Summary by CodeRabbit

  • New Features
    • Expanded runtime support for WeakRef and FinalizationRegistry methods across direct, reflective, prototype, and value-based access.
    • Added accurate branding and string-tag reporting for weak-reference types.
  • Bug Fixes
    • Invalid or foreign receivers now use their own behavior instead of being incorrectly treated as weak collections.
    • Weak-reference methods now validate receivers consistently and report incompatible calls correctly.
  • Tests
    • Added comprehensive coverage for receiver shapes, reflection, branding, collisions, registration, mutation, and identity behavior.
  • Documentation
    • Documented runtime support and known subclassing limitations.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7200e089-c9a2-4688-a860-fd4b8232076a

📥 Commits

Reviewing files that changed from the base of the PR and between e316ecb and 8f6ece7.

📒 Files selected for processing (12)
  • changelog.d/7953-weakref-receiver-shapes.md
  • crates/perry-runtime/src/object/collection_proto_thunks.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs
  • crates/perry-runtime/src/object/global_this/proto_methods.rs
  • crates/perry-runtime/src/object/instanceof.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/object/native_call_method.rs
  • crates/perry-runtime/src/object/object_ops/prototype.rs
  • crates/perry-runtime/src/object/to_string_tag.rs
  • crates/perry-runtime/src/object/weakref_proto_thunks.rs
  • crates/perry-runtime/src/weakref.rs
  • test-files/test_gap_weakref_receiver_shapes_7947.ts

📝 Walkthrough

Walkthrough

The runtime adds branded dispatch and prototype methods for WeakRef and FinalizationRegistry. Weak collection methods now brand-check receivers and delegate foreign receivers to ordinary dispatch. Tests cover receiver shapes, reflection, collisions, branding, tags, and identity.

Changes

Weak wrapper runtime behavior

Layer / File(s) Summary
Weak wrapper dispatch and branding
crates/perry-runtime/src/object/weakref_proto_thunks.rs, crates/perry-runtime/src/object/mod.rs, crates/perry-runtime/src/object/native_call_method.rs
Adds class-gated dispatch, receiver branding, foreign-receiver fallback, and prototype thunks for all four weak-wrapper types.
Prototype and property access integration
crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs, crates/perry-runtime/src/object/global_this/proto_methods.rs, crates/perry-runtime/src/object/collection_proto_thunks.rs, crates/perry-runtime/src/object/instanceof.rs, crates/perry-runtime/src/object/object_ops/prototype.rs, crates/perry-runtime/src/object/to_string_tag.rs
Installs WeakRef and FinalizationRegistry methods, resolves instance method values, and updates weak-wrapper classification and object tags.
Intrinsic brand checks and fallback
crates/perry-runtime/src/weakref.rs
Brand-checks weak-wrapper receivers before internal reads or mutations and redispatches foreign receivers.
Receiver-shape regression coverage
test-files/test_gap_weakref_receiver_shapes_7947.ts, changelog.d/7953-weakref-receiver-shapes.md
Adds coverage for receiver shapes, reflection, collisions, branding, tags, identity, and weak-collection behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • PerryTS/perry#6659: Updates related weak collection brand-check and incompatible-receiver handling.

Suggested labels: parity, ready

Suggested reviewers: thehypnoo

Sequence Diagram(s)

sequenceDiagram
  participant MethodCall
  participant WeakWrapperDispatch
  participant BrandCheck
  participant WeakWrapperStorage
  participant ForeignReceiverMethod
  MethodCall->>WeakWrapperDispatch: resolve method and receiver
  WeakWrapperDispatch->>BrandCheck: identify receiver brand
  alt valid weak wrapper
    BrandCheck-->>WeakWrapperDispatch: matching wrapper brand
    WeakWrapperDispatch->>WeakWrapperStorage: execute intrinsic operation
  else foreign receiver
    BrandCheck-->>WeakWrapperDispatch: foreign receiver
    WeakWrapperDispatch->>ForeignReceiverMethod: ordinary dynamic dispatch
  end
Loading
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7947-weakref-receiver-shapes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Ralph Küpper added 3 commits August 12, 2026 17:09
…, and brand-check every folded weak intrinsic

`WeakRef.prototype.deref` and `FinalizationRegistry.prototype.register`/
`.unregister` had no runtime existence at all — they were purely an HIR fold
keyed on a bare local NAME recorded by `pre_scan_weakref_locals`, which walks
module statements and function *declarations* only. Every other receiver shape
threw `TypeError: deref is not a function`: an array element, an object
property, a call result, a `for…of` binding, a function parameter, a `.map`
callback, and any binding inside an arrow function, function expression or
class method. Two of twenty measured receiver shapes worked.

Three additions close it: `try_weak_method_dispatch` gains
`CLASS_ID_WEAKREF`/`CLASS_ID_FINALIZATION_REGISTRY` arms; brand-checking
prototype thunks are installed on both prototypes; and the by-name value read
resolves those thunks so `typeof wr.deref === "function"` and
`wr.deref.bind(wr)` work. `Object.prototype.toString` gains the two tags.

The same investigation turned up a silent wrong answer (#7948): the fold is
scope-blind and its helpers did not brand-check, so one genuine
`const r = new WeakRef(x)` anywhere in a module folded EVERY same-named
`r.deref()` onto `js_weakref_deref`, which answered `undefined` for a user
class instance, an object literal, an array or a parameter — exit code 0. The
weak collections had the same hole through initializer shapes the ambiguity
poison pass cannot see (`get`/`set`/`has`/`add`/`delete`). All nine folded
helpers now brand-check their receiver and hand a foreign one back to ordinary
dynamic dispatch, so a mis-fold degrades to the correct slow path.

Closes #7947. Closes #7948.
…hod surface + weak-intrinsic brand checks)
…t only the gap suite

The gap suite is tag-gated; `cargo-test` is a required per-PR context. Three
unit tests pin the two halves: a foreign receiver carrying its OWN deref/get/
has/delete/add must see that method run (asserted by sentinel return value, so
the test cannot pass on the pre-fix `undefined`), a genuine wrapper must still
take the intrinsic path, and WeakRef.deref must be reachable through
try_weak_method_dispatch while a non-own method still falls through to None.
@proggeramlug
proggeramlug force-pushed the fix/7947-weakref-receiver-shapes branch from d841be9 to 8f6ece7 Compare August 12, 2026 15:34
@proggeramlug
proggeramlug marked this pull request as ready for review August 12, 2026 15:34
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Validation record

Rebased onto a769fafc6 (current main); git diff --name-status origin/main HEAD shows only this PR's 12 files. All results below are from the post-rebase build, with PERRY_RUNTIME_DIR pinned and the .a mtimes verified to post-date the edits.

Correctness

check result
test_gap_weakref_receiver_shapes_7947.ts vs node 26.5.1 byte-exact, exit 0 (48 lines)
19/19 realistic-apps corpus byte-exact, fail=0
cargo test -p perry-runtime 2232 tests, 0 failed (3 new)
cargo fmt --all -- --check clean
scripts/check_file_size.sh clean (weakref.rs 1988 → 1975 after the move)
scripts/gc_runtime_root_holders.py OK — 81 holders scanned, no new unclassified
scripts/addr_class_inventory.py fails identically on both arms (pre-existing hit in object/tests.rs:646, untouched here). The new module uses try_read_gc_header, the approved predicate, and does not appear in the report.

Sabotage verification (fix + tests committed first)

Both arms applied at once; they attribute cleanly because the tests assert independent things.

sabotage test that failed observed
js_weakref_deref brand check → if false folded_weak_helpers_delegate_a_foreign_receiver_to_its_own_method left: 9222246136947933185 = 0x7FFC000000000001 = TAG_UNDEFINED — the exact production symptom
("deref", CLASS_ID_WEAKREF) dispatch arm → if false dynamic_dispatch_reaches_weakref_and_finreg_methods left: None
genuine_wrappers_still_take_the_intrinsic_path stayed green under both, so the tests are not failing indiscriminately

The first row is the load-bearing one: it asserts a foreign receiver's own method actually runs (sentinel return value), so it cannot pass on the pre-fix undefined.

An earlier end-to-end arm caught it too — with the dispatch arm disabled the gap test dropped from 48 correct lines to an immediate throw, exit 1.

These live in cargo-test, a required per-PR context, rather than only in the tag-gated gap suite.

Gap suite: all 8 flagged "regressions" are byte-identical on both arms

550 tests on macOS: 528 pass / 16 parity_fail / 6 crash. The snapshot gate flagged 8 regressions. A/B'd against a pristine build of the merge-base — none is mine:

flagged test fix arm base arm verdict
specabi_reassign exit 0 exit 0 identical output
zlib_3285_params exit 0 exit 0 identical output
fetch_request_from_node_incoming_message exit 134 exit 134 identical but for the thread id
http_client_no_redirect_follow exit 134 exit 134
http_overloads_3226plus exit 134 exit 134
http_req_async_iterator exit 134 exit 134
http_res_socket_writable_onfinished exit 134 exit 134
net_connect_bound_value exit 134 exit 134

All six crashes abort at the same site — perry-ext-http/src/server/server.rs:911, and net_connect_bound_value at tokio…/listener.rs:304 — i.e. #7932's HTTP reactor, the known main-red cause. Neither specabi_reassign nor zlib_3285_params mentions any weak API, and this PR contains no codegen edits. The 10 node_fail -> parity_fail status changes are oracle-availability differences between this host and the committed Linux-CI snapshot; the runner itself warns there is no macOS baseline.

On the baseline choice: the A/B baseline is my merge-base 1bd5eeb6b, not current main. That isolates this delta exactly — building from current main would fold in #7960/#7961/#7968, which landed after I branched, making them confounders rather than controls. This is a correctness A/B (outputs byte-compared between the two arms), so #7968's perf regression does not bear on it.

@proggeramlug
proggeramlug merged commit 6e40963 into main Aug 12, 2026
0 of 19 checks passed
@proggeramlug
proggeramlug deleted the fix/7947-weakref-receiver-shapes branch August 12, 2026 15:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant