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

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

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions