Skip to content

Merge train: #9795, #9844, #9845, #9853, #9857, #9860, #9862, #9864, #9865, #9868 (+ #9869) - #9883

Merged
proggeramlug merged 22 commits into
mainfrom
train132
Sep 6, 2026
Merged

Merge train: #9795, #9844, #9845, #9853, #9857, #9860, #9862, #9864, #9865, #9868 (+ #9869)#9883
proggeramlug merged 22 commits into
mainfrom
train132

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Merge train: #9795, #9844, #9845, #9853, #9857, #9860, #9862, #9864, #9865, #9868, plus a rooting fix (#9869) the assembly surfaced.

A GC bug found while merging: #9869

#9864 roots for-in's output and receiver across the callbacks that ownKeys / getOwnPropertyDescriptor / getPrototypeOf can invoke. It could not reach one place, because the deferred-shadow-set rework (468a57d64) landed after that patch was written: VisitedLevels records each walked prototype level as a plain NaN-boxed f64 and dereferences it later, in build_shadow_setmark_own_namesjs_object_get_own_property_names.

Between the record at level N and that read, the walk crosses js_object_keys_value (allocates a key array) and js_object_get_prototype_of (a Proxy getPrototypeOf trap is arbitrary user JS). Either can move the recorded object, and VisitedLevels is a plain Rust struct no scanner reaches — the "unrooted cache of a raw heap pointer" shape that gc_root_dominance_check.py structurally cannot see, since it reads emitted LLVM IR. Filed as #9869; it was already on main.

Fixed by storing RuntimeHandles, which the collector rewrites. RuntimeHandle is Copy, so the inline arm still costs no allocation and the "no malloc per for-in" property the rework exists for is preserved.

The first version of that fix was wrong, and #9864's own test caught it. The runtime handle stack is strictly LIFO (Drop truncates to the scope's base), so rooting into an OUTER scope while an inner one is live has the inner scope's drop silently discard the handle — the lifetime cannot catch this, because RuntimeHandle<'scope> ties to the scope's borrow, not to stack position. The per-level scope now closes before the push.

Gate changes, and why they are not relaxations

That split then broke four path-keyed things, all fixed: three addr_class_allowlist.txt entries, the thread_local_cold_allowlist.json key (same 272 cold declarations after), and two tests that read page_meta's source to audit flush obligations. Those two now concatenate all three parts — pointing them at mod.rs alone would have gone green while dropping the functions that moved to page_class.rs from the audit.

Conflict resolutions

#9845 was written against pre-split regex.rs and re-adds ~430 lines inline (ProgramKey, NEVER_MATCH_PATTERN, expand_js_replacement_fancy, replace_regex_str_fancy). Those hunks were dropped after diffing against the split files — and that diff mattered: main's copies use the ratchet-compliant with_mut_ptr::<ArrayHeader,_>(|a| a) where the PR's use the older raw get_raw_mut_ptr, so taking the PR's version would have quietly reverted a raw-handle-ratchet conversion. #9845's genuine change, the NEVER_MATCH_SOURCENEVER_MATCH_PATTERN unification, is kept; it also removes a duplicate const main was carrying.

gc_runtime_root_holders.json conflicts are resolved structurally (union by (file, name), pins recomputed from the tree, re-audit sentences unioned with longest-wins on containment) rather than as text, which splices entries and can drop a required justification while updating its hash.

Validation

run_lint_gates: all 64 gates passed; 2 CI-only skipped

suite passed failed
perry-runtime 3233 0
perry-codegen 1920 0
perry-hir 625 0
perry-stdlib 132 0

Reviewer note

This train needed more intervention than its predecessors — the page_meta split is a refactor of mine riding along, and the census-gate edit changes a correctness gate's assertion. Both are worth a second pair of eyes.

Summary by CodeRabbit

  • New Features

    • Added opt-in Solid JSX compilation with reactive components, spreads, refs, fragments, and keyed updates.
    • Added FormData support for Blob/File values, filenames, multipart request bodies, and generated content types.
    • Added optional GC diagnostics and allocation-site sampling.
    • Added an experimental regex engine configuration.
  • Bug Fixes

    • Improved Proxy and for…in enumeration reliability during garbage collection.
    • Fixed native-instance type leakage between same-named bindings.
    • Prevented certain regex backtracking performance cliffs.
    • Improved idle garbage-collection reclaim retries.
  • Performance

    • Reduced overhead in garbage-collection trigger paths, page classification, and regex caching.

Ralph Küpper and others added 22 commits September 6, 2026 13:03
…9847)

`lower_assign` handles `X = <native module>.<method>(...)`. Its class-name
match ends in a catch-all, and it registered the result through
`push_module_native_instance` — keyed on the identifier TEXT and scoped to
the whole module, never truncated. So any method on any recognised native
module, assigned to a variable, claimed that spelling for the rest of the
program.

Minified bundles reuse single letters everywhere, which makes the collision
the normal case rather than a corner. `cli_2.1.112.js` (claude-code) compiles
as one module, imports `child_process` as `fA1`, contains
`let O; try { O = fA1.spawn(z.file, z.args, z.options) }` inside one helper,
and binds the name `O` 5,381 times. Every later `O` was typed
`child_process::Instance` — including the `for (let { segment: O } of ...)`
binding in `string-width` that holds a grapheme STRING, whose
`O.codePointAt(0)` lowered as
`NativeMethodCall { module: "child_process", class_name: Some("Instance") }`
and reached the right answer only because native-instance dispatch falls
through to a generic path on a string receiver — once per grapheme, in the
loop that dominates a claude-code turn.

The tag now keys on the `LocalId` the assignment target resolves to. This is
the same correction #7775 already made in this file for `new Proxy` bindings
(`proxy_locals` -> `proxy_local_ids`) after a proxy bound to `a` in one
function made every other function's `a.prop` lower to `js_proxy_get`.

Scope-truncating the assignment path would NOT have worked: the module-wide
table exists for the cross-function case — a module-level `let client;`
assigned inside `init()` and read inside `handler()` — and truncating at
scope exit would have dropped exactly that. Keyed on the binding it reaches
just as far, because both functions resolve `client` to the same `LocalId`,
while a same-named binding in another scope is simply a different binding.

`lookup_native_instance` gains an id-keyed arm ahead of the module-wide one,
short-circuited when the module has no bare-assignment handle at all (the arm
sits on the miss path of every identifier property access). A target that
resolves to no local — a bare global — still registers and resolves by name;
that is the same hole #7775 documented, kept for the same reason and strictly
no worse than the previous behaviour, which used it for every assignment.

Nothing pattern-matches `child_process` or `codePointAt`: the mislowered call
disappears because the tag never reaches that binding.

(cherry picked from commit 47b5e7c)
`set_last_index_throwing` asks `get_property_attrs(re, "lastIndex")` on every
global or sticky `test()`/`exec()`, because a user may make `lastIndex`
non-writable and the spec's `Set(R, "lastIndex", n, true)` must then throw.
That question is meant to be answered by #6759 phase C2's per-object meta
summary without touching the tables — but `may_have_descriptor_entry` reached
the summary through `meta_capable_object`, which answers only for
`GC_TYPE_OBJECT`. A `RegExp` is its own cell type, so the filter returned the
conservative "maybe" for every RegExp receiver and the probe ran:
`key.to_string()` — a `String` allocation — plus a SipHash of `(usize, String)`,
on roughly 96,500 global `test()` calls per 400-character claude-code reply.

The capability was already there and simply unwired. #6759 phase 1 unified the
metadata edge behind `cell_meta_slot`, which answers for Object, Error, Map,
Set, RegExp, Promise and Date; `RegExpHeader::meta` is traced by
`GcLayoutSlotKind::RegExpFields` and moves with its header. So this adds no
state and no new invariant: it asks the narrower question the summary actually
needs (`descriptor_summary_meta`) instead of the `ObjectHeader`-shaped one the
other callers of `meta_capable_object` need, and every cell type with a meta
edge benefits, not only RegExp.

The three-way answer is the contract. `None` means the cell type has no meta
edge and the caller must stay conservative; `Some(null)` means the edge exists
and no record was ever installed, which PROVES absence; `Some(meta)` means read
the summary words. Collapsing the first two would turn a conservative "maybe"
into a false "no" for the types that still lack an edge.

Install and probe move together, which is the safety argument: all five
descriptor-summary sites now share one predicate, so an owner whose install set
the key bit is always found. Every insert into `property_descriptors` /
`accessor_descriptors` routes through `set_property_attrs` /
`set_accessor_descriptor` and therefore through `note_meta_descriptor_key`; the
touches outside this module are all removals, which can only make a probe more
conservative. `js_regexp_new` writes `meta = null` on every construction, so a
fresh header at a recycled address cannot inherit a dead tenant's bits.

Counters, diagnostic only and armed with `PERRY_REGEX_DIAG`:
`desc_regexp_probes` (RegExp receivers this filter sees) and
`desc_regexp_meta_negative` (those it now proves absent). The second was 0 by
construction before this change.

Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp
(cherry picked from commit 845bf69)
VisitedLevels stored each walked level as a plain NaN-boxed f64 and
dereferenced it after the walk had crossed an allocating call and a
possible Proxy getPrototypeOf trap, so a collection in that window left
it holding a stale pointer. Store RuntimeHandles, which the collector
rewrites; RuntimeHandle is Copy, so the inline arm still does not
allocate.
`gc_check_trigger` runs on every `gc_malloc`, and `gc_budgeted_due_trigger`
resolved eleven raw `thread_local!` declarations one `_tlv_get_addr` call at
a time. Measured with `sample` on the compiled claude-code TUI streaming a
3300-char reply (14,578 active main-thread samples, callers resolved by an
explicit ancestor walk): `_tlv_get_addr` was 380 main-thread leaf samples,
71 of them with `gc_budgeted_due_trigger` as the immediate caller, 36 in
`old_page_account_dirty_slots`, 31 in `scan_dirty_object_slots`, 27 in
`gc_malloc_header_is_tracked`.

Sixty-seven declarations move to `crate::perry_thread_local!`.

Why they were still cold is a measurement bug in the gate, not an oversight:
`scripts/check_thread_locals.py` ratchets on raw `thread_local!` BLOCKS per
file, and a block holds any number of declarations — so `gc/policy.rs`
counted as 6 while declaring 28, and adding a `static` to a recorded block
passed silently. In the same unit as the hot side, main was 318 hot against
339 cold declarations. The gate now ratchets on declarations (385/272) and
`--self-test` gained the direction that catches it.

`ARENA_TOTAL_BYTES`, `BLOCK_POOL` and `BLOCK_POOL_BYTES` stay raw and say so:
they are read from `Arena::new`, which runs as `tls_hot::fill`'s first
provider, so a `HotKey` there re-enters `fill` — which has not yet written
the `temp_roots` field it gates on — and re-runs `ARENA`'s initializer
without bound.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m
(cherry picked from commit 2e99865)
…TABLE

WIP — committed to preserve state while the lane is paused for box load
(load 298, 19.8/21.5 GB swap). NOT measured on the rig; do not land as is.

Replaces the 4-way round-robin page-generation cache with a direct-indexed
table over the arena's 1 MiB address classes. `PageGenerationMap` stays
authoritative: every miss falls through to it exactly as before, so this is a
cache replacement, not a map replacement. The 4-way set is retained in the same
binary behind `PERRY_GC_PAGE_CLASS_TABLE=0` as the positive control.

STATE OF THIS COMMIT

Applied, complete:
  * the table itself (`lookup`/`insert`/`rebase_to_cover`/`invalidate`), base
    taken from the first insert, epoch-stamped entries, O(1) whole-table
    invalidation;
  * sizing: `INITIAL_SPAN = 4096`, `BASE_SLACK = SPAN / 2`. The draft's
    `S = 1024` was mis-tuned — with base `first_key - S` the span covered is
    `min(S + 1, N - S)`, maximised at `S = N / 2`, so `S = 1024` covered 1,025
    classes against a measured span of 1,021 while `S = N / 2` covers 2,048 for
    the same 160 KB. A `const` assert now fails the build for any pairing
    covering less than twice the measured span; the old pairing fails it;
  * out-of-span coverage: an insert outside the table rebases it up to a
    16,384-class cap, and past the cap the key is left uncached and falls
    through to the map — never silently mis-indexed;
  * the arm is a plain `u8` field in the set's first cache line, not the
    `OnceLock` env read the draft had on the lookup path. That path runs ~440 M
    times per turn and an acquire load on each would have been paid by BOTH
    arms of the A/B while still being charged against main;
  * `#[repr(C, align(64))]` so "the hot fields share one cache line" is true
    rather than likely;
  * counters (`hits`/`misses`/`inserts`/`oos`/`rebases`/`refused`) and the
    `[gc-page-class]` line, emitted per copying minor under `PERRY_GC_DIAG`
    because the rig SIGKILLs the process. `oos` is on the miss path only and is
    what distinguishes a residual miss that is an unregistered address from one
    that is the table failing.

Verified:
  * all four tests pass on the pristine tree (`cargo test -p perry-runtime
    --lib page_class_table`, dev profile, 4 passed);
  * the four sabotages each fail on their own named assertion — base-from-first-
    registration, out-of-span handling, range containment, and invalidation.
    Two of them produce a literal misclassified pointer (`left: Old, right:
    Nursery` and `left: Nursery, right: Old`), which is the failure mode this
    structure has to be proof against;
  * every `PAGE_GENERATIONS` mutation site was enumerated (three, plus one
    read-only census walk) and each ends with an unconditional
    `invalidate_generation_cache()`. The table holds ~2,000 entries where the
    4-way set held 4, so a missing invalidation the old structure survived by
    luck would be a live misclassification here.

NOT done — this is what the lane owes:
  * the rig. The relink was killed mid-`cargo build` at the coordinator's
    pause, so there is no candidate binary and NO number in this commit has
    been measured on cc;
  * `cargo test -p perry-runtime --release -- gc:: arena::`;
  * `cargo fmt` (the `arena/mod.rs` re-export is not in sorted order) and
    clippy;
  * a changelog fragment.

Pre-registered falsifiers, written before any measurement, are in
`secret-tests/cc-perf-campaign/RESULT_page_class_table.md`. The headline is
that the spec's "miss rate below 2 %" bar is arithmetically unreachable: 22.3 %
of today's misses are on addresses in no registered block, which the map cannot
answer either, so nothing is cached for them in either arm. The derived floor
is ~4.5 %, and the decision turns on misses to REGISTERED classes going to ~0.

(cherry picked from commit 165aa78)
…xport

Formatting and documentation only; no behaviour change.

`cargo fmt` on the touched files, restricted to the lines this branch added.
Note for whoever runs the fmt gate: `arena/mod.rs` is ALREADY not rustfmt-clean
on main at an unrelated `#[cfg(test)]` re-export, and reformatting it would have
put that pre-existing churn in this diff, so it is deliberately left alone.

(cherry picked from commit 93ffee5)
Issue #9831 measured the ArenaBytes arm firing 51 times in one 66-delta
claude-code reply, each collection freeing a median 131 KB, while the
adaptive step sat saturated at 1 GiB. The issue located the discarded
backoff in the arm's own re-arm arithmetic; correcting that (the issue's
refuted branch) bought -10.8 % CPU for +22 % settled footprint and was
rightly rejected.

The arm's re-arm is not what re-fires it. Between two consecutive
firings the arena grows a few hundred KB, against a trigger armed 16 MB
(and below the ceiling, up to 128 MB) above the post-collection total.
What pulls the trigger back down is the tiny-parse pressure guard:
after every `JSON.parse` that grew the arena by <= 1 MB,
`gc_bump_malloc_trigger` (and `gc_schedule_parse_boundary_collection_
if_pressure`, and the boundary collector they arm) tests the absolute
`arena_in_use_bytes() >= 48 MB` and, if so, sets the trigger to "now".
That threshold is a quantity no collection can lower below the live
set, so on a program whose live set never drops under it every small
parse -- one per SSE delta -- forced a minor at the next safepoint.
The step those minors doubled was consulted by nothing.

The guard now also requires the arena to have grown, since the last
collection of any kind ended, by a headroom priced from the step:
the step rescaled so that its power-on value (128 MB, the ceiling)
buys the 16 MB floor, and each doubling the arm's ceiling clamp
discards buys the guard one more doubling, bounded by the same
ceiling. A productive collection halves the step and the guard keeps
the cadence it always had; an unproductive one earns it room. The
boundary collector re-prices a pending request so a collection that
already satisfied it is not followed by a second one.

Measured on the compiled claude-code TUI (cli_2.1.112.js, Linux, same
perry binary, runtime-only A/B, 7 interleaved rounds, 3300-char
streamed reply, chunk 50):

  turn CPU   base 30.2-41.5 s (mean 35.1)   fix 27.8-29.2 s (mean 28.6)
  post-turn RSS   base 754-1057 MB (mean 803)  fix 733-855 MB (mean 786)
  post-idle RSS   base 527-1073 MB (mean 736)  fix 517-843 MB (mean 722)
  peak RSS        1964-2062 MB both arms

The fix wins CPU in every pair (-8 % to -30 %); footprint is flat within
the base's own spread. The base arm is bimodal in both, which is what an
absolute in-use threshold does. PERRY_GC_DIAG on one reply: copying
minors 104 -> 84 (ArenaBytes 41 -> 13), old-gen fulls 19 -> 7, and the
guard forced exactly one collection, after a genuine 16 MB of growth
(`[gc-tiny-parse]` is the new witness line). test_memory_json_churn --
the guard's motivating shape -- is byte-identical in output and RSS in
all four GC modes; 48/48 test_gap_gc_* and 8/8 test_gap_json_* pass.

The arm's own arithmetic is left as it was and now says why.

Claude-Session: https://claude.ai/code/session_015kqVkH6rHzfvXskGAj3tRv
(cherry picked from commit 0d92005)
…name

Two build breaks the merge produced:

- `old_gen_in_use_bytes_slot_index` was re-exported twice from
  `arena/mod.rs` (E0252) after #9853 and #9827 both added the line.
- `VisitedLevels` gained a lifetime parameter when its levels became
  RuntimeHandles, and an associated `Self::INLINE` is not permitted in
  the array length of a generic struct, so it became the free const
  `VISITED_INLINE`. enumeration_tests.rs still named the old path.
…ctions

A declined idle compaction is currently a terminal state. The reducer's
activity gate wants `2^backoff` collections it did not start, and
`external_collections()` subtracts only its own — so a COMPACTION is what
registers as external. When the compactor's residue gate declines, no
compaction runs, nothing registers, `since_attempt` never reaches 1, and the
reducer never runs again. The decision removes the only event that could
revisit it.

Measured on the claude-code TUI, 400-char turn then 120 s idle, quiet host,
both rounds of each arm: A settles 757/759 -> 512/527 MB; R ends the turn 19 MB
BETTER at 738/742 and finishes at 748/748 — 221 MB worse. R's residue ratio is
23.68/23.67 % against a 25 % gate and starts zero compactions; A is at
25.94/25.95 % and starts two. Within-arm spread is 0.01-0.02 points, so this is
a stable operating point just under a threshold, not a coin-flip. The largest
piece of the loss is downstream: A right-sizes arena capacity 182.45 -> 81.79 MB
across three observations, R holds 168.82 MB on one.

This adds `StartReason::IdleElapsed`, extending the exemption that already sits
twelve lines above it for the identical deadlock — `ArenaRightSize` bypasses the
same gate because arena blocks need a second full observation an idle mutator
will never produce (#9709). A requirement denominated in mutator collections
cannot be met by a heap whose mutator is idle, which is exactly when the reducer
is wanted.

The constant was not the fix, and that is measured rather than asserted: the
same R binary in a 5 s window DID clear the residue gate at 25.81 %, compacted,
and released 0 (`kept_promise=false`). A's own second compaction releases 0 at
54.6 % residue. Half of A's compactions in that capture released nothing,
aborting ~4x earlier on what looks like a pause budget. Lowering 25 -> 23 %
would have bought a compaction that releases nothing and a `backoff_shift` bump.

Anti-spin needs no new rule: the wait is `IDLE_RECLAIM_REARM_MS <<
backoff_shift`, the SAME shift that prices the activity arm, so an unproductive
full doubles it — 15 s, 30 s, 60 s, 120 s, 240 s — and the arm is DISARMED at
`IDLE_RECLAIM_MAX_BACKOFF_SHIFT` rather than merely slowed, so a heap with
nothing to give is asked five bounded times and then not again until real
activity resets the shift. A productive full resets it, so a heap still giving
memory back keeps being asked every 15 s.

Tests, both sabotage-proved and each failing on its own named assertion:
`a_parked_heap_is_re_armed_by_elapsed_idle_alone` (no external collection
anywhere in the test; asserts the REASON via a counter, not the attempt count)
and `an_unproductive_elapsed_streak_doubles_the_wait_and_then_disarms`.
Removing the arm fails the first; removing the backoff scaling fails "must not
re-arm before the doubled wait"; removing the disarm fails "at the maximum shift
the elapsed arm is disarmed". `cargo test -p perry-runtime --lib -- gc::
arena::` is green at 1,143 passed / 0 failed.

NOT addressed here, and measured rather than assumed: after R's single reclaim,
`[gc-general-reclaim] examined=66 released=0 has_live=39 aging=22` — 39 of 66
arena blocks hold a live object, against 3 of 65 in A. Only an evacuation can
consolidate those, and whether an idle young evacuation is also needed is a
separate change.

Refs #9831.

(cherry picked from commit 0846d67)
…l origins, allocation-site sampling

Three instruments for the cc-perf campaign, all inert unless asked for.

`PERRY_GC_DIAG=1` gains the lines that say WHY the collector ran:
`[gc-trigger]` (every predicate input at each decision site), `[gc-full]`
(the arm behind each synchronous full mark-sweep, counted per site),
`[gc-budgeted] start/done` (steps, per-phase step time, root-scan share),
`[gc-charge]` (mutator-assist / synchronous-full time per calling site,
resolved to JS display names) and `[gc-survival]` (per copying minor, the
root that first reached each surviving byte — shadow stack, native stack
map, named scanner, remembered set by old-parent type — with transitive
reach charged to the originating root through a parallel worklist origin
vector).

`PERRY_ALLOC_SITE_SAMPLE=<bytes>` samples the arena allocation sites byte-
proportionally across the runtime allocators AND the codegen inline bump
path (the mirrored inline block limit is capped at one interval while
sampling, so the fast path returns to the runtime once per interval).

The survival test is sabotage-checked: disabling the drain propagation
charges the 40 elements to `worklist_drain` and the test fails on that row.
The knob's OFF state and magnitude parse are pinned next to the other GC
knobs. `gc_diag_enabled()` gets the per-thread test override the census
already has, so the diag paths are testable without touching the process
environment.

Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2
(cherry picked from commit 157afd9)
…es, engine prototype switch

Rebased onto main after #9764 landed as ddbe0b1; the site cache,
header-authoritative lookups and find-only global `test` are main's now and
are gone from here. The program-cache coherence fix moved to #9801.

Three changes remain.

* The capture-group cliff. `repeat_matcher::capture_layout` takes a pattern
  off the linear engine when ECMA-262's RepeatMatcher capture semantics are
  observable — a capture directly under a quantifier, or a capture inside a
  negative lookaround. That routing is a correctness requirement, but the
  engine it routes to is a classical backtracker with no step budget, so
  adding parentheses fell from linear time to exponential
  (`/^(a+)+$/.test("a"*28 + "!")`: 16,522 ms; node 4,798 ms). 6.3 % of 4,463
  real literals take that route. Both engines accept the same LANGUAGE and
  differ only in capture ASSIGNMENT, so `linear_rules_out_match` asks the
  linear program first and a subject it rules out — which is what every ReDoS
  input is — never reaches the backtracker. This removes the reachable
  exponential case; it does not bound the worst case (that needs the step
  budget open upstream as ridiculousfish/regress#177).

* Allocation-free cache probes. The three compiled-program caches were
  `HashMap<(String, String), _>`, so every probe allocated two Strings and
  copied the pattern text, once per RegExp OBJECT. `ProgramKey =
  (Arc<str>, Arc<str>)` makes a probe two refcount increments; the remaining
  materialisations are cold (`RegExp.prototype.compile`, the syntax-error
  fallback).

* `PERRY_REGEX_ENGINE=regress`, off by default: routes every pattern through
  the ECMAScript backtracker and installs a shared never-match placeholder as
  the standard program, so the tier-0 engine architecture can be measured in a
  real binary. Not a supported configuration — the backtracker has no budget.

Tests: quantified_capture_pattern_does_not_backtrack_on_a_non_matching_subject,
plus the `capture_layout` predicate assertions rewritten around the
`(layout, needed)` pair.

Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2
(cherry picked from commit 89be3b1)
…c arm

`js_regexp_new` allocated every `RegExpHeader` with `gc_malloc`. On the
claude-code TUI that is 199,873 of 199,926 malloc-tracked GC allocations per
400-character reply — 100.0 % of the malloc arm — at 80 bytes each, 99.2 % of
them freed, with the registry swinging 101,929 -> 1,689 across one minor
(`PERRY_GC_TRACE`). Each one costs a mimalloc allocation, a `MALLOC_STATE`
push, a malloc-registry `PtrHashSet` insert that rehashes as it grows, and at
death a sweep visit and a free.

`GC_TYPE_REGEXP` has been `ArenaOrMalloc` and movable all along: the move hook
rekeys `REGEX_POINTERS` / `REGEX_SOURCE_TABLE` / the expando owner, the layout
kind traces `pattern_ptr` / `flags_ptr` / `meta`, and
`test_movable_regexp_evacuation_migrates_all_address_owned_state` has exercised
the arena arm through a test-only allocator. What blocked production was young
death: the copied minor's from-space flip runs no per-object finalize hooks, so
a nursery header dying young would leak its `Arc` programs and registry
entries. Handled now the way `Map`/`Set`/`Error` handle theirs:

* `finalize_dead_copied_minor_from_space_regexps` after a copied minor,
* `collect_dead_registered_regexps_post_trace` at sweep entry for the
  non-copying cycle kinds,
* the existing `gc_type_finalize_unmarked_payload` for a tenured header.

Deadness reuses the audited `owner_is_dead_copied_minor_from_space` predicate
(now exposed per-type), which requires `GC_FLAG_ARENA` set and
`MARKED|FORWARDED` clear — so an evacuated header and a malloc'd one are both
skipped.

Every regex program cache keys on pattern/flags CONTENT, not on the header
address, so nothing else needs rekeying.

This changes the collection schedule, deliberately: the `MallocCount` trigger
loses essentially all of its input while ~16 MB a reply moves into the nursery.
Schedule numbers are reported with the change, not assumed.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m
(cherry picked from commit 85bedc3)
…he LIFO handle stack

Four gate failures the assembled tree produced, and the rooting bug the
suite caught:

- The runtime handle stack is strictly LIFO (`Drop` truncates to the
  scope's base), so rooting into an OUTER scope while an inner one is
  live has the inner scope's drop discard the handle. #9869's
  `visited.push(&scope, ..)` sat inside #9864's per-level scope and hit
  "runtime handle used after its scope was dropped". The per-level scope
  now closes before the push. Caught by
  gc::tests::rooted_for_in::for_in_grown_result_and_receiver_survive_prototype_collection.

- shape_descriptor_census asserted `gc_malloc(.. GC_TYPE_REGEXP)` at
  `js_regexp_new`; #9845 deliberately moves that birth to the nursery, so
  the assertion now accepts either allocator. What it checks is unchanged
  and is the point: RegExp is born with its OWN GcHeader kind, never as a
  generic object something later re-identifies by payload magic. Verified
  the updated gate still fails when the birth kind is blunted.

- #9853's page-class table pushed arena/page_meta.rs to 2559 lines. Split
  into page_meta/{mod,page_class,tests}.rs; the page-class tests move next
  to their subject. Both feature configurations build.

- That split also stranded six frontier entries in
  gc_runtime_root_holders.json on the old path, and the PASS1_MARKED
  census pin needed its re-audit for #9860's and #9845's gc/mod.rs
  re-export additions before the hash could move.
… split

A file-cap split renames a path, and every gate and test that names that
path keeps pointing at a file that no longer exists:

- addr_class_allowlist.txt: three entries, retargeted to the child each
  line actually moved into (the GcHeader cast to mod.rs, the two
  band-literal test fixtures to tests.rs).
- thread_local_cold_allowlist.json: the recorded count is per FILE, so
  the key moves to page_meta/mod.rs. Same 272 cold declarations after.
- arena/tests.rs and arena/tests_promoted_runs.rs read page_meta's source
  to audit every function's flush obligation. They now concatenate all
  three parts. Pointing them at mod.rs alone would have gone green while
  silently dropping the functions that moved to page_class.rs from the
  audit — a smaller test that still passes.
- Three prose references updated so nothing names the old path.
@proggeramlug
proggeramlug merged commit 890514a into main Sep 6, 2026
15 of 19 checks passed
@proggeramlug
proggeramlug deleted the train132 branch September 6, 2026 12:53
@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: d20916b0-a795-4d15-9b75-ccb8507b6b50

📥 Commits

Reviewing files that changed from the base of the PR and between a681446 and 0bf4f1e.

⛔ Files ignored due to path filters (1)
  • packages/perry-solid/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (79)
  • changelog.d/4644-retained-growth-verifier.md
  • changelog.d/9830-gc-trigger-path-hot-tls.md
  • changelog.d/9831-idle-reclaim-elapsed-rearm.md
  • changelog.d/9840-regexp-header-nursery.md
  • changelog.d/9845-gc-page-class-direct-table.md
  • changelog.d/9864-for-in-callback-roots.md
  • changelog.d/9865-solid-jsx.md
  • changelog.d/9868-formdata-upload.md
  • changelog.d/9869-visited-levels-rooting.md
  • changelog.d/gc-churn-attribution-diag.md
  • changelog.d/native-instance-binding-scope-9847.md
  • changelog.d/regex-backtracking-cliff.md
  • changelog.d/regex-borrowed-cache-keys.md
  • changelog.d/regex-engine-prototype-switch.md
  • crates/perry-codegen/src/lower_call/options/fetch.rs
  • crates/perry-codegen/src/runtime_decls/strings_part2.rs
  • crates/perry-hir/src/lib.rs
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/expr_assign.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-hir/src/solid_jsx.rs
  • crates/perry-hir/tests/native_instance_binding_scope.rs
  • crates/perry-runtime/src/arena/allocators.rs
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/page_meta/mod.rs
  • crates/perry-runtime/src/arena/page_meta/page_class.rs
  • crates/perry-runtime/src/arena/page_meta/tests.rs
  • crates/perry-runtime/src/arena/tests.rs
  • crates/perry-runtime/src/arena/tests_promoted_runs.rs
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/dead_owner.rs
  • crates/perry-runtime/src/gc/idle_reclaim.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/oldgen.rs
  • crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs
  • crates/perry-runtime/src/gc/tests/idle_reclaim.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/tests/rooted_for_in.rs
  • crates/perry-runtime/src/hot_diag.rs
  • crates/perry-runtime/src/object/descriptor_state.rs
  • crates/perry-runtime/src/object/field_get_set/enumeration.rs
  • crates/perry-runtime/src/object/field_get_set/enumeration_tests.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/proxy/reflect.rs
  • crates/perry-runtime/src/regex.rs
  • crates/perry-runtime/src/regex/lazy.rs
  • crates/perry-runtime/src/regex/tests.rs
  • crates/perry-runtime/src/tls_hot.rs
  • crates/perry-stdlib/src/fetch/body_metadata.rs
  • crates/perry-stdlib/src/fetch/dispatch.rs
  • crates/perry-stdlib/src/fetch/mod.rs
  • crates/perry-stdlib/src/fetch/request_ctor.rs
  • crates/perry/src/commands/compile/collect_modules.rs
  • crates/perry/src/commands/compile/host_config.rs
  • crates/perry/src/commands/compile/types.rs
  • crates/perry/src/main.rs
  • crates/perry/tests/solid_jsx_config.rs
  • packages/perry-solid/README.md
  • packages/perry-solid/examples/counter.tsx
  • packages/perry-solid/package.json
  • packages/perry-solid/src/index.ts
  • packages/perry-solid/src/jsx-runtime.ts
  • packages/perry-solid/src/renderer.ts
  • packages/perry-solid/test/jsx/.gitignore
  • packages/perry-solid/test/jsx/host.ts
  • packages/perry-solid/test/jsx/main.tsx
  • packages/perry-solid/test/jsx/oracle.cjs
  • packages/perry-solid/test/jsx/package.json
  • packages/perry-solid/test/native-smoke.tsx
  • packages/perry-solid/tsconfig.json
  • scripts/addr_class_allowlist.txt
  • scripts/gc_runtime_root_holders.json
  • scripts/shape_descriptor_census.py
  • scripts/thread_local_cold_allowlist.json
  • test-files/test_gap_gc_for_in_proxy_callback_roots.ts
  • test-files/test_issue_9842_form_data_blob_upload.ts
  • test-parity/gc_repsel_corpus.txt
  • tests/release/packages/perry-solid/expected-jsx.txt
  • tests/release/packages/perry-solid/fixture.sh

📝 Walkthrough

Walkthrough

Changes

Runtime and garbage collection

Layer / File(s) Summary
Direct page-class cache
crates/perry-runtime/src/arena/page_meta/*, scripts/*
Page-generation classification uses a direct table with rebasing, epoch invalidation, diagnostics, fallback behavior, and updated audits.
Elapsed idle reclaim
crates/perry-runtime/src/gc/idle_reclaim.rs, crates/perry-runtime/src/gc/tests/idle_reclaim.rs
Idle reclaim retries after elapsed intervals with exponential backoff and maximum-shift disarming.
RegExp lifecycle and descriptor handling
crates/perry-runtime/src/regex.rs, crates/perry-runtime/src/gc/*, crates/perry-runtime/src/object/descriptor_state.rs
RegExp headers use nursery allocation and explicit cleanup paths. Descriptor summaries support RegExp metadata.
GC-safe enumeration
crates/perry-runtime/src/object/field_get_set/*, crates/perry-runtime/src/proxy/reflect.rs, crates/perry-runtime/src/gc/tests/rooted_for_in.rs
Enumeration and Proxy descriptor callbacks use rooted handles across moving collections.
Runtime diagnostics
changelog.d/gc-churn-attribution-diag.md, changelog.d/9830-gc-trigger-path-hot-tls.md
The changelog records GC attribution, allocation-site sampling, and hot TLS coverage changes.

Solid JSX compilation

Layer / File(s) Summary
Solid JSX lowering and compiler wiring
crates/perry-hir/src/solid_jsx.rs, crates/perry/src/commands/compile/*
The compiler lowers Solid JSX when configured through package or TOML settings.
Solid runtime surface
packages/perry-solid/src/*, packages/perry-solid/tsconfig.json
The package exports JSX types, runtime bindings, intrinsic mappings, and TypeScript JSX configuration.
Solid validation and examples
packages/perry-solid/test/jsx/*, packages/perry-solid/examples/counter.tsx, tests/release/packages/perry-solid/*
Fixtures test reactivity, components, keyed lists, refs, spreads, fragments, disposal, and release output.

FormData multipart requests

Layer / File(s) Summary
FormData values and FFI
crates/perry-codegen/src/lower_call/options/fetch.rs, crates/perry-stdlib/src/fetch/*
FormData append and set accept filenames and preserve Blob/File metadata.
Multipart request integration
crates/perry-stdlib/src/fetch/mod.rs, crates/perry-stdlib/src/fetch/request_ctor.rs, test-files/test_issue_9842_form_data_blob_upload.ts
Fetch and Request serialize FormData bodies and set generated content types while preserving explicit headers.

Native-instance binding scope

Layer / File(s) Summary
Binding-specific registration and lookup
crates/perry-hir/src/lower/*
Native-instance tags use resolved LocalId values before the module-wide fallback.
Regression coverage
crates/perry-hir/tests/native_instance_binding_scope.rs
Tests cover binding isolation, native dispatch, cross-function lookup, and identifier spelling changes.

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

Sequence Diagram(s)

Solid JSX compilation

sequenceDiagram
  participant ProjectConfig
  participant PerryCompiler
  participant SolidJsxLowerer
  participant PerrySolidRuntime
  ProjectConfig->>PerryCompiler: select jsx: "solid"
  PerryCompiler->>SolidJsxLowerer: lower parsed JSX module
  SolidJsxLowerer->>PerrySolidRuntime: emit createElement and createComponent calls
  PerryCompiler->>PerrySolidRuntime: link generated runtime imports
Loading

FormData request serialization

sequenceDiagram
  participant JavaScript
  participant FormData
  participant FetchOrRequest
  JavaScript->>FormData: append or set Blob, File, or text
  FetchOrRequest->>FormData: serialize multipart body
  FormData-->>FetchOrRequest: bytes and generated content type
  FetchOrRequest-->>JavaScript: construct request with body and headers
Loading
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch train132

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