Skip to content

chore: merge train 222 (v0.5.1601) - #10732

Merged
proggeramlug merged 23 commits into
mainfrom
train222r
Sep 19, 2026
Merged

proggeramlug merged 23 commits into
mainfrom
train222r

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Merge train 222 — eight PRs validated together as one tree, released as v0.5.1601.

Trains land as their own PR, so the source PRs are closed, not merged, and their close-keywords never fire. Every issue they resolve is listed at the bottom.

Contents

PR Change
#10643 fix(runtime): cache Web Crypto method closures instead of reallocating per read
#10644 fix(runtime): fix Buffer.prototype's own-property shape
#10647 fix(runtime): materialize Object.prototype.__proto__ as a real accessor
#10650 fix(hir): replace this inside for-of iterator wrapper exprs when lifting a Symbol.iterator generator
#10651 perf(runtime): stop hoisting cold-path thread-locals into o[k]'s fast lane (−5.8%)
#10653 fix(transform): do not beta-reduce a local arrow whose param is captured by a nested closure
#10657 perf(regex): poll the safepoint on units read, not on pieces
#10690 refactor(stdlib): remove the validator native binding, compile the real package from source

Plus one commit of the train's own, explained below.

test(gc): two debug-only GC twins gated under a release profile

cargo test --release -p perry-runtime fails two tests on main, and has for some time:

gc::tests::copy_slot_decode::sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check
gc::tests::heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds

Both assert that a debug-only guard fires, and neither is cfg-gated, so neither can pass under a release profile. debug_assert_heap_change_open() is #[cfg(debug_assertions)]; the other says so in its own doc comment ("In the debug build cargo test runs…"). CI never sees this because its cargo-test job builds debug.

Gated with #[cfg_attr(not(debug_assertions), ignore)] rather than #[cfg(debug_assertions)]: an ignored test is still reported by name, while a cfg'd-out one is indistinguishable from one that was deleted. The comment says explicitly that the tests are correct and the profile changed what the code means, and points at [profile.gcaudit] as the only profile giving release codegen with assertions live — so nobody later "fixes" the tests themselves.

Effect visible in this train's own run: runtime rc=0, where train 221 reported rc=101.

Validation

Assembled on e1194660fe and proven before validating. Every PR fully represented, checked by patch-id and by subject+author rather than by a pathspec diff; source heads asserted unchanged since assembly.

Green: fmt, file_size, raw_handle (self-test, ceilings, vs-main), gc_runtime_root_holders, check_test_registration, addr_class_inventory, cargo check --workspace --all-targets under -D warnings, the release build of all five pinned artifacts, every unit suite, both derived integration suites, and the 14-area gap sweep with each area asserted to have run a non-zero number of tests.

lint completed its full compile tier (6 of 6 derived commands) with no failure outside the known-red public-baseline step. That assertion is new: train 221's lint was killed by an external SIGTERM after 4 of 6 and was recorded identically to a pass, because the step is expect=None to tolerate the known-red baseline. The driver now parses the script's own banner for the derived count and requires the executed count to match.

One caveat recorded in the run and repeated here: the gap sweep began while an unrelated 870-fixture sweep was still running (load 15 on 10 cores). No regressions appeared, so nothing needed re-verification — but had any appeared, the note in the results file requires them to be re-run serially before being believed.

Issues resolved

Closes #10427
Closes #10426
Closes #10482
Closes #10445
Closes #10567

Ralph Küpper and others added 23 commits September 19, 2026 14:08
…g per read

globalThis.crypto.randomUUID/getRandomValues and crypto.subtle's KEM
methods (encapsulateBits/decapsulateBits/encapsulateKey/decapsulateKey)
allocated a fresh closure on every property read via plain
js_closure_alloc, so the method had no stable identity
(crypto.randomUUID === crypto.randomUUID was false) and every read
allocated. Use the existing func-ptr-keyed js_closure_alloc_singleton
cache instead, matching how other builtin methods stay identity-stable
across reads.
Buffer.prototype had 36 bogus own properties (including bare string
literals "function"/"undefined" that had drifted in from nearby
prose/JS-idiom comments, plus DataView/Uint8Array.prototype/
Object.prototype methods that belong further up the prototype chain)
and was missing 16 of Node's own members: the 14 internal
<encoding>Slice/<encoding>Write methods and the deprecated offset/
parent accessors.

Root cause: BUFFER_PROTOTYPE_METHODS (which populates Buffer.prototype's
own enumerable properties) was generated from the SAME name table
buffer_dispatch::is_buffer_method_name uses to decide whether a property
read on a Buffer INSTANCE should synthesize a bound-method closure -
deliberately broad, for duck-typed inherited-method reads - conflating
that broad instance-read predicate with the narrow set of names that
should be Buffer.prototype's own keys.

Decoupled the two: curated BUFFER_PROTOTYPE_METHODS down to Node's real
96-name own-property surface (removing the 36 bogus entries, adding the
14 internal Slice/Write methods with real dispatch behavior, and adding
offset/parent as accessor descriptors), while leaving
is_buffer_method_name's broader instance-read predicate untouched.

Also fixed a dormant gap the new ucs2Write method depends on:
js_buffer_write_len's encoding match never handled tag 6 (utf16le/
ucs2), silently writing raw UTF-8 bytes instead - a pre-existing defect
in buf.write(str, offset, 'utf16le') too.
Object.prototype had no own __proto__ accessor, so hasOwnProperty,
Object.hasOwn, getOwnPropertyNames, getOwnPropertyDescriptor,
Reflect.ownKeys, and the in operator all disagreed with Node about it.
Install a real { get, set, enumerable: false, configurable: true }
accessor descriptor on Object.prototype (gate-neutral, so no dynamic
property read/write fast path is affected), backed by the existing
js_object_get_prototype_of / Annex B legacy setPrototypeOf logic.

Also fixes a latent receiver-binding gap in
primitive_builtin_prototype_property's inherited-property fallback,
exposed by the new accessor: an accessor inherited transitively from
Object.prototype through a primitive's builtin wrapper prototype (e.g.
Number.prototype) was invoked with this bound to the intermediate
prototype object instead of the original primitive receiver.
…/SetValues when lifting a Symbol.iterator generator method

A generator method keyed by [Symbol.iterator] is lifted to a top-level
function with this as an explicit param (synthesize_symbol_iterator_wrapper
in lower_decl/class_decl.rs), and replace_this_in_stmts rewrites Expr::This
to that param throughout the body. Its expression walker was missing arms
for GetIterator/GetAsyncIterator/MapEntries/SetValues -- the wrapper exprs a
for-of iterable lowers to when it cannot be proven a plain Array/Map/Set
(stmt_loops.rs lower_stmt_for_of_inner). A for-of over this.gen() inside
such a method left an unreplaced Expr::This nested inside one of these
wrappers, which evaluates to undefined outside any method body:
Cannot read properties of undefined (reading 'gen').
…f/spread/Array.from

Covers the #10445 repro (spread, for-of, Array.from, named-generator
control), a two-level generator chain (iterator method's for-of iterable
is itself another method that also iterates via this), a Symbol.iterator
generator on a class EXPRESSION, and yield* delegation alongside a for-of
over this.method() in the same generator.
… lane

js_object_get_field_by_name's Proxy-receiver block and RuntimeHandleScope's
raw-thread_local! fallback were both small enough to inline into the hot
dynamic-key-read path. A thread_local! address resolution is readnone from
LLVM's point of view, so once inlined, the optimizer hoisted the proxy
registry's and the transient-handle root stack's TLS lookups out of their
guards (is_proxy_id_band, a "size"-key check) and ran them unconditionally
on every js_object_get_field_by_name call, Proxy or not. Splitting each
into its own #[inline(never)] function keeps the optimizer from seeing
inside at the call site, so nothing gets hoisted past the guard. Also skip
try_read_gc_header's redundant is_plausible_heap_addr recheck in
try_data_get_bytes's prototype-chain loop, where the caller already proved
it true one statement above.

Measured on a two-property-object o[k] loop (never touching a Proxy or a
.size key): 544.9 -> 513.0 instructions/access (-5.9%), via
(dyn80-dyn16)/64 differenced against 2x the iteration count to cancel
per-process fixed overhead. The (loop80-loop16)/64 no-op control reads
~0 in both arms (base: -0.01..-0.04, mine: -0.02..0.13), confirming the
technique resolves changes this small. Verified via disassembly that both
_tlv_get_addr calls are gone from the function's prologue.
…red by a nested closure

closure_local_inline's beta-reduction clones the arrow's return expression
fresh per call site and substitutes each parameter with that call's
argument via substitute_locals. For a parameter read inside a NESTED
closure (e.g. (f, isOpt) => arr.forEach(([k,v]) => check(k, v, isOpt))),
substitute_locals bakes a non-LocalGet argument straight into that nested
closure's body and drops it from the closure's captures list, but never
mints a fresh func_id for the rewritten closure literal. Codegen compiles
exactly one body per func_id (whichever Expr::Closure occurrence its
module-wide scan sees first), so every call site's clone of the nested
closure keeps sharing the SAME func_id -- with more than one call site,
only the first-seen clone's baked-in argument is ever compiled, and every
other call silently runs it too.

Bail out of the beta-reduction when any parameter is captured by a nested
closure, leaving such an arrow as a real, per-call closure -- each
invocation then creates its own closure instance whose nested callback
correctly captures that call's argument by reference.
…ultiple call sites

Covers the #10567 repro (nested destructuring forEach callback), an arrow
with several params where the captured one is neither first nor last,
nested arrows (outer -> middle -> inner, two closure boundaries away),
an arrow declared inside a class method, a by-write capture control (a
multi-statement arrow body, never a closure_local_inline candidate), and
the plain-function controls from the original issue.
Building a replacement's output walks its pieces twice, measuring and then
encoding, and each pass polled the GC safepoint once per piece. `try_fold` stops
at QUANTUM units *or* at the end of a piece, and a piece is usually two or three
units -- an original span, a template span, a capture -- so a subject with
200,000 matches ran the safepoint hundreds of thousands of times per pass for a
handful of units of reading each.

That check costs about 436 instructions: it evaluates the whole budgeted trigger
ladder, which has no cheap "nothing is due" precheck.

Both passes now poll once per POLL_UNITS units read. Unlike the collection loop
in `perex_replace_direct`, these passes are downstream of the replacement's
traced pieces and its replacer's strings, so they do produce garbage and polling
far less often costs peak RSS. POLL_UNITS is therefore a measured trade, not a
bound inherited from elsewhere: at `api::QUANTUM` (4096) the instruction win is
the same but peak RSS is +13.2% median on an allocating replace at n=1,000,000,
over the accepted +10% budget. At 512 the win survives and the cost does not.

Instructions, both arms from one commit, release, plain main:

  replace, string template      27,837,140,955 -> 20,090,148,511   -27.8%
  replace1m (both forms)       275,168,155,979 -> 249,292,630,136    -9.4%
  replace, callback, ASCII      51,289,448,826 -> 47,903,744,797     -6.6%
  replace, callback, Unicode    61,275,226,072 -> 58,023,887,665     -5.3%

Peak RSS on replace1m, nine interleaved rounds: median +0.3%, mean +0.1%,
max -0.4%, against a +10% budget. Answers are identical to Node 26.5.1 on every
probe, including the correctness differential from #10605.

Why 512 rather than 4096: a piece is two or three units, so 512 still removes
about 99 percent of the polls while giving the collector eight times the
openings. Both figures above are measured; the knee between them is not located.
Native validator has 9+ methods throwing "not implemented", trim() silently
returns undefined, and implemented checks (isEmail/isURL/isUUID/isJSON/
isEmpty) return 0/1 rather than real booleans (visible via
JSON.stringify(validator.isEmail(...)) === "1", not "true"). Real npm
validator matches Node for all 50 checks.

Per #10678 (duplicate extern "C" exports across perry-ext-*/perry-stdlib
pairs), this binding existed twice, plus a shared helper crate used only by
the two duplicates:

- crates/perry-ext-validator/ — the governance-tracked binding crate.
- crates/perry-stdlib/src/validator.rs (425 lines) — a second, independent
  implementation behind the `bundled-validator` feature (default-on via the
  `validation` umbrella, itself in `full`), exporting the same js_validator_*
  symbols.
- crates/perry-validation/ — "Shared borrowed string validators for Perry's
  bundled and extension bindings" (its own doc comment): a small email/URL/
  UUID grammar helper consumed exclusively by the two crates above. With
  both gone, nothing references it, so it goes too.

Removed all three crates, the 5-entry NativeModSig dispatch block in
native_table/utils_crypto.rs (isEmail/isURL/isUUID/isJSON/isEmpty — the only
validator methods with a dedicated codegen row; the rest were reachable only
through the deleted FFI crates), the 16 js_validator_* FFI declarations in
runtime_decls/stdlib_ffi/streams_events.rs, the well_known_bindings.toml
entry, the NATIVE_MODULES/manifest rows, the validation/bundled-validator
stdlib features (and "validation" from perry-stdlib's `full` feature list),
and the 16 Android stub exports.

Regenerated docs/api/perry.d.ts, docs/src/api/reference.md, and
docs/src/native-libraries/governance.md. Updated workspace-architecture.json
(workspace_members 83->81, externalize 33->32, keep 45->44 — two crates
removed, perry-ext-validator was "externalize" and perry-validation was
"keep"/runtime-core).
…lib provider

Standalone workspace (its own Cargo.lock, not a member of the main
workspace), so cargo check --workspace never touched it. Referenced the
now-deleted validation feature from perry-stdlib's Cargo.toml.
The rebase over origin/main (which already applied #10687's jsonwebtoken
removal) resolved the manifest header-count conflict with placeholder
values from before the rebase. Recompute them from the actual resolved
tree via scripts/regen_api_docs.sh's two perry --print-api-manifest
invocations: 2085 entries across 134 modules (perry.d.ts), 3027 entries
across 136 modules (reference.md).
`cargo test --release -p perry-runtime --lib` fails on main with:

    gc::tests::copy_slot_decode::sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check
    gc::tests::heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds

Both assert that a DEBUG-ONLY guard fires, and neither is cfg-gated, so both
are structurally incapable of passing under a release profile:

  * `debug_assert_heap_change_open()` is `#[cfg(debug_assertions)]`. Under
    --release it cannot panic, so `catch_unwind(...).is_err()` is false.
  * copy_slot_decode's own doc comment already says it: "In a release build
    `restore_surviving_dirty_coverage` would re-add the page the arm failed to
    remember ... In the debug build `cargo test` runs, the same walk
    cross-checks the dirty scan's per-slot re-remembering".

CI never sees this because its `cargo-test` job builds debug. It surfaces in
any release-profile run, which is how merge-train validation found it.

`#[cfg_attr(not(debug_assertions), ignore)]` rather than `#[cfg(debug_assertions)]`:
an ignored test is still reported by name in the release run, while a cfg'd-out
one is indistinguishable from a test that was deleted. The debug run -- the one
that can actually exercise these -- is unchanged.
@proggeramlug
proggeramlug merged commit 7c5d04d into main Sep 19, 2026
23 of 26 checks passed
@proggeramlug
proggeramlug deleted the train222r branch September 19, 2026 13:42
@coderabbitai

coderabbitai Bot commented Sep 19, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 58e87ed6-3ec4-4a9c-9903-dacc6f9a6110

📥 Commits

Reviewing files that changed from the base of the PR and between e119466 and 073113e.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (54)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10643-webcrypto-method-identity.md
  • changelog.d/10644-buffer-prototype-shape.md
  • changelog.d/10647-object-prototype-dunder-proto.md
  • changelog.d/10650-symbol-iterator-generator-this.md
  • changelog.d/10651-dynamic-key-cold-tls.md
  • changelog.d/10653-arrow-param-capture.md
  • changelog.d/10657-replace-poll-on-units.md
  • changelog.d/10690-validator-native-binding-removal.md
  • crates/perry-api-manifest/src/entries.rs
  • crates/perry-api-manifest/src/entries/part_1.rs
  • crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs
  • crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs
  • crates/perry-ext-validator/Cargo.toml
  • crates/perry-ext-validator/src/lib.rs
  • crates/perry-hir/src/analysis.rs
  • crates/perry-runtime/src/buffer/copy_write.rs
  • crates/perry-runtime/src/buffer/from.rs
  • crates/perry-runtime/src/gc/roots/runtime_handles.rs
  • crates/perry-runtime/src/gc/tests/copy_slot_decode.rs
  • crates/perry-runtime/src/gc/tests/heap_generation.rs
  • crates/perry-runtime/src/object/buffer_dispatch.rs
  • crates/perry-runtime/src/object/field_get_set/accessors.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs
  • crates/perry-runtime/src/object/global_this/ctor_thunks.rs
  • crates/perry-runtime/src/object/global_this/proto_methods.rs
  • crates/perry-runtime/src/object/native_get.rs
  • crates/perry-runtime/src/object/native_module/callable_exports.rs
  • crates/perry-runtime/src/proxy.rs
  • crates/perry-runtime/src/regex/perex_replace_storage.rs
  • crates/perry-runtime/src/value/addr_class.rs
  • crates/perry-stdlib/Cargo.toml
  • crates/perry-stdlib/src/lib.rs
  • crates/perry-stdlib/src/validator.rs
  • crates/perry-transform/src/closure_local_inline.rs
  • crates/perry-ui-android/src/stdlib_stubs.rs
  • crates/perry-validation/Cargo.toml
  • crates/perry-validation/UPSTREAM_VALIDATOR_LICENSE
  • crates/perry-validation/src/lib.rs
  • crates/perry-validation/src/tests.rs
  • crates/perry/src/commands/stdlib_features.rs
  • crates/perry/well_known_bindings.toml
  • docs/api/perry.d.ts
  • docs/src/api/reference.md
  • docs/src/native-libraries/governance.md
  • test-files/test_gap_10426_buffer_prototype_shape.ts
  • test-files/test_gap_10427_webcrypto_method_identity.ts
  • test-files/test_gap_10445_symbol_iterator_generator_this.ts
  • test-files/test_gap_10482_object_prototype_dunder_proto.ts
  • test-files/test_gap_10567_arrow_param_closure_capture.ts
  • test-files/test_gap_dynamic_key_proxy_receiver.ts
  • tests/release/packages/next-app-route/provider/stdlib/Cargo.toml
  • workspace-architecture.json
 ________________________________________________________
< Your error handling is basically thoughts and prayers. >
 --------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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