Skip to content

Distinct native-handle objects have no identity: two TextEncoders are ===, collide as Map/Set keys, and a WeakMap returns a value stored under a different object #10821

Open

Description

@proggeramlug

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

Activity

  1. proggeramlug commented on Sep 21, 2026

    @proggeramlug
    ContributorAuthor

    Two of the thirteen families are done, so this stays open.

    Both are now GC_TYPE_OBJECT with a real ShapeId, a family class id and a per-family prototype, carrying zero own keys so Object.keys, getOwnPropertyNames, JSON.stringify, spread and Object.assign match node for free.

    The timer half fixed a live ambiguity rather than just tidying representation: timer ids and perry-ffi registry handles shared the pointer-tagged small-integer band and both count from 1, so the first net.createServer() and the first setTimeout() of a program produced the same value and the dispatcher guessed between server.close() and clearTimeout(t). #10836 carries a witnessed repro.

    DECODER_REGISTRY was deleted outright with the text family — a mutex-guarded map that grew one entry per new 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 globalThis bootstrap on the first setTimeout (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.

  2. proggeramlug commented on Sep 21, 2026

    @proggeramlug
    ContributorAuthor

    Claiming row 3, the perry/tui family (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 the useApp / useStdout / useFocusManager singletons — become ordinary GC_TYPE_OBJECTs with class ids 0xFFFF_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 and useRef counts from 1 — so useApp() === Text("hi") is true today, and a Map keyed on both keeps one entry. And state(0), the first of a program, is slot 0, i.e. the value handed to JS is POINTER_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.

  3. proggeramlug commented on Oct 4, 2026

    @proggeramlug
    ContributorAuthor

    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.

  4. added
    bugConfirmed defect or regression
    parityCompatibility gap with Node.js, ECMAScript, or the supported ecosystem
    on Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regressionparityCompatibility gap with Node.js, ECMAScript, or the supported ecosystem

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions