Skip to content

runtime: every built-in iterator step allocates a "next" key string to prove nothing was patched (the guard that should prevent it is dead after the first iterator) #9846

Description

@proggeramlug

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_prototypeensure_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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions