Skip to content

fix(gc): root the inlined spread's [Symbol.iterator] walk (#7498) - #7527

Merged
proggeramlug merged 4 commits into
mainfrom
fix/7498-spread-symbol-iterator-rooting
Aug 6, 2026
Merged

fix(gc): root the inlined spread's [Symbol.iterator] walk (#7498)#7527
proggeramlug merged 4 commits into
mainfrom
fix/7498-spread-symbol-iterator-rooting

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #7498.

What was stale, and why no root could have saved two of them

[...obj.arr] has two lowerings. Out of line it calls js_iterator_to_array
the drain #7495 rooted. Inlined, it routes through array_from_spread_value,
which resolves [Symbol.iterator] through the whole prototype-walk tower
first
, and three frames on that walk held a GC-managed value in a place the
collector cannot see.

1. symbol::get::req_handle_symbol_fallback — the fault in #7498's first
trace.
It reads the receiver into a bare usize, interns a "_req" key —
an allocation — and then reads a field off the pre-move address:

let raw = (bits & POINTER_MASK) as usize;
let kh = js_string_from_bytes(b"_req".as_ptr(), 4);        // ALLOCATES
let req = js_object_get_field_by_name_f64(raw as *const ObjectHeader, kh);  // STALE

js_object_get_field_by_name then reads that copy's keys_array out of
retired from-space — a 40-byte GC_TYPE_ARRAY, which is exactly what the
quarantine reports. This helper runs on every heap-object symbol read whose
own-symbol lookup missed, so the window is unconditional; the reproducer faults
5/5 rather than intermittently. (CLAUDE.md's "deterministic ⇒ a table, not a
register" heuristic does not apply here: it is a register, in a code path with
no branch between the read and the allocation.)

2. A &str / &[u8] borrowed out of the key's StringHeader — the 56-byte
GC_TYPE_STRING in #7498's second trace.
get_field_by_name_object_tail
slices the property name straight out of the key's payload and hands it down:

let key_bytes = std::slice::from_raw_parts(key_ptr, key_len);
...
array_prototype_property_value(name, obj as usize)

and array_prototype_property_value then allocates three times before reading
it (js_get_global_this_builtin_value interns "Array", closure_get_dynamic_prop
can run an accessor, and js_string_from_bytes reads its source bytes
after its own string_storage_alloc).

A RuntimeHandleScope cannot fix this shape. Rooting the key keeps the
object alive and rewrites the slot; it does nothing for a &str already
pointing at the pre-move address, and there is no slot to rewrite. Confirmed
under lldb — the faulting instruction is the ldrsb of the UTF-8 scan inside
js_string_from_bytes, i.e. the borrow itself:

EXC_BAD_ACCESS (code=2, address=0x4542cf5ffac)
frame #0: array_prototype_property_value + 272
->  ldrsb  w9, [x21, x8]

The only sound shape is to stop borrowing: the new HeapKeyBytes copies the
bytes off the heap once, before the arm's first allocation. Property names are
short, so the common case is a 64-byte stack buffer and no allocator traffic at
all; the spill keeps that total rather than "usually".

3. array_from_spread_value's receiver — carried through a dozen
classification probes and the entire symbol walk, then used to rebind this for
the [Symbol.iterator]() factory and as the js_array_is_array fallback. Rooted
first, before anything in the function allocates, and the argument is shadowed by
a reader so the pre-collection address is not nameable below.

Also rooted, same shape, same measured path: default_object_prototype_property_value's
key/receiver (plus the displaced this and accessor-receiver override that its
own doc comment flagged as a residual), and the two subclass-marker probes
fetch_subclass_handle_id / temporal_subclass_cell, each of which allocates a
key string between reading its receiver and using it.

Raw-handle debt: 999 (baseline 999), unchanged. None of the touched modules
has a ceiling entry, so a single bare get_raw_{mut,const}_ptr in any of them
would turn the gate red. Every handle here is NaN-boxed and read back with
get_nanbox_f64 / get_nanbox_u64.

Overlap with #7516, declared rather than duplicated

#7516 (open) fixes js_get_global_this_builtin_value — the globalThis read
under array_prototype_property_value — with the same root-then-re-read shape.
This branch had that fix and it has been dropped, so the two PRs do not
collide textually and the work is not done twice. Measured: the witness below is
clean 5/5 with it and clean 5/5 without it, so nothing here depends on #7516
landing first. array/flat_clone.rs, #7516's other array-side fix, is untouched
here.

Witness

test-files/test_gap_gc_spread_symbol_iterator_rooting.ts, registered in
test-parity/gc_repsel_corpus.txt. It is test_gap_gc_iterator_drain_rooting's
shrunk twin: same shape, small enough that clone inlines, so the spread takes
the other lowering. Its size is load-bearing and the file says so.

Latent by construction, like 10_store_receiver_across_alloc.ts: evacuation
copies rather than zeroes, so the stale read returns the correct old bytes and
this file printed the right checksum before the fix on both links. Only
unmapping retired from-space makes it a signal.

before after
default (auto-optimize) link, plain byte-exact with the oracle byte-exact with the oracle
PERRY_NO_AUTO_OPTIMIZE=1, plain byte-exact with the oracle byte-exact with the oracle
PROTECT_FROMSPACE=1 DEPTH=200, no-auto link FAULT 5/5 clean 5/5
PROTECT_FROMSPACE=1 DEPTH=200, auto-optimize link FAULT 5/5 clean 5/5

The before-fault, symbolicated through PERRY_LINK_MAP (the perry link strips
the symbol table, so atos/nm return nothing):

[gc-fromspace-protect] FAULT: signal 10 at 0x23ad0d05f48
  retired_by_minor=#1  last-known object: obj_type=1 (GC_TYPE_ARRAY) size=40
  array_from_spread_value + 1200
  js_object_get_symbol_property + 2496
  js_object_get_field_by_name_f64 + 244
  js_object_get_field_by_name + 132

The clean verdict is not vacuous. The same run with PERRY_GC_DIAG=1 prints
four quarantine retirements and a live copying minor on each:

[gc-fromspace-protect] mode=ProtectPages retired_set=#0 blocks=17 sets_held=1/200
[gc-copy-minor] ran copied_objects=11794 copied_bytes=644088 ... trigger=ArenaBytes
[gc-fromspace-protect] mode=ProtectPages retired_set=#1 ...
[gc-copy-minor] ran copied_objects=5777 ... promoted_objects=4784

The two auto-optimize rows are a real A/B: the runtime archive was rebuilt from
source on both sides (PERRY_WORKSPACE_ROOT set, target/perry-auto-*/…/libperry_runtime.a
asserted on the [link] invoking: line), so they differ only in runtime source.
The instrument fires without PERRY_GC_ZEAL here — these are ordinary
trigger=ArenaBytes copying minors — and it also faults at the default
DEPTH=4, so the depth is not doing the work.

The protected run is NOT clean everywhere, and that is filed, not absorbed

test_gap_gc_iterator_drain_rooting — the OUT-of-line sibling — still faults and
still prints badLen 1 instead of badLen 0 (5/5) on this machine, on
origin/main as well
(there it is worse: TypeError: next is not a function,
5/5). #7498's two faults are gone from its protected run; what remains is
#7528: js_native_call_method reading its rooted receiver into a local ~330 lines
and a dozen allocating probes before is_closure_ptr dereferences it:

array_from_spread_value + 2148   (js_closure_call0 — the bound @@iterator thunk)
js_native_call_method + 5604
->  ldr  w8, [x28, #0xc]         (the closure magic, on retired from-space)

Knob bisect agrees it is the copying minor: PERRY_GEN_GC=0,
PERRY_GC_SCAVENGE=0 and PERRY_WRITE_BARRIERS=0 each make it correct,
PERRY_GEN_GC_EVACUATE=0 does not.

Patching the one faulting line was tried and reverted. The fault moved 800
bytes further into the same function — which is the signal that the whole
receiver needs re-reading (99 uses), not one arm of it. That is a separate PR
against a different function; it is #7528, not a vague remainder here.
The corpus note on test_gap_gc_iterator_drain_rooting is updated to say so:
its own text said "when #7498 lands, a protected run should go silent — if it
does not, there is a third site", and there is.

Unrelated and unchanged by this branch: test_gap_iterator_helpers_2874 fails
byte-identically before and after (TypeError: Cannot read properties of undefined (reading 'toArray') — the Iterator helpers surface, not a rooting
bug).

Validation

cargo test -p perry-runtime --no-fail-fast: 1744 passed, 0 failed.
cargo fmt --all -- --check, scripts/raw_handle_debt.py (999/999),
scripts/check_file_size.sh, scripts/addr_class_inventory.py and
scripts/check_test_registration.py all green, re-run after the final commit.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed rare crashes and incorrect behavior during array spreading, iterator processing, symbol-iterator handling, and property lookups when garbage collection occurs.
    • Improved reliability for arrays, custom iterators, subclasses, and prototype-based property access.
    • Preserved property keys and object references safely during memory-management operations.
  • Tests

    • Added regression coverage for repeated array spreads and symbol-iterator behavior under garbage collection.
    • Expanded documented coverage for related iterator and property-lookup scenarios.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d08a8da9-30be-41aa-b734-4279c59bde86

📥 Commits

Reviewing files that changed from the base of the PR and between 906e8a8 and e1be288.

⛔ 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 roots receivers, iterator values, symbols, methods, and results across moving-GC operations. It copies borrowed property keys into owned storage. A regression witness covers repeated inlined array spread.

Changes

Moving-GC rooting fixes

Layer / File(s) Summary
Root-safe key representations
crates/perry-runtime/src/object/field_get_set.rs, crates/perry-runtime/src/value/nanbox.rs, crates/perry-runtime/src/value/mod.rs, crates/perry-runtime/src/array/iterator.rs
Adds HeapKeyBytes and the crate-internal nanbox_string_key helper. The spread iterator uses the shared helper.
Relocation-safe property lookup
crates/perry-runtime/src/object/field_get_set.rs, crates/perry-runtime/src/object/field_get_set/accessors.rs, crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs, crates/perry-runtime/src/symbol/get.rs
Roots receivers, symbols, accessor state, prototypes, and generated keys. Reloads relocated values after allocation-capable lookups and calls.
Rooted spread and iterator materialization
crates/perry-runtime/src/array/iterator.rs
Roots spread inputs, iterator symbols, methods, receivers, iterator results, and accumulators during classification, dispatch, invocation, and draining.
GC regression witness
test-files/test_gap_gc_spread_symbol_iterator_rooting.ts, test-parity/gc_repsel_corpus.txt, changelog.d/7527-spread-symbol-iterator-rooting.md
Adds repeated inlined-spread validation and records spread-symbol-iterator coverage, iterator-drain results, and the remaining separate fault.

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

Sequence Diagram(s)

sequenceDiagram
  participant Spread as array_from_spread_value
  participant Lookup as js_object_get_symbol_property
  participant Handles as RuntimeHandleScope
  participant GC as Moving GC
  participant Drain as js_iterator_to_array

  Spread->>Handles: Root spread receiver and iterator values
  Spread->>Lookup: Resolve Symbol.iterator
  Lookup->>Handles: Root receiver and copy property key
  Lookup->>GC: Allocation-capable prototype lookup
  GC-->>Handles: Relocate rooted values
  Handles-->>Lookup: Re-read relocated receiver and key
  Lookup-->>Spread: Return iterator method
  Spread->>Drain: Invoke iterator and drain results
  Drain->>GC: Allocate during iteration
  GC-->>Handles: Relocate iterator and accumulator
  Drain-->>Spread: Return materialized array
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7495 — Directly extends iterator-drain rooting work in array/iterator.rs.
  • PerryTS/perry#7299 — Directly relates to GC-rooting fixes for spread operations.
  • PerryTS/perry#6941 — Directly relates to rooting receivers across allocation-capable property operations.

Suggested reviewers: jdalton

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the GC rooting fix for the inlined spread Symbol.iterator lookup.
Description check ✅ Passed The description provides detailed scope, related issue, implementation changes, test evidence, and validation results, despite not using the template headings.
Linked Issues check ✅ Passed The changes satisfy #7498 by rooting prototype-walk values, copying key bytes before allocation, and adding the required inlined-spread regression witness.
Out of Scope Changes check ✅ Passed The changes remain within scope; the added documentation and residual #7528 note support the fix and its regression coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7498-spread-symbol-iterator-rooting

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.

Ralph Küpper added 3 commits August 6, 2026 15:01
`[...obj.arr]` has two lowerings. Out of line it calls `js_iterator_to_array`
(rooted by #7495); inlined it routes through `array_from_spread_value`, which
resolves `[Symbol.iterator]` through the whole prototype-walk tower first. Three
frames on that walk held a GC-managed value the collector cannot see, and
`PERRY_GC_PROTECT_FROMSPACE=1` faults on all of them.

* `symbol::get::req_handle_symbol_fallback` read the receiver into a bare
  `usize`, interned a `"_req"` key -- an allocation -- and then read a field off
  the PRE-move address. It runs on every heap-object symbol read whose
  own-symbol lookup missed, so the window is unconditional; the reproducer
  faults 5/5, not intermittently.
* `array_prototype_property_value`, and the array + object arms of
  `get_field_by_name_object_tail`, took the property name as a `&str` / `&[u8]`
  BORROWED OUT OF THE KEY'S `StringHeader`. No root fixes that shape: a borrow
  is not a slot the collector can rewrite. They copy the bytes off the heap
  once, before their first allocation, through the new `HeapKeyBytes` (stack
  buffer, spill only for a >64-byte key).
* `array_from_spread_value` carried the spread receiver through a dozen
  classification probes and the entire symbol walk, then used it to rebind
  `this` for the `[Symbol.iterator]()` factory.

Also roots `default_object_prototype_property_value`'s key/receiver and the two
subclass-marker probes (`fetch_subclass_handle_id`, `temporal_subclass_cell`),
each of which allocated a key string between reading its receiver and using it,
and re-reads `js_object_get_symbol_property`'s receiver after the fallback.

All handles are NaN-boxed, so `scripts/raw_handle_debt.py` stays at 999.

Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH
The corpus note on `test_gap_gc_iterator_drain_rooting` asked for exactly this
check ("if it does not go silent, there is a third site"). There is, and it now
has an issue with the repro, the lldb faulting instruction, the knob bisect and
the reason the one-line patch was reverted.

Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH
@proggeramlug
proggeramlug force-pushed the fix/7498-spread-symbol-iterator-rooting branch from 50dcf94 to 906e8a8 Compare August 6, 2026 13:02
@coderabbitai

coderabbitai Bot commented Aug 6, 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

Rebased onto main (now at 5edfe99, i.e. with #7516 merged) and the
witness A/B was re-measured from scratch on that base, not carried over:

runtime source at 5edfe99 witness output PROTECT_FROMSPACE=1 DEPTH=200
pristine (this branch's runtime diff reverse-applied, rebuilt) byte-exact with the oracle FAULT 5/5
this branch byte-exact with the oracle clean 5/5

The conflict was test-parity/gc_repsel_corpus.txt#7516 appended its own
#7497 section to the same tail. Both sections are kept; nothing was dropped.

That rebase also settles the #7516 overlap empirically rather than by argument.
This branch deliberately does not carry a js_get_global_this_builtin_value
fix (it had one; it was dropped once #7516 was found to be fixing the same
function), and the row above shows the witness is clean without it now that
#7516's version is in the base — so the two PRs are complementary, not
duplicates. array/flat_clone.rs, #7516's other array-side fix, is untouched
here.

cargo test -p perry-runtime --no-fail-fast on the rebased tree: 1760
passed, 1 failed
promise::keyed_table::tests::settling_many_keys_is_not_quadratic,
the known wall-clock flake on a loaded machine. Characterized rather than
chased: 2 of 3 isolated re-runs pass, and it is a Duration ratio assertion
with no relationship to anything in this diff.

scripts/check_file_size.sh, scripts/raw_handle_debt.py (999/999),
scripts/addr_class_inventory.py, scripts/check_test_registration.py and
cargo fmt --all -- --check all re-run after the final commit, all green.

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.

gc: array_from_spread_value's Symbol.iterator lookup derefs a from-space object (inlined [...obj.arr])

1 participant