Skip to content

fix(runtime): arr[Symbol.iterator] read an array's capacity as a class_id (#7563) - #7569

Merged
proggeramlug merged 2 commits into
mainfrom
fix/7563-map-subclass-values-override
Aug 7, 2026
Merged

fix(runtime): arr[Symbol.iterator] read an array's capacity as a class_id (#7563)#7569
proggeramlug merged 2 commits into
mainfrom
fix/7563-map-subclass-values-override

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #7563.

What the crash actually is

Reported as "a class X extends Map that overrides values() SIGSEGVs when the override is iterated". Map turns out to be incidental, and so does the iteration. The same crash reproduces with no Map anywhere in the program and with no for-of on the path:

class Plain {
  values(): IterableIterator<number> {
    return [777][Symbol.iterator]();   // <-- SIGSEGV
  }
}
new Plain().values();

It is a stack overflow from infinite recursion, not a stale or null pointer. EXC_BAD_ACCESS (code=2, address=0x16f603fe0) at str xzr, [sp], #-0x50 — the guard page — with a ~26 000-frame cycle:

frame #4  perry_method_repro_ts__MyMap__values          <- the user's override
frame #3  js_native_call_method_value
frame #2  js_object_get_symbol_property                 <- the array's @@iterator read
frame #8  js_native_call_value
frame #7  dispatch_bound_method
frame #6  call_vtable_method
frame #10 perry_method_repro_ts__MyMap__values          <- back to the override

Root cause

ObjectHeader is { object_type: u32, class_id: u32, … }; ArrayHeader is { length: u32, capacity: u32 }. The two u32s at offset 4 alias, so an array pointer read as an ObjectHeader reports its capacity as a class_id.

arr[Symbol.iterator] resolves through js_class_method_bind(arr, "values") (symbol/get.rs, the #321 arm that makes typeof arr[Symbol.iterator] === "function" hold). That builder's receiver→class step — class_id_from_method_receiver in crates/perry-runtime/src/object/native_module.rs — read the field with a bare (*obj).class_id, guarded against closures and against the handle band but never against the allocation's actual type.

So whenever the class whose id equalled the array's capacity happened to own a method named values, method_owner_class_id found it and the canonical class method was returned as the array's iterator. Class ids are handed out from 1 in declaration order and the default capacity is MIN_ARRAY_CAPACITY-clamped, so the collision is the common case for small programs — and when the colliding class was the calling class, values re-entered values forever.

The mis-dispatch is directly observable in its non-fatal form, and it is capacity-indexed exactly as predicted:

class A { two() { return [1, 2][Symbol.iterator](); } }
class B { values() { return 42; } }
new A().two();   // pre-fix: resolves B.values -> 42 -> "TypeError: value is not iterable"

Fix

One line: use js_object_get_class_id, the guarded accessor that already existed for exactly this read. It rejects the handle band, the std::alloc'd Map/Set/Regex headers (which have no GcHeader to probe), and any allocation whose GcHeader.obj_type is not GC_TYPE_OBJECT. The bare read bypassed all three.

The sibling symbol-method arm in object/native_call_method.rs already routed through that accessor — verified with a targeted probe, not assumed.

The guard is deliberately not narrower than the invariant it protects: a genuine class instance still resolves to its id, asserted in the same test.

Not #7561

rewrite_collection_view_for_of declines a subclass receiver exactly as its doc comment claims. The crash needs neither a for-of nor a Map — calling m.values() and discarding the result is enough — and the offending line predates #7561 by hundreds of commits (it traces back through #5631's file split to #4630). That matches the issue's report that it reproduces at 969b447cc.

Verification (local; both directions)

check before after
issue reproducer SIGSEGV (rc 139) byte-identical to node, rc 0
test_gap_7563_…ts SIGSEGV (rc 139) byte-identical to node, rc 0
object::tests::array_receiver_is_never_read_as_a_class_id FAILS (Some(16) — the capacity) passes

Native-base-subclass family sweep, all byte-identical to node:

values / keys / entries / [Symbol.iterator] overrides on Map, Set and Array subclasses; an indirect subclass (class Leaf extends Mid extends Map); a class-expression subclass; non-overriding subclasses keeping the built-in surface; and super.values() from inside an override still reaching the native base.

Gates: cargo test -p perry-runtime --no-fail-fast 1812 passed / 0 failed; raw_handle_debt.py 998 (baseline 998); check_file_size.sh OK; cargo fmt --all -- --check clean.

No rooting change, so the GC instruments are not implicated.

Out of scope

The issue's "Related shapes" table (Map.prototype.values = …, Map.prototype[Symbol.iterator] = …, own-instance m.values = …) still diverges — Perry uses the original, node honours the patch. Confirmed unchanged by this PR; that is the pre-existing statically-typed-collection fast-path family (#7542), and the issue itself files it as context rather than as the actionable part.

#7564 (make_iter_result's five allocations per .next()) is untouched — this fix does not go near that code, and widening a memory-safety fix into a performance refactor was not warranted.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an issue where arrays could be mistaken for class instances during iterator method resolution.
    • Prevented incorrect method dispatch that could cause recursive failures or crashes.
    • Preserved correct behavior for genuine class instances and built-in collection subclasses.
    • Improved reliability for array iteration across inheritance, overrides, and custom class scenarios.
  • Tests

    • Added coverage for array iterators, inheritance patterns, class overrides, and built-in collection behavior.
    • Added regression tests confirming correct handling of arrays and genuine class instances.

@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: 26376282-b7cb-4218-ad79-5d3f88b81205

📥 Commits

Reviewing files that changed from the base of the PR and between 3523355 and 76a0f4a.

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

📝 Walkthrough

Walkthrough

The runtime now uses guarded class-ID access when resolving method receivers. Tests verify that arrays are rejected as class instances while genuine class instances resolve correctly. Integration coverage exercises array iterators and related subclass behavior.

Changes

Array iterator class-ID fix

Layer / File(s) Summary
Guarded receiver class lookup
crates/perry-runtime/src/object/native_module.rs
class_id_from_method_receiver is visible to parent-module tests and uses js_object_get_class_id instead of direct header access.
Runtime invariant regression
crates/perry-runtime/src/object/tests.rs
The test confirms that arrays return no class ID and genuine class instances retain class-ID resolution.
Iterator behavior regression
test-files/test_gap_7563_array_iterator_class_id_confusion.ts, changelog.d/7569-array-iterator-class-id-confusion.md
Regression coverage exercises iterator dispatch, subclass overrides, inheritance, class expressions, built-in behavior, and super calls. The changelog records the issue and coverage.
Release version metadata
Cargo.toml, CLAUDE.md
The workspace package and documented current version change from 0.5.1321 to 0.5.1322.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • PerryTS/perry#6599: Hardens iterator and dynamic receiver dispatch against invalid class metadata.

Suggested labels: bug

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the runtime bug involving array capacity being read as a class ID during Symbol.iterator access.
Description check ✅ Passed The description explains the root cause, fix, scope, related issue, regression coverage, and verification results, although it does not use the template headings.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7563-map-subclass-values-override

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.

…s_id (#7563)

ObjectHeader is { object_type: u32, class_id: u32, ... } and ArrayHeader is
{ length: u32, capacity: u32 }, so the two u32s at offset 4 alias: an array
pointer read as an ObjectHeader reports its capacity as a class_id.

arr[Symbol.iterator] resolves through js_class_method_bind(arr, "values"), and
that builder's receiver->class step, class_id_from_method_receiver, read the
field with a bare (*obj).class_id -- guarded against closures and the handle
band, but never against the allocation's actual type. So whenever the class
whose id equalled the array's capacity owned a method named `values`, the
array's iterator resolved to THAT class's method. When it was the calling
class, `values` re-entered `values` until the stack guard page: EXC_BAD_ACCESS
at `str xzr, [sp], #-0x50`, ~26 000 frames deep.

Reported as a `class X extends Map` values() override bug, but Map is
incidental and so is the iteration -- the crash reproduces with no Map in the
program and no for-of on the path:

    class Plain { values() { return [777][Symbol.iterator](); } }
    new Plain().values();   // SIGSEGV

Use js_object_get_class_id, the guarded accessor that already existed for this
read: it rejects the handle band, the std::alloc'd Map/Set/Regex headers (no
GcHeader to probe), and any allocation whose GcHeader.obj_type is not
GC_TYPE_OBJECT. The sibling symbol-method arm in native_call_method.rs already
routed through it and was never affected -- verified, not assumed.

Not #7561: rewrite_collection_view_for_of declines a subclass receiver exactly
as documented, and the offending line predates it by hundreds of commits
(traces through #5631's file split to #4630), matching the report that it
reproduces at 969b447.

Coverage: test-files/test_gap_7563_array_iterator_class_id_confusion.ts
(byte-compared against node; SIGSEGVs at the parent commit) and
object::tests::array_receiver_is_never_read_as_a_class_id (fails with Some(16),
the array's capacity, before the fix).
@proggeramlug
proggeramlug force-pushed the fix/7563-map-subclass-values-override branch from 3bfc255 to 3523355 Compare August 7, 2026 04:14
@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

Copy link
Copy Markdown
Contributor Author

While probing the native-base-subclass family for this fix I found a second, independent memory-safety bug in the same area and filed it as #7570 — it is not fixed here, and it survives this PR.

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

Different root cause: "is a Map" is decided from the declared type, so an annotated Map<K, V> variable/parameter holding a subclass instance takes the raw js_map_* lowering and a plain ObjectHeader is dereferenced as a MapHeaderentries: *mut f64 at offset 8 reads parent_class_id ‖ field_count, two u32 class ids glued into a pointer.

stop reason = EXC_BAD_ACCESS (code=2, address=0x1ffff0032)
  frame #0: perry_runtime::map::map_set_string_key_value + 708
->  0x10016b970 <+708>: str    x21, [x20], #0x8

Dropping the annotation (const m = new MyMap<…>()) types the receiver as the subclass and everything works — that is the shape test_gap_6325_map_set_subclass.ts already covers, which is why it never went red.

Kept out of this PR deliberately: it is a codegen/type-analysis fix in a different layer, and widening a one-line memory-safety fix to reach it would make both harder to review and to revert.

@proggeramlug
proggeramlug merged commit 56d1e8e into main Aug 7, 2026
10 of 12 checks passed
@proggeramlug
proggeramlug deleted the fix/7563-map-subclass-values-override branch August 7, 2026 04:34
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.

SIGSEGV: iterating a values() override on a class X extends Map

1 participant