Skip to content

fix(runtime): a Map/Set subclass in a base-typed binding was read as a raw header (#7570) - #7573

Merged
proggeramlug merged 7 commits into
mainfrom
fix/7570-map-declared-type-lowering
Aug 7, 2026
Merged

fix(runtime): a Map/Set subclass in a base-typed binding was read as a raw header (#7570)#7573
proggeramlug merged 7 commits into
mainfrom
fix/7570-map-declared-type-lowering

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #7570.

The bug

class MyMap<K, V> extends Map<K, V> {}

const m: Map<string, number> = new MyMap<string, number>();
m.set("a", 1);          // <-- SIGBUS, exit 138, before anything prints
console.log("size:", m.size);

Node prints size: 1. Reproduced on main at v0.5.1323, exit 138 with zero output.

Annotating a binding with a base type is everyday TypeScript. A parameter
(function f(m: Map<string, number>)) is the more likely way to hit this in real
code — NestJS's ModulesContainer extends Map is exactly this shape. All five
binding forms are affected: const, parameter, class field, return type, and
as Map<…> cast.

Verified root cause

Perry models a Map/Set subclass instance as a plain ObjectHeader carrying the
real collection under a hidden field (object/map_set_subclass.rs). The two
headers overlay 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_idfield_count

entries is two u32 class ids glued into a pointer, and the first store
through it faults (map_set_string_key_value + 708, str x21, [x20], #0x8).

The instance reaches those raw entry points because "is a Map" was decided
from the declared TypeScript type
— at three layers, all keyed on the same
fact:

  • crates/perry-codegen/src/type_analysis/strings.rs:135 (is_map_expr) and
    :13 (is_set_expr) — satisfied by Type::Generic { base: "Map" }, no
    subclass or brand check. Feed lower_call/property_get/map_set.rs:22,131,289,317
    and expr/property_get.rs:486,495 (.size).
  • crates/perry-hir/src/lower/expr_call/local_array_methods.rs:915-1032 — the
    HIR fold to Expr::MapSet / MapGet / MapHas / SetAdd / … on
    ctx.lookup_local_type(...) == Generic { base: "Map" | "Set" }.
  • crates/perry-hir/src/lower/stmt_loops.rs:1306,1326 and
    lower/for_head.rs:303 — the for…of index fast paths
    (js_map_size + js_map_entry_value_at, js_set_value_at).

A declared type is a hint, never a layout fact — CLAUDE.md's Known
Limitations
says annotations are erased and nothing validates them at runtime.
The unannotated form (const m = new MyMap(), covered by
test_gap_6325_map_set_subclass.ts) always worked precisely because it types as
the subclass and dispatches through subclass_backing_of. The annotation is
what routes the value onto the raw lowering.

Confirmed by reading the emitted IR for a probe covering every binding form:
raw js_map_set / js_map_get_string_key / js_map_size /
js_map_values_iter_obj / js_set_add / js_set_value_at calls on all of them.

Fix, and why this shape

Resolve the receiver at the raw runtime entry points, not by tightening one
codegen predicate at a time. map::clean_map_ptr and set::clean_set_ptr — the
funnels that 27 and 30 js_map_* / js_set_* entries already share — now
brand-check what they are handed:

  • a genuine header (GC_TYPE_MAP / GC_TYPE_SET) passes straight through. This
    is the only case that costs anything: one GcHeader.obj_type load — the 8
    bytes immediately preceding the header, so normally the same cache line — plus
    a compare;
  • a class X extends Map | Set instance is redirected onto its hidden backing
    (redirect_collection_receiver, #[cold] / #[inline(never)]);
  • a plain object merely annotated Map<K, V> resolves to null, so every entry
    degrades through its existing null branch (undefined / 0 / false)
    instead of dereferencing a forged pointer;
  • anything with no readable GcHeader (handle-band ids, tag remnants,
    non-pointer garbage) is passed through unchanged — exactly the pre-fix
    behaviour. Narrowing that is a separate, riskier change.

Why not "refuse the raw lowering when the static type is a subclassable native
base"
: that costs the fast path for every Map<K, V>-annotated binding in
every program, including the overwhelming majority that never declare a
subclass, and "does this program declare a Map subclass?" is a whole-program
question that per-module parallel codegen cannot answer soundly.

Why not a codegen-emitted runtime guard: it would have to be repeated at
~20 lowering sites across two crates, and each new site is a new way to forget.
The runtime funnel is fail-closed — it covers every binding form and every
future caller. Same precedent as promise/checked_dispatch.rs, which already
does exactly this for class X extends Promise.

Four sites needed more than the funnel:

  • js_set_add / js_set_has / js_set_delete / js_set_clear /
    js_set_to_array never called clean_set_ptr at all — they went straight to
    find_value_index on the raw pointer.
  • collection_iter_object::{map,set}_iter_obj_raw store the pointer into the
    iterator object rather than using it immediately, so the redirect has to happen
    before capture.
  • Map.prototype.set and Set.prototype.add return their receiver. For a
    subclass the receiver and the collection differ, so the write goes to the
    backing while the instance comes back — otherwise m.set(k, v) === m was
    false and chaining handed out the backing. The receiver is rooted across the
    store (RuntimeHandle::across_mut): it is a movable ObjectHeader and the
    store allocates.
  • js_map_foreach / js_set_foreach derive the collection they report as the
    callback's 3rd argument (and the self === m identity) from the map being
    iterated, which after resolution is the backing. They now pass the receiver
    through as the collection override — the same contract
    js_map_foreach_with_collection already serves for the unannotated path — and
    only when the resolution actually moved, so a plain Map keeps
    has_override == false and behaves exactly as before.

Validation (local; CI has a deep backlog and may not report)

  • test-files/test_gap_7570_map_set_declared_base_type.ts — byte-identical to
    node --experimental-strip-types (v26.5.1, the .node-version pin), exit 0.

    Covers all five binding forms, the whole iteration surface (for-of, spread,
    Array.from, forEach, .entries()/.keys()/.values()),
    size/get/has/delete/clear, receiver identity and chaining, indirect
    subclasses, a subclass with its own constructor and fields, and non-subclass
    controls including the specialized numeric- and string-keyed entry points.
  • Sabotage, both directions, verified on a cleared object cache: with
    crates/perry-runtime/ reverted in full to the parent commit and the test
    file untouched, the same file exits 138 with zero output; with the fix
    restored it exits 0, byte-identical.
  • Fast path proven still live: the change is runtime-only, and the emitted
    LLVM IR for a probe exercising every affected form is byte-identical before
    and after (diff over --trace llvm, 8,910 lines, zero differences). Plain
    new Map() still lowers to js_map_set_string_number /
    js_map_get_string_key / js_map_size exactly as before. Additionally, the
    unit test a_genuine_map_takes_the_fast_path_and_is_never_redirected asserts
    redirect_collection_receiver returns 0 for a real MapHeader, so the
    fast-path identity cannot have come from a redirect that happened to agree.
  • Four sabotage-shaped unit tests in object/map_set_subclass.rs: each first
    asserts the header byte the pre-fix code misread is still sitting there
    (object_type == 1 at MapHeader.size's offset), and only then that the entry
    point returns the resolved answer — so a green run proves the redirect fired,
    not merely that nothing threw.
  • Collection-family parity A/B (rebased onto main at v0.5.1334): the
    map / set / iter / weak / collection / foreach / spread sweeps
    (~110 tests) produce the identical failure set with the fix and with
    crates/perry-runtime/ reverted in full — test_effect_pipe_map,
    test_gap_2514_settracesigint, test_phase2v3_3_show_toast_set_text,
    test_gap_ratelimiter_memory, test_issue_4034_object_literal_semantics,
    test_issue_2656_weakref_finalization_gc, test_issue_610_foreach. Every one
    is pre-existing and none references Map/Set. Zero regressions.
  • cargo test -p perry-runtime: 1842 passed, 0 failed.
  • cargo test -p perry-codegen --lib: 672 passed, 0 failed.
  • python3 scripts/raw_handle_debt.py: 998 (baseline 998), per-module
    ceilings held.
  • python3 scripts/addr_class_inventory.py, python3 scripts/class_id_collisions.py,
    ./scripts/check_file_size.sh, cargo fmt --all -- --check: all clean.

Scope — declared-type-keyed lowerings NOT fixed here

I swept every is_<native>_expr predicate and every declared-type HIR fold.
Two families remain, both filed separately rather than folded into this PR:

Everything else in the sweep already re-validates at the runtime boundary:
Promise (subclass_backing_promise), RegExp (is_valid_regex_ptr), Error
(object_type == OBJECT_TYPE_ERROR), Date, DataView / typed arrays / Buffer
(lookup_typed_array_kind, is_registered_buffer), URLSearchParams
(resolve_search_params_receiver), WeakMap/WeakSet (object-backed, no raw fold).

No version bump (maintainer bumps at merge).

https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH

Summary by CodeRabbit

  • Bug Fixes

    • Fixed Map and Set operations for subclass instances, including lookups, mutations, iteration, clearing, and forEach.
    • Preserved subclass identity when chaining mutating methods.
    • Invalid collection receivers now fail safely without crashes or data corruption.
    • Maintained existing behavior and performance for standard Map and Set instances.
  • Tests

    • Added comprehensive regression coverage for subclass usage across annotations, casts, fields, parameters, and return values.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b2da6bef-1b9d-467c-9463-d93837458a95

📥 Commits

Reviewing files that changed from the base of the PR and between 3dd8685 and 4d1c6ae.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • CLAUDE.md
  • Cargo.toml

📝 Walkthrough

Walkthrough

Runtime Map and Set entry points now resolve subclass receivers to hidden backing collections. Mutations preserve subclass identity. Iterators and collection operations use resolved headers. Regression tests cover annotated bindings, invalid receivers, chaining, iteration, and specialized Map setters.

Changes

Map and Set receiver resolution

Layer / File(s) Summary
Subclass receiver redirection
crates/perry-runtime/src/object/map_set_subclass.rs
Collection receivers distinguish genuine Map and Set headers, subclass instances with hidden backings, and invalid objects. Tests cover backing resolution, header integrity, and fail-closed behavior.
Map operation resolution
crates/perry-runtime/src/map.rs
Map mutations resolve backing storage while returning the original receiver. Specialized string-key setters and forEach use receiver-preserving paths.
Set operation resolution
crates/perry-runtime/src/set.rs
Set mutation, lookup, deletion, clearing, array conversion, and forEach resolve subclass receivers. add preserves receiver identity.
Iterator and end-to-end validation
crates/perry-runtime/src/collection_iter_object.rs, test-files/test_gap_7570_map_set_declared_base_type.ts, changelog.d/7573-map-set-declared-base-type-receiver.md, CLAUDE.md, Cargo.toml
Map and Set iterators resolve backing collections. Regression tests cover annotated access, iteration, chaining, subclass state, controls, and specialized Map operations. The changelog records the runtime changes and validation. Project version metadata changes to 0.5.1335.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AnnotatedBinding
  participant RawCollectionEntryPoint
  participant redirect_collection_receiver
  participant HiddenBackingCollection
  AnnotatedBinding->>RawCollectionEntryPoint: invoke Map or Set operation
  RawCollectionEntryPoint->>redirect_collection_receiver: resolve receiver
  redirect_collection_receiver->>HiddenBackingCollection: select matching hidden backing
  HiddenBackingCollection-->>RawCollectionEntryPoint: return result or original receiver
Loading

Suggested labels: bug, parity

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address issue #7570 by resolving subclass receivers, preserving genuine collection fast paths, and covering annotated binding forms and operations.
Out of Scope Changes check ✅ Passed The runtime changes, regression tests, and changelog entry directly support the linked issue and stated pull request objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly identifies the runtime Map/Set subclass bug and the base-typed binding condition.
Description check ✅ Passed The description is detailed, on-topic, and covers the bug, root cause, implementation, issue reference, scope, and comprehensive validation.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7570-map-declared-type-lowering

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test-files/test_gap_7570_map_set_declared_base_type.ts (1)

120-131: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add Set.prototype.entries() coverage for the base-typed subclass binding.

Section 6 exercises values(), keys(), spread, forEach, and for-of on s6, but not entries(). entries() reaches js_set_entries_iter_obj, which is one of the set_iter_obj_raw callers changed in this PR. The Map side covers entries() at line 108. Add the Set equivalent so both iterator kinds are exercised through an annotated subclass receiver.

💚 Proposed addition
 console.log("6 set keys:", [...s6.keys()].join(","));
+console.log("6 set entries:", JSON.stringify([...s6.entries()]));
 console.log("6 set spread:", [...s6].join(","));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-files/test_gap_7570_map_set_declared_base_type.ts` around lines 120 -
131, Add Set.prototype.entries() coverage to section 6 using the annotated
subclass receiver s6, matching the existing Map entries() test and the style of
the other s6 iterator checks. Record and log the returned key-value pairs so
js_set_entries_iter_obj and the modified set iterator path are exercised.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test-files/test_gap_7570_map_set_declared_base_type.ts`:
- Around line 120-131: Add Set.prototype.entries() coverage to section 6 using
the annotated subclass receiver s6, matching the existing Map entries() test and
the style of the other s6 iterator checks. Record and log the returned key-value
pairs so js_set_entries_iter_obj and the modified set iterator path are
exercised.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 47b6507c-47ac-4129-baad-2e59d6eb41b1

📥 Commits

Reviewing files that changed from the base of the PR and between f3d2908 and 73e561c.

📒 Files selected for processing (6)
  • changelog.d/7573-map-set-declared-base-type-receiver.md
  • crates/perry-runtime/src/collection_iter_object.rs
  • crates/perry-runtime/src/map.rs
  • crates/perry-runtime/src/object/map_set_subclass.rs
  • crates/perry-runtime/src/set.rs
  • test-files/test_gap_7570_map_set_declared_base_type.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/perry-runtime/src/object/map_set_subclass.rs (1)

159-161: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Validate the raw address at the resolver boundary.

Call crate::value::addr_class::is_plausible_heap_addr(addr) before boxing addr. Return 0 when the address is implausible. Do not rely on ad hoc address-floor checks in downstream receiver classification.

Based on learnings: receiver and address classifiers must use crate::value::addr_class::is_plausible_heap_addr for the handle-band and heap-floor check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/map_set_subclass.rs` around lines 159 - 161,
Update redirect_collection_receiver to validate addr with
crate::value::addr_class::is_plausible_heap_addr before converting it through
JSValue::pointer; return 0 immediately when validation fails. Remove or avoid
relying on downstream ad hoc address-floor checks for this resolver boundary.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/object/map_set_subclass.rs`:
- Around line 102-165: Update redirect_collection_receiver and
subclass_backing_of so the boxed receiver is rooted before the backing-key
allocation, then reloaded after js_string_from_bytes completes before deriving
the ObjectHeader pointer used by js_object_get_field_by_name_f64. Ensure the
root remains valid across moving GC and add a regression test that forces
evacuation during this redirect path.

---

Nitpick comments:
In `@crates/perry-runtime/src/object/map_set_subclass.rs`:
- Around line 159-161: Update redirect_collection_receiver to validate addr with
crate::value::addr_class::is_plausible_heap_addr before converting it through
JSValue::pointer; return 0 immediately when validation fails. Remove or avoid
relying on downstream ad hoc address-floor checks for this resolver boundary.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a67a4a8b-adcf-441c-bf6c-164893a6e889

📥 Commits

Reviewing files that changed from the base of the PR and between f3d2908 and 73e561c.

📒 Files selected for processing (6)
  • changelog.d/7573-map-set-declared-base-type-receiver.md
  • crates/perry-runtime/src/collection_iter_object.rs
  • crates/perry-runtime/src/map.rs
  • crates/perry-runtime/src/object/map_set_subclass.rs
  • crates/perry-runtime/src/set.rs
  • test-files/test_gap_7570_map_set_declared_base_type.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/perry-runtime/src/collection_iter_object.rs
  • changelog.d/7573-map-set-declared-base-type-receiver.md
  • test-files/test_gap_7570_map_set_declared_base_type.ts
  • crates/perry-runtime/src/map.rs
  • crates/perry-runtime/src/set.rs

Comment on lines +102 to +165
/// #7570 — resolve a raw Map/Set RECEIVER address that is NOT a genuine
/// `MapHeader`/`SetHeader` to the collection the operation must actually run
/// on. `want` selects which backing kind the caller can use.
///
/// Why this exists: codegen decides "this receiver is a Map" from the
/// **declared** TypeScript type of the binding (`is_map_expr` /
/// `Type::Generic { base: "Map" }`), then emits a raw `js_map_*` call whose
/// first act is to dereference the receiver as a `MapHeader`. A declared type
/// is a hint, never a layout fact (CLAUDE.md, *Known Limitations*: annotations
/// are erased, nothing validates them at runtime), so any binding annotated
/// with the BASE type — `const m: Map<K, V> = new MyMap()`, a parameter, a
/// class field, a return type, an `as Map<…>` cast — can be holding a
/// SUBCLASS instance, which perry models as a plain `ObjectHeader`. The two
/// headers overlay field-for-field, so `entries: *mut f64` reads
/// `parent_class_id ‖ field_count` — two `u32` class ids glued into a pointer
/// — and the first `.set()` stores through it (SIGBUS).
///
/// The unannotated path never had this problem because it dispatches through
/// [`subclass_backing_of`]. This is the same redirect, performed at the raw
/// runtime entry points so it is **fail-closed**: it covers every binding form
/// and every future caller, rather than one predicate at a time.
///
/// Returns `0` for an object that is not a Map/Set subclass instance (a plain
/// object mis-annotated as a native collection), so the caller degrades to its
/// existing null handling — `undefined` / `0` / `false` — instead of
/// dereferencing a forged pointer.
///
/// Marked `#[cold]`/`#[inline(never)]`: the genuine-header fast path never
/// reaches here, and keeping the body out of line preserves the inlined
/// receiver check at the ~57 `js_map_*` / `js_set_*` entry points.
///
/// # This ALLOCATES, and its callers hold unrooted JSValue args
///
/// [`subclass_backing_of`] builds the hidden field's key with
/// `js_string_from_bytes`, so reaching this arm is a collection point — and it
/// runs at the TOP of e.g. `js_map_set`, before that function roots its `key` /
/// `value` params. The exposure is the #7213 shape, and it is closed by the same
/// accident described in `string/alloc.rs`: an allocation here reaches the
/// alloc-point arm of `gc_check_trigger`, which takes
/// `ManualGcScanGuard::force_full_scan`, and a forced conservative stack scan
/// makes the copying minor ineligible. So the collection this can cause never
/// MOVES anything, and the same conservative scan finds the raw args on the
/// native stack.
///
/// Recorded rather than pre-emptively fixed, for two reasons. The shape is
/// already load-bearing on hotter paths — `native_call_method`'s
/// `collection_methods.rs` calls `subclass_backing_of` on every native method
/// call on an object, and `field_get_set/get_field_by_name.rs` on every `.size`
/// read — so this adds no NEW class of exposure. And the obvious fix (a
/// thread-local caching the interned key `StringHeader`) is itself an unrooted
/// runtime cache of a heap pointer, the invisible-root hazard CLAUDE.md warns
/// about, which would have to be registered with
/// `gc_register_mutable_root_scanner` to be sound. If #7213's premise ever
/// changes — if the alloc-point arm stops forcing a conservative scan — this
/// call site must be revisited together with the two above.
#[cold]
#[inline(never)]
pub(crate) fn redirect_collection_receiver(addr: usize, want: CollectionKind) -> usize {
let boxed = f64::from_bits(JSValue::pointer(addr as *const u8).bits());
match (subclass_backing_of(boxed), want) {
(Some(CollectionBacking::Map(m)), CollectionKind::Map) => m as usize,
(Some(CollectionBacking::Set(s)), CollectionKind::Set) => s as usize,
_ => 0,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Root the receiver across the backing-key allocation.

redirect_collection_receiver calls subclass_backing_of, which derives obj before js_string_from_bytes can allocate. If that allocation evacuates the object, js_object_get_field_by_name_f64 can dereference a stale ObjectHeader pointer.

Root the boxed receiver inside subclass_backing_of. Reload it after key creation. Derive obj only after the reload. Add a regression test that forces moving evacuation in this path.

Based on learnings: production GC does not conservatively scan Rust stack locals, and NaN-boxed values must be rooted and reloaded across allocating operations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/map_set_subclass.rs` around lines 102 - 165,
Update redirect_collection_receiver and subclass_backing_of so the boxed
receiver is rooted before the backing-key allocation, then reloaded after
js_string_from_bytes completes before deriving the ObjectHeader pointer used by
js_object_get_field_by_name_f64. Ensure the root remains valid across moving GC
and add a regression test that forces evacuation during this redirect path.

Source: Learnings

Ralph Küpper added 5 commits August 7, 2026 10:02
…a raw header (#7570)

`const m: Map<string, number> = new MyMap<string, number>(); m.set("a", 1)`
took SIGBUS (exit 138) before printing anything. Node prints `size: 1`.

Codegen decides "this receiver is a Map" from the DECLARED TypeScript type
(`is_map_expr` <= `Type::Generic { base: "Map" }`, plus the matching HIR folds
and for-of fast paths), then emits a raw `js_map_*` call that dereferences the
receiver as a `MapHeader`. A declared type is a hint, never a layout fact.
Perry models a Map/Set subclass instance as a plain `ObjectHeader`, and the two
headers overlay field-for-field, so `entries: *mut f64` reads
`parent_class_id || field_count` -- two u32 class ids glued into a pointer --
and the first store through it faults.

Resolve the receiver at the raw runtime entry points instead of tightening one
codegen predicate at a time. `clean_map_ptr` / `clean_set_ptr` -- the funnels 27
and 30 `js_map_*` / `js_set_*` entries already share -- now brand-check what
they are handed: a genuine `GC_TYPE_MAP`/`GC_TYPE_SET` header passes through
(one `GcHeader.obj_type` load plus a compare), a subclass instance is redirected
onto its hidden backing, and a plain object merely annotated `Map<K, V>`
resolves to null so each entry degrades through its existing null branch instead
of dereferencing a forged pointer. Anything with no readable `GcHeader` is
passed through exactly as before.

Three sites needed more than the funnel: `js_set_add`/`has`/`delete`/`clear`/
`to_array` never called `clean_set_ptr` at all; the iterator-object constructors
STORE the pointer rather than using it immediately; and `Map.prototype.set` /
`Set.prototype.add` return their RECEIVER, which for a subclass is the instance
and not the backing, so it is rooted across the store and handed back.

Adds `test_gap_7570_map_set_declared_base_type.ts` (all five binding forms, the
whole iteration surface, receiver identity, indirect subclasses, and
non-subclass controls) plus four sabotage-shaped unit tests that assert the
misread header byte is still present before asserting the resolved answer.

Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH
…7570)

`js_map_foreach` / `js_set_foreach` derive the collection they report as the
callback's 3rd argument (and the `self === m` identity) from the map being
iterated. After the receiver resolution that is the hidden backing, not the
instance, for a base-typed binding holding a `class X extends Map | Set`.
Pass the receiver through as the collection override -- the same contract
`js_map_foreach_with_collection` already serves for the unannotated path --
and only when the resolution actually moved, so a plain Map keeps
`has_override == false` and behaves exactly as before.

Gap test now asserts `self === m` inside forEach for the subclass, the Set
twin, and the plain-Map control.

Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH
@proggeramlug
proggeramlug force-pushed the fix/7570-map-declared-type-lowering branch from 787f45a to 5505db4 Compare August 7, 2026 08:12
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@proggeramlug
proggeramlug merged commit 2ba5950 into main Aug 7, 2026
@proggeramlug
proggeramlug deleted the fix/7570-map-declared-type-lowering branch August 7, 2026 09:45
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit before merge — verified, merged as v0.5.1335

Sabotage-verified decisively. Rather than reverting the runtime wholesale
(which does not build — the module declaration lives outside the changed files),
I neutered redirect_collection_receiver to always return 0 and rebuilt. The
reproducer goes straight back to exit 138, zero output — precisely the
failure I confirmed on main before this work started. The fix is load-bearing,
not incidental.

Verified all five binding forms plus the controls, with a probe I wrote
rather than the PR's, byte-identical to node 26.5.1 at exit 0:

size: 2 get: 1 has: true
param: 3            field: 1            return: 1           cast: 1
set: 2 1,2
forEach: a=1:true|b=2:true|c=3:true     <- 3rd arg identity holds
for-of: fo:a1|fo:b2|fo:c3
real Map: 1 7 r                          <- unmodified control

The forEach row is worth calling out: coll === m is true, so the receiver
identity fix is real and not just "it stops crashing".

The PR's own gap test is byte-identical too (41 lines, exit 0). Gates re-run
here: perry-runtime --lib 1842 passed / 0 failed, addr_class_inventory,
class_id_collisions, raw_handle_debt (998/998), check_file_size.sh and
cargo fmt --check all clean.

On the full gap sweep — merged without it, deliberately

The report offered to run the full 495-test sweep (~6 hours) before merging. I
declined, and the reasoning should be on the record rather than implicit:

  • The emitted IR is byte-identical pre/post (8,910 lines, zero diff). This
    is a runtime-only change, so there is no codegen regression surface for a
    broad sweep to find.
  • The blast radius is the collection family by construction. The brand check
    only adds a redirect for a receiver that is not a real MapHeader/SetHeader;
    a genuine one passes through on a single GcHeader.obj_type load and compare.
    The ~110-test collection-family A/B with an identical failure set is aimed at
    exactly that surface.
  • Six hours at the harness's per-test auto-optimize rate would also have
    re-consumed the disk that had just been recovered.

If a Map/Set regression does surface later, this is the assumption to re-examine
first.

The framing worth keeping

This is CLAUDE.md's "TS types don't drive layout" rule violated in its most
dangerous direction — a declared type used as proof of runtime memory
layout
— and it was keyed at three independent layers, not one: the codegen
predicates, the HIR fold, and the for-of index fast paths. Fixing it at the
runtime entry points instead of at ~20 codegen sites is the right call precisely
because each of those sites is a separate opportunity to forget.

The follow-ups filed rather than silently absorbed are the other half of the
value: #7574 (class X extends Array is the identical shape, still
unguarded — is_array_expr gates three tiers and only the general index-get
tier brand-checks) and #7575 (m instanceof MyMap is false with or
without the annotation, pre-existing).

Also: the crash on test_gap_gc_same_module_call_argument_rooting recorded
during the disk-full window was correctly discarded and re-run 10/10 clean. That
is the right handling — a full-disk window corrupts long runs silently, so
results from it are not evidence in either direction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant