Distinct native-handle objects have no identity: they compare ===, collide as Map/Set keys, and a WeakMap returns a value stored under a different object.
Repro
const a = new TextEncoder();
const b = new TextEncoder();
console.log("a === b ->", a === b);
console.log("Object.is(a, b) ->", Object.is(a, b));
const m = new Map([[a, "one"], [b, "two"]]);
console.log("map size / get(a) ->", m.size, m.get(a));
console.log("set size ->", new Set([a, b]).size);
const wm = new WeakMap();
wm.set(a, "A");
console.log("weakmap get(b) ->", wm.get(b));
console.log("[a].indexOf(b) ->", [a].indexOf(b));
| expression |
node 26.8.1 |
perry v0.5.1618 |
a === b |
false |
true |
Object.is(a, b) |
false |
true |
m.size, m.get(a) |
2 one |
1 two |
new Set([a, b]).size |
2 |
1 |
wm.get(b) |
undefined |
A |
[a].indexOf(b) |
-1 |
0 |
The WeakMap line is the worst shape: a value stored under a is readable through b, which is
an unrelated object. Any library that uses a WeakMap/Map keyed by an instance for per-instance
private state (a very common pattern) silently shares that state between instances.
Cause
Native-module handles are small integers carried under POINTER_TAG
(crates/perry-runtime/src/value/addr_class.rs documents the band map). A JS value's identity is
its NaN-box bits, so identity collapses to registry id equality rather than object identity.
TextEncoder is the extreme case because it is stateless and every instance shares one sentinel:
// crates/perry-runtime/src/text.rs
pub const TEXT_ENCODER_SENTINEL_ID: i64 = 1;
#[no_mangle]
pub extern "C" fn js_text_encoder_new() -> i64 {
TEXT_ENCODER_SENTINEL_ID
}
so every TextEncoder in a program is literally the same value.
This is not specific to TextEncoder. It is a property of the representation, so every family that
mints ids the same way is exposed the moment two handles can share an id, or the moment a program
compares two handles of any family. The executable ledger scripts/native_result_ledger.tsv
counts 179 runtime symbols returning NR_HANDLE_ID across perry-stdlib and 15 perry-ext-*
crates; crates/perry-runtime/src/hot_diag/receiver_repr.rs enumerates 13 such families
(common, fetch, zlib, proxy, timer, text, tui, async_hook, async_resource, symbol_global,
external_buffer, sab, null_stub).
Fix
Give handles a real heap identity: a native handle becomes a GC cell (the existing
GC_TYPE_NATIVE_HANDLE, kind 15, already used by the perry-ffi native-library path) carrying
(family, registry id), and JS receives the cell address. Identity is then the cell address, which
is what every one of the six operations above actually wants.
This is lane 5 ("honest tags") of the object-model single-path work and is also the prerequisite for
the invariant POINTER tag => dereferenceable GC cell, which removes the small-handle guard from
every emitted property read (21 icmp ugt ..., 1048575 sites in perry-codegen) and the 441
hand-rolled address floors that scripts/addr_class_ratchet_baseline.txt tracks.
A prototype converting the text family lands all six rows on node's answers with ~200 lines,
entirely in perry-runtime and with no codegen change; PR to follow.
Related
Distinct native-handle objects have no identity: they compare
===, collide asMap/Setkeys, and aWeakMapreturns a value stored under a different object.Repro
a === bfalsetrueObject.is(a, b)falsetruem.size,m.get(a)2 one1 twonew Set([a, b]).size21wm.get(b)undefinedA[a].indexOf(b)-10The
WeakMapline is the worst shape: a value stored underais readable throughb, which isan unrelated object. Any library that uses a
WeakMap/Mapkeyed by an instance for per-instanceprivate state (a very common pattern) silently shares that state between instances.
Cause
Native-module handles are small integers carried under
POINTER_TAG(
crates/perry-runtime/src/value/addr_class.rsdocuments the band map). A JS value's identity isits NaN-box bits, so identity collapses to registry id equality rather than object identity.
TextEncoderis the extreme case because it is stateless and every instance shares one sentinel:so every
TextEncoderin a program is literally the same value.This is not specific to
TextEncoder. It is a property of the representation, so every family thatmints ids the same way is exposed the moment two handles can share an id, or the moment a program
compares two handles of any family. The executable ledger
scripts/native_result_ledger.tsvcounts 179 runtime symbols returning
NR_HANDLE_IDacross perry-stdlib and 15perry-ext-*crates;
crates/perry-runtime/src/hot_diag/receiver_repr.rsenumerates 13 such families(common, fetch, zlib, proxy, timer, text, tui, async_hook, async_resource, symbol_global,
external_buffer, sab, null_stub).
Fix
Give handles a real heap identity: a native handle becomes a GC cell (the existing
GC_TYPE_NATIVE_HANDLE, kind 15, already used by the perry-ffi native-library path) carrying(family, registry id), and JS receives the cell address. Identity is then the cell address, whichis what every one of the six operations above actually wants.
This is lane 5 ("honest tags") of the object-model single-path work and is also the prerequisite for
the invariant
POINTER tag => dereferenceable GC cell, which removes the small-handle guard fromevery emitted property read (21
icmp ugt ..., 1048575sites in perry-codegen) and the 441hand-rolled address floors that
scripts/addr_class_ratchet_baseline.txttracks.A prototype converting the
textfamily lands all six rows on node's answers with ~200 lines,entirely in perry-runtime and with no codegen change; PR to follow.
Related
struct Box { value: u64 }has no header, so identity and capture counts live in address-keyed side tables — ~9.8% of native tsc, and likely a memory loss too #10703 —struct Boxhas no header, so identity lives in address-keyed side tables (same class ofproblem, different value kind).
export function isSame(x){ return x === f }returns false for the importedf#10554,globalThis.crypto.randomUUIDreturns a new function object on every read (crypto.randomUUID === crypto.randomUUIDis false) #10427 — other identity-parity gaps, unrelated mechanisms.