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 the ObjectHeader
m.values() → crates/perry-codegen/src/lower_call/property_get/map_set.rs:114-127
→ js_map_values_iter_obj on the ObjectHeader
for (const [, v] of m) → the index fast path → js_map_size +
js_map_entry_value_at on the ObjectHeader
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.
A
class X extends Mapinstance stored in a variable annotatedMap<K, V>is handed to the raw
js_map_*entry points as if it were a realMapHeader.It is a plain
ObjectHeader, so the very first.set()writes through apointer fabricated out of two
u32class-id fields.Found while fixing #7563. It is a different bug: #7563 is an
array-capacity/class-id confusion in
js_class_method_bindand is fixed byPR #7569; this one survives that fix.
Repro
A parameter annotation is the same shape and is the more likely way to hit this
in real code:
Without the annotation (
const m = new MyMap<string, number>()) the receivertypes as the subclass and everything works — that is the shape
test_gap_6325_map_set_subclass.tscovers.Backtrace (
PERRY_DEBUG_SYMBOLS=1)A store through
x20, i.e. the entries cursor.Root cause
ObjectHeaderandMapHeaderoverlay each other field-for-field:MapHeaderfieldObjectHeader)size: u32object_type(= 1)capacity: u32class_identries: *mut f64parent_class_id||field_count— twou32class ids glued into a pointerentries_ptrthen 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 byType::Generic { base: "Map" }with no subclass or runtime-brand check, so anannotated 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 theObjectHeaderEach bypasses
subclass_backing_of, which is what makes the unannotated pathwork. None of the runtime entries re-validate:
clean_map_ptr(
crates/perry-runtime/src/map.rs:732-743) only strips the NaN-box tag andnull-checks, and
is_registered_map(:194) is not consulted byjs_map_size(:1023),js_map_entry_key_at(:1904),js_map_entry_value_at(:1921),js_map_keys(:1992),js_map_values(
:2024), orjs_map_values_iter_obj(
crates/perry-runtime/src/collection_iter_object.rs:117).The same argument applies to
Setand thejs_set_*entries.Suggested shape
Either teach the codegen "is a Map" predicates that a class-typed value whose
chain reaches
Mapis a subclass (route it through the backing like theunannotated path does), or give the raw runtime entries a brand check —
is_registered_mapis already the dereference-free authority and short-circuitson
map_registry_never_used(), so a guard there is cheap. The second is thefail-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.