Skip to content

SIGBUS: a Map subclass instance in a variable annotated Map<K, V> is dereferenced as a raw MapHeader #7570

Description

@proggeramlug

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.sizejs_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.

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