What
Every step of every built-in iterator allocates a 4-byte "next" key string.
object/iterator_prototypes.rs::call_overridden_iterator_next is 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(). It ends in a by-name prototype lookup:
let key = scope.root_raw_const_ptr(crate::string::js_string_from_bytes(b"next".as_ptr(), 4));
let method = ... js_object_get_field_by_name(proto, key) ...
so on the overwhelmingly common path — nothing patched — it mints a fresh
string per iteration step purely to conclude that nothing was patched.
Why the existing guard does not prevent it
The function carries an early-out meant for exactly this:
if ITERATOR_PROTOTYPE_PTR.load(Ordering::Acquire) == 0 { return None; }
"the tower was never materialized, so no override can exist". But every
iterator allocator calls attach_iterator_prototype → ensure_iterator_prototypes,
which materializes the tower. The guard is true exactly once per program and
false forever after, so it protects nothing on any program that has allocated
a single iterator.
Impact
Third-ranked allocation 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. Caller walk in the shipped binary:
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
It is not specific to any one iterator: one allocation per step of every array,
Map, Set and string iterator in the program.
Fix
An allocation-free proof of "not overridden" on the common path: 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 — required because defineProperty(proto,"next",{get}) leaves the
old closure in the data slot). Anything else — replaced, deleted, an accessor,
a bound copy of the original — takes the by-name path unchanged.
Measured
Counter on a relinked claude-code binary (the 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:
| reply |
probes = pre-fix "next" strings |
byname = post-fix |
| 400 chars, run A |
144,189 |
0 |
| 400 chars, run B |
144,303 |
0 |
| 3300 chars |
887,076 |
0 |
byname = 0 on every one of the 173 per-minor reports across the three runs:
the allocation-free proof answers 100 % of probes on a real program. At 32 B
that is 4.6 MB (400-char) and 28.4 MB (3300-char) of allocation removed per
process.
What
Every step of every built-in iterator allocates a 4-byte
"next"key string.object/iterator_prototypes.rs::call_overridden_iterator_nextis the per-stepprobe that lets a user replacement of
%ArrayIteratorPrototype%.next(and theMap / Set / String family prototypes) drive
for…of, spread,Array.fromandmanual
.next(). It ends in a by-name prototype lookup:so on the overwhelmingly common path — nothing patched — it mints a fresh
string per iteration step purely to conclude that nothing was patched.
Why the existing guard does not prevent it
The function carries an early-out meant for exactly this:
"the tower was never materialized, so no override can exist". But every
iterator allocator calls
attach_iterator_prototype→ensure_iterator_prototypes,which materializes the tower. The guard is true exactly once per program and
false forever after, so it protects nothing on any program that has allocated
a single iterator.
Impact
Third-ranked allocation 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. Caller walk in the shipped binary:
It is not specific to any one iterator: one allocation per step of every array,
Map, Set and string iterator in the program.
Fix
An allocation-free proof of "not overridden" on the common path: the
prototype's OWN
nextslot still holds a closure whose native entry is thecanonical thunk (#9480's certified non-allocating own-field read), AND no
accessor descriptor is recorded for
"next"on it (#6759 C2's per-key accessorBloom bit — required because
defineProperty(proto,"next",{get})leaves theold closure in the data slot). Anything else — replaced, deleted, an accessor,
a bound copy of the original — takes the by-name path unchanged.
Measured
Counter on a relinked claude-code binary (the fix plus a measurement-only
hit/miss counter). Before the fix every probe allocated, so
hits + bynameisthe pre-fix count and
bynameis what survives:"next"stringsbyname= post-fixbyname = 0on every one of the 173 per-minor reports across the three runs:the allocation-free proof answers 100 % of probes on a real program. At 32 B
that is 4.6 MB (400-char) and 28.4 MB (3300-char) of allocation removed per
process.