Skip to content

chore: merge train 253 — ten PRs (v0.5.1633) - #10918

Merged
proggeramlug merged 14 commits into
mainfrom
land-train-253
Sep 21, 2026
Merged

proggeramlug merged 14 commits into
mainfrom
land-train-253

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Lands ten PRs as v0.5.1633, expedited at the owner's request for #10885, #10896 and #10901.

source change
#10885 perf(runtime): a default-off census that says WHY each ShapeId was minted
#10886 fix(runtime): preserve dynamic parent for spread super
#10896 perf(#10863): a read site whose hot key is in the overflow region
#10899 fix(http): preserve upgrades over custom connections
#10900 fix(hir): honor lexical fetch bindings
#10901 perf(runtime): publish a transition edge when a descriptor write …
#10902 perf(regex): skip the flags Get on a pristine RegExp
#10909 fix(hir): preserve computed field key scope
#10910 perf(string): scan for ASCII a word at a time, and memchr a one-byte needle
#10912 fix(hir,runtime): Uint8Array<ArrayBuffer>-annotated receivers

Assembly

Three were stacked, not conflicting. #10899 and #10900 build on #10886, and #10901 on #10885; their first commits were already in the train, so only their unique commits were taken.

One real conflict: #10900 and #10909 each add a test function at the same point in perry-hir/src/lower/tests.rs. The hunk had both sides truncated mid-statement, so the complete function was extracted from its source commit and spliced in. Both tests run and pass.

object/mod.rs reached 2020 lines — the ten PRs add 41 on top of the 1979 left by v0.5.1632. Split the cell-metadata edge (cell_meta_slot and its #[cfg(test)] companion) into object/cell_meta.rs, leaving 1961. The boundary deliberately excludes cell_expando_ensure, which holds the file's single raw-handle debt site; moving it would land that site in a module with no ceiling.

Evidence

  • perry-runtime full suite: 4233 passed, 0 failed, 0 SIGABRT — completing, not truncated.
  • perry-hir full suite: 471 passed, 0 failed.
  • cargo check --workspace --all-targets under -D warnings: clean.
  • cargo fmt --all -- --check: clean.
  • check_file_size, raw_handle_debt, addr_class_inventory, gc_store_site_inventory, shape_descriptor_census, gc_runtime_root_holders, check_test_registration, native_result_ledger: all rc=0.
  • No attribution trailers; no contributor PR touches the version files.

Not run, and stated rather than implied: the gap sweep and the compiler-output suites. Expedited on the owner's request.

A note on #10900

During assembly #10900 was briefly committed with its test but without its fix — the result of resolving the conflict by hand. The test failed, which is how it was caught, and the three missing source files were restored; the landed commit matches the original exactly. Worth recording because it is the property that made the error safe: a regression test that fails when its fix is absent.

Ralph Kuepper and others added 14 commits September 21, 2026 19:03
…nted (#10868)

Perry mints 2,287,869 ShapeIds per `ts.transpileModule` of a 1,201-line file
to describe 10,867 distinct layouts, and that layout count does not grow with
the work. The band is 2^30 and exhaustion is `shape_id_exhausted_abort()` ->
`std::process::abort()`, so it is ~476 transpiles to a hard abort. Counting
mints does not say why; splitting them by cause does.

`PERRY_SHAPE_MINT_DIAG=<path|1>` labels every mint by which of
`shape_descriptor_ensure_with_holes`' six identity facts moved against the
family already indexed under the same keys-array ADDRESS, taking the cheapest
explanation the family offers rather than the worst sibling in the list. It
hashes the ordered key-NAME list separately, so a new layout
(`fresh_keys_new_list`) and the same layout at a new array address
(`fresh_keys_known_list`) are different rows -- the distinction that turns
this from a budget problem into a defect. `identical` is the row that would
mean the `facts_key` memo failed; it reads 0 on every workload measured, and
it is listed so that is a measurement rather than an assumption.

Alongside: memo hits, retirement count and retirement AGE, a family-size
histogram, `#[track_caller]` call sites, and the transition cache's own
outcomes -- where one split carries the diagnosis. `TRANSITION_CACHE_SIZE` is
16384 and DIRECT-MAPPED, so `miss_empty` (a cold edge) and `miss_COLLIDE`
(the slot holds a different live edge) are different findings. On tsc that
reads 8.4 % hits, `miss_COLLIDE` 1,221,842 against `miss_empty` 60,186, and
96.1 % evicting inserts.

The control that says the machinery works: `pool` -- N objects given the same
eight field NAMES through a dynamic key, so HIR cannot fold them into one
closed-shape literal -- mints 4,534 at N=1,000 and 4,534 at N=10,000, constant,
with cache hits rising 84.6 % -> 98.2 %. Any fix has to keep that row flat.

Costs nothing when off. Every call site is `#[cfg(feature = shape-mint-diag)]`
and the feature is not in `default`, so the shipped runtime is unchanged; the
module itself always compiles so its tests cannot bit-rot behind a feature
nobody builds. Two trees at 89dd494, fixtures compiled by each arm's own
perry, output byte-identical to node on every row, per-iteration fitted
N=2,000 -> 20,000, min of 3:

  same 93.59 / 93.59   pool 15571.81 / 15571.92   del 21328.62 / 21334.33
  accd 16110.85 / 16113.09   acc 19142.89 / 19145.02   diff 220295.71 / 220302.70

i.e. unchanged to the method's resolution (the `same` row resolves to 0.01 %).
The gate is there because an earlier form was not free: with the hooks
unconditional the same rows read +0.15 % to +0.55 %, and the residual tracked
MINT VOLUME rather than cache traffic.

Tests: 8 unit tests over the classifier, which always compile. Must-fail, two
sabotages applied to the tree and reverted: making the FIRST sibling win
instead of the cheapest turns `the_cheapest_explanation_wins_regardless_of_
family_order` and `an_exact_sibling_outranks_a_one_fact_sibling` red;
reporting an exact sibling as `Mixed` instead of `Identical` turns
`a_family_member_matching_every_fact_is_a_memo_failure` and
`an_exact_sibling_outranks_a_one_fact_sibling` red.

Full suite on both arms, skipping the one test that aborts the process on main
(`bun_compat::plugin::tests::calls_setup_for_objects_and_functions_without_
running_hooks`, a non-unwinding panic, pre-existing): this branch 4,194 passed
/ 9 failed, control 4,186 passed / the SAME 9 failed. Net: 8 passing tests, no
new failure.

Review follow-ups (#10885):

The `Cargo.toml` comment claimed the mint-side counters stay unconditional so a
stock build could still take a before/after of mint volume. It cannot: every
call site is gated, including `note_mint`, `note_retire`, `note_memo_hit` and
`key_list_content_hash`, so an unfeatured build measures NOTHING. The comment
was stale from the design before the staging measurement — and the durable
version is that we gated the mint side BECAUSE the residual tracked mint
volume. Gating `#[track_caller]`, then the transition-cache probes that LOOK
hot (1.4 M lookups per transpile), still left +0.32 % / +0.35 % on `accd` /
`acc`; those fixtures mint 3 and 4 ids per object while `pool` and `same` mint
almost none and were already at zero. The comment now says what the code does,
which matters here because the opposite error — being told data exists when it
does not — is exactly what the dump's "the zeros below are absence of
measurement, not absence of collisions" line exists to prevent.

`TcMiss` also recorded one counter for two different refusals: the target array
refusing the `GC_FLAG_SHAPE_SHARED` stamp, and its length/capacity no longer
matching the edge. Split into `Unshared` and `TargetLen`, for the same reason
`Empty` and `Collide` are separate.

(cherry picked from commit edef434)
Baseline measured on f88acda: megamorphic=0 / armed=100% on an overflow hot
key reproduces exactly. The armed way block costs a DIRECTLY MEASURED 12.95
instr/read (m64arm 772.92 vs m64noarm 759.97 - the same program with and
without a shape that arms the site), which is 1.7% of a 773-instr read and not
the +37% #7753 quoted against a cheaper read.

Predicted counts: megamorphic 0 -> ~97.7%, armed 100% -> ~0.76%, in_ways 0,
encoded-slot-in-a-way 0. Predicted cost: m64arm -11.4..-13.4; m64 only -2..-10
because that fixture's armed way genuinely serves 1/64 of its reads and the
latch zeroes it.

(cherry picked from commit accafd9)
A read site whose hot key lives in the overflow (spill) region could never
latch megamorphic, however many shapes it rotated. It kept the `armed` state
it earned during warm-up for the life of the process and the emitted gate kept
running four dependent loads and a compare tree on every read, all of which
could only ever miss. Measured on the issue fixture: megamorphic=0 across
16.8M primes, every one of them `armed`.

`pic_prime_get` computed one predicate for two different questions:

  * may the displaced MRU entry be absorbed INTO a way? No, if its slot is
    overflow-encoded -- the emitted way path computes obj + header + slot*8
    straight from the slot word, so an encoded slot there is a wild load.
    That is #9287, it is correct, and this commit does not touch it;
  * did a DIFFERENT shape just displace the MRU entry? That is the evidence
    the latch counts, and on such a site it is true on every read.

One `return` served both, so the consecutive-eviction run never advanced.
Split them: `evicted` records the displacement, `cascade` still decides
absorption, and a suppressed cascade on an ARMED site (`state > 0`, the exact
predicate the emitted gate evaluates) advances the run and latches at
PIC_MEGAMORPHIC_EVICTIONS. Nothing is written to a way on that path; only the
gate the ways sit behind turns itself off. A rotation in which no shape carries
the key inline never arms and is untouched.

Counts, PERRY_IC_DIAG over 20M reads, predicted before the arm was built and
committed in .lane/10863-prediction.md:

  m64        armed 100.0% -> 0.758% (predicted <2%, point estimate 0.76%)
             megamorphic 0.0% -> 96.97% (predicted >90%)
             in_ways 0 -> 0, way_encoded_slot 0
  m64inline  unchanged: fresh 0.048% / armed 0.919% / megamorphic 99.03%
  m64noarm   unchanged: fresh 100% / armed 0 / megamorphic 0

Cost, perf stat instructions:u, min of 3, marginal per read fitted 500k->5M,
flat to 0.1 instr/read at a 2M midpoint:

  m64        757.98 -> 737.69   -20.29  -2.68%
  m64arm     772.92 -> 755.09   -17.83  -2.31%
  m64noarm   759.97 -> 764.81    +4.84  +0.64%   (see below)
  m64inline 1352.85 -> 1352.83   -0.02   0.00%
  own3/w4/inh/inh3  123.00/192.00/349.00/288.00 -> identical

#7753's "+37%" does not transfer and is not claimed: here every read already
misses into the full handler (~740 instr), so the way block is a ~20-instruction
slice of it.

The m64noarm row is a real regression and is attributed, not excused: a sabotage
arm with this fix compiled out measures +4.17 on the same fixture with behaviour
identical to base, so the residual is codegen layout in pic_prime_get /
get_field_ic_miss_impl. Six structural variants were measured; m64noarm landed
between +4.17 and +7.17 in all of them, including a minimal two-line variant
(+7.17). This is the best of the six and the only one that leaves m64inline
exactly unchanged.

Evidence that no encoded slot enters a way, which is the one thing that must
not regress:

  * PERRY_IC_DIAG gains `way_encoded_slot`, scanning the four ways for a slot
    carrying IC_SLOT_OVERFLOW_BIT on every prime. Reads 0 across ~57M primes on
    four fixtures. The probe is #[cold] #[inline(never)] behind the existing
    ic_on() arming, so an unarmed run pays what it paid before.
  * assert_no_encoded_slot_in_a_way runs after EVERY individual prime in four
    new unit tests, not once at the end.

Sabotaged deliberately and the two new latch tests fail with the issue's exact
signature (fresh=2 armed=25199 megamorphic=0) while the other ten pass;
restored afterwards.

(cherry picked from commit 65b2bc6)
String.prototype.replace (and match) read `rx.flags` on every call to learn two
bits, global and unicode. That Get went through the generic property path --
js_reflect_get, an accessor-descriptor lookup, a shape lookup, a setjmp trap
frame -- then the builtin getter performed eight more observable Gets, built a
flags string, and the caller re-parsed it with from_utf8. On a 3-character
subject that was most of the call (#10518).

On a pristine RegExp none of that is observable, so the two bits are read
straight off the header instead. "Pristine" has to cover all nine accessors the
Get consults, not just `flags`: the builtin getter reads hasIndices, global,
ignoreCase, multiline, dotAll, unicode, unicodeSets and sticky, and overriding
any one of them changes the answer. So the predicate requires:

  * the instance's prototype is the canonical RegExp.prototype (a subclass or
    a reassigned prototype fails);
  * none of the nine names is an own property of the instance, as an accessor
    or as data;
  * RegExp.prototype's nine accessors are all still the builtins.

The last check costs nine descriptor lookups, so its verdict is memoized
against prop_plan_semantic_epoch -- the counter the property system already
bumps for every event that can change what an inherited read answers, exposed
for exactly this and already used by promise::then_probe. It is deliberately not
keyed on the pointer-identity epoch, which the collector bumps at poll cadence.

js_regexp_new stores `unicode` as contains('u') || contains('v'), which is what
scan_flags derives, so the two paths agree by construction.

  "a+b".replace(/\+/g, " ")   104,802 -> 29,933 instructions, 98.1x -> 28.8x node

The existing perex_match_reads_overridden_flags_and_global_getters test, which
defeated an earlier version of this that checked only `flags`, passes. Answers
match Node on overridden prototype getters, a lying getter, an own `flags`
accessor, an own data `global`, the u/v flags and match; the one differing row
is a subclass overriding `flags`, which diverges identically on main (#10700).

(cherry picked from commit 52a4681)
…split

Two per-call costs in `js_string_split_n`, both measured on #10519's own
reproduction -- a 211-byte JWT split on "." -- after the engine path stopped
answering plain-string splits.

The source is scanned once per call to decide whether every part can take the
all-ASCII metadata shortcut. That scan was `bytes.iter().all(|&b| b < 0x80)`,
one byte per iteration; `perf annotate` put about 74% of this function's own
time in its six-instruction loop, roughly 19% of the whole call. `is_ascii()`
is the same predicate and std tests a word at a time.

The parts are then found with `str::split(&str)`, which takes the two-way
`StrSearcher` and pays its setup in full for a single-character needle -- the
common `split(".")`, `split(",")`, `split(" ")` shape. A one-byte delimiter
takes std's memchr-based searcher instead.

The `byte < 0x80` guard on that second path is load-bearing rather than
decorative: string storage can hold malformed bytes (the comment above the
ASCII scan documents `[0x80, b'|', 0xF0]`), and `0x80 as char` is U+0080, which
re-encodes as two bytes and would split on the wrong occurrences.

  JWT split on ".":  5,424 -> 4,268 instructions per call, -21.3%

Against #10519's 345x and ~230k instructions, that row is now 9.2x Node 26.5.1.
Split conformance is unchanged: the 27 cases where a byte scan and a UTF-16
unit scan can disagree, and the 12 non-string separator forms, all still match
Node.

(cherry picked from commit 437fe12)
…nger fold to the Array search helpers (#10894)

TypeScript 5.7 made every typed array, `DataView` and (via @types/node)
`Buffer` generic over the backing buffer, and `tsc` prints that argument,
so `m: Uint8Array<ArrayBuffer>` is now a common annotation. HIR lowered it
as `Type::Generic { base: "Uint8Array" }`, which no typed-array / buffer
recognizer matches (they key on `Type::Named`), so the receiver read as
"known not a string" and `m.indexOf(44, from)` folded to
`Expr::ArrayIndexOf` over a `BufferHeader`: the byte payload was walked as
NaN-boxed f64s and a present byte answered -1. `join` printed
`5.09e-313-…`. `Float32Array<…>` survived only because it is a registry
typed array the helpers already re-check.

Two layers:

* HIR: a type reference or `new X<…>()` whose base is a typed array,
  `Buffer` or `DataView` erases its arguments to `Type::Named(X)` (a user
  class of that name keeps `Generic`). The lowered body is now identical
  under both spellings for 41 members, asserted in
  `buffer_backed_generic_tests.rs`.
* runtime: `js_array_indexOf_jsvalue` / `js_array_last_index_of_jsvalue` /
  `js_array_includes_jsvalue` / `js_array_join` ask
  `buffer_receiver_dispatch` above the array-only funnel (the #8137
  pattern), so a Buffer that reaches them through a lying type
  (`Readonly<Uint8Array>`, `as unknown as number[]`) is answered by the
  shared uint8 dispatcher instead of by reinterpreting its bytes.

Found by guest271314's Native Messaging host, whose reply splitter never
split because `indexOf` returned -1; `test_gap_10894_native_messaging_split`
is that loop verbatim at 1 MiB, 1 MiB + 5 B, 2 MiB and 5 MiB.

(cherry picked from commit 73c3698)
…alue (#10868)

`Object.defineProperty` minted 3 ShapeIds per object for a data descriptor and
4 for an accessor, linearly and without bound, while the transition cache
reported lookups and ZERO inserts. This makes the data case constant.

Measured rather than read. Instrumenting every decline reason showed exactly
one predicate refusing, 1000 times out of 1000, at `object_ops/keys_array.rs`:

    defineProperty key-add, edge outcomes:
      no publish: new_index >= inline_capacity      1000
      eligible                                      1000

The receiver IS eligible and the lookup DOES run.

Why it refuses every time: `INLINE_SLOT_FLOOR` is 2, and #7916 documents it as
a pure FOOTPRINT dial -- "purely a growth-headroom dial for objects that gain
properties by name after birth". A two-field literal therefore allocates
exactly two slots and has zero growth headroom, so the first key added after
birth lands at `new_index == inline_capacity == 2`, spills to overflow, and the
edge is never published. Every receiver forks onto a private keys array, and
from there the fork cascade does the rest: once the predecessor ShapeId is
private, even #10287's DETERMINISTIC generation is deterministic in a value no
other object shares, which is why an accessor costs 4 ids and not 1. A dial
tuned for bytes silently decided whether transition edges get published at all;
neither subsystem could see that from its own side.

Raising the floor is not the fix: it trades footprint against a standing RSS
budget and only moves the cliff to four-field literals. The correct behaviour
already exists next door -- `field_set_by_name/tail.rs`'s overflow arm
publishes the edge with no inline-capacity gate at all, which is why the `pool`
control (the same objects grown by [[Set]]) already mints a constant 4,534 ids
however much work it does. This is missing wiring, not a missing capability.

Two changes, because publishing alone would have measured as a wash: the
adopter gate refused overflow-located edges too, on the stated grounds that "a
keys-only install (an accessor claiming its slot) writes no value, so the
overflow entry such an edge implies would never be created". That reason is
correct for accessors and over-broad for data. So publish the edge when the
install will write a value, AND let the adopter take one in that case.
`ensure_key_in_keys_array_for_value` is the opt-in and
`define_property_force_store_value` is its only caller; every keys-only claim
keeps today's behaviour by construction. `cached_target_fits` is untouched (it
guards a different hazard), and the live-bound bump is now guarded in BOTH
adopt arms -- unreachable in the first-key arm today, since its precondition is
a null keys array, but an unguarded bump beside a gate that now admits overflow
slots is a trap for whoever widens that precondition.

Measured with #10885's census. Six controls, two arms built from their own
trees, `cmp`-distinct binaries, output byte-identical to node on every row;
mints via `PERRY_SHAPE_MINT_DIAG`, instructions fitted N=2,000 -> 20,000,
min of 3:

  fixture  mints @1k/@10k before -> after     instr/iter before -> after
  same     1 / 1          -> 1 / 1            93.58 -> 93.58
  pool     4,534 / 4,536  -> 4,534 / 4,536    15596.14 -> 15595.14
  del      5,516 / 14,516 -> 5,516 / 14,516   21365.89 -> 21387.20
  accd     3,002 / 30,002 -> 5 / 5            16188.99 -> 12539.77  (-22.5%)
  acc      4,002 / 40,002 -> 4,002 / 40,002   19244.19 -> 19315.15
  diff     22,516/184,516 -> 22,516/184,516   220937.30 -> 220936.49

`accd` goes CONSTANT -- 5 ids whether it runs 1,000 iterations or 10,000 --
with 22.5% fewer instructions, so this is a speedup and not merely a smaller
counter.

`acc` being unchanged TO THE ID is not a missed opportunity; it is the result
that makes the change sound. The accessor is a keys-only claim whose implied
overflow entry is never created, so it must keep the private path. A change
that improved both rows would have broken the keys-only-install argument rather
than fixed the data one.

tsc (`ts.transpileModule`, typescript 5.8.2 from source, output identical to
node): MINTS 2,288,352 -> 2,287,800 and distinct key-NAME lists 10,867 ->
10,867. Flat, as predicted before measuring: tsc's mints are 97.4%
`fresh_keys_known_list` + `key_count` from the [[Set]] CoW path, which is a
different fork source. The requirement here was must-not-regress, and a flat
tsc row confirms the attribution. The layout-count invariant held, so shape
IDENTITY did not move -- only mint volume.

`cargo test -p perry-runtime` on both arms back to back, same host state,
skipping the one test that aborts the process on main
(`bun_compat::plugin::tests::calls_setup_for_objects_and_functions_without_
running_hooks`, a non-unwinding panic, pre-existing): 4,204 passed / 0 failed
on both. `cargo check` clean in both feature configurations.

This is ONE fork source among several, not a fix for #10868. The exit criterion
there is: mints ~= distinct layouts on tsc, no abort, and same layout + same
facts => same ShapeId always. Reaching it needs canonical shape identity,
because the authoritative dedup (`by_facts`) keys on the keys-array ADDRESS and
that address is produced by a lossy 16,384-entry direct-mapped cache.

(cherry picked from commit 89f157b)
2020 -> 1961. Moves cell_meta_slot and its cfg(test) companion; stops before
the #[inline]/#[cfg_attr] pair that a comment-walk would swallow, and excludes
cell_expando_ensure which holds the file's one raw-handle debt site.
@coderabbitai

coderabbitai Bot commented Sep 21, 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: cfe644fb-5fff-4715-b84b-bc6c872258b0

📥 Commits

Reviewing files that changed from the base of the PR and between 841b605 and bce1aa4.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (49)
  • .lane/10863-prediction.md
  • .lane/10863-result.md
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10660-dynamic-spread-super-parent.md
  • changelog.d/10863-overflow-key-megamorphic-latch.md
  • changelog.d/10887-node-fetch-shadowed-global.md
  • changelog.d/10888-create-connection-upgrade.md
  • changelog.d/10889-computed-field-key-captures.md
  • changelog.d/10912-cell-meta-split.md
  • changelog.d/10912-uint8array-generic-annotation.md
  • crates/perry-ext-http/src/client_connect_override.rs
  • crates/perry-ext-net/src/raw_bridge.rs
  • crates/perry-ffi/src/raw_net.rs
  • crates/perry-hir/src/destructuring/helpers.rs
  • crates/perry-hir/src/destructuring/var_decl/native_fetch.rs
  • crates/perry-hir/src/ir/constants.rs
  • crates/perry-hir/src/ir/decl.rs
  • crates/perry-hir/src/ir/mod.rs
  • crates/perry-hir/src/lower/tests.rs
  • crates/perry-hir/src/lower_decl/class_captures.rs
  • crates/perry-hir/src/lower_types.rs
  • crates/perry-hir/src/lower_types/buffer_backed_generic_tests.rs
  • crates/perry-hir/src/lower_types/extract.rs
  • crates/perry-runtime/Cargo.toml
  • crates/perry-runtime/src/array/join.rs
  • crates/perry-runtime/src/array/search.rs
  • crates/perry-runtime/src/array/typed_array_receiver_tests.rs
  • crates/perry-runtime/src/hot_diag.rs
  • crates/perry-runtime/src/object/cell_meta.rs
  • crates/perry-runtime/src/object/class_constructors.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs
  • crates/perry-runtime/src/object/field_get_set/ic_miss.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/object/object_ops.rs
  • crates/perry-runtime/src/object/object_ops/descriptor_helpers.rs
  • crates/perry-runtime/src/object/object_ops/keys_array.rs
  • crates/perry-runtime/src/object/regex_proto_thunks.rs
  • crates/perry-runtime/src/object/shape_mint_census.rs
  • crates/perry-runtime/src/object/shapes.rs
  • crates/perry-runtime/src/regex/perex_match_search.rs
  • crates/perry-runtime/src/regex/perex_replace.rs
  • crates/perry-runtime/src/string/split.rs
  • crates/perry/tests/issue_10660_dynamic_super_evaluation.rs
  • crates/perry/tests/issue_10888_create_connection_upgrade.rs
  • crates/perry/tests/issue_9287_overflow_slot_ic.rs
  • scripts/gc_runtime_root_holders.json
  • test-files/test_gap_10894_native_messaging_split.ts
  • test-files/test_gap_10894_typed_array_generic_annotation.ts
 __________________________________________
< Lint long and prosper with cleaner code. >
 ------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( 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.

proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
The three gap-suite reds on this PR — `test_gap_http2_settings`,
`test_gap_3527_http_ctor_prototype`, `test_gap_gc_net_once_flags_rekey` — are
not compile failures. They are `PERRY_COMPILE_TIMEOUT` expiring. In the CI log
each one is exactly 300.1 s wide:

    02:37:46.96 -> 02:42:47.08   test_gap_3527_http_ctor_prototype   300.12 s
    02:45:24.62 -> 02:50:24.77   test_gap_gc_net_once_flags_rekey    300.15 s
    02:47:57    (shard 2)        test_gap_http2_settings             300.1  s

All three compile and pass by hand on this branch (perrymaster, cold
`target/perry-auto-*`): 3m43s, and byte-parity against the node 26.5.1 oracle.

WHY THE BUDGET IS TOO SMALL

#10757 sized one 300 s budget for every fixture, on the stated belief that
"the fast-mode/PERRY_SKIP_BUILD tiers don't pay [an auto-optimize rebuild] per
test". They do, and the reason is four hundred lines further down the same
file: the #7629 block unsets PERRY_NO_AUTO_OPTIMIZE for every fixture that
routes a module to a `perry-ext-*` wrapper, because no single prebuilt stdlib
can serve the mixed corpus. perry then runs `cargo build` for a
feature-stripped runtime + stdlib + wrapper INSIDE the per-test compile
budget, once per distinct feature set, into a fresh `target/perry-auto-<hash>`.
The gap-suite workflow comment already records that cost as ~200 s per
distinct feature set; with runner variance it measures 270-300 s. So the
rebuild sits ON the line:

    #10930  test_gap_gc_net_once_flags_rekey   287.4 s   PASS  (12.6 s margin)
    #10930  test_gap_http2_settings            300.13 s  COMPILE_FAIL
    #10930  test_gap_3527_http_ctor_prototype  300.13 s  COMPILE_FAIL

That is merge train 254 — v0.5.1634, now on main. The same red, on a rotating
cast, is in every recent run: 11 fixtures on #10918, 5 on #10892, 4 on #10930.
All three merged. A gate whose red is overridden by hand every time is not a
gate, and this one cannot even tell a killed compile from a rejected one.

THE FIX

Split the budget by the property that predicts the cost — this compile may
rebuild the toolchain — and not by test name. `PERRY_EXT_COMPILE_TIMEOUT`
(default 900 s, 3x the observed cost) applies when auto-optimize is on for
that compile AND the fixture routes to an ext wrapper, reusing the existing
`test_routes_to_ext_wrapper` predicate. The ordinary 300 s budget is
untouched, so a genuine hang in a plain compile is still bounded at 300 s, and
the shard's 110-minute cap has room (shards run 17-46 min).

A killed compile now says so — `compile TIMEOUT after <N>s — killed, not
rejected` — in the console line and at the head of the persisted
`*.compile_error.log`. The old `(compile error)` with no message is what made
this take a night to find: the fixture compiled fine by hand, and the cause
was only visible by subtracting two timestamps out of a CI log.

WITNESS

Four arms on perrymaster, `test_gap_http2_settings`, PERRY_SKIP_BUILD=1 (the
CI path), `target/perry-auto-*` and the perry object cache wiped before each
cold arm:

  A  unpatched, cold, PERRY_COMPILE_TIMEOUT=120
     -> FAIL (compile error)                  killed at 2:03
  B  patched,   cold, PERRY_COMPILE_TIMEOUT=120
     -> PASS                                  4:02
  C  patched,   cold, PERRY_COMPILE_TIMEOUT=900 PERRY_EXT_COMPILE_TIMEOUT=60
     -> FAIL (compile TIMEOUT after 60s — killed, not rejected)
  D  patched,   test_gap_symbols (not ext-routed), PERRY_COMPILE_TIMEOUT=1
     -> FAIL (compile TIMEOUT after 1s — killed, not rejected)

A vs B is the same fixture, same cold state, same ordinary budget: the patch
is the only difference. C shows the ext budget is the one governing an
ext-routed fixture. D shows the ordinary budget still bites, so this is not a
blanket raise.

The three fixtures then pass through the harness on this branch:
test_gap_http2_settings, test_gap_3527_http_ctor_prototype and
test_gap_gc_net_once_flags_rekey — 1/1 parity pass each, 0 compile fail.
proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
The previous commit gave a compile that may rebuild the toolchain its own
budget, scoped to fixtures that route a module to a `perry-ext-*` wrapper.
CI then showed there is a second way in — and showed it in one line, which is
the whole point of the label that commit added:

    FAIL  test_gap_6558_webassembly_graceful_fail
          (compile TIMEOUT after 300s — killed, not rejected)

No ext-routed import, auto-optimize off, so it kept the ordinary 300 s budget.
It still builds native artifacts inside the compile:

      wasm-host: building perry-wasm-host from workspace source
      wasm-host (no-auto): rebuilding runtime with wasm-host feature

Cold, that compile measures 395 s here — over the line by a third, and under
the old `(compile error)` wording it would have read as a broken fixture.
It is in #10918's list of the same red for the same reason.

So the predicate gets the second property, symmetric with the first and still
a property of the test rather than its name. Across the four runs sampled
(#10859, #10918, #10892, #10930) the two together cover the entire observed
population: 13 of 14 distinct fixtures route to an ext wrapper, and the 14th
is this one. `PERRY_EXT_COMPILE_TIMEOUT` is renamed
`PERRY_TOOLCHAIN_COMPILE_TIMEOUT` — it was never only about ext wrappers, and
it has not shipped under the old name.

WITNESS — three more arms, same harness, on `test_gap_6558_webassembly_graceful_fail`
unless stated:

  E  PERRY_COMPILE_TIMEOUT=1  PERRY_TOOLCHAIN_COMPILE_TIMEOUT=900
     -> PASS                         the ordinary budget is not in force
  F  PERRY_COMPILE_TIMEOUT=900 PERRY_TOOLCHAIN_COMPILE_TIMEOUT=1
     -> FAIL (compile TIMEOUT after 1s — killed, not rejected)
                                     the toolchain budget is
  G  test_gap_symbols (neither ext-routed nor wasm), PERRY_COMPILE_TIMEOUT=1
     -> FAIL (compile TIMEOUT after 1s)
                                     the ordinary budget still bites

E and F are the same fixture and the same harness with the two budgets
swapped, so they isolate which one governs; G is the control that this is not
a blanket raise.

Run 35692315543 on the previous commit: gap shards 1, 2, 3, 4 and 5 green —
including 2 and 3, which carried all three of this PR's original reds — and 6
red on this one fixture alone.
proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
The three gap-suite reds on this PR — `test_gap_http2_settings`,
`test_gap_3527_http_ctor_prototype`, `test_gap_gc_net_once_flags_rekey` — are
not compile failures. They are `PERRY_COMPILE_TIMEOUT` expiring. In the CI log
each one is exactly 300.1 s wide:

    02:37:46.96 -> 02:42:47.08   test_gap_3527_http_ctor_prototype   300.12 s
    02:45:24.62 -> 02:50:24.77   test_gap_gc_net_once_flags_rekey    300.15 s
    02:47:57    (shard 2)        test_gap_http2_settings             300.1  s

All three compile and pass by hand on this branch (perrymaster, cold
`target/perry-auto-*`): 3m43s, and byte-parity against the node 26.5.1 oracle.

WHY THE BUDGET IS TOO SMALL

#10757 sized one 300 s budget for every fixture, on the stated belief that
"the fast-mode/PERRY_SKIP_BUILD tiers don't pay [an auto-optimize rebuild] per
test". They do, and the reason is four hundred lines further down the same
file: the #7629 block unsets PERRY_NO_AUTO_OPTIMIZE for every fixture that
routes a module to a `perry-ext-*` wrapper, because no single prebuilt stdlib
can serve the mixed corpus. perry then runs `cargo build` for a
feature-stripped runtime + stdlib + wrapper INSIDE the per-test compile
budget, once per distinct feature set, into a fresh `target/perry-auto-<hash>`.
The gap-suite workflow comment already records that cost as ~200 s per
distinct feature set; with runner variance it measures 270-300 s. So the
rebuild sits ON the line:

    #10930  test_gap_gc_net_once_flags_rekey   287.4 s   PASS  (12.6 s margin)
    #10930  test_gap_http2_settings            300.13 s  COMPILE_FAIL
    #10930  test_gap_3527_http_ctor_prototype  300.13 s  COMPILE_FAIL

That is merge train 254 — v0.5.1634, now on main. The same red, on a rotating
cast, is in every recent run: 11 fixtures on #10918, 5 on #10892, 4 on #10930.
All three merged. A gate whose red is overridden by hand every time is not a
gate, and this one cannot even tell a killed compile from a rejected one.

THE FIX

Split the budget by the property that predicts the cost — this compile may
rebuild the toolchain — and not by test name. `PERRY_EXT_COMPILE_TIMEOUT`
(default 900 s, 3x the observed cost) applies when auto-optimize is on for
that compile AND the fixture routes to an ext wrapper, reusing the existing
`test_routes_to_ext_wrapper` predicate. The ordinary 300 s budget is
untouched, so a genuine hang in a plain compile is still bounded at 300 s, and
the shard's 110-minute cap has room (shards run 17-46 min).

A killed compile now says so — `compile TIMEOUT after <N>s — killed, not
rejected` — in the console line and at the head of the persisted
`*.compile_error.log`. The old `(compile error)` with no message is what made
this take a night to find: the fixture compiled fine by hand, and the cause
was only visible by subtracting two timestamps out of a CI log.

WITNESS

Four arms on perrymaster, `test_gap_http2_settings`, PERRY_SKIP_BUILD=1 (the
CI path), `target/perry-auto-*` and the perry object cache wiped before each
cold arm:

  A  unpatched, cold, PERRY_COMPILE_TIMEOUT=120
     -> FAIL (compile error)                  killed at 2:03
  B  patched,   cold, PERRY_COMPILE_TIMEOUT=120
     -> PASS                                  4:02
  C  patched,   cold, PERRY_COMPILE_TIMEOUT=900 PERRY_EXT_COMPILE_TIMEOUT=60
     -> FAIL (compile TIMEOUT after 60s — killed, not rejected)
  D  patched,   test_gap_symbols (not ext-routed), PERRY_COMPILE_TIMEOUT=1
     -> FAIL (compile TIMEOUT after 1s — killed, not rejected)

A vs B is the same fixture, same cold state, same ordinary budget: the patch
is the only difference. C shows the ext budget is the one governing an
ext-routed fixture. D shows the ordinary budget still bites, so this is not a
blanket raise.

The three fixtures then pass through the harness on this branch:
test_gap_http2_settings, test_gap_3527_http_ctor_prototype and
test_gap_gc_net_once_flags_rekey — 1/1 parity pass each, 0 compile fail.
proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
The previous commit gave a compile that may rebuild the toolchain its own
budget, scoped to fixtures that route a module to a `perry-ext-*` wrapper.
CI then showed there is a second way in — and showed it in one line, which is
the whole point of the label that commit added:

    FAIL  test_gap_6558_webassembly_graceful_fail
          (compile TIMEOUT after 300s — killed, not rejected)

No ext-routed import, auto-optimize off, so it kept the ordinary 300 s budget.
It still builds native artifacts inside the compile:

      wasm-host: building perry-wasm-host from workspace source
      wasm-host (no-auto): rebuilding runtime with wasm-host feature

Cold, that compile measures 395 s here — over the line by a third, and under
the old `(compile error)` wording it would have read as a broken fixture.
It is in #10918's list of the same red for the same reason.

So the predicate gets the second property, symmetric with the first and still
a property of the test rather than its name. Across the four runs sampled
(#10859, #10918, #10892, #10930) the two together cover the entire observed
population: 13 of 14 distinct fixtures route to an ext wrapper, and the 14th
is this one. `PERRY_EXT_COMPILE_TIMEOUT` is renamed
`PERRY_TOOLCHAIN_COMPILE_TIMEOUT` — it was never only about ext wrappers, and
it has not shipped under the old name.

WITNESS — three more arms, same harness, on `test_gap_6558_webassembly_graceful_fail`
unless stated:

  E  PERRY_COMPILE_TIMEOUT=1  PERRY_TOOLCHAIN_COMPILE_TIMEOUT=900
     -> PASS                         the ordinary budget is not in force
  F  PERRY_COMPILE_TIMEOUT=900 PERRY_TOOLCHAIN_COMPILE_TIMEOUT=1
     -> FAIL (compile TIMEOUT after 1s — killed, not rejected)
                                     the toolchain budget is
  G  test_gap_symbols (neither ext-routed nor wasm), PERRY_COMPILE_TIMEOUT=1
     -> FAIL (compile TIMEOUT after 1s)
                                     the ordinary budget still bites

E and F are the same fixture and the same harness with the two budgets
swapped, so they isolate which one governs; G is the control that this is not
a blanket raise.

Run 35692315543 on the previous commit: gap shards 1, 2, 3, 4 and 5 green —
including 2 and 3, which carried all three of this PR's original reds — and 6
red on this one fixture alone.
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