Skip to content

merge train: land #10096, #10108, #10111, and #10112 - #10121

Merged
proggeramlug merged 9 commits into
mainfrom
train164
Sep 12, 2026
Merged

merge train: land #10096, #10108, #10111, and #10112#10121
proggeramlug merged 9 commits into
mainfrom
train164

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Merge train landing #10096, #10108, #10111 and #10112 on top of main
e8f912392 (train163).

The individual PRs merge as their own branch, so GitHub's close keywords do not
fire — each original is closed with a pointer comment after this lands, and the
issues it names are swept by hand.

PR issue commits
#10108 b13d3824d
#10096 #10088 56eb7eba6, c9fb41291
#10111 c8efcde5a, 759302d21, 6e0b8dee6
#10112 9ad197404

No stacks; no shared commits between any open pair; nothing already upstream.

The finding: #10096 predates train162's buffer rework

#10096 branched from train161's main (c58c1fae5). Train162's #10071
(zero-copy Buffer/Uint8Array subarrays) has since deleted
view::propagate_written_range_from_receiver outright — views now share
their backing storage, so a write through the target's data pointer is already
visible to every view of it and there is nothing to propagate. Both arms of
#10096's new match called that function, so taking the hunk verbatim would not
have compiled.

Resolved by keeping #10096's actual contribution — the bulk_copy_source_ptr
fast path and its deliberate ptr::copy (memmove, because buf.set(buf.subarray(2))
can genuinely overlap) — and dropping the two propagate calls. The overlap
handling matters more after #10071, not less, since views really do alias now.
Verified every other symbol the PR depends on still exists on current main:
view::resolve_data_ptr (now a thin buffer_data wrapper, which is exactly what
a bulk copy wants), typedarray::data_ptr, buffer_data_mut,
collect_buffer_set_bytes, and the three 1-byte KIND_* constants.

Audit notes

#10111's third commit removes lone surrogates from the locale-compare
corpus.
That reads like a weakened test, so I checked the replacement rather
than the justification: utf16_cmp_ascii_fast_path_tests::lone_surrogates_fall_back_to_byte_order
does exist, its helper takes &[u8], and it asserts U+D800/U+DC00 ordering
against each other, against ASCII, and against empty. It is a relocation to a
sound helper, not a deletion. The corpus entries were forging surrogates with
from_utf8_unchecked for a &str-taking comparator, which trips std's UB
precondition check — that is why CI's debug-profile cargo-test job aborted with
SIGABRT while release runs were green.

#10108 deletes a saturated negative accelerator, which is the safe direction:
the RegistryAddrWindow min/max bound stays and keeps doing all of the
rejecting, including the debug-build machine check that re-derives every
rejection from the authoritative tables. The gc_runtime_root_holders gate is
green after the static's removal, so it left no stale entry behind.

Maintainer fix commits

  • changelog: add fragment for #10108 — that PR changed crates/ without a
    fragment, which check_changeset_fragment.sh requires. Body summarised from
    the commit message's own measurements.
  • chore: bump workspace version to 0.5.1538 — none of the four PRs bumped
    it, which is the maintainer's job at merge time; Cargo.lock regenerated with
    it.

Validation

run_lint_gates.sh                                              1 of 83 FAILED, 2 CI-only skipped
cargo check -p perry-stdlib                                    ok
cargo check -p perry-runtime --tests --features regex-engine   ok
RUSTFLAGS=-Dwarnings cargo check -p perry --bins                ok
RUST_TEST_THREADS=1 cargo test --release -p perry-runtime      3623 passed, 0 failed
RUST_TEST_THREADS=1 cargo test --release -p perry-codegen      1982 passed, 0 failed
RUST_TEST_THREADS=1 cargo test --release -p perry-hir           647 passed, 0 failed
RUST_TEST_THREADS=1 cargo test --release -p perry-stdlib        137 passed, 0 failed

6389 tests, zero failures. Exit codes are captured from each command itself, not
from a pipeline.

Only one lint gate fails — public benchmark evidence freshness, genuinely red
on main since 2026-07-29 and scoped out by #9969. The two API-doc gates that
normally also fail are a CARGO_TARGET_DIR artifact and pass once
target/release/perry is symlinked before the run; worth doing rather than
explaining away, because without the symlink the shell truncates the redirect
target before exec'ing the missing binary and leaves
docs/src/api/reference.md at 0 bytes.

Each suite was checked for having actually exercised its subject, not merely for
not throwing: 65 locale/buffer tests in the runtime log including
utf16_cmp_ascii_fast_path_tests::lone_surrogates_fall_back_to_byte_order (the
coverage #10111 relies on when it drops the corpus entries), and 7 destructuring
tests in the HIR log for #10112.

The two new gap tests were run against the pinned Node 26.5.1, with the
static archives rebuilt first
(-p perry -p perry-runtime-static -p perry-stdlib-static) and their mtimes
confirmed newer than the last commit:

test_gap_10086_array_destructuring_fast_path   PASS
test_gap_10088_uint8array_set_bulk_copy        PASS

Validated at head b4d2491941a9d84bbc387088ffa8d6e4ba281dc9.

Summary by CodeRabbit

  • New Features

    • Array destructuring now uses a faster direct-access path for eligible arrays while preserving standard iterator behavior when needed.
    • Uint8Array and Buffer bulk copies are faster, including safe handling of overlapping ranges.
  • Bug Fixes

    • Array iteration now detects exposed or modified iterator prototypes more reliably.
    • Buffer copy operations maintain correct view data and error behavior.
  • Performance

    • Improved localeCompare performance by reducing temporary allocations.
    • Reduced buffer registry overhead.
  • Documentation

    • Updated version information, changelog entries, and TypeScript parity documentation.

Ralph Küpper added 9 commits September 12, 2026 09:35
BUFFER_LIKE_ADDR_FILTER ends every measured claude-code run with all 1,024 bits
set and rejects nothing, while costing three hash rounds and up to three
dependent loads on every one of ~26.6 M admitted probes per run.

Its adoption note asked the capacity question and answered it from a
400-character reply: 213 cumulative registrations, live_max 201, true positives
0.207% of admits, predicting a 10.0% false-positive rate. An ordinary command
(startup, two real Read calls, streamed reply) falsifies both premises. The
population is 15x larger - 3,232 cumulative admissions and 1,618 live against
1,024 bits - so the filter saturates, and every rejection comes from the window
in front of it at 15.51%, not the 25.94% the note quotes. And 88.87% of admitted
probes find a real registered buffer, against 0.207% before, so a filter cannot
remove work the registry genuinely has to do.

Six interleaved pairs, one binary and one environment variable: minimum command
CPU 1.22 -> 1.19 s (-2.46%), medians 1.28 -> 1.20 s, paired median -3.60%,
faster in five pairs and tied in the sixth, peak RSS maxima 664.5 -> 635.2 MiB.
Node anchor in the same session 0.41/0.42 s.

The min/max window stays and keeps doing all of the rejecting, including the
debug-build machine-check that re-derives every rejection from the authoritative
tables. Deleting a negative accelerator cannot produce a wrong answer.

(cherry picked from commit b13d382)
…er-byte view lookups (#10088)

collect_buffer_set_bytes paid a view-registry lookup per byte for a
Buffer source, and a per-element dispatch/coercion call per byte for a
TypedArray source, materializing the result into an intermediate
Vec<u8> before copying it into the target a second time. For a Buffer
source, or a same-element-width (1-byte) TypedArray source
(Int8Array/Uint8Array/Uint8ClampedArray), resolve the source's raw
byte span once via the existing view::resolve_data_ptr /
typedarray::data_ptr resolvers and copy it straight into the target
with ptr::copy (a memmove, so a source/destination overlap through a
shared backing buffer stays correct). Array/Object sources and
wider/BigInt TypedArray sources are unchanged.

Conflict resolved on the train: this branch is based on train161's `main`, and
train162's #10071 (zero-copy Buffer/Uint8Array subarrays) has since deleted
`view::propagate_written_range_from_receiver` outright — views now share their
backing storage, so a write through the target's data pointer is already visible
to every view of it and there is nothing to propagate. The bulk-copy fast path
and its `ptr::copy` overlap handling are kept verbatim; the two propagate calls
are dropped because the function no longer exists.
(cherry picked from commit c9fb412)
The primary (case-insensitive) collation pass built two fresh lowercased
`String`s with `str::to_lowercase` and compared those, so every comparison
paid two heap allocations and two full Unicode case-mapping passes over both
operands to answer a question usually decided by the first character — and a
sort pays that O(n log n) times. Walk the two lowercased scalar streams in
lockstep instead, stopping at the first difference, with a byte-level loop
for the leading all-ASCII run.

On the non-ASCII path `locale_compare_canonical` also stops materializing two
NFC `String`s per comparison: an `is_nfc_quick` check (a table lookup per
scalar, no allocation) skips the rewrite for text that is already NFC, which
covers precomposed letters, CJK and emoji.

The ordering is deliberately unchanged. U+03A3 is the one scalar whose
lowercase mapping is context-dependent (final sigma ς vs medial σ); the walk
reports it rather than guessing and falls back to `str::to_lowercase`, which
implements the Final_Sigma rule.

Tests pin both halves of that claim:
`matches_the_allocating_reference_{on_every_pair,on_random_strings}` are
differential against the exact formulation this replaces, over a corpus
spanning ASCII, Latin-1 accented letters in both spellings, CJK, emoji,
combining marks, the two special case mappings and WTF-8 lone surrogates,
and assert the corpus reaches all three arms;
`locale_compare_is_a_strict_weak_ordering` proves reflexivity, antisymmetry
and transitivity over every triple, so `Array.prototype.sort` stays
well-defined; `canonical_equivalents_stay_equal` and
`documented_guarantees_hold` cover canonical equivalence, case-only
differences, empty/prefix/long-common-prefix pairs and the documented
divergence from ICU.

Also make the limitation public and accurate. `docs/typescript-parity-gaps.md`
listed `localeCompare()` and `toLocaleLowerCase()`/`toLocaleUpperCase()` as
"Missing (needs Intl)"; all three are implemented. The new note and the
rewritten `js_string_locale_compare` doc comment state what the ordering
actually guarantees — canonical equivalence, case-insensitive code point
order, a lowercase-first case tiebreak — and what it does not: no collation
weights, no locale tailoring, and an order that differs from Node for
accented letters, symbols and emoji, by design rather than by omission.

Refs #10094

(cherry picked from commit c8efcde)
The corpus forged them with `from_utf8_unchecked`, because a lone surrogate
has no valid `&str` spelling and `locale_compare_default` takes `&str`.
`chars()` over such a slice yields a value that is not a valid `char`, and
std's UB precondition check catches that in a debug build: CI's `cargo-test`
job (debug profile) aborted with SIGABRT instead of reporting an ordering.
A `--release` run does not enable the check, which is why this was green
locally.

That is a property of the runtime's WTF-8-as-`&str` view (`string_as_str`),
unchanged by this PR and identical on both sides of the differential, so the
entries could only ever have proven the checker works. The sound byte-level
lone-surrogate coverage stays where its helper takes `&[u8]`:
`utf16_cmp_ascii_fast_path_tests::lone_surrogates_fall_back_to_byte_order`.
The corpus doc comment now says so.

Verified on the debug profile CI actually uses: `cargo test -p perry-runtime`
→ 3606 passed, 0 failed, 4 ignored.

(cherry picked from commit 6e0b8de)
`[x, y] = [y, x]` and `const [a, b] = pair(i)` lowered through the full spec
iterator protocol — `GetIterator`, one `iteratorNextResult` per element (each
allocating a `{ value, done }` result object), two property reads off that
result, and `IteratorClose` — none of which is observable for an array whose
iteration protocol is the pristine builtin. #10086 measured a flat 76x Node for
the swap and 78x for the destructured return.

The lowering now emits both arms and branches on the same runtime guard `for…of`
over a proven array uses (`Expr::ArrayIterationPatched`). A spread-free array
literal written in place has its elements spilled into temps before the guard
and read straight from them, so the array is never built; a source whose static
type proves a plain Array is read by index with `length` re-read per element,
exactly as `IteratorStep` does.

The branch is per element, not around the whole pattern: a pattern DECLARES
bindings, so lowering it twice would give each binding two `LocalId`s, and both
arms have to keep the spec's interleaving (`let [a = f(), b] = src` evaluates
`f()` between producing element 0 and element 1).

A rest element, a nested pattern, an empty pattern, a generator / Set / Map /
string source and anything without a static array proof keep the unguarded
iterator lowering, statement-for-statement identical to before.

The guard itself had a hole, which this closes: a replaced
`%ArrayIteratorPrototype%.next` is detected per `.next()` call, so an arm that
never calls `.next()` could not see it — `for…of` over a proven array has
iterated unpatched elements since #7760. The exported byte (renamed
`PERRY_ARRAY_ITERATION_NOT_PRISTINE`) is now also set when the array-iterator
prototype object escapes to user code through `Object.getPrototypeOf` /
`Reflect.getPrototypeOf`, the only way to name it in order to patch it. Setting
it on escape rather than on the write is deliberate: the object is ordinary, so
a precise hook would have to cover every mutation funnel and missing one fails
silently toward a wrong answer. `ARRAY_PROTO_ITERATOR_MODIFIED` keeps its
original narrow meaning, so the spread and `js_get_iterator` paths are unchanged.

(cherry picked from commit 9ad1974)
The PR changed crates/ without one, which the changeset gate requires. Body
summarised from the commit message's own measurements.
Train164 (#10096, #10108, #10111, #10112) lands on main at 0.5.1537; none of the
PRs bumped the version, which is the maintainer's job at merge time. Cargo.lock
regenerated so every workspace member's inherited version moves with it.
@proggeramlug
proggeramlug merged commit 323e83a into main Sep 12, 2026
21 of 23 checks passed
@proggeramlug
proggeramlug deleted the train164 branch September 12, 2026 08:35
@coderabbitai

coderabbitai Bot commented Sep 12, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 91ab1b93-bdb9-4928-a9f0-01bb20251e56

📥 Commits

Reviewing files that changed from the base of the PR and between e8f9123 and b4d2491.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (25)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10096-uint8array-set-bulk-copy.md
  • changelog.d/10108-buffer-addr-filter-removal.md
  • changelog.d/10111-locale-compare-allocations.md
  • changelog.d/10112-array-destructuring-fast-path.md
  • crates/perry-codegen/src/expr/literals_vars.rs
  • crates/perry-codegen/src/runtime_decls/objects.rs
  • crates/perry-hir/src/destructuring/array_fast.rs
  • crates/perry-hir/src/destructuring/assignment_stmt.rs
  • crates/perry-hir/src/destructuring/mod.rs
  • crates/perry-hir/src/destructuring/pattern_binding.rs
  • crates/perry-hir/src/destructuring/var_decl.rs
  • crates/perry-hir/src/ir/expr.rs
  • crates/perry-hir/tests/array_destructuring_fast_path.rs
  • crates/perry-runtime/src/array/indexing_support.rs
  • crates/perry-runtime/src/array/mod.rs
  • crates/perry-runtime/src/buffer/access.rs
  • crates/perry-runtime/src/buffer/header.rs
  • crates/perry-runtime/src/object/iterator_prototypes.rs
  • crates/perry-runtime/src/object/object_ops/prototype.rs
  • crates/perry-runtime/src/string/compare.rs
  • docs/typescript-parity-gaps.md
  • test-files/test_gap_10086_array_destructuring_fast_path.ts
  • test-files/test_gap_10088_uint8array_set_bulk_copy.ts

📝 Walkthrough

Walkthrough

The pull request adds guarded fast paths for array destructuring and buffer copying, removes the buffer address filter, and reduces allocations in locale comparison. It also adds tests, changelog entries, documentation updates, and increments the workspace version.

Changes

Array destructuring fast path

Layer / File(s) Summary
Fast-path planning and lowering
crates/perry-hir/src/destructuring/*
Eligible array literals and proven arrays use guarded indexed reads. Other patterns retain iterator lowering.
Runtime guard and code generation
crates/perry-runtime/src/array/*, crates/perry-runtime/src/object/*, crates/perry-codegen/src/*
The renamed guard is set when array iteration becomes non-pristine, including iterator-prototype exposure.
Lowering and behavior tests
crates/perry-hir/tests/*, test-files/test_gap_10086_array_destructuring_fast_path.ts
Tests cover fast-path selection, iterator fallback, evaluation order, patterns, generators, and patched iterators.

Buffer copy and address probing

Layer / File(s) Summary
Bulk buffer copying
crates/perry-runtime/src/buffer/access.rs, test-files/test_gap_10088_uint8array_set_bulk_copy.ts
Compatible byte sources use direct span copying with overlap support. Other sources retain per-element coercion.
Address-window probing
crates/perry-runtime/src/buffer/header.rs
The saturated address filter and environment toggle are removed. Registration checks use the address window.

Locale comparison allocation reduction

Layer / File(s) Summary
Streaming comparison implementation
crates/perry-runtime/src/string/compare.rs
Lowercase comparison uses streaming scalar reads, and NFC text can skip normalization allocations.
Comparison tests and contract documentation
docs/typescript-parity-gaps.md, changelog.d/10111-locale-compare-allocations.md, crates/perry-runtime/src/string/compare.rs
Tests compare the new implementation with the previous formulation and document its ordering guarantees.

Release metadata

Layer / File(s) Summary
Version metadata
Cargo.toml, CLAUDE.md
The documented and workspace versions change from 0.5.1537 to 0.5.1538.

Estimated code review effort: 5 (Critical) | ~90 minutes

Change: Other

Sequence Diagram(s)

sequenceDiagram
  participant TypeScript
  participant DestructuringHIR
  participant PerryRuntime
  participant ArraySource
  TypeScript->>DestructuringHIR: lower array destructuring
  DestructuringHIR->>PerryRuntime: read PERRY_ARRAY_ITERATION_NOT_PRISTINE
  PerryRuntime-->>ArraySource: return iteration guard state
  ArraySource->>ArraySource: read indexed elements or initialize iterator
  ArraySource-->>TypeScript: bind destructured values
Loading

Possibly related PRs

  • PerryTS/perry#8662: Changes the patched array iterator next fallback path, which this pull request also preserves for guarded array destructuring.
✨ 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 train164

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.

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.

1 participant