Repository navigation
Description
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
- perf(box):
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 of
problem, different value kind). - A function referenced inside its own module is a different object from the same function imported elsewhere:
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.
Activity
feat(runtime): setTimeout / setInterval / setImmediate return ordinar…
feat(runtime): TextEncoder / TextDecoder instances are ordinary objec…
feat(runtime): setTimeout / setInterval / setImmediate return ordinar…
proggeramlug commentedon Sep 21, 2026
Two of the thirteen families are done, so this stays open.
TextEncoder/TextDecoder) — v0.5.1627, feat(runtime): TextEncoder / TextDecoder instances are ordinary objects (#10821) #10831setTimeout/setInterval/setImmediate) — v0.5.1627, feat(runtime): setTimeout / setInterval / setImmediate return ordinary objects (#10821) #10836Both are now
GC_TYPE_OBJECTwith a real ShapeId, a family class id and a per-family prototype, carrying zero own keys soObject.keys,getOwnPropertyNames,JSON.stringify, spread andObject.assignmatch node for free.The timer half fixed a live ambiguity rather than just tidying representation: timer ids and
perry-ffiregistry handles shared the pointer-tagged small-integer band and both count from 1, so the firstnet.createServer()and the firstsetTimeout()of a program produced the same value and the dispatcher guessed betweenserver.close()andclearTimeout(t). #10836 carries a witnessed repro.DECODER_REGISTRYwas deleted outright with the text family — a mutex-guarded map that grew one entry pernew TextDecoder()and was never pruned.The remaining eleven families are enumerated in
crates/perry-runtime/src/hot_diag/receiver_repr.rs.One cost worth recording for whoever takes the next family: making these ordinary objects surfaced a ~6 ms one-time
globalThisbootstrap on the firstsetTimeout(fixed in v0.5.1627), and removing that bootstrap's side effect then broke eight tests that had been silently relying on it — one of which SIGABRTed the runtime suite and hid the other seven for four trains. Expect the same shape: a family conversion touches prototype installation, which touches realm bootstrap.proggeramlug commentedon Sep 21, 2026
Claiming row 3, the
perry/tuifamily (tracker in `HONEST_HANDLE_TAG_PLAN_2026-09-20.md`) — branch `feat/honest-tags-tui`, in progress now.Scope: the six JS-visible kinds the module hands back — widget (
Text/Box/Table/…),state(),useRef, and theuseApp/useStdout/useFocusManagersingletons — become ordinaryGC_TYPE_OBJECTs with class ids0xFFFF_240B..0x2410.What the census turned up that the tracker row did not say: the three registries are not three id spaces, they are six colliding ones.
useApp()is the constant 1,useStdout()2,useFocusManager()3, the widget tree counts from 1 anduseRefcounts from 1 — souseApp() === Text("hi")istruetoday, and aMapkeyed on both keeps one entry. Andstate(0), the first of a program, is slot 0, i.e. the value handed to JS isPOINTER_TAG | 0— a null pointer wearing the pointer tag, which is the exact shape the invariant exists to forbid.If another session is already on this family, say so and I will drop it.
feat(runtime): perry/tui handles are ordinary objects (#10821)
fix(runtime): the unresolved-namespace stub is an ordinary object, no…
test(runtime): gate A covers the header-less class too, not only smal…
Merge origin/main into turnloop/integration
fix(runtime): wrong liveness accessor in a merged test; per-test the …
fix(timer): install the real Timeout/Immediate handle as callback `this`
proggeramlug commentedon Oct 4, 2026
TextEncoder/TextDecoder are now ordinary GC objects with a family class id (native_class_ids.rs), so two instances are distinct and work as Map/Set/WeakMap keys. Is this issue fully fixed on main, or are other native-handle families still affected? The census (secret-tests native-handle-census-20261004) lists the remaining handle families; those are being converted under #10508/#10694 and follow-ups.