Skip to content

Merge train 207: hit-path wave 2, GC relocation and weak-holder facts, module path memo, globalThis new, Windows link (v0.5.1585) - #10398

Merged
proggeramlug merged 30 commits into
mainfrom
train207r
Sep 17, 2026
Merged

proggeramlug merged 30 commits into
mainfrom
train207r

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

This train lands #10378, #10381, #10347, #10375, #10384 and #10388 as v0.5.1585, on fdc437f966.

26 source commits. Two were already on main (#10381 stacks on #10371, which landed earlier) — confirmed by finding their patch-ids on main, not assumed.

Train repairs

A conflict with #10387, landed an hour earlier in train 206. Both it and #10381 edit array/mod.rs's re-export list: #10387 adds reclassify_array_numeric_layout_from_slots, #10381 removes transfer_array_numeric_layout along with the function and its only caller at gc/layout.rs:1274. The merge keeps the first and drops the second. A sorted union — the usual reflex for an entry list — would have kept a symbol that no longer exists and broken the build; this was verified by compiling. rustfmt then rewraps the list, since it holds one fewer symbol; that is carried as a separate whitespace-only commit.

cargo fmt --check: #10375's split added builtin::lower_global_intrinsic_new after the new:: group, where rustfmt orders it before field_init. Whitespace only.

The holders gate: #10347's CANONICAL_MODULE_PATHS and CANONICAL_MODULE_DIRS are new identity-ratcheted thread-locals, so lint fails until each carries a verdict. Both memo filesystem text only — String keys with String/PathBuf values, no JSValue, NaN-boxed word or arena pointer — so neither is a GC root. Recorded as researched not_a_gc_pointer verdicts rather than pinned as frontier debt, because the inventory states plainly that a frontier pin is not a GC-safety verdict.

Validation

Validated head 9ecf2b0ffd. Five-package release build pinned and hash-verified, and re-verified after the gap run so nothing rebuilt underneath the fixtures.

  • Crate suites: codegen 1570, runtime 3983, stdlib 139, hir 433, transform 137, cli 1139 — all green except main's one known runtime failure (gc::tests::heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds). The counts rise exactly where these PRs add tests, which is the positive check that the new tests are registered and running.
  • All nine preflight gates pass, including both raw-handle ratchet invocations, the holders gate, and the unrooted-local-shape check against main.
  • Gap: 13 filters — instanceof, prototype, param, template, subclass, weak, array, string, global, module, spread, class, concat.

Every red attributed

--filter is a substring match over all fixtures, so these runs also select test_issue_*/test_ws_*/test_perry_* names that CI's gap suite (--filter test_gap_) never runs; stale snapshot entries for those surface as noise. Each was chased to a mechanism:

Assembly verification

Every one of the 26 source commits is accounted for by patch-id and authorship: 22 exact matches, 2 conflict-resolved with the reason recorded per commit, 2 confirmed already on main by patch-id lookup. An unaccounted commit fails the proof rather than being labelled "probably already landed".

Before merging, the pushed head and unchanged main are checked again. After merging, the rewritten commits are checked for preserved authorship and the main tree must match the validated train exactly.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed new globalThis.X() so it consistently uses the global constructor even when a local binding has the same name.
    • Improved correctness for garbage collection, array subclass operations, in checks, string indexing, and packed numeric loops.
  • Performance

    • Reduced module-loading filesystem work and improved several array, string, lookup, class, and garbage-collection operations.
  • Compatibility

    • Resolved Windows build issues and preserved expected behavior across supported runtime scenarios.
  • Version

    • Updated to version 0.5.1585.

Ralph Küpper and others added 30 commits September 17, 2026 03:01
Base: 6c9e2a6 (PR #10371's head), which this stacks on.

`layout_transfer` runs for every evacuated object on every copying minor, both
old-generation evacuations and `js_array_grow`. All four callers copy the
source header's `_reserved` into the destination first — the minor through
`reserved_with_copied_survival_age`, which rewrites only the age bits — so
every layout fact a header carries has already arrived: the layout state,
`GC_LAYOUT_ALL_POINTERS`, the raw-f64 / holes flags, `GC_ARRAY_ELEMENT_SHAPE`
and `GC_OBJ_TYPED_LAYOUT_INTACT`.

The funnel re-derived that half anyway, per object: two header
classifications, a rewrite of bits that were already equal, an out-of-line
call per array and per object, the #7510 flag-and-filter gate evaluated twice,
and — for every intact object — a ShapeId-keyed `SHAPE_LAYOUTS` probe whose
answer a relocation cannot change. Measured with gdb `stepi` on the #10362
retained-graph fixture: 160 instructions per moved array, 245 per moved
object, 518M in total (4.2% of the run), none of which reached a side-table
record. Both per-object maps held one key (`PERRY_LAYOUT_DIAG`).

The contract the callers always satisfied is now the funnel's stated contract,
asserted in test and debug builds, and the funnel moves only what a header
cannot carry: the element-shape record (#7480), the residual static-prototype
owner registry (#9304), and the per-object `TYPED_LAYOUTS` / `LAYOUT_SLOT_MASKS`
entries (#7510). Each is gated inline by the bit or latch that governs it, and
the record moves themselves live in a `#[cold]` slow path.

* `gc/layout/transfer.rs` (new): the funnel, its gates and the contract
  assertion. `gc/layout.rs` drops to 1919 lines, off the 2000-line cap.
* `gc/layout_tables.rs`: `per_object_layouts_may_hold_either` answers the
  #7510 gate for both maps and both addresses in one hot-slot resolution.
* `object/prototype_chain.rs`: the registry latch is readable without the call.
* `array/header.rs`, `array/element_shape.rs`: `transfer_array_numeric_layout`
  and `clear_element_shape_ptr` were only ever called by the header half of the
  funnel and are deleted. That also drops a spurious
  `invalidate_representation_change` a hole-tolerant array took on every move
  (the transfer cleared the flag it was about to set again); the counter feeds
  `PERRY_TYPED_FEEDBACK_TRACE` output only.

Behaviour: one change, the lazy intact downgrade. The bit is a fact of the
object and of two tables a move does not touch, so re-asking at move time could
only downgrade objects that happen to move. The state it cleared — intact while
no descriptor is reachable — is legal and handled: `shape_install_shared`
poisons a shape's shared entry and leaves "any still-INTACT siblings" to fall
back, #8115 clears the bit at the first contradicting store, the trace falls
back to `GC_LAYOUT_UNKNOWN` and scans every slot, and the query helpers answer
"no descriptor". An unmoved sibling keeps its bit today, so nothing could have
depended on the move clearing it.

`test_poisoned_shape_intact_and_per_object_record_survive_a_copying_minor`
builds that exact state and drives it through a real copying minor: the moved
receiver keeps the bit, answers every query as it did before the move, and its
child survives and is rewritten, while the sibling that poisoned the shape
keeps its per-object record across the move. It fails on the parent commit (the
old funnel clears the bit) and fails again under a sabotaged funnel that does
not move the per-object records.
`test_layout_transfer_requires_the_relocation_header_copy` pins the contract.

instructions:u, min of 5, base 6c9e2a6 vs this:
  gc3       12,275,577,365 -> 11,880,548,143  -3.22%
  w1000      1,081,647,372 ->  1,054,559,901  -2.50%
  w5000      2,018,014,139 ->  1,919,532,121  -4.88%
  w20000     4,994,632,943 ->  4,797,474,484  -3.95%
  oldyoung   1,503,368,405 ->  1,471,395,073  -2.13%
  alloc-only   320,266,214 ->    320,265,756   0.00%

That is 154 instructions per relocation on gc3 (2,560,042 relocations on both
arms, counted with uprobes), and 71% of the ceiling a full knockout of the
funnel measured. The cold path is entered 1,213 times, 0.05% of relocations.
object_alloc_class_inline_keys_impl calls register_class whenever
parent_class_id != 0, and codegen ALSO emits one js_register_class_parent per
inheriting class in the init prelude, so by the time user code allocates, the
edge is always already published.

Re-publishing it bumped the process-global prop_plan epoch -- discarding every
cached store plan in the program -- then took a write lock on CLASS_REGISTRY
and re-inserted the same pair. prop_plan_epoch_bump's own contract says its
callers are rare cold paths by construction; an allocation is not one.

An unchanged edge now answers from the dense parent mirror, the same indexed
load every chain walk already uses, and returns. A new or CHANGED edge falls
through to the full publication, so re-parenting still flushes --
test_gap_subclass_alloc_registration pins that, and the same test covers a
re-parent through Object.setPrototypeOf and a second class sharing the parent.

This carries NO measured win, and that is deliberate to state. Every
allocation shape I could build either takes the inline allocator -- which
never calls register_class, so the fixture is vacuous -- or measures the same
in both arms: a subclass allocated through the dynamic-class entry is 7,298
instructions before and 7,296 after, and the child-minus-parentless difference
is +3,362 before and +3,370 after. The work removed is real at the source
level; what it is worth in a running program is unmeasured here.
array_subclass_fast_pop_validated bumped the process-global prop_plan epoch on
every pop of an Array subclass, discarding every cached store plan in the
program. The bump sits right after clear_packed_subclass_numeric_proof, which
is idempotent: a pop loop retires a proof on its FIRST iteration and nothing
afterwards, so every later pop paid a program-wide invalidation for a change
that did not happen.

The retire now reports whether it actually retired one, and only that answer
flushes. The shape-version install below it needs no bump of its own: the
sibling push path (array_subclass_fast_push_one_validated) performs the same
install_cache_carried_object_shape_version and has never bumped, and a
per-object shape version is not an input to the store-plan verdict, which is
keyed on (class_id, interned key) and invalidated by vtable mutation,
descriptor/prototype changes and GC.

Unit test pins the contract in both directions: the first retire reports true,
later ones report false, and retiring nothing leaves the epoch where it was.
The gap fixture interleaves pops with stores through the same plans, adds a
prototype setter mid-loop (the change the flush exists to expose), freezes a
receiver after pops, and mixes element kinds.
A class-typed parameter is validated by walking every declared field on its
inheritance chain by name. Measured at ~326 instructions per field, so a
3-field class pays ~1,000 per call and an 8-field class ~2,600.

For a field declared `number` the walk re-derives what the object header
already states. `expr/class_field_inline_guard.rs` relies on the same
implication to skip its guard call: "intact bit set + class_id/keys match"
implies "slot K is raw-f64". So when EVERY field on the chain is a raw-f64
candidate, (class chain reaches C, GC_OBJ_TYPED_LAYOUT_INTACT) carries the
whole proof, and the descriptor emits OP_CLASS_NOMINAL instead: two header
facts, no field names serialized at all.

One non-numeric field puts the whole chain back on the walk. The intact bit
is a raw-f64 claim; it says a string field's slot is in the POINTER mask,
which is not "it holds a string" — and a clone that inlines `s.length`
trusts exactly that. A fieldless class is excluded too: it has no value fact
to carry, so demanding the bit could only reject receivers the walk accepts.

Instructions per call, both arms re-run in the same window against
base1579 (dynamic-dispatch driver, 2e6 calls, best of 3):

  1 number field    5,309 -> 4,952   -6.7%
  3 number fields   6,174 -> 5,436  -12.0%
  8 number fields   7,808 -> 5,838  -25.2%
  string + number   6,822 -> 6,749   -1.1%  (control: stays on the walk)

The fast route is proven entered rather than inferred: three receivers with
identical field values and identical output cost 5,571 (class-allocated),
8,646 (Object.create(C.prototype)) and 12,507 (a real instance whose intact
bit a string store retired) instructions per call. A guard that rejected
everything could not produce that spread.

Also corrects the #8099 note that identity alone "bought nothing". That
verdict is real but local to tree/tree_wide, whose reference-typed fields
route both bodies through js_typed_feedback_class_field_get_guard. Codegen
never reads these field nodes: the clone is compiled with
SpecParamGuard::proof, which is `param.ty`, and forcing `fields` empty
leaves all 24 emitted clone bodies across a 16-function probe set unchanged.
The descriptor is the runtime ENFORCEMENT of the proof, not the proof.
`declaration_guards` refused a descriptor whose validation work grows with
the input — unless the body contained a loop, on the theory that array
reducers and similar consumers amortize validation over their own traversal.

They do not. The walk is a SECOND full pass over the same array, and the
clone's saving per element is smaller than the walk's cost per element, so
the guarded arm loses at every length and loses by MORE the longer the array
gets — the opposite of what amortization predicts, which is why no array
length rescues the rule.

Instructions per call against base1579, both arms re-run in one window
(dynamic-dispatch driver, best of 3):

  1600 elements   Pt[]      1,539,568 -> 410,171   -73.4%
                  string[]    604,460 -> 477,884   -20.9%
  16 elements     Pt[]         19,191 ->   7,624   -60.3%
                  string[]     10,308 ->   8,737   -15.2%

Controls, same window, same binaries — the identical bodies taking an
unproven parameter, which never had a descriptor to lose:

  1600 elements   Pt[] via any   555,495 -> 551,116   -0.8%
                  string[]     531,291 -> 529,172   -0.4%

Refusing is the win: the fallback is the generic body, and a refused
parameter still keeps its declared type, so it lands BELOW the `any` twin
rather than at it.

There is no O(1) substitute to reach for instead. A raw-f64 layout flag could
settle `number[]`, but that case never had a guard to speed up — wave 1's
`spec_clone_consumes_no_proof` already drops it, because an index loop over
a number array lowers identically with and without the proof. The cases that
still carried a walk were `string[]` and `C[]`, and no header bit claims
"every element is a string".

The body is no longer an input to the decision, so `declaration_guards` no
longer takes one and `body_contains_loop` is deleted. That makes the old
behavior unexpressible rather than merely untested.
A call site packing trailing arguments into a rest or `arguments` bundle
emitted `js_array_alloc` plus one `js_array_push_f64` per element. Every push
re-classified the receiver, re-resolved forwarding, re-noted the slot layout
and re-checked the barrier — for a three-element bundle, 857 instructions of
construction for numbers and 1,839 for objects.

An array literal of the same width has been built inline since #5391: one
bump allocation, a header that already claims pointer-free (and raw-f64 when
every element is a plain double), then N stores. A bundle is the same shape
with its values already lowered, so it now uses the same emitter, extracted as
`emit_array_from_lowered_values`.

Rooting is unchanged and still load-bearing (#7154): every element is re-read
from the group's slots before the allocation, whose slow arm collects, and the
finished array is adopted into the same scope so a second bundle's allocation
cannot sweep the first. Bundles wider than the inline threshold keep the push
loop.

Per call at a static call site: `f(1, 2, 3)` into `...xs` 857 -> 92,
`f(o, o, o)` 1,839 -> 494.
`Array.prototype.map` filling a plain result array paid the ownership and
forwarding proof of its own receiver three times per element:
`clean_arr_ptr` inside the raw-f64 canonicalization, an
`addr_class::try_read_gc_header` inside the layout-note elision check, and a
third header read inside the numeric-layout note. The caller has just
re-derived the live head from its root for this iteration, so one read answers
all three.

`fill_resolved_array_slot` keeps the protocol `note_array_slot_layout_only`
runs — canonicalize under a raw-f64 layout, store, retire the numeric claim on
a non-number, note the slot layout unless that note is provably a no-op, and
keep the born-old remembered-set edge — and falls back to the fully
re-classifying helper for any head it cannot prove from that one read
(unrecognized, or forwarded).

`a.map(v => v + 1)` over 16 elements: 6,265 -> 4,234 instructions.

The fixture covers both this and the rest-bundle change: element kinds, holes,
-0/NaN, object identity, a callback that mutates and grows its source, a
result longer than the 64-element branch, frozen and subclass receivers, and
`arguments`. It matches node 26.5.1 normally and under the seeded moving-GC
stress (4,028 copying minors on the object path).
…being on

`js_typed_feedback_numeric_array_push_guard` is called on every `a.push(v)`
that takes the guarded numeric tier. It built an `Observation` — a
`gc_header_for_user_addr` lookup, a length read, a `classify_array` walk over
the receiver's element layout and a `stable_value_kind` — and handed it to
`guard_observe`, which throws it away and returns `contract_valid` unchanged
whenever typed-feedback recording is off. Recording is off by default, so that
was the whole cost of the call.

This is #5094's gate. Every sibling array guard already carries it
(`plain_array_index_get_guard_impl`, the four packed loop guards, both index
set guards, `js_typed_feedback_array_get_f64`); the push guard and two
declared-but-unemitted wrappers were the last ones that did not. The gated
branch returns exactly what `guard_observe` would have returned in that mode,
so the observing path and the recorded feedback are untouched.

Measured on the dynamic-dispatch census, best-of-5 over 200k calls, minus the
zero-iteration run and the same-arity identity baseline:

  arrPushPop   970.3 -> 861.3   -109.0  (-11.2%)

No other probe moved: arrSet +0.3, arrLen -1.4, arrSumForOf +0.3,
arrSumIndex +0.8, anyArrGet -1.6, objArrFieldGet +0.7, f64Get -0.5.

`typed_feedback_enabled()` is hardcoded `true` under `#[cfg(test)]`, so the
runtime unit tests only ever take the observing path and cannot cover the new
branch. `test_gap_numeric_push_guarded.ts` covers it end to end instead, where
recording is off: it drives every receiver shape the guard declines — frozen,
sealed, non-extensible, non-writable length, an index accessor, sparse, a
subclass, a Proxy, a mid-program `Array.prototype` index setter — plus a
growing dense array and a TypedArray/Buffer receiver, and matches node both
normally and under GC stress (8 copying minors, 6720 objects moved,
from-space quarantine armed, evacuation verified).

Two cases assert resulting state rather than a throw, both pre-existing gaps
that behave identically before this change: Perry does not throw when pushing
to a non-extensible array, and `map` does not preserve a subclass receiver.
…eceiver

The packed-numeric fast clone re-derived the element base on every iteration:
reload the rooted slot through the `asm "", "=r,0"` launder, mask the handle,
load `size` at `-4` and `capacity` at `+4`, shift, add, subtract — about twenty
instructions to reach one `load double`. The launder is opaque to LLVM by
design, so LICM could not hoist any of it even though none of it varies.

The clone already publishes a pre-masked receiver handle for exactly this: the
poll-refreshed receiver cache `acc_scope.hoist_receivers` installs, which the
packed STORE path (`expr/index_set_packed_loop.rs`) has read through
`receiver_descriptor_handle_i64` since it was added. The read path was simply
never converted. It is now, and the element-base chain hangs off a plain `i64`
alloca nothing in the clone writes, so LLVM hoists it into the preheader.

Soundness is #9379's, not a new claim: the matcher admits no call, closure or
await; reads and writes lower to bare `double` load/store on existing slots, so
no growth, no realloc and no barrier; and the back-edge poll is suppressed for
this clone for exactly that reason. With no safepoint the receiver cannot move
and its header words cannot change for the clone's whole dynamic extent. The
fact is dematerialized before the slow clone is lowered, so nothing leaks past
the clone it was proved for, and a receiver with no hoisted cache still gets
the inline bitcast-and-mask from the same helper.

Measured on the dynamic-dispatch census, best-of-5 over 200k calls, minus the
zero-iteration run and the same-arity identity baseline (16-element arrays):

  arrSumIndex   659.7 -> 584.4   -75.3  (-11.4%)
  arrSumForOf   892.9 -> 845.7   -47.2  ( -5.3%)

No other probe moved: arrSet -1.2, arrLen +0.2, arrPushPop -0.5,
anyArrGet +0.2, objArrFieldGet -0.5, f64Get +0.8, arrMapInc -11.

`test_gap_packed_loop_cached_receiver.ts` covers every shape that leaves the
clone — a side exit on a non-numeric element, a hole read, a foreign index, a
receiver grown during the loop, an allocating body that puts a real safepoint
back, plus frozen, subclass and typed-array receivers — through both the
indexed and the `for…of` form. It matches node normally, under GC stress
(136 copying minors, objects moved, from-space quarantine armed, evacuation
verified) and at `PERRY_GC_SCHEDULE_RATE=1` with quarantine depth 64.

`cargo test --release -p perry-codegen --tests`: 36 suites, 0 failures.
Root-dominance corpus: 196 modules, 15430 root stores, exactly the 2 known
`test_gap_gc_regexp_receiver_rooting` violations, 0 moving-minor reachable.
`for (const v of a)` over a packed numeric array paid an incremental-mark
root-shading test on every element. `const v = a[i]` is an array alias, so
`enable_persistent_shadow_slot_for_array_alias` gives it a persistent shadow
slot, and the only per-store cost of such a slot is
`emit_persistent_shadow_root_barrier` — an atomic load of
`PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT`, a compare and a branch, plus the
block split, once per element.

`expr_is_known_non_pointer_shadow_value` exists to skip exactly that for a value
that cannot be a heap reference, and it already admits a masked-window element
read on this reasoning. The packed-numeric loop fact is the same class of proof
and was simply not listed: the entry guard proved a dense raw-f64 (or packed
i32/u32) plain Array, the clone it scopes has no safepoint and no growth
(#9379), and the fast condition bounds the counter by the length read at loop
entry — so `arr[i]` reads a raw numeric word and shading it is a no-op. The fact
is dematerialized before the slow clone lowers, so this never leaks past the
clone it was proved for.

Restricted to offset 0. `arr[i ± c]` is in bounds only under a range-validated
fact, and an out-of-bounds element read consults the prototype chain, where
`Array.prototype[7] = {}` is a genuine heap reference that must stay rooted. The
counter read cannot leave the array; the offset read can, and keeps its barrier.

Measured on the dynamic-dispatch census with both arms re-run in the same window
(best-of-5 over 200k calls, minus the zero-iteration run and the same-arity
identity baseline, 16-element arrays):

  arrSumForOf   895.0 -> 813.9   -81.1  (-9.1%)

of which -31.5 is this change and the rest the element-base hoist it stacks on.
`arrSumIndex` is unaffected (-76.8, the hoist alone) because an indexed loop
binds no element local. No probe regressed: largest increase +6.1 (strTemplate,
untouched), and the five largest are +4.8..+6.1 — the noise floor.

`packed_loop_shadow_barrier_tests.rs` pins both directions in emitted IR, and
each fails without this change (1 shading test where 0 is required): the counter
read's binding shades nothing in `for.packed_f64_fast.body`, the offset read's
binding still shades exactly once in the `packed_f64_loop.foreign.inbounds`
block its bounds check creates, and a third test puts both in one clone so
neither arm can pass vacuously. Every test panics if its block was not emitted,
so a count cannot be taken over a clone that never ran.

`test_gap_packed_loop_proto_index_rooting.ts` is the end-to-end half: it installs
an object at `Array.prototype[7]`, has an `a[i + 3]` loop over a length-5 array
read it out of bounds, retains that capture, then churns the nursery for 60
rounds re-running both loops and asserts the object's identity and payload
survive. It matches node normally and under GC stress with
`PERRY_GC_FROMSPACE_SCAN_ABORT=1`:

  seed=37 rate=0.2   2524 from-space scans, all clean, dangling=0,
                     missing_rewrites=0; 5048 copying minors, max 6720 objects
                     moved; live set up to 33170 objects / 160486 words
  seed=91 rate=1.0  12694 from-space scans, all clean, dangling=0,
                     missing_rewrites=0

`cargo test --release -p perry-codegen --tests`: 36 suites, 0 failures.
Root-dominance corpus: 168/168 sources, 196 modules, 15430 root stores, 0
violations on the `--moving-only` CI arm with 40/40 seeded violations caught.
`s[i]` walked js_string_index_get_boxed -> js_string_index_get ->
js_string_char_at -> ascii_char_string: a thread-local canonical-table lookup
returning a heap StringHeader that the caller immediately NaN-boxed, and, for a
short-string receiver, a full materialization of the receiver onto the heap
first just to index it.

An ASCII receiver's character is one byte, which is exactly a short-string
value, so both ends pack inline: 272 -> 223 instructions per read on a heap
receiver and 158 -> 109 on a short one. The value is unchanged — a short and a
heap string with the same bytes compare equal everywhere — and two equal
characters now share one bit pattern instead of one pointer.
ordinary_has_property asked object_static_prototype up front, spending a
shape/registry probe on every [[HasProperty]] — including the common walk that
finds an own key on the first hop and returns from the loop. Its only consumer
is the class-vtable fallback reached after the whole walk misses, and the walk
runs no user code, so the answer cannot change in between.

"a" in {a,b}: 957 -> 905 per call; a 40-key own hit 970 -> 917; a miss
4,773 -> 4,746.
The template desugaring wraps every substitution in StringCoerce so it is
toString-first rather than +'s valueOf-first (#6078), but js_string_concat_chain
formats each part itself. For a part already proven a string the wrapper is the
identity, and the coerce only mints an intermediate heap string for the helper
to copy and drop.

`${s}:${n}` 1,437 -> 1,411 instructions per call (one of its two
js_string_coerce calls is gone). The number substitution keeps its wrapper: a
declared-number parameter is not provably non-pointer — an annotation can lie —
and String(obj) and the helper's slow path can disagree for an object with both
valueOf and toString.
`in` was the last common operator with no cache slot at all: `"k" in o`
lowered to a bare `js_in_operator` call that re-derived the receiver's keys
array from its ShapeId (a shape-slab probe) and re-scanned it, every time.
That is 955 instructions for an own-key hit and 980 for one on a 40-key
object, against ~15 for reading the same key through the property PIC.

The answer is a property of the SHAPE, not of the object — two objects with
the same ShapeId have the same keys array — so a site with a literal key
caches one ShapeId and answers `true` inline when the receiver still carries
it. Everything else calls `js_in_operator_presence_ic`, which computes the
real answer (including the TypeError a primitive right operand owes) and may
arm the site; the inline path can only ever produce `true`.

Only positives are cached, and they need no prototype-chain epoch: the cached
claim is about an OWN key, so `Object.setPrototypeOf`, a late `Proto.x = 1`
and a `delete Proto.x` cannot falsify it. A negative would be a claim about
the whole chain and there is no chain epoch in this runtime to key one on, so
`"zz" in o` still calls the runtime every time. Invalidation of a positive
needs only that losing the key moves the receiver off the guard: a compacting
delete publishes a new ShapeId, and a tombstoning delete (#9064) keeps the
ShapeId but sets OBJ_FLAG_STABLE_TOMBSTONES, which the guard rejects. Shape
ids are allocated monotonically and never reused, and their range is disjoint
from every class id, so a stale stamp can only miss. The cache holds two
integers and no heap pointer, so it is not a GC root.

Instructions per call, base v0.5.1579 vs this branch, both arms rebuilt and
re-measured in one window (3M iterations, best of 3, minus a zero-iteration
run; controls identity 177.0 -> 177.1, id2 149.1 -> 145.9):

  "a" in {a,b}        967 ->  41
  "k39" in <40 keys>  980 ->  41
  "zz" in {a,b}     4,783 -> 4,668   (miss: answer is not cacheable)
  "toString" in o   2,377 -> 2,351   (inherited: site declines after 8 tries)

test_gap_in_operator_presence_cache.ts proves the invalidation against a warm
cache: delete, re-add, 500 delete/re-add cycles, a prototype swapped for
another and for null, a key appearing and disappearing on the prototype, an
own key deleted so the prototype shows through, eight shapes through one site,
descriptors, accessors, a Proxy `has` trap that answers false for a key the
target has, and a delete performed inside the hot loop. It matches Node under
`PERRY_GC_FROMSPACE_SCAN_ABORT=1` with seed 37: 3,932 copying minors,
3,932 clean from-space scans, dangling=0, missing_rewrites=0.
`o instanceof C` answers a miss by falling through a ladder of built-in
probes. Two steps into that ladder sat this pair:

    let candidate_proto = class_decl_prototype_object(cur);
    let target_proto    = class_decl_prototype_object(class_id);
    if !candidate_proto.is_null() && !target_proto.is_null()
        && object_has_user_prototype_override(candidate_proto) && ...

Both are class-registry reads — thread-local + RwLock + map — and both ran
eagerly, on every call that reached the ladder, which is every MISS. They
exist for one case: `util.inherits(Derived, Base)`, which re-points a
prototype without creating an extends edge between the constructors. The
question they set up to ask, `object_has_user_prototype_override`, is cheap:
two dependent loads off the receiver's meta record. The expensive half was
only there to find an object to ask it about.

`OBJECT_META_FLAG_USER_PROTO_OVERRIDE` is set at exactly one site, so a
process-wide latch stored just before it answers for every receiver at once.
A program that never re-points a prototype — which is nearly all of them —
now pays one acquire load instead of two registry probes. Set, never cleared,
and published before the flag it guards (the discipline `OBJECT_PROTOTYPES_-
NONEMPTY` above it already uses), so it is conservative in the safe
direction: a false positive costs a probe pair, a false negative is
impossible.

Instructions per call, base v0.5.1579 vs this branch, both arms rebuilt with
identical flags and measured in one window (3M iterations, best of 3, minus a
zero-iteration run; control `idle` 11.0 -> 11.0):

  a instanceof B        (miss)            669 -> 498
  c3 instanceof B       (miss, 4 deep)  1,062 -> 891
  a instanceof A        (hit)               74 ->  74
  c3 instanceof A       (hit, 4 deep)      270 -> 270
  map/error/plain-object misses            flat

No class-id band test is involved, which is the better outcome: the
"is this a user class id" question is not on the path at all. (For the
record, it would have been sound — all 87 reserved class-id constants fall
inside the two documented bands, 4 in 0x7FFF_FF00..=0x7FFF_FFFF and 83 at or
above 0xFFFF_0000, while user ids are a dense sequence from 1.)

test_gap_instanceof_miss_ladder.ts covers the latch's own hazard — a
`setPrototypeOf` performed AFTER the sites are hot, and `util.inherits` with
a method resolved through the linked prototype — plus `Symbol.hasInstance` in
both its static-method and defineProperty forms, a Proxy, a bound
constructor, Map/Error/Promise/Array/Function/Object, subclasses of Map and
Error, a 4-level chain, structurally identical twins, null-prototype
receivers, primitives, and a non-callable right operand. Matches Node under
`PERRY_GC_FROMSPACE_SCAN_ABORT=1` with seed 37: 2,182 copying minors, 4,364
clean from-space scans, dangling=0, missing_rewrites=0.

The fixture deliberately does not assert five behaviours it found to diverge
from Node on this commit's PARENT; each carries a comment saying so, and they
are reported separately rather than fixed here.
std::fs::canonicalize is a full realpath - a readlink per path component, every
time - and module registration called it once per module. Sibling modules share
every ancestor, so opencode --version issued 86,745 readlink calls over 9,467
distinct paths: 98.7% of every syscall it made, and 0.32s of system time.

Directories are resolved once and reused, taking a 400-module fixture from
4,010 readlink calls to 405. Paths with . or .. components, and basenames that
really are symlinks, still go through std::fs::canonicalize.

This is a wall-clock fix, not an instruction-count one: it moves instructions:u
by 0.04%.
… binding shadows X (#10359)

`globalThis.X` names the global object's property, never a module binding,
but every arm that lowered the qualified construct by NAME resolved it
against the module's bindings. With `import { Event } from "./ev"` in scope,
`new globalThis.Event("ping")` built the imported class, while the aliased
`const E = globalThis.Event; new E()` form was correct.

Four by-name paths, all now back off when a binding shares the name:

- the #6726 re-dispatch through the bare-identifier arm ignored the shadow
  only for the dedicated intrinsic nodes (SetNew, ErrorNew, ...); names with
  none (Event, Request, MessageChannel, a multi-argument typed array) reached
  the by-name tail (`New { class_name }` / FuncRef / LocalGet). The tail now
  builds `NewDynamic` over the global property instead;
- `lower_new_member_native`'s globalThis fetch and MessageChannel arms;
- `lower_new_non_ident`'s global-object fetch arm (`const g = globalThis;
  new g.Headers()`);
- codegen's `try_static_class_name` folded a `globalThis.X` callee onto a
  same-named module class, import or class alias (`class Widget {}` plus
  `globalThis.Widget = class {...}` built the module class).
… `new globalThis.X()` (#10359)

When `try_static_class_name` declines a `globalThis.X` callee because a
module class, class alias or import shares the name, the construct fell
back to reading the property and constructing its runtime value. That is
right for Event/Request/Headers, but several intrinsics are only complete
through codegen's builtin table: ReadableStream/WritableStream/
TransformStream came back without methods and WebSocket without
readyState. Route the declined global-object callee through
`lower_builtin_new` (bypassing module classes) — the construct the
unshadowed form reaches — and keep the runtime read only for names no
builtin arm owns.
…kend

Two Windows-only breaks on main (CI run 35103968637).

windows-arm64-build, 7 x LNK2019 on js_lru_cache_{new,get,set,has,delete,
clear,peek}: perry-runtime's lru_subclass module declares the cache ABI as
extern "C" and leaves it to whichever provider the PROGRAM links. A Rust
binary that links perry-runtime without one still carries the references,
and the Windows legs build two such binaries -- the perry compiler and the
crate's own --lib test harness. Elsewhere that is invisible because ld64
-dead_strip / ld --gc-sections drop the thunks (and their references) before
the linker reports; link.exe resolves before /OPT:REF, so the same inputs
are hard unresolved externals there.

A Cargo feature cannot express "this link has no provider": the job builds
-p perry -p perry-runtime-static -p perry-stdlib-static in ONE invocation, so
perry-stdlib's perry-runtime/stdlib is unified onto the copy of perry-runtime
that the compiler links, and anything gated on it (stdlib_stubs, an
external-*-symbols flag) is compiled out in exactly the failing configuration.
Use MSVC's weak default instead: an #[cfg(all(windows, target_env = "msvc"))]
module emits one /ALTERNATENAME:js_lru_cache_<op>=perry_lru_cache_absent_<op>
directive per symbol through .drectve, with no-op fallbacks that report via
stub_diag. link.exe substitutes an alternate only for a symbol still undefined
after every input is read, so a link that does carry perry_stdlib.lib or the
ext archive binds the real implementation -- unlike an unconditional
definition, which would duplicate or silently shadow it.

windows-build, E0425 cannot find function `reorder_child` in module `widgets`:
perry-ui-windows-winui #[path]-includes perry-ui-windows' ffi/mod.rs, so
widget_layout_extras.rs resolves widgets:: against winui's own widgets.rs,
which had add_child_at / remove_child / clear_children but no reorder_child.
Add it in that module's shape -- delegate to the Win32 backend when Fluent is
inactive, otherwise reorder the node's child list under with_node_mut, with
the Win32 implementation's guards. perry_ui_widget_reorder_child is a live
UI dispatch-table entry that every other backend implements, so cfg'ing the
caller out would be a regression rather than a fix.
…10362)

Base: 33690c5 (main).

`CopyingNurseryCollector::visit_slot_with_parent` called
`weakref::is_weak_target_trace_slot(parent, slot)` for every slot of every
traced object: an out-of-line call that re-reads the parent's `obj_type` and
`class_id` and then rejects on class, for every slot of every ordinary object.
The per-object form already existed — #10182 gave `weakref::is_weak_holder_header`
to the full mark in `gc/trace.rs`, and the copying minor never got it.

The fact is read LAZILY, on the first slot that needs it, not eagerly per
object. Eagerly regressed all six fixtures (+0.88% to +5.09% instructions):
a great many traced objects — strings, pointer-free arrays — have no slot to
visit at all, and paid two lookups for an answer nobody then asked for.

instructions:u, min of 5, same host, base vs this:
  gc3       12,572,426,882 -> 12,447,923,597  -0.99%
  w5000      2,049,874,315 ->  2,016,880,468  -1.61%
  w20000     5,094,865,215 ->  5,025,205,279  -1.37%
  oldyoung   1,525,472,951 ->  1,508,726,393  -1.10%
  w1000      1,090,121,743 ->  1,081,323,176  -0.81%
  alloc        320,285,196 ->    320,285,242   0.00%

Peak RSS within 0.1% on all six. Max pause (min of 5, w20000 interleaved over
9) better or flat on all six. On a control isolating the slot term — 60k records
whose fields all point at one shared object, against the same records holding
doubles — the pointer-slot-attributable instruction count falls 4.4% at K=2,
5.9% at K=8 and 6.3% at K=16, i.e. 25.5 instructions per pointer-slot visit.

The slot visit moves to a new `gc/copying_parent_facts.rs`; the 2000-line file
lint required the split, and `gc/copying.rs` ends up smaller than before.

Tested: `gc::tests::copy_slot_hoists` pins the behaviour with a collection —
a target reachable only through a rooted WeakRef's weak slot must die in the
nursery — and its sabotaged twin makes the per-object fact read false, which
evacuates through the weak slot and keeps the target alive.

NOT included, deliberately: the same hoist for
`barrier_parent_needs_remembering`'s generation clause. It measured as the half
that makes a 1-pointer-slot object pay (oldyoung +0.198% with it, -1.10%
without), and no sabotage could be made to fail for it — sticky dirty-page
coverage carries an old->young edge independently of the remembered-set
re-insertion, so forgetting the fact changes nothing observable. It needs its
own witness before it is worth landing.
#10375's split added `builtin::lower_global_intrinsic_new` after the
`new::` group; rustfmt orders it before `field_init`. Whitespace only.
`CANONICAL_MODULE_PATHS` and `CANONICAL_MODULE_DIRS` are new
identity-ratcheted thread-locals, so the holders gate fails until each
carries a verdict. Both memo filesystem text only — `String` keys with
`String`/`PathBuf` values — with no JSValue, NaN-boxed word or arena
pointer anywhere in either map, so neither is a GC root. Recorded as
researched `not_a_gc_pointer` verdicts rather than pinned as frontier
debt, because a frontier pin is explicitly not a GC-safety verdict.
Resolving #10381's conflict with #10387 in `array/mod.rs` dropped
`transfer_array_numeric_layout` (deleted by #10381 along with its only
caller) and kept `reclassify_array_numeric_layout_from_slots` (added by
#10387). One fewer symbol refills the list differently, so rustfmt
rewraps it. Whitespace only.
@coderabbitai

coderabbitai Bot commented Sep 17, 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: e92a625a-c1bc-419c-9f8b-1fd9d41ce1b1

📥 Commits

Reviewing files that changed from the base of the PR and between fdc437f and 9ecf2b0.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (77)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10306-module-path-canonicalize-memo.md
  • changelog.d/10375-new-globalthis-shadowed-binding.md
  • changelog.d/10378-hit-path-instruction-wave2.md
  • changelog.d/10381-gc-relocation-address-keyed-records.md
  • changelog.d/10384-windows-build-breaks.md
  • changelog.d/10388-copying-minor-weak-holder-fact.md
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/codegen/param_guard.rs
  • crates/perry-codegen/src/expr/array_literal.rs
  • crates/perry-codegen/src/expr/in_presence_ic.rs
  • crates/perry-codegen/src/expr/index_get/guarded_array.rs
  • crates/perry-codegen/src/expr/logical_collections.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/new_dynamic.rs
  • crates/perry-codegen/src/expr/packed_loop_shadow_barrier_tests.rs
  • crates/perry-codegen/src/expr/shadow_slot.rs
  • crates/perry-codegen/src/expr/v8_interop.rs
  • crates/perry-codegen/src/lower_call/builtin.rs
  • crates/perry-codegen/src/lower_call/mod.rs
  • crates/perry-codegen/src/lower_string_concat.rs
  • crates/perry-codegen/src/rooting/mod.rs
  • crates/perry-codegen/src/rooting/temp_root.rs
  • crates/perry-codegen/src/runtime_decls/strings.rs
  • crates/perry-hir/src/lower/expr_new.rs
  • crates/perry-hir/src/lower/expr_new/helpers.rs
  • crates/perry-hir/src/lower/expr_new/member.rs
  • crates/perry-hir/src/lower/expr_new/non_ident.rs
  • crates/perry-hir/src/lower/tests.rs
  • crates/perry-hir/src/lower/tests/global_this_new_shadowed.rs
  • crates/perry-runtime/src/array/element_shape.rs
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/array/header_gc_slots.rs
  • crates/perry-runtime/src/array/iter_methods.rs
  • crates/perry-runtime/src/array/mod.rs
  • crates/perry-runtime/src/array/subclass.rs
  • crates/perry-runtime/src/array/subclass_tests.rs
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/copying_parent_facts.rs
  • crates/perry-runtime/src/gc/layout.rs
  • crates/perry-runtime/src/gc/layout/transfer.rs
  • crates/perry-runtime/src/gc/layout_tables.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/tests/copy_slot_hoists.rs
  • crates/perry-runtime/src/gc/tests/layout_trace/array_layout.rs
  • crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/tests/support.rs
  • crates/perry-runtime/src/lru_subclass.rs
  • crates/perry-runtime/src/module_require.rs
  • crates/perry-runtime/src/object/class_meta_registry.rs
  • crates/perry-runtime/src/object/class_registry/parent_static.rs
  • crates/perry-runtime/src/object/field_get_set.rs
  • crates/perry-runtime/src/object/field_get_set/has_property.rs
  • crates/perry-runtime/src/object/field_get_set/has_property_ic.rs
  • crates/perry-runtime/src/object/instanceof.rs
  • crates/perry-runtime/src/object/prototype_chain.rs
  • crates/perry-runtime/src/param_type_guard.rs
  • crates/perry-runtime/src/string/char_ops.rs
  • crates/perry-runtime/src/typed_feedback.rs
  • crates/perry-ui-windows-winui/src/widgets.rs
  • scripts/gc_runtime_root_holders.json
  • test-files/_helpers/new_globalthis_shadowed_10359.ts
  • test-files/test_gap_array_subclass_pop_plan_cache.ts
  • test-files/test_gap_in_operator_presence.ts
  • test-files/test_gap_in_operator_presence_cache.ts
  • test-files/test_gap_instanceof_miss_ladder.ts
  • test-files/test_gap_new_globalthis_shadowed_10359.ts
  • test-files/test_gap_nominal_class_param_guard.ts
  • test-files/test_gap_numeric_push_guarded.ts
  • test-files/test_gap_packed_loop_cached_receiver.ts
  • test-files/test_gap_packed_loop_proto_index_rooting.ts
  • test-files/test_gap_rest_bundle_and_map_fill.ts
  • test-files/test_gap_string_index_character.ts
  • test-files/test_gap_subclass_alloc_registration.ts
  • test-files/test_gap_template_number_formatting.ts

📝 Walkthrough

Walkthrough

Changes

The pull request bumps the version to 0.5.1585, fixes shadowed globalThis constructors, adds compiler and runtime fast paths, updates GC relocation and copying behavior, memoizes module path canonicalization, fixes Windows builds, and adds regression coverage.

Estimated code review effort: 5 — 90 minutes

Change: Other

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

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch train207r

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