Skip to content

Merge train 217: implicit-this exception safety, template/map/regex/GC perf, defineProperty attrs, instanceof and Symbol accessor fixes (v0.5.1595) - #10631

Merged
proggeramlug merged 42 commits into
mainfrom
train217r
Sep 18, 2026
Merged

proggeramlug merged 42 commits into
mainfrom
train217r

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

This train lands 13 PRs as v0.5.1595. Each source commit is verified to preserve its patch-id and authorship.

Three things this train decided, not just carried

#10564 completes a fix that shipped half-done. Train 216 landed its rooting half and deliberately withheld the Fixes keyword, because the PR gained three exception-safety commits after that train was assembled. Those commits close a hole where an inner js_throw crossing a bare save/call/restore site leaves IMPLICIT_THIS and new.target stuck at the inner value instead of the enclosing try's baseline — proven by a test built only from pre-existing public entry points, which fails on the pre-fix tree. It costs +7.83% instructions on a 5M-iteration try/catch loop that never throws (895.28M → 965.34M, ~14 per try-push), and the author records that none of the four named bare sites is proven reachable. That trade was put to the owner with those numbers and approved. #10490 closes here.

#10580 adopts perex 0.1.9 from inside the supply-chain soak window. Its Search::run single-entry decision needs perex::executor::Run, which lands in 0.1.9 — a release ~11 hours old against a 7-day global-min-publish-age, so the resolver skipped it and perry-runtime would not compile. The window is not weakened: SOAK_DAYS and global-min-publish-age are untouched, and the version is pinned in Cargo.lock instead, which is the escape the mechanism itself defines (too-new releases are skipped unless already locked). Verified to build with no override environment set, and npm run soak still reports all surfaces matching SOAK_DAYS=7 with no drift. Owner-approved and recorded in a dated fragment, because stepping outside a soak window should be visible rather than silent.

Two train repairs, both for defects the gates caught rather than style. #10597's new guard used the bare is_valid_obj_ptr, which is only a floor check (addr >= 0x1000) and, per its own documentation, deliberately does not reject the fetch/zlib/proxy handle bands; since handles are pointer-tagged with small addresses, a handle receiver would have reached the deref and segfaulted on Linux while macOS hid it. It now uses is_above_handle_band, the sanctioned predicate. And #10579 took codegen/artifacts.rs to 2006 lines, so the retained-source collection moved to artifact_source_text::collect_user_fn_source (1949 + 216 lines).

Validation

Validated head a8f74b0dff. Five-package release build pinned and hash-verified, and re-verified after the gap run (artifacts_match_pin_after_gap=True).

  • Crate suites: codegen 1620 · runtime 4040 (+2 known) · stdlib 139 · hir 454 · transform 152 · cli 1140.
  • All nine preflight gates pass on the merged tree, now derived from the driver itself rather than hand-listed; lint is 82/83, the one red being the public-benchmark freshness step known-red on main.
  • Gap: gate-scoped (run_gap_tests.sh's test_gap_ filter is silently discarded by a caller's --filter, so gap sweeps run the whole corpus #10585 landed in train 216, so repeated --filter now intersects instead of last-wins). Each line records what the run actually selected against what test_gap_* matching predicts, so a filter that silently fails to narrow is a red:
gap_template_selected ran=4 gate_scope=4 all_matching=4 13:55:37Z
gap_map_selected ran=20 gate_scope=20 all_matching=34 13:56:48Z
gap_regex_selected ran=11 gate_scope=11 all_matching=25 14:00:53Z
gap_defineproperty_selected ran=4 gate_scope=4 all_matching=6 14:01:14Z
gap_slot_selected ran=3 gate_scope=3 all_matching=3 14:01:31Z
gap_argument_selected ran=10 gate_scope=10 all_matching=14 14:02:13Z
gap_timer_selected ran=3 gate_scope=3 all_matching=7 14:02:32Z
gap_builtin_selected ran=14 gate_scope=14 all_matching=21 14:05:52Z
gap_instanceof_selected ran=13 gate_scope=13 all_matching=16 14:09:10Z
gap_symbol_selected ran=15 gate_scope=15 all_matching=20 14:10:03Z
  • No unexplained gap regressions at allunexplained_regressions={}.

Issues closed by this train

A merge train closes its source PRs rather than merging them, so the Fixes #N keywords in those PR bodies never evaluate. They are carried here, on the PR that actually merges, so they fire:

Fixes #10490
Fixes #10574
Fixes #10480
Fixes #10476
Fixes #10478
Fixes #10479
Fixes #10556
Fixes #10477
Fixes #10481

Summary by CodeRabbit

  • Bug Fixes

    • Corrected instanceof behavior for imported constructors, native objects, subclasses, and varied value types.
    • Preserved user-defined methods that share names with built-ins.
    • Fixed inherited Symbol accessors, class accessor descriptors, object prototypes, and Object.create() constructor resolution.
    • Improved reliability for reflective calls, argument handling, exceptions, garbage collection, and mock timers.
    • Fixed long-array map behavior and zero-argument string search methods.
  • Performance

    • Reduced work for template literals, regular-expression searches, array methods, and garbage collection.
  • New Features

    • Added optional compact function-source output via --function-source or PERRY_FUNCTION_SOURCE.
  • Chores

    • Updated the project version to 0.5.1595.

Ralph Küpper and others added 30 commits September 18, 2026 15:03
Several runtime guards displace IMPLICIT_THIS/new.target around a call
they don't own (the Temporal/Intl subclass super() bridges, the
handle-method prototype-walk accessor dispatch, the stdlib listener/
getter dispatchers) with a bare save/call/restore statement pair, not
an RAII guard. When the bracketed call throws, the restore statement
textually follows it, so neither longjmp nor a system unwind ever
reaches it -- both cells stay pinned at whatever the failed call set
them to for every later read.

Add implicit_this and new_target as two more members of the
catch_savepoints! family exception.rs already maintains for exactly
this shape (shadow stack, runtime handles, call-method depth, ...):
captured at every try-push, replayed by js_throw before it transports
the exception, uniformly for both the setjmp and the unwind handler
kind (both already funnel into one try_push_with_kind ->
CatchSavepoint::capture(), and js_throw calls .restore()
unconditionally before branching on transport).

The captured bits are a second root for whatever heap value they hold
while a try is open, invisible to the live cell's own scanner, so
scan_exception_roots_mut now also walks the live prefix of the
per-thread savepoints slab and rewrites both fields across a moving
collection.

Proven with a unit test that reproduces the bare save/call/restore
shape using only pre-existing public entry points (so the same test
body fails on the unfixed tree and passes here): an inner js_throw
crossing the bare site leaves IMPLICIT_THIS/new.target stuck at the
inner value instead of the enclosing try's baseline. NOT verified: that
any of the four named sites is reachable in the way the finding
assumes -- see the PR comment.
…ests

The perex GC-isolation test harness (register_host_roots) clears the
production scanner registry and manually restores only what its own
tests need. implicit_this/new_target in exception.rs's catch_savepoints!
are a second root for whatever these tests displace IMPLICIT_THIS to
across a throw (previously nothing in scan_exception_roots_mut needed
scanning for these specific tests, since their thrown values are always
plain numbers) -- register it alongside the live-cell scanner it already
restores, or a forced-evacuation minor between the try-push and the
throw leaves the savepoint copy stale and the post-catch receiver wrong.

Fixes the 3 new failures the previous commit introduced under
`cargo test --release -p perry-runtime --tests` (perex_dispatch,
perex_replace, perex_split's "restores this" tests); full suite is back
to 3975 passed, 1 pre-existing failure (a_free_or_move_outside_every_
scope_is_caught_in_debug_builds, a debug_assert! funnel that cannot
fire under --release, unrelated to this change and already documented
in #10564's own PR body), 4 ignored.
A template literal opening on a substitution (`${x}...`, no literal text
before the first `${`) unconditionally seeded its desugared concat chain
with a literal `Expr::String("")`: every interior quasi already skipped
itself when empty, but the leading one never got the same guard, so it
survived to `js_string_concat_chain` as an always-empty classification
slot, and a single-substitution template missed the chain fold's 3-part
minimum entirely.

Separately, a `number`-typed parameter's substitution kept its redundant
`StringCoerce` wrapper unless codegen had a dataflow *proof* it was
numeric; a plain, unspecialized function parameter only ever has the
declared annotation. `js_string_concat_chain`'s own classify loop already
tag-dispatches every part and falls back to the exact `js_jsvalue_to_string`
/ `js_string_materialize_to_heap` calls `js_string_coerce` itself forwards
to for every non-numeric shape, so trusting the declared type here (the
same trust `is_declared_string_expr` already extends to strings) cannot
change the output, only which code path produces it.

Measured on `${s}:${n}` (s a short string, n a non-integer double),
differencing two probes to cancel fixed per-process cost: 1173 -> 955
instructions per evaluation (-18.6%), loop16/loop80 control ~0 in both
arms. An integer-interpolation variant isolates the leading-quasi fix
alone at -5.4%, confirming the larger win comes from the StringCoerce
elision.
…ements

Array.prototype.map's plain-result fill used the once-resolved-header fast
path (fill_resolved_array_slot) only for a source of at most 64 elements;
longer sources fell back to note_array_slot, which re-classifies the
result's ownership/forwarding through clean_arr_ptr on every element
(array_numeric_layout) and unconditionally pays layout_note_slot. The
result pointer is re-derived from its own GC root immediately before
either helper runs, with no intervening allocation or safepoint, so
fill_resolved_array_slot's contract holds regardless of length -- the
64-element split was scope, not a correctness boundary.

Also drops a redundant raw ptr::write of the mapped value that ran right
before both branches; both helpers perform their own (possibly
canonicalized) store of the same slot, so it was always immediately
overwritten.

a.map(x => x + v) over a 16-vs-80-element number[]: 450.8 -> 206.1
instructions per element (-54.3%), measured as the marginal per-call cost
difference of two probes differing only in element count (control
(loop80-loop16)/64 ~0 in both arms).
…lass accessors

A ClassBody get/set accessor lives in the class vtable, not the
address-keyed descriptor tables defineProperty writes. A generic
descriptor (no get/set/value/writable, e.g. { enumerable: true })
against an existing class accessor fell through to the ordinary
define path, which could not see the class key: it appended a new
data-property keys-array entry with writable: false that shadowed
the class accessor on writes, breaking the setter and leaking a
stale enumerable/configurable reading.

Add a per-(class_id, is_static, name) attrs side table
(class_registry/accessor_attrs.rs) that a generic descriptor against
a declared accessor updates instead of materializing a shadowing
data property, and route getOwnPropertyDescriptor, enumeration,
has-own and delete through it.

Fixes #10480
CLASS_ACCESSOR_ATTRS (added for #10480) stores only scalars/String,
never a heap pointer -- verdict not_a_gc_pointer in
scripts/gc_runtime_root_holders.json.
…rable

Object.defineProperty(C, 'x', { enumerable: true }) on a declared
static accessor updated getOwnPropertyDescriptor/Object.keys/for-in
via the #10480 side table, but js_object_property_is_enumerable's
ClassRef branch only ever checked static FIELDS, so
C.propertyIsEnumerable('x') stayed false. Check the declared static
accessor's tracked enumerable attribute alongside the static-field
check.

Found by CodeRabbit review on #10582; verified against Node before
fixing (propertyIsEnumerable: false vs Node's true, while the
descriptor/for-in/keys already agreed with Node).
#10362)

`visit_gc_layout_slot_descriptors` called `HeapChildSlotIterator::next` once per
payload slot. For the common case — a `Masked` selection whose mask is
`LayoutSlotMask::Inline` — that call re-dispatched the selection, re-decoded the
mask's niche and rebuilt the limit and cursor masks FOR EVERY SLOT, for about
eight instructions of work.

The mask's set bits ARE the slot indices, in order, so the arm now takes the
word once (`take_inline_mask_word`) and walks it with `trailing_zeros` and
`word &= word - 1`. Every other selection — including a `Heap` mask, i.e. more
than 64 payload slots — keeps the iterator unchanged.

`take_inline_mask_word` carries the iterator's two side conditions with it: the
one-shot raw-numeric accounting `next`'s first call performs, and the cursor,
which it leaves at the end so a later `next` yields nothing. The prefix and meta
edges are the caller's, taken before the payload; the helper debug-asserts they
are already gone, because losing one of them is the only way this can go wrong
without disagreeing with `next` on any payload index.

Base: 01063a8, the head of #10552 — a correctness fix to this same file, which
lands first.

MEASURED ON A DISTINCT-CHILD CONTROL. The `rec*_ptr` controls this campaign has
been using aim every pointer field at ONE shared object, so `mark_addr`'s
one-entry address memo answers 83.3% of its classifications (exact: 360,094
`classify_arena` calls under 2,160,037 `mark_addr` calls). On the real fixtures
it answers 0.0%. `dist*_ptr` points field j of record n at `pool[(n*K+j) % 4096]`
instead: memo hit rate 0.19%, same slot count, same object population.

Per pointer-slot visit, exact self Ir under callgrind, dist16_ptr (base arm):

    97.3  CopyingPointerSet::classify_arena
    82.4  CopyingNurseryCollector::visit_slot_with_weak_fact
    75.8  HeapChildSlotIterator::next
    67.6  CopyingNurseryCollector::scan_object_fields::{closure#0}
    44.5  CopyingNurseryCollector::mark_addr
    31.1  visit_gc_layout_slot_descriptors
    18.5  the rewrite trampoline
   -----
   417.1  slot path = 42.3% of the program

THE RANKING MOVED: `next` is third on this control, not first. What the blind
control hid is `classify_arena` (20.5 -> 97.3 Ir/slot) and `mark_addr`
(28.4 -> 44.5); `next` itself is unchanged by the control, 75.9 -> 75.8. It is
the largest REMOVABLE item, not the largest item, and those are different
claims.

The walk removes 69.7 Ir per pointer-slot visit (exact, dist16_ptr: -164,025,897
over 2,352,324 visits), of which -161,432,000 is `next` and -2,599,000 is the
descriptor loop itself.

WHAT IT COVERS, on gc3: 6,181,927 traced-object visits, half of which yield no
payload slot at all and never entered the iterator; 12,784,094 pointer-slot
visits, of which 9,693,123 (75.8%) come through the masked arm and 3,090,971
(24.2%) are the shared shape-record `keys` prefix edge, one per masked object,
which this does not touch. Of the iterator calls the collector actually made,
the walk removes 12,364,032 of 12,784,116 — 96.7%. On oldyoung, whose masked
slots are mostly behind one `Heap` mask, it removes 66.7%.

ALL THREE CONSUMERS of the descriptor walk, inclusive Ir on gc3, exact:

  copying minor (run_copied_minor_attempt)     4,665,326,035 -> 4,294,459,662   -7.95%
  full mark (drain_trace_worklist_step)        1,913,478,665 -> 1,595,408,917  -16.62%
  remembered rebuild (rebuild_evacuated_...)     738,658,748 ->   608,717,911  -17.59%
  dirty scan (scan_dirty_object_slots)           218,745,912 ->   218,703,379   -0.02%

instructions:u, min of 5, whole program:
  gc3       11,771,100,698 -> 10,976,853,287  -6.75%
  w20000     4,735,487,965 ->  4,457,784,668  -5.86%
  w5000      1,888,564,009 ->  1,796,034,248  -4.90%
  w1000      1,046,189,863 ->  1,021,502,063  -2.36%
  oldyoung   1,455,160,271 ->  1,439,702,970  -1.06%
  alloc        320,187,977 ->    320,187,679  -0.00%

Witness: `gc::tests::layout_inline_mask`. The equivalence is a property, so it is
tested as one: the walk's index sequence must equal the iterator's for every mask
word (empty, one bit at each end, full width, both alternations, 64 pseudo-random
words) crossed with every live slot count (0, 1, ..., 63, 64, 65, 96, 128), and
every index it yields must be live and in the mask. Two sabotaged twins are
armed in-tree and must fail; two more were applied to the real source and the
witnesses went red — an off-by-one in the word's limit fails the property, and a
visit loop that stops one bit early fails only the collection witness, which is
how we know the two cover different code.
…n move them

#10532 removed the argument-count ceilings on dynamic calls, but two
flows in that path held GC values in plain Rust locals across an
allocation:

- Reflect.apply rebinds a concise-method callee's captured `this`,
  which CLONES the closure -- the one shape rebind_explicit_this
  allocates for. The callee, receiver and every argument sat in plain
  locals across that clone.
- CreateListFromArrayLike's array-like path allocates an index-key
  string per element (and can run a getter), with the source object
  and previously-read elements sitting in plain locals across it.
- A (...fixed, ...rest) body that also synthesizes `arguments` builds
  TWO arrays; the second allocation could move the first, which the
  bundler then handed the callee by its pre-move address.

Root only where a collection is actually possible: a new
rebind_explicit_this_allocates predicate (kept in sync with
rebind_explicit_this's clone shape by a same-file test) lets the
common non-cloning Reflect.apply path skip rooting entirely, and only
the cloning shape takes the rooted slow path. value_can_move() skips
rooting immediate values (numbers, undefined/null, etc.) in the
array-like path. A first cut of this rewrite rooted unconditionally
and cost Reflect.apply +38.8% instructions; this version measures near
zero (see perf table in the PR).

Adds gc/collection_points.rs (named, test-only collection points so a
rooting regression test can arm a copying minor at a specific
allocation inside one call) and
gc/tests/runtime_roots/call_argument_lists.rs, which reproduces all
three flows and asserts the callee observes post-collection addresses.

Recovered from a mirror after the original build host was destroyed
mid-validation; re-verified (apply, build, new tests, cargo fmt,
lint gates) from scratch on a fresh clone.
…lists

CodeRabbit review of #10587: create_list_from_array_like's value_can_move
only recognized NaN-boxed (POINTER_TAG/STRING_TAG/BIGINT_TAG) values as
movable. Some closures (the Promise executor's resolve/reject, from
js_promise_new_with_executor) and some TypedArray/Buffer pointers on
certain platforms are handed through as a raw pointer bitcast to f64 --
no NaN-box tag, top16 == 0 -- rather than POINTER_TAG-boxed (see the
existing comments in object/native_call_method/handle_methods.rs and
value/dynamic_object.rs for the established precedent). Such a value
read out of an array-like object's indexed property and copied bare
into the local out Vec was invisible to value_can_move, so a
collection triggered by a later index's key-string allocation could
move it while it sat unrooted.

value_move_kind now also recognizes a raw heap-pointer-shaped bit
pattern via the existing addr_class::is_plausible_heap_addr predicate,
and roots it through RuntimeHandleScope::root_heap_word_u64 (the
raw-aware HeapWord slot -- root_nanbox_f64's Nanbox slot only rewrites
tagged bit patterns and would silently do nothing for a raw one).

Also extends the test-only collection_points harness with
arm_collection_point_after(site, skip), so a test can put the forced
collection on a LATER loop iteration than the first -- needed here
because the one-shot arm firing on the very first iteration meant
every element was always read fresh post-collection, never exercising
the "already read, then a later allocation moves it" window the fix
protects. New test:
array_like_argument_lists_root_raw_untagged_heap_pointer_elements,
proven to fail against the previous commit's create_list_from_array_like
and pass with this fix.
A one-shot mock timer (node:test's mock.timers) left the queue via
state.callbacks.remove(idx) and was destructured into (id, callback,
args, context), dropping the popped entry's ScheduledTimerId pin right
there -- before call_timer_callback had run, let alone finished. A
callback that then churned more than TIMER_REF_STATES_CAP timers
evicted its own handle mid-dispatch, exactly as #10447 evicted long-
lived timers before the pin existed.

Carry the ScheduledTimerId in the dispatch action tuple instead, and
let it drop only after call_timer_callback returns. Interval mock
timers are unaffected: their entry stays in the queue across a tick,
so their pin was never at risk.
scripts/gc_runtime_root_holders.py flags any new static/thread_local
whose type could hold a GC heap pointer that no registered scanner in
its own file reaches. crates/perry-runtime/src/timer/tests_inline.rs's
new SELF_ID: AtomicI64 (added by the mock-timer dispatch-pin
regression test) stores a scheduled timer id, never a heap pointer,
and only exists under #[cfg(test)]. Classify it test_only.
A call whose method NAME matched a Date/Number/Array builtin (`getTime`,
`toFixed`, `toISOString`, `toSorted`, `endsWith`, ...) lowered straight to
the builtin's runtime entry point regardless of the receiver, because a
method name alone was treated as proof of receiver kind. Any class,
function-constructor prototype, or object literal defining a same-named
method (dayjs's `toISOString`, decimal.js/bignumber.js's `toFixed`, a
`Clock.getTime`, ...) had its own method silently skipped in favor of the
builtin, producing NaN, "[object Object]", Invalid Date or an uncaught
RangeError instead of calling the user's code (#10476). A 0-arg call of a
user method sharing a name with a required-arg String builtin
(`endsWith`/`includes`/`startsWith`) didn't even compile.

Add crates/perry-codegen/src/lower_call/property_get/builtin_kind_guard.rs:
for a receiver the compiler has proven to be a Date/number/array, keep the
direct builtin call; for any other receiver, evaluate it once (rooted), then
branch at runtime on its actual kind to either the builtin or the universal
method dispatcher, which resolves an own/inherited user method and still
reaches the builtin via the prototype chain for a real Date/number/array.
Known-class receivers are left to the existing class dispatch tower.
Companion changes in lower_array_method.rs, lower_string_method.rs,
number_string.rs and the HIR-side expr_call lowering route the affected
method names through this guard instead of the unconditional builtin path.

Adds test-files/test_gap_10476_builtin_named_guarded_receivers.ts and
test_gap_10476_builtin_named_user_methods.ts (both fail to compile or
mismatch Node on main; both pass byte-for-byte against Node after this
fix), plus unit coverage in builtin_kind_guard_tests.rs and
builtin_named_user_methods_tests.rs.

Known cost: for a receiver whose kind is NOT statically provable (an
`any`-typed or otherwise unproven object calling a builtin-named method),
the new runtime kind check adds real overhead. On a synthetic microbench
exercising 200k iterations of such calls, `dayjs_shape` and `money_shape`
land well outside the usual 20%-of-Node floor (see PR body for numbers);
the prior "fast" baseline numbers for those two shapes were never valid,
since the baseline computed the wrong answer (money_shape) or crashed
(dayjs_shape) instead of doing the work Node does.
`class Sub extends EventEmitter {}` compiled `new Sub() instanceof
EventEmitter` through js_instanceof_dynamic, which never registered or
consulted the class-chain parent edge that Array/Map/Set/Error subclassing
uses. A genuine subclass instance is a real ObjectHeader carrying Sub's own
class id, not a handle and not prototype-linked to EventEmitter.prototype,
so it was invisible to the handle/prototype probes there and always
answered false.

Register EventEmitter's reserved class id as a valid `extends` parent
(builtin_parent_reserved_class_id in instance_misc1.rs), and have the
dynamic-dispatch EventEmitter branch in instanceof.rs delegate to
js_instanceof(value, CLASS_ID_EVENT_EMITTER) first, so it picks up the
class-chain walk; the general prototype walk stays as a fallback for
util.inherits-style shapes.

Extends test_gap_10556_instanceof_native_emitter.ts to cover a direct
subclass, a two-level subclass, the default-import form, and a
util.inherits function-constructor subclass, alongside the existing direct
EventEmitter coverage.
…ation

js_instanceof_dynamic's INT32-tag classification (and value_is_callable's
matching guard) treated any value tagged 0x7FFE as a codegen-emitted class
reference and dispatched its low 32 bits straight into js_instanceof as a
class id. That tag band is also a legal IEEE-754 NaN payload a JS program
can construct directly (e.g. via DataView), so a crafted number sharing the
tag was misread as a class id instead of reaching the unresolved-RHS
TypeError / correctly answering non-callable.

Both call sites now go through class_ref_id, which additionally requires
is_class_id_registered -- the same helper every other class-ref check in
this crate already uses.
x instanceof F was always false when F was an imported non-class
constructor function. Lowering only attached a runtime value to an
identifier RHS for a local, a module function or a native module, so an
imported binding reached codegen as a bare name, resolved to no class id
and folded to js_instanceof(v, 0) - false for every instance. Every
import form was affected (named, default, CJS module.exports/exports.F),
while ns.F, a local alias and the check inside the defining module all
worked.

Imported bindings now lower to their value and take the prototype-chain
path. Codegen keeps the static class-id check for an imported class, and
for a binding that is not a compiled source-module import (its value form
is a placeholder), so the class fast path and the reserved builtin ids are
unchanged.
Ralph Küpper and others added 12 commits September 18, 2026 15:20
find_near and find_near_lent each built a Search and then advanced it: two
view acquisitions per JS regex call, and a Search moved between them, for a
search whose first quantum decides it. Search::run acquires the views once
and runs that quantum in the same borrow, so a decided search never builds a
Search at all.

Run::Paused falls into the existing advance loop unchanged, and a pause from
a Frames/Undo shortage re-raises on the next advance (perex keeps it in
state.blocked) straight into the scratch-growth branch, so both paths behave
exactly as Search::new followed by advance did.

Instructions per call, control subtracted, one Perry commit and one perex
commit differing only by this patch, release build, min of 5 interleaved
rounds on a 16-core Linux host:

  .test() anchored           4,382.2 -> 4,063.7   -7.3%
  .test() inline literal     5,698.2 -> 5,379.7   -5.6%
  exec, two groups           7,566.9 -> 7,237.9   -4.3%
  .test() unanchored miss    2,438.7 -> 2,133.7  -12.5%
  .test() unanchored hit     5,813.1 -> 5,481.1   -5.7%
  200,000-char subject   1,439,713 -> 1,439,003   -0.0%

305 to 332 instructions whatever the pattern, which is the fixed cost of the
call; it vanishes against a subject long enough for matching to dominate.
Every probe returns an identical answer on both arms and on Node 26.5.1.

Wall-clock figures are deliberately omitted: the measuring host carried other
sessions' builds throughout, and round-to-round spread reached 68-244% against
effects of 0.5-25%, enough to invert the sign of a known-good result. Only
instruction counts are quoted.

perex 0.1.9 supplies Search::run and Run. Its src/ is byte-identical to the
commit the figures above were measured against, and re-measuring the released
crate reproduced every row to 0.1 instructions.
…receiver

An inherited Symbol-keyed accessor (Object.defineProperty(Fn.prototype,
sym, ...), an object-literal get [sym]() reached through Object.create,
or a declared class prototype) ran its getter/setter with no receiver at
all, so it observed whatever this happened to be ambient. fastify
5.10.0's Reply.prototype[kRouteContext] getter crashed every HTTP
request with TypeError: Cannot read properties of undefined (reading
'request').

own_symbol_property now resolves to an OwnSymbolSlot (Accessor or Data)
before deciding how to read it, so every prototype-chain walk
(resolve_proto_chain_symbol, explicit_prototype_symbol_slot,
declared_prototype_chain_symbol) can thread the read/write's actual
receiver through to the accessor invocation. Reflect.get/set for a
Symbol key now reach the receiver-aware entry points directly. An
inherited SETTER is now consulted too - obj[sym] = v used to silently
shadow it with a new own data property instead of running it - gated by
a symbol-id-keyed accessor filter (symbol_may_have_accessor) so the
common no-accessor write path stays cheap.
…aths

Two CodeRabbit-flagged gaps in the #10481 receiver fix:

1. get.rs: the parent-closure fallback (a class extending a function
   value, e.g. class Svc extends Context.Tag(id)<...>() {}) called the
   no-receiver js_object_get_symbol_property entry point, so
   Reflect.get(Svc, sym, other) saw the parent closure as this instead
   of other.
2. properties.rs: an inherited symbol setter was never reached on a
   non-extensible receiver - the OBJ_FLAG_NO_EXTEND check returned early
   before the inherited-accessor walk ran. [[Set]] through an inherited
   accessor never creates a new own property, so non-extensibility must
   not block it; moved the inherited-setter check ahead of that gate.
…#10574)

The retained-source pool already shared nested function bodies, but it
disabled intern when unique-string lengths summed past 8 MiB. That is
the tsc case: ~24 MB of overlapping slices of a ~6 MB module, so
__cstring kept one copy per function.

Over-budget modules now still share into the longest parent (the CJS
factory / module wrapper). --function-source=header stores
`function name(params) { /* source elided */ }` instead of the body
for the remaining unique-source win; full interned source stays the
default so fn.toString() is byte-identical.
…urce=header

Header mode resolved a function's name and parameters through
`function_by_id`, which searches only `hir.functions` and class members.
Arrow functions, function expressions and nested declarations lower to an
`Expr::Closure` nested in an expression tree, so every one of them missed
and fell through to `synthesize_function_header("", &[])` — emitting
`function () { /* source elided */ }` with no name and no parameters.

On typescript@5.9.3 that was ~9,600 of 9,644 functions, all interned onto
one shared anonymous string: the linked binary held 48 distinct headers,
of which 27 carried parameters. That breaks the documented contract for
this mode (Angular/Vue-style parameter-name DI, name extraction) and is
worse than Static Hermes, which at least emits `function f(a0, a1)`.

`ClosureHeaders` maps FuncId -> (params, is_arrow) from the `closures`
slice `emit_module_artifacts` already holds, so this is a map build rather
than a new traversal. Arrows now render as `(a, b) => { ... }`: calling an
arrow `function` misreports the function kind on top of eliding the body.

tsc, header mode, after: 4,007 distinct headers, 3,963 of them carrying
parameters (was 27), one empty header left as the genuine unknown-id
fallback. Binary 67,782,520 -> 68,112,752 bytes (+330 KB, +0.5%) — real
headers intern less than one shared anonymous string did. `--noEmit`
output and exit code stay byte-identical to node.

Nested function declarations keep their parameters but not yet their name,
because lowering records `closure_display_names` for function expressions
and object methods but not for nested declarations. `fn.name` is
unaffected and still matches node (`"inner"`), so `Function.prototype.name`
consumers — including tsc's own `Debug.getFunctionName`, which checks the
name property before parsing `toString()` — do not see this.

Also in this commit, from review of the same PR:
- CLI beats env for `--function-source`, matching the precedence documented
  for `--cache-dir`/`PERRY_CACHE_DIR`. The flag is now `Option<String>` so
  an explicit `--function-source=full` is distinguishable from an omitted
  flag; previously an exported `PERRY_FUNCTION_SOURCE` silently won.
- An unrecognised `PERRY_FUNCTION_SOURCE` is rejected instead of quietly
  selecting full source, so a typo no longer retains every function body
  with no diagnostic.
- Reverted the version bump in Cargo.toml/CLAUDE.md per CONTRIBUTING.md
  ("maintainer handles these at merge").

Tests: closures_keep_their_names_and_parameters and
arrow_closures_keep_arrow_syntax both fail on the unfixed code, verified by
removing the fallback and re-running — left `function () { ... }` against
the expected `function (epsilon) { ... }` and `(g, d) => { ... }`.
@proggeramlug
proggeramlug merged commit 68a5454 into main Sep 18, 2026
23 of 25 checks passed
@proggeramlug
proggeramlug deleted the train217r branch September 18, 2026 14:31
@coderabbitai

coderabbitai Bot commented Sep 18, 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: 4beec9e7-a9a9-4887-8a72-a88b48fc9216

📥 Commits

Reviewing files that changed from the base of the PR and between 0058bab and a8f74b0.

⛔ Files ignored due to path filters (3)
  • Cargo.lock is excluded by !**/*.lock
  • build.log is excluded by !**/*.log
  • npm-ci.log is excluded by !**/*.log
📒 Files selected for processing (117)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10564-implicit-this-scope-rooting.md
  • changelog.d/10576-template-literal-part-elision.md
  • changelog.d/10577-array-map-resolved-fill-past-64.md
  • changelog.d/10579-function-source-intern.md
  • changelog.d/10580-search-run-one-entry.md
  • changelog.d/10582-define-property-accessor-attrs.md
  • changelog.d/10584-inline-mask-walk.md
  • changelog.d/10587-argument-list-roots.md
  • changelog.d/10588-mock-timer-dispatch-pin.md
  • changelog.d/10591-builtin-named-user-methods.md
  • changelog.d/10592-instanceof-value-kinds.md
  • changelog.d/10596-instanceof-imported-fn-ctor.md
  • changelog.d/10597-inherited-symbol-getter-receiver.md
  • crates/perry-codegen/src/codegen/artifact_source_text.rs
  • crates/perry-codegen/src/codegen/artifacts.rs
  • crates/perry-codegen/src/codegen/emission_order_tests.rs
  • crates/perry-codegen/src/codegen/function_source_header.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/codegen/retained_source_pool.rs
  • crates/perry-codegen/src/expr/instance_misc1.rs
  • crates/perry-codegen/src/expr/instanceof_imported_rhs_tests.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/lower_array_method.rs
  • crates/perry-codegen/src/lower_call/property_get.rs
  • crates/perry-codegen/src/lower_call/property_get/builtin_kind_guard.rs
  • crates/perry-codegen/src/lower_call/property_get/builtin_kind_guard_tests.rs
  • crates/perry-codegen/src/lower_call/property_get/number_string.rs
  • crates/perry-codegen/src/lower_string_concat.rs
  • crates/perry-codegen/src/lower_string_method.rs
  • crates/perry-codegen/src/temp_root_coverage/mod.rs
  • crates/perry-codegen/src/type_analysis.rs
  • crates/perry-codegen/src/type_analysis/numeric.rs
  • crates/perry-hir/src/lower/expr_call/array_only_methods.rs
  • crates/perry-hir/src/lower/expr_call/builtin_named_user_methods_tests.rs
  • crates/perry-hir/src/lower/expr_call/local_array_methods.rs
  • crates/perry-hir/src/lower/expr_call/mod.rs
  • crates/perry-hir/src/lower/expr_call/url_date_instance.rs
  • crates/perry-hir/src/lower/expr_misc.rs
  • crates/perry-hir/src/lower/lower_expr/arm_bin.rs
  • crates/perry-hir/src/lower/tests.rs
  • crates/perry-hir/src/lower/tests/instanceof_rhs.rs
  • crates/perry-runtime/src/array/iter_methods.rs
  • crates/perry-runtime/src/closure/dispatch.rs
  • crates/perry-runtime/src/closure/dispatch/bound.rs
  • crates/perry-runtime/src/closure/mod.rs
  • crates/perry-runtime/src/closure/registry.rs
  • crates/perry-runtime/src/cluster.rs
  • crates/perry-runtime/src/collection_iter_object.rs
  • crates/perry-runtime/src/exception.rs
  • crates/perry-runtime/src/exception/savepoints.rs
  • crates/perry-runtime/src/exception/savepoints/tests.rs
  • crates/perry-runtime/src/gc/collection_points.rs
  • crates/perry-runtime/src/gc/layout_slot_visit.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/tests/layout_inline_mask.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/call_argument_lists.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/perex_public.rs
  • crates/perry-runtime/src/object/class_registry.rs
  • crates/perry-runtime/src/object/class_registry/accessor_attrs.rs
  • crates/perry-runtime/src/object/class_registry/prototype_objects.rs
  • crates/perry-runtime/src/object/delete_rest.rs
  • crates/perry-runtime/src/object/descriptors.rs
  • crates/perry-runtime/src/object/field_get_set/class_object_props.rs
  • crates/perry-runtime/src/object/field_get_set/entries_shape.rs
  • crates/perry-runtime/src/object/field_get_set/enumeration.rs
  • crates/perry-runtime/src/object/instanceof.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/object/object_ops.rs
  • crates/perry-runtime/src/object/object_ops/define_class_accessor.rs
  • crates/perry-runtime/src/object/object_ops/define_property.rs
  • crates/perry-runtime/src/object/object_ops/has_own.rs
  • crates/perry-runtime/src/object/object_ops/prototype.rs
  • crates/perry-runtime/src/object/this_binding.rs
  • crates/perry-runtime/src/object/util_types.rs
  • crates/perry-runtime/src/proxy.rs
  • crates/perry-runtime/src/proxy/reflect.rs
  • crates/perry-runtime/src/proxy/reflect_misc.rs
  • crates/perry-runtime/src/regex/perex_runtime.rs
  • crates/perry-runtime/src/symbol.rs
  • crates/perry-runtime/src/symbol/accessors.rs
  • crates/perry-runtime/src/symbol/get.rs
  • crates/perry-runtime/src/symbol/inherited_accessor_tests.rs
  • crates/perry-runtime/src/symbol/properties.rs
  • crates/perry-runtime/src/timer.rs
  • crates/perry-runtime/src/timer/tests_inline.rs
  • crates/perry-runtime/src/value/addr_class.rs
  • crates/perry/src/commands/compile/build_cache.rs
  • crates/perry/src/commands/compile/object_cache.rs
  • crates/perry/src/commands/compile/object_cache/object_cache_tests.rs
  • crates/perry/src/commands/compile/run_pipeline.rs
  • crates/perry/src/commands/compile/types.rs
  • crates/perry/src/commands/dev.rs
  • crates/perry/src/commands/run/mod.rs
  • docs/src/cli/flags.md
  • scripts/addr_class_ratchet_baseline.txt
  • scripts/gc_runtime_root_holders.json
  • scripts/local_binding_type_allowlist.json
  • test-files/fixtures/issue_10477_fn_ctor/cjs_default.cjs
  • test-files/fixtures/issue_10477_fn_ctor/cjs_named.cjs
  • test-files/fixtures/issue_10477_fn_ctor/default_fn.ts
  • test-files/fixtures/issue_10477_fn_ctor/default_var.ts
  • test-files/fixtures/issue_10477_fn_ctor/lib.ts
  • test-files/test_gap_10476_builtin_named_guarded_receivers.ts
  • test-files/test_gap_10476_builtin_named_user_methods.ts
  • test-files/test_gap_10477_instanceof_imported_function_ctor.ts
  • test-files/test_gap_10478_object_create_constructor.ts
  • test-files/test_gap_10479_instanceof_value_kinds.ts
  • test-files/test_gap_10480_define_property_generic_descriptor_accessors.ts
  • test-files/test_gap_10480_define_property_generic_descriptor_sloppy.cts
  • test-files/test_gap_10481_inherited_symbol_getter_receiver.ts
  • test-files/test_gap_10556_instanceof_native_emitter.ts
  • test-files/test_gap_array_map_resolved_fill_scale.ts
  • test-files/test_gap_template_literal_leading_part.ts
 _____________________________________
< CORS error? I'll cross that bridge. >
 -------------------------------------
  \
   \   \
        \ /\
        ( )
      .( 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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Attributes-only Object.defineProperty/defineProperties on a class accessor makes it read-only: the setter is lost and the attributes are not applied x instanceof C segfaults when x is a short (1–5 byte) inline string — crashes ajv 8 compile() and every fastify route with a schema Object.create(proto).constructor is a bogus class reference instead of proto.constructor; ctor instanceof ctor then segfaults (lodash isEqual(cloneDeep(x), x)) x instanceof F is always false when F is an imported non-class constructor (ES5 function, factory-made function, CJS module.exports = F); namespace access and local aliases work User methods named like Date/Number/Array built-ins (getTime, toFixed, toISOString, toSorted, …) are compiled as the built-in regardless of the receiver — the user method never runs

1 participant