-
-
Notifications
You must be signed in to change notification settings - Fork 161
feat(intl): Segments view mode — answer a grapheme loop without materialising a record or a substring #9870
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
8f4f87a
fix(runtime): prove an unpatched iterator prototype without allocating
e126360
test: pin the iterator-prototype `next` patch surface against node
90ebcf6
test: cover a non-callable prototype `next` in the patch fixture
069660c
chore: renumber the changelog fragment to the filed issue (#9846)
5a48920
feat(intl): Segments view mode — answer a grapheme loop without mater…
fe0576a
feat(intl): keepalive anchors for the view-mode entry points
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
55 changes: 55 additions & 0 deletions
55
changelog.d/9846-iterator-next-override-probe-allocation.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| ### Fixed | ||
|
|
||
| - **Every built-in iterator step allocated a `"next"` key string to learn that | ||
| nothing was patched.** `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()` — ended in a by-name prototype lookup that minted a fresh | ||
| 4-byte `"next"` string on every call. One 32-byte allocation per iteration | ||
| step of every array, Map, Set and string iterator in the program. | ||
|
|
||
| The existing early-out could not prevent it. `ITERATOR_PROTOTYPE_PTR == 0` | ||
| ("the tower was never materialized, so no override can exist") is **dead on | ||
| any program that has allocated one iterator**: every iterator allocator calls | ||
| `attach_iterator_prototype`, which calls `ensure_iterator_prototypes`, which | ||
| builds the tower. The guard is true exactly once and false forever after. | ||
|
|
||
| Replaced by an allocation-free proof that runs on the path every real program | ||
| takes: the prototype's OWN `next` slot still holds a closure whose native | ||
| entry is the canonical thunk (the certified non-allocating own-field read, | ||
| #9480), AND no accessor descriptor is recorded for `"next"` on it (the | ||
| per-key Bloom bit `set_accessor_descriptor` sets before inserting, #6759 C2 — | ||
| needed because `defineProperty(proto, "next", {get})` leaves the old closure | ||
| in the data slot and puts the accessor in the side table). Anything else — | ||
| replaced, deleted, an accessor, a bound copy — takes the by-name path | ||
| unchanged. | ||
|
|
||
| Affected files: | ||
|
|
||
| - `crates/perry-runtime/src/object/iterator_prototypes.rs` — the | ||
| `prototype_next_is_canonical` probe, ahead of the by-name lookup. | ||
|
|
||
| Measured: the 2026-09-06 claude-code allocation census ranked this site third | ||
| by count — ~122,880 allocations of 32 bytes per 400-character reply, 17.1 % | ||
| of the top-30 allocation count — and misattributed it to `Intl.Segmenter` | ||
| substring copying. Resolved by an explicit caller walk in the shipped binary: | ||
| `js_for_of_next+0xd0` → `dispatch_array_iterator_method_inner+0x218` (a `bl` | ||
| to `call_overridden_iterator_next`) → `+0x67c` (a `bl` to | ||
| `js_string_from_bytes_with_capacity`) → `string_storage_alloc`. | ||
|
|
||
| Validation: `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, and covers restore-by-identity, a second replace after | ||
| a restore, a bound copy of the original (which must NOT be mistaken for the | ||
| builtin), an accessor `next`, and a deleted `next`. The unit counter asserts | ||
| that 1,000 probes on an unpatched iterator with the tower materialized move | ||
| the arena by ZERO bytes, with the minor-cycle count pinned so a collection | ||
| inside the window cannot manufacture a zero delta. | ||
|
|
||
| Counter on a relinked claude-code binary (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): a 400-character reply runs | ||
| **144,189 / 144,303** probes and a 3300-character reply **887,076**, with | ||
| **`byname = 0` on every one of the 173 per-minor reports across three runs** | ||
| — the proof answers 100 % of probes on a real program. At 32 B a string that | ||
| is 4.6 MB and 28.4 MB of allocation removed per process respectively. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| ### Added | ||
|
|
||
| - **`Intl.Segmenter` view mode: five runtime entry points that answer a | ||
| grapheme loop's questions without materialising a record or a substring.** | ||
| The compiler half (PR #9859) proves that a | ||
| `for (let {segment: O} of X.segment(q))` loop never lets the record or `O` | ||
| escape, and then drives a cursor instead of building either. | ||
|
|
||
| ``` | ||
| js_segments_view_open(segmenter, input) -> cursor | 0.0 | ||
| js_segments_view_next(cursor) -> 1.0 | 0.0 (allocation-free) | ||
| js_segments_view_code_point_at(cursor, k) -> number | undefined (allocation-free) | ||
| js_segments_view_segment(cursor) -> string (materialise-on-miss) | ||
| js_segments_view_regexp_test(cursor, regex) -> true | false | undefined | ||
| ``` | ||
|
|
||
| The cursor is an **ordinary GC object** whose slot 0 holds the input as a | ||
| traced value, so the collector rewrites it like any other field — no | ||
| registered root, no side table, no new scanner. Every entry point re-derives | ||
| its `&str` on entry and drops it before returning. | ||
|
|
||
| `open` **declines with no observable effect**, in a fixed order: a | ||
| non-pristine `Intl.Segmenter`, a replaced `segment`, a non-grapheme | ||
| granularity, an input that is not ALREADY a string primitive (checked before | ||
| any coercion, because `build_segments` runs user `toString` and throws on a | ||
| Symbol), a non-UTF-8 (WTF-8 lone surrogate) input, or an empty one. It never | ||
| throws and never allocates before the final step; the compiler then evaluates | ||
| `X.segment(q)` exactly once in its original position. | ||
|
|
||
| `_code_point_at`'s `k` is **segment-relative and segment-bounded** — `k` past | ||
| the segment's end is `undefined` even though the input continues — and decodes | ||
| from the cursor's byte offset, so `k = 0` is O(1) rather than a walk from | ||
| index 0. | ||
|
|
||
| `_regexp_test` matches against a **bounded haystack whose bounds are the | ||
| string's ends**, so `^`, `$` and lookbehind are segment-local; it is | ||
| three-valued and returns `undefined` ("I decline, materialise and call the | ||
| normal path") for a global or sticky regex, whose `test` is stateful in | ||
| `lastIndex`, and for a patched `RegExp.prototype.test`. | ||
|
|
||
| Affected files: | ||
|
|
||
| - `crates/perry-runtime/src/intl/segments_view.rs` — the entry points. | ||
| - `crates/perry-runtime/src/regex.rs` — `regexp_test_str_bounded`, the | ||
| bounded-haystack primitive. | ||
| - `crates/perry-runtime/src/object/regex_proto_thunks.rs` — | ||
| `regexp_prototype_test_is_canonical`, the allocation-free proof that | ||
| `RegExp.prototype.test` is still the builtin. | ||
|
|
||
| Measured: the loop this exists for is 60-85 % of claude-code's active | ||
| main-thread CPU and allocates ~420,000 times per 400-character reply. The | ||
| falsifier is a unit counter — 200 `next` + `code_point_at` steps move | ||
| `arena_in_use_bytes` by **zero**, with the minor-cycle count pinned so a | ||
| collection cannot manufacture the zero. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add a language to the fenced code block.
markdownlint reports MD040 for this block. Use
text, because the content is a signature list.📝 Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 markdownlint-cli2 (0.23.2)
[warning] 9-9: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Source: Linters/SAST tools