Summary
The HIR pre-scan pre_scan_weakref_locals (crates/perry-hir/src/lower/pre_scan/weakref_locals.rs) records bare identifier names bound to new WeakRef/WeakMap/WeakSet/FinalizationRegistry/Proxy(...) and then folds <name>.<method>() straight to the corresponding runtime intrinsic. The sets are name-keyed with no scope discrimination, and the intrinsic helpers do not brand-check their receiver.
Consequence: if any binding anywhere in the module is new WeakRef(x) under the name r, then every r.<method>() in that module is folded onto the intrinsic — including an r that is a plain object, a user class instance, an array, or a function parameter. js_weakref_deref reads its __perry_wr_target slot by name off the foreign object, finds nothing, and answers undefined.
No throw. Exit code 0. Wrong answer. This is #7775 (proxy_locals) one class over, and #7775's fix — subtracting ambiguous names from the set — does not cover it.
Repro (silent, both arms exit 0)
const obj = { tag: "T" };
class Cache {
v: number;
constructor(v: number) { this.v = v; }
deref(): number { return this.v * 10; }
}
function objLiteral() { const r = { deref: () => 40 }; return r.deref(); }
function userClass() { const r = new Cache(4); return r.deref(); }
function arrayWithDeref() { const r: any = [1,2,3]; r.deref = () => 41; return r.deref(); }
function paramNamed(r: { deref: () => number }) { return r.deref(); }
function genuine() { const r = new WeakRef(obj); return (r.deref() as any).tag; }
function control() { const q = new Cache(4); return q.deref(); }
console.log("A objLiteral expect 40 ->", objLiteral());
console.log("B userClass expect 40 ->", userClass());
console.log("C arrayWithDeref expect 41 ->", arrayWithDeref());
console.log("D paramNamed expect 42 ->", paramNamed({ deref: () => 42 }));
console.log("E genuine expect T ->", genuine());
console.log("F control expect 40 ->", control());
| cell |
node 26.5.1 |
perry |
|
A objLiteral ({ deref } literal) |
40 |
undefined |
✗ silent |
B userClass (class Cache { deref() }) |
40 |
undefined |
✗ silent |
C arrayWithDeref (arr.deref = …) |
41 |
undefined |
✗ silent |
| D paramNamed (function parameter) |
42 |
undefined |
✗ silent |
E genuine (the real WeakRef) |
T |
T |
✓ |
F control (name q, no collision) |
40 |
40 |
✓ |
Delete genuine() — the only function that mentions WeakRef — and A–D all become correct. The presence of an unrelated WeakRef binding elsewhere in the module is the entire trigger.
The same hole exists for WeakMap/WeakSet
weakmap_locals/weakset_locals/proxy_locals are subtracted by the ambiguity poison pass, but that pass only recognises two initializer shapes (new <OtherClass>() and a call/await result). It cannot see an object literal, an array, or a parameter, so the identical hijack goes through with method names that are far more common than deref — get, set, has, add, delete:
function objLiteralGet() { const m = { get: (k: string) => "lit:" + k }; return m.get("a"); }
function paramGet(m: { get: (k: string) => string }) { return m.get("b"); }
function arrayGet() { const m: any = []; m.get = (k: string) => "arr:" + k; return m.get("c"); }
function genuineWeakMap(){ const k = {z:1}; const m = new WeakMap<object,number>(); m.set(k,5); return m.get(k); }
| cell |
node |
perry |
| objLiteralGet |
lit:a |
undefined |
| paramGet |
p:b |
undefined |
| arrayGet |
arr:c |
undefined |
| genuineWeakMap |
5 |
5 |
A one-letter m/r/g/p reused for a WeakMap in one function and a cache/options object in another is exactly what minified bundles look like.
Root cause
Two independent decisions compose into a wrong answer:
pre_scan_weakref_locals is name-keyed and scope-blind (documented, deliberate) and only partially poisoned (new Other() + call/await inits; weakref_locals/finreg_locals are not poisoned at all — the in-file comment justifies that with "have no runtime method-dispatch fallback … so dropping them could regress a genuine instance with no upside").
- The intrinsic helpers the fold targets —
js_weakref_deref, js_weakmap_get, js_finreg_register, … — accept any pointer and read their internal slot by name. A foreign receiver produces undefined rather than a diagnostic.
Name poisoning can only ever be a partial patch for (1), because the 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 (2): every folded intrinsic must brand-check its receiver and fall back to ordinary dynamic method dispatch on mismatch, so a wrong fold degrades to the correct slow path instead of a wrong answer.
Scope / relation to #7947
#7947 is the loud half of the same design: WeakRef.deref / FinalizationRegistry.register have no runtime dispatch fallback at all, so every receiver shape except a directly-named local throws TypeError. The fix for #7947 (adding runtime dispatch + prototype thunks for WeakRef/FinalizationRegistry) supplies exactly the fallback a brand-check needs, and the PR for #7947 also brand-checks js_weakref_deref / js_finreg_register / js_finreg_unregister.
This issue tracks the remaining half: js_weakmap_get/set/has/delete and js_weakset_add/has/delete (and the Proxy property fold) still hijack same-named foreign receivers through initializer shapes the poison pass cannot see.
Found while investigating #7947.
Summary
The HIR pre-scan
pre_scan_weakref_locals(crates/perry-hir/src/lower/pre_scan/weakref_locals.rs) records bare identifier names bound tonew WeakRef/WeakMap/WeakSet/FinalizationRegistry/Proxy(...)and then folds<name>.<method>()straight to the corresponding runtime intrinsic. The sets are name-keyed with no scope discrimination, and the intrinsic helpers do not brand-check their receiver.Consequence: if any binding anywhere in the module is
new WeakRef(x)under the namer, then everyr.<method>()in that module is folded onto the intrinsic — including anrthat is a plain object, a user class instance, an array, or a function parameter.js_weakref_derefreads its__perry_wr_targetslot by name off the foreign object, finds nothing, and answersundefined.No throw. Exit code 0. Wrong answer. This is #7775 (
proxy_locals) one class over, and #7775's fix — subtracting ambiguous names from the set — does not cover it.Repro (silent, both arms exit 0)
{ deref }literal)40undefinedclass Cache { deref() })40undefinedarr.deref = …)41undefined42undefinedWeakRef)TTq, no collision)4040Delete
genuine()— the only function that mentionsWeakRef— and A–D all become correct. The presence of an unrelatedWeakRefbinding elsewhere in the module is the entire trigger.The same hole exists for
WeakMap/WeakSetweakmap_locals/weakset_locals/proxy_localsare subtracted by the ambiguity poison pass, but that pass only recognises two initializer shapes (new <OtherClass>()and a call/await result). It cannot see an object literal, an array, or a parameter, so the identical hijack goes through with method names that are far more common thanderef—get,set,has,add,delete:lit:aundefinedp:bundefinedarr:cundefined55A one-letter
m/r/g/preused for a WeakMap in one function and a cache/options object in another is exactly what minified bundles look like.Root cause
Two independent decisions compose into a wrong answer:
pre_scan_weakref_localsis name-keyed and scope-blind (documented, deliberate) and only partially poisoned (new Other()+ call/await inits;weakref_locals/finreg_localsare not poisoned at all — the in-file comment justifies that with "have no runtime method-dispatch fallback … so dropping them could regress a genuine instance with no upside").js_weakref_deref,js_weakmap_get,js_finreg_register, … — accept any pointer and read their internal slot by name. A foreign receiver producesundefinedrather than a diagnostic.Name poisoning can only ever be a partial patch for (1), because the 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 (2): every folded intrinsic must brand-check its receiver and fall back to ordinary dynamic method dispatch on mismatch, so a wrong fold degrades to the correct slow path instead of a wrong answer.
Scope / relation to #7947
#7947 is the loud half of the same design:
WeakRef.deref/FinalizationRegistry.registerhave no runtime dispatch fallback at all, so every receiver shape except a directly-named local throwsTypeError. The fix for #7947 (adding runtime dispatch + prototype thunks forWeakRef/FinalizationRegistry) supplies exactly the fallback a brand-check needs, and the PR for #7947 also brand-checksjs_weakref_deref/js_finreg_register/js_finreg_unregister.This issue tracks the remaining half:
js_weakmap_get/set/has/deleteandjs_weakset_add/has/delete(and theProxyproperty fold) still hijack same-named foreign receivers through initializer shapes the poison pass cannot see.Found while investigating #7947.