Repository navigation
Description
A class X extends Map instance stored in a variable annotated Map<K, V>
is handed to the raw js_map_* entry points as if it were a real MapHeader.
It is a plain ObjectHeader, so the very first .set() writes through a
pointer fabricated out of two u32 class-id fields.
Found while fixing #7563. It is a different bug: #7563 is an
array-capacity/class-id confusion in js_class_method_bind and is fixed by
PR #7569; this one survives that fix.
Repro
class MyMap<K, V> extends Map<K, V> {}
const m: Map<string, number> = new MyMap<string, number>();
m.set("a", 1); // <-- SIGBUS, before anything prints
console.log("size:", m.size);$ node --experimental-strip-types ann.ts
annotated size: 2
annotated get: 1
annotated values: 1,2
annotated keys: a,b
annotated spread: [["a",1],["b",2]]
param-annotated: 11
rc=0
$ ./ann
rc=138 # SIGBUS, zero output
A parameter annotation is the same shape and is the more likely way to hit this
in real code:
function take(mm: Map<string, number>): number {
let t = 0;
for (const [, v] of mm) t += v; // same fault
return t;
}
take(new MyMap<string, number>([["x", 5]]));Without the annotation (const m = new MyMap<string, number>()) the receiver
types as the subclass and everything works — that is the shape
test_gap_6325_map_set_subclass.ts covers.
Backtrace (PERRY_DEBUG_SYMBOLS=1)
stop reason = EXC_BAD_ACCESS (code=2, address=0x1ffff0032)
frame #0: perry_runtime::map::map_set_string_key_value + 708
frame #1: main + 332
-> 0x10016b970 <+708>: str x21, [x20], #0x8
A store through x20, i.e. the entries cursor.
Root cause
ObjectHeader and MapHeader overlay each other field-for-field:
MapHeader field |
offset | actually reads (on an ObjectHeader) |
|---|---|---|
size: u32 |
0 | object_type (= 1) |
capacity: u32 |
4 | class_id |
entries: *mut f64 |
8 | parent_class_id || field_count — two u32 class ids glued into a pointer |
entries_ptr then dereferences that fabricated address.
The receiver reaches those entry points because "is a Map" is decided from the
declared type. is_map_expr
(crates/perry-codegen/src/type_analysis/strings.rs:135-163) is satisfied by
Type::Generic { base: "Map" } with no subclass or runtime-brand check, so an
annotated variable/parameter/field holding a subclass instance takes the raw
lowering:
m.set(…)/m.get(…)/m.size→js_map_*on theObjectHeaderm.values()→crates/perry-codegen/src/lower_call/property_get/map_set.rs:114-127
→js_map_values_iter_objon theObjectHeaderfor (const [, v] of m)→ the index fast path →js_map_size+
js_map_entry_value_aton theObjectHeader
Each bypasses subclass_backing_of, which is what makes the unannotated path
work. None of the runtime entries re-validate: clean_map_ptr
(crates/perry-runtime/src/map.rs:732-743) only strips the NaN-box tag and
null-checks, and is_registered_map (:194) is not consulted by
js_map_size (:1023), js_map_entry_key_at (:1904),
js_map_entry_value_at (:1921), js_map_keys (:1992), js_map_values
(:2024), or js_map_values_iter_obj
(crates/perry-runtime/src/collection_iter_object.rs:117).
The same argument applies to Set and the js_set_* entries.
Suggested shape
Either teach the codegen "is a Map" predicates that a class-typed value whose
chain reaches Map is a subclass (route it through the backing like the
unannotated path does), or give the raw runtime entries a brand check —
is_registered_map is already the dereference-free authority and short-circuits
on map_registry_never_used(), so a guard there is cheap. The second is the
fail-closed option and covers every future caller.
Related: #7563 / PR #7569 (the array-capacity twin of this "read a header as the
wrong type" family), and the note in CLAUDE.md's Known-weak areas on native
base-class subclassing.
Activity
docs(runtime): record the #7213 allocation shape at the #7570 redirect
fix(runtime): forEach's 3rd argument must stay the subclass instance (#…
fix(runtime): a Map/Set subclass in a base-typed binding was read as …
docs(runtime): record the #7213 allocation shape at the #7570 redirect
fix(runtime): forEach's 3rd argument must stay the subclass instance (#…
fix(runtime): a Map/Set subclass in a base-typed binding was read as …