Skip to content

fix(runtime): prove an unpatched iterator prototype without allocating a "next" key string - #9848

Closed
proggeramlug wants to merge 4 commits into
PerryTS:mainfrom
proggeramlug:fix/9846-iterator-next-probe-allocation
Closed

fix(runtime): prove an unpatched iterator prototype without allocating a "next" key string#9848
proggeramlug wants to merge 4 commits into
PerryTS:mainfrom
proggeramlug:fix/9846-iterator-next-probe-allocation

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Closes #9846.

What was wrong

call_overridden_iterator_next — the per-step probe that lets a user
replacement of %ArrayIteratorPrototype%.next (and the Map / Set / String
family prototypes) drive for…of, spread, Array.from and manual .next()
minted a fresh 4-byte "next" key string on every iteration step of
every built-in iterator, purely to run a by-name prototype lookup that
concluded nothing was patched.

The early-out meant to prevent that cannot fire. ITERATOR_PROTOTYPE_PTR == 0
means "the tower was never materialized, so no override can exist" — but every
iterator allocator calls attach_iterator_prototypeensure_iterator_prototypes,
which materializes the tower. The guard is true exactly once per program and
false forever after.

The fix

An allocation-free proof of "not overridden" on the path every real program
takes: the prototype's OWN next slot still holds a closure whose native entry
is the canonical thunk (#9480's certified non-allocating own-field read), AND
no accessor descriptor is recorded for "next" on it (#6759 C2's per-key
accessor Bloom bit — needed because defineProperty(proto,"next",{get}) leaves
the old closure in the data slot and records the accessor in the side table).
Anything else — replaced, deleted, an accessor, a bound copy of the original —
takes the by-name path, unchanged.

The slow path deliberately keeps js_string_from_bytes rather than interning
"next": interning would make the counter below pass whether or not the fast
path fires.

Counter — one binary gives both arms

Before the fix every probe allocated, so on a binary carrying this fix plus a
measurement-only hit/miss counter, hits + byname is the pre-fix count and
byname is what survives. Relinked claude-code bundle, stream_scale rig:

reply probes = pre-fix "next" strings byname = post-fix per-minor reports
400 chars, run A 144,189 0 43
400 chars, run B 144,303 0 39
3300 chars 887,076 0 91

byname = 0 on every one of the 173 reports: the proof answers 100 % of
probes on a real program. At 32 B a string that is 4.6 MB / 28.4 MB of
allocation removed per process. The two 400-char runs differ by 0.08 %.

That counter is not optional and is the reason it was taken on the real bundle:
the accessor half is a per-key Bloom bit, so any accessor whose key collides
in that word would disable the fast path forever with no test failing.
byname = 0 rules that out; hits = 0 would have meant the change was inert
while every unit test still passed.

No timing claim is made from that binary (it carries the counter, and the box
was under heavy concurrent load). For the record only: turn_cpu_s 7.28 / 6.82
at 400 chars and 24.99 at 3300, RSS 551 / 555 / 809 MB.

Where it was found

Third-ranked site by count in a claude-code allocation census (2026-09-06:
~122,880 × 32 B per 400-character reply, 17.1 % of the top-30 count), which had
misattributed it to Intl.Segmenter substring copying. Resolved by an explicit
caller walk in the shipped binary, because the census's js: frame labels are
nearest-symbol and were wrong:

js_for_of_next+0xd0
  -> dispatch_array_iterator_method_inner+0x218   (bl call_overridden_iterator_next)
    -> call_overridden_iterator_next+0x67c        (bl js_string_from_bytes_with_capacity)
      -> string_storage_alloc

Tests

  • test-files/test_gap_iterator_prototype_next_patch.ts drives a replaced
    next through for…of, spread, Array.from and manual .next() on all
    four families, plus restore-by-identity, a second replace after a restore, a
    bound copy of the original (same native entry, different this — must NOT be
    mistaken for the builtin), an accessor next, a deleted next, and five
    non-callable next values. crates/perry/tests/issue_9846_… byte-compares
    its output against node v26.5.1 — re-captured independently on this box
    before this PR, all 28 lines identical.
  • Unit counter: 1,000 probes on an unpatched iterator with the tower
    materialized must move arena_in_use_bytes by zero, with the minor-cycle
    count pinned across the window so a collection cannot manufacture the zero.
  • cargo test -p perry-runtime --release --lib -- --test-threads=1: 3,171
    passed, 0 failed
    , 4 ignored.

Sabotage — four arms, each failing only its named assertion

arm effect result
remove the fast path (pre-fix behaviour) counter test fails: "allocated 32000 bytes over 1000 calls" — exactly 32 B × 1000
proof always succeeds both semantics tests fail (replaced next, accessor next)
drop ONLY the accessor Bloom half only probe_declines_when_an_accessor_next_is_defined_on_the_prototype fails
drop ONLY the native-entry comparison only probe_honours_a_replaced_prototype_next_and_a_restored_one fails

Each half of the proof therefore has a test that fails when, and only when,
that half is removed.

Summary by CodeRabbit

  • Performance

    • Improved built-in iterator performance by avoiding unnecessary memory allocation during standard iteration.
  • Bug Fixes

    • Iterator prototype customizations are now handled correctly across arrays, maps, sets, and strings.
    • Restored, replaced, accessor-based, deleted, bound, and non-callable next implementations now produce the expected iteration results or errors.
  • Tests

    • Expanded coverage for for…of, spread syntax, Array.from, and manual iterator usage.

Conformance context, and what the suite cannot see

This PR does not touch Intl.Segmenter, but it was found while working on it,
so for the record: scripts/test262_subset.py --dir intl402/Segmenter on this
branch is 72 pass / 0 diff / 0 runtime-fail / 2 compile-fail (74 judged;
--all-features: 74 / 0 / 1 / 2 of 77). All three failures are constructor
locale handling — the two locales-invalid.js cases are the only ones that
includes: [testIntl.js] and fail compiling that harness.

Two limits of that suite, so a reviewer knows what it cannot see: it buckets by
agreement with node, so a case both engines get wrong scores pass; and
Array.isArray(segments), segments.length and segments[0] — perry answers
true / a number / a record where V8 answers false / undefined /
undefined — are covered by none of the 79 cases.

Ralph Küpper and others added 4 commits September 6, 2026 06:35
`call_overridden_iterator_next` minted a fresh 4-byte "next" key string on
every built-in iterator step, purely to run a by-name prototype lookup that
concluded nothing was patched. The `ITERATOR_PROTOTYPE_PTR == 0` early-out
that was supposed to prevent this is dead after the first iterator any
program allocates: every iterator allocator calls `attach_iterator_prototype`
-> `ensure_iterator_prototypes`, which materializes the tower.

Adds `prototype_next_is_canonical`: the prototype's own `next` slot holds a
closure whose native entry is the canonical thunk, and no accessor descriptor
is recorded for "next". Both reads are non-allocating. Any other state falls
through to the by-name path, unchanged.

This is the third-ranked site by count in the 2026-09-06 claude-code
allocation census (~122,880 x 32 B per 400-character reply), which had
attributed it to `Intl.Segmenter` substring copying. Caller walk in the
shipped binary `cc_relink/cc_int_0905`:

  js_for_of_next+0xd0
    -> dispatch_array_iterator_method_inner+0x218   (bl call_overridden_iterator_next)
      -> call_overridden_iterator_next+0x67c        (bl js_string_from_bytes_with_capacity)
        -> string_storage_alloc

Measured on a relinked claude-code binary carrying this fix plus a
measurement-only hit/miss counter. Before the fix every probe allocated, so
`hits + byname` is the pre-fix count and `byname` is what survives:

  400-char reply, run A   144,189 probes   byname 0
  400-char reply, run B   144,303 probes   byname 0
  3300-char reply          887,076 probes   byname 0

`byname = 0` on every one of the 173 per-minor reports across the three runs:
the proof answers 100 % of probes on a real program, which is what rules out
the one silent failure mode (the accessor half is a per-key Bloom bit, so a
colliding accessor on the prototype would disable the fast path with no test
failing).

`cargo test -p perry-runtime --release --lib -- --test-threads=1`: 3,171
passed, 0 failed. Four sabotage arms, each failing only its named assertion:
removing the fast path entirely reads exactly 32,000 bytes over 1,000 probes;
dropping only the accessor half fails only the accessor test; dropping only
the native-entry comparison fails only the replaced-`next` test.
An integration arm for the allocation-free proof: compiles
`test-files/test_gap_iterator_prototype_next_patch.ts` and byte-compares
stdout against node v26.5.1, captured 2026-09-06 on this box.

Three of the lines are the ones that can only pass if the proof is exactly
right:

  F-bound-copy 100,200  a `bind` of the original has the SAME native entry as
                        the builtin thunk but a different `this`; a proof that
                        compared native entries without first reading the
                        prototype's own slot would print `1,2`.
  G-accessor 1,2 true   `defineProperty(proto,"next",{get})` leaves the old
                        closure in the data slot, so the own read alone still
                        sees the canonical closure — only the per-key accessor
                        Bloom bit makes the proof decline.
  H true                a deleted `next` must throw a TypeError, never fall
                        through to the builtin advance.
The allocation-free proof reads the prototype's own `next` slot as a RAW
value before deciding anything, so a number, a string, `undefined`, `null`
and a plain object each have to defeat it and throw a TypeError rather than
be mistaken for the builtin closure. Node v26.5.1 throws for all five;
pinned in the integration arm.
The fragment was written before the issue existed and carried 9840, which is
an unrelated open GC issue. PerryTS#9846 is the filed report for this defect.
@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 2fb796cb-7e53-4bb7-8c9f-9af7924b18b0

📥 Commits

Reviewing files that changed from the base of the PR and between bcce8de and 069660c.

📒 Files selected for processing (4)
  • changelog.d/9846-iterator-next-override-probe-allocation.md
  • crates/perry-runtime/src/object/iterator_prototypes.rs
  • crates/perry/tests/issue_9846_iterator_prototype_next_patch.rs
  • test-files/test_gap_iterator_prototype_next_patch.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

The runtime replaces the dead iterator prototype guard with an allocation-free canonical-thunk check. Tests cover allocation behavior and patched, restored, accessor, deleted, bound, and non-callable next cases across iterator families.

Changes

Iterator next override probing

Layer / File(s) Summary
Canonical next probe
crates/perry-runtime/src/object/iterator_prototypes.rs, changelog.d/...
call_overridden_iterator_next now skips by-name lookup when the prototype owns the canonical thunk and has no accessor descriptor.
Runtime probe validation
crates/perry-runtime/src/object/iterator_prototypes.rs
Unit tests verify zero allocation and correct handling of replacement, restoration, and accessor cases.
Iterator behavior regression coverage
test-files/test_gap_iterator_prototype_next_patch.ts, crates/perry/tests/issue_9846_iterator_prototype_next_patch.rs
Integration coverage validates Array, Map, Set, and String iterator behavior across patched, restored, deleted, bound, accessor, and non-callable next values.

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

Merge Risk: ⚪ Minimal · up to 06966

This change removes per-step iterator probe allocation while preserving observable behavior when iterator prototype methods are modified. The covered override and error cases leave no current merge-blocking risk.

Sequence Diagram(s)

sequenceDiagram
  participant IteratorOperation
  participant call_overridden_iterator_next
  participant prototype_next_is_canonical
  participant IteratorPrototype
  participant ByNameLookup
  IteratorOperation->>call_overridden_iterator_next: request iterator next step
  call_overridden_iterator_next->>prototype_next_is_canonical: inspect prototype next
  prototype_next_is_canonical->>IteratorPrototype: read own slot and accessor state
  prototype_next_is_canonical-->>call_overridden_iterator_next: canonical or overridden
  call_overridden_iterator_next->>ByNameLookup: resolve next for overridden cases
  ByNameLookup-->>IteratorOperation: invoke resolved next
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 3 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy issue #9846. They add an allocation-free canonical-next proof, retain the by-name path for replaced, deleted, accessor, bound, and non-callable values, and add coverage for the r…
Out of Scope Changes check ✅ Passed The changes are within scope for issue #9846. The runtime fix, regression tests, allocation tests, measurements, and changelog entry all support the iterator-probing allocation fix.
Title check ✅ Passed The title clearly and concisely describes the main runtime fix: proving an unpatched iterator prototype without allocating the "next" key string.
Description check ✅ Passed The description is detailed and covers the problem, fix, issue reference, validation, measurements, and limitations. It does not use the repository template headings or checklist, but the required inf…
Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 3 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@proggeramlug proggeramlug added the run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke label Sep 6, 2026
@proggeramlug

Copy link
Copy Markdown
Contributor Author

CI note, recorded before anyone reads the red: this PR carries run-extended-tests, which brings three reds that every labelled campaign PR inherits — gc-ratchet, gc-root-dominance and native-roots-rs4gc. The last one is the relevant caution here: it fails on macos-14 aarch64 and ubuntu-24.04-arm aarch64 and passes on x86-64 and Windows, and it failed the same way on #9816, a different lane also touching for-of iteration.

Because this PR touches for-of iteration too, that shared-red history is a reason to CHECK rather than a reason to dismiss: the failure signature should be compared against #9816's before it is attributed to either change. The rest of the fail column while the run is queued is the known gh pr checks artefact (queued and cancelled jobs render in the same column as failures).

https://claude.ai/code/session_014knX724SYDogwzsXybCGxp

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main via merge train #9875. Validated as a tree: 64/64 lint gates, and perry-runtime/codegen/hir/stdlib all green (5,891 tests, 0 failures). Thanks!

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

Labels

run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke

Projects

None yet

1 participant