Merge train: 10 PRs (#9755, #9801–#9813) + gate follow-ups - #9817
Merged
Conversation
added 27 commits
September 5, 2026 16:58
A minor-scoped root scan — the copying minor's preflight/mark/rewrite passes and a budgeted `GcCollectionKind::Minor` trace — can neither move nor sweep an old-generation object, so a side-table entry whose key and values are all old is a provable no-op for it. Every registered scanner still walked its whole table on every such pass: on the compiled claude-code TUI that is ~35k shape families, ~120k descriptors and ~13k closure-prop owners per walk, three walks per copying minor, 41 minors per streamed reply, all reporting `slots=0` — 34–56 ms of scanner time per minor (`[gc-scanner-profile]`, 2026-09-04), and the same walk again in every budgeted minor's initial root scan and final remark. Each of the five tables that dominated that profile — closure dynamic props/prototypes/deleted keys, string-keyed descriptors, the shape family + slot-index maps, the transition cache and the shape cache — now keeps a young-entry log (`gc/young_log.rs`): the keys of entries that may hold a pointer a minor can act on (nursery, longlived, malloc-GC). Every writer notes the key BEFORE publishing the entry; a minor-scoped scanner visits only the logged keys, with the same per-entry body as the full walk, and re-logs an entry iff it is still relevant afterwards; a full trace walks everything and rebuilds the log. The copied-minor and fallback-minor dead-owner prunes of the same tables iterate the log too (only a young owner can be dead on a minor, and a young owner is always logged). The visitor carries the scope (`RuntimeRootVisitor::young_scope`, set for the copying passes and for a minor-only budgeted trace). Three rules from the design note (perry-young-gc-fixed-cost.md): 1. arm before publish — each note precedes the insert; 2. machine-check the writer set — under `debug_assertions` a minor-scoped walk first re-derives the relevant set from the authoritative table and panics on any key the log does not name; this caught two sites while landing (the migrate-after-delete slot-index insert, and the from-space index key that outlives its family's mark-pass move); 3. a skip needs a counter — `[gc-young-log]` prints per table and cycle how many keys were logged / visited / kept and the table size, and the tests read the rows back. Also: the post-minor `restore_surviving_dirty_coverage` (#5029), which re-walked every slot of every object on the pre-cycle dirty pages, now skips the objects the minor's own dirty scan visited completely (every slot on a dirty page and inside the body) — for those the scan's per-slot re-remembering is the same predicate on the same value, so the walk could only re-insert pages already restored. The budgeted cycle keeps the full walk: it interleaves with the mutator, and a store into an already-dirty page leaves no trace. Under `debug_assertions` the skipped objects are still walked and any page the walk would have added panics; `[gc-restore-coverage]` prints objects walked/skipped and pages added. Tests: `gc::tests::young_log_tests` (per table: a young entry reachable only through the table moves and is re-keyed through the partial walk; an old entry adds no visit; a dead young owner is pruned from the log), plus the whole `gc::` suite (1048) with the rule-2 assertions active. Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2
…he size gate `cargo fmt` output plus the `scripts/check_file_size.sh` splits this change needs: it took `object/mod.rs` 1998 -> 2208, `object/descriptor_state.rs` 1815 -> 2044, `gc/roots.rs` 1994 -> 2027 and `gc/cycle.rs` 1998 -> 2023, and three of those four sat within two lines of the 2000-line limit on main. Each split follows the gate's own recipe (extract a function group into a sibling, re-export by name) and each is a group that already read as a unit: * `object/side_table_roots.rs` — the transition-cache and shape-cache root scanners and dead-owner prunes, now four full-walk/minor-scoped pairs. * `object/descriptor_state/young.rs` — the minor-scoped descriptor walk and the re-derivation of the relevant set rule 2 checks it against. * `gc/roots/stack_bottom.rs` — the four `#[cfg]` arms of `get_stack_bottom`. The doc comment on the first arm describes a trace-phase mark helper rather than `get_stack_bottom`; it was already attached to that item and moves with it verbatim rather than being re-pointed at the next item. * `gc/cycle/registered_root_scan.rs` — the two registered-root scan cursors the budgeted root scan resumes through. `scripts/gc_rekeyed_key_tables.json` follows `scan_transition_cache_slot` to its new file (the gate reported it as one UNCLASSIFIED site and one STALE entry, which is the gate working), and the two `#[cfg(test)]` transition-cache seams are re-exported for `gc::tests::dead_owner_side_tables`. No behaviour change: every moved item keeps its body, and visibility widens only to the narrowest scope the new boundary needs. Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2
Sabotage audit of this change's own writer set, prompted by the fact that the
GC gates that would independently catch a missed root (`gc-native-roots`,
`gc-root-dominance`, `gc-ratchet`, `gc-stress`) are red on main and cannot
vouch for it. Deleting the production arm site in `shape_cache_insert` and
running the suite gave **11 passed / 0 failed**: the deletion was invisible.
Three defects, all in the verification rather than the mechanism:
1. **Three `#[cfg(test)]` seeds re-implemented the arming** instead of using
the writer's, so the tests validated a rule that was not the one shipping,
and the production arm sites were never exercised. The transition cache's
two seeds did not even carry the same predicate — production arms on
`addr_is_minor_relevant(next_keys) || (len_marker == 0 &&
addr_is_minor_relevant(kid))`, `test_seed_transition_cache_entry` on
`... || addr_is_minor_relevant(key_ptr)` (classifying a packed length as an
address whenever a marker was set), and `test_seed_transition_cache_root`
on `next_keys` alone. Both caches now arm through one helper —
`arm_shape_cache_young` / `arm_transition_cache_young` — that every writer,
production and seed, calls; a predicate cannot now be right in one writer
and wrong in another.
2. **The young-log tests drove the seeds, not the writers.** They now go
through `test_shape_cache_insert` / `test_transition_cache_insert`, which
are nothing but calls to `shape_cache_insert` / `transition_cache_insert`
— a seam with logic of its own is what let a deleted arm stay green.
3. **`debug_assert_logged` (rule 2) is compiled out of `--release`**, so no
release `cargo test` run has ever enforced rule 1. The new `gcaudit`
profile is release codegen with debug assertions on, which is what the
audit below was run under.
Also adds the test for the clause no seed ever exercised: a young interned KEY
under an OLD target, which arms only through the `kid` half of the production
predicate.
Audit result, one build with each arm site suppressed in turn (21 sites):
14 fail a test when removed, and the failure is rule 2's own diagnostic
("young log for <table> does not name <key> ..."). Seven do not, because no
test exercises their path at all — `transfer_descriptor_owner`,
`install_fresh_accessor_property`, `set_builtin_accessor_descriptor`,
`ShapeTableInner::family_push_front`, `shape_slot_lookup_verdict`,
`shape_keys_grown` and `shape_index_migrate_after_delete` (the last four are
the whole of the `shapes.indices` arming). They are recorded in the PR rather
than silently left: rule 2 checks any test that reaches them, so closing them
is a matter of exercising the paths, not of writing per-site assertions.
Full suite under `--profile gcaudit`: 3153 passed, 0 failed, and no rule-2
violation anywhere.
Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2
…ncovered The arm-site audit reported seven production sites whose removal failed no test. Four of them were the ENTIRE arming of `shapes.indices` — the table #9756 restructures into 4-byte cells — so that PR was changing a table whose rule-1 writers nothing exercised. A missed `note` there is a keys array the minor does not visit and therefore does not keep: a collected live object, found later as a wrong property read, not as a red test. Four tests, one per site, each driving the production writer: * `building_a_slot_index_on_a_young_keys_array_arms_the_log` — `shape_slot_lookup_verdict`'s `build` arm, reached through `shape_slot_lookup(.., build = true)` on a 40-key young array (above `KEYS_INDEX_THRESHOLD`, or no index is built at all). * `growing_an_indexed_keys_array_arms_the_log_for_the_new_address` — `shape_keys_grown`, the owned-array grow migration. * `migrating_an_index_after_a_delete_arms_the_log_for_the_new_address` — `shape_index_migrate_after_delete`, which needs a COMPLETE index (`indexed_len >= old_key_count`) or it declines and never arms. * `installing_an_external_shape_id_arms_the_family_log` — `ShapeTableInner::family_push_front`, reached through `install_external_shape_id`. Each asserts the accelerator followed its keys array across a copying minor, but the load-bearing check is rule 2: the minor-scoped walk re-derives the relevant set from `indices` and `families` and panics on any key the log does not name. Suppression audit, each site removed in turn from one build — every one now fails, with rule 2's own diagnostic ("young log for shapes.families+indices does not name <addr> ..."), and each fails exactly the test written for it: | site | test that catches its removal | |---|---| | `family_push_front` | `installing_an_external_shape_id_arms_the_family_log` | | `shape_slot_lookup_verdict` build arm | `building_a_slot_index_on_a_young_keys_array_arms_the_log` | | `shape_keys_grown` | `growing_an_indexed_keys_array_arms_the_log_for_the_new_address` | | `shape_index_migrate_after_delete` | `migrating_an_index_after_a_delete_arms_the_log_for_the_new_address` | The three remaining uncovered sites — `transfer_descriptor_owner`, `install_fresh_accessor_property`, `set_builtin_accessor_descriptor` — are on ground neither PR restructures and are recorded as known-uncovered in the PR rather than half-covered here. The test seams added for this (`test_build_slot_index`, `test_shape_index_migrate_after_delete`, `test_install_external_shape_id`) are pass-throughs to the production functions, reachable from `gc::tests` because `shapes_slot_list` and `keys_lookup` are private modules; they carry no logic of their own, which is the property whose absence caused the original gap. Whole suite under `--profile gcaudit`: 3153 passed, 0 failed.
…ach cycle The alloc census on this branch shows the logs paying back part of what the scanners saved: whole-process allocated volume moves only -1.0 %, while allocation COUNT rises +29.2 % (30.9 M -> 39.9 M calls per 400-char reply), concentrated in one size class (8.33 M -> 15.53 M). The logs replaced a small number of large rehashes with a large number of small allocations. Part of that is structural in the logs themselves: `take_sorted` used `std::mem::take`, which leaves a Vec of ZERO capacity behind, so every note made during the walk — and every note until the next collection — re-grew the log from empty, and each walk allocated a fresh `kept` Vec that was then dropped. On the compiled claude-code TUI those are 20k-entry Vecs rebuilt per table per collection, which is the same allocate-from-scratch shape the logs were added to remove from the scanners, reintroduced one level down. `YoungLog` now keeps a `spare` buffer: `take_sorted` swaps it in rather than leaving nothing behind, `take_spare` hands it to a walk for its `kept` list, and `extend`/`stash_spare` round both back, keeping whichever has the larger capacity. The five minor-scoped walks take their `kept` buffer from the log instead of `Vec::new()`. No behaviour change: the log's contents, ordering and dedup are what they were — only the allocations behind them are reused. Whole suite under `--profile gcaudit` (debug assertions, so rule 2 is live): 3153 passed, 0 failed. Magnitude is not yet measured on this branch: attributing the remaining count needs `PERRY_ALLOC_CENSUS` (#9771) built against it, which is the next step. The mechanism is not in doubt — a zero-capacity Vec regrown to 20k entries per table per collection — but how much of the +7.2 M this recovers is not claimed here.
Rebasing onto main brings in #9768's `family_append_fresh`, the append that skips `IdList`'s membership scan for a freshly allocated id. It is the append `shape_descriptor_intern` uses, and it did not exist when this branch added rule-1 arming to `family_push_back` / `family_push_front`, so the rebase merges clean and silently drops the note for every freshly interned descriptor. `keys` is the canonical keys array's ADDRESS and the minor-scoped rekey scanner visits only logged keys, so an unlogged family is invisible to a copying minor: the keys array moves, the family stays filed under the old address, and the descriptor is lost. Both intents kept — the membership scan stays gone, the note comes back. Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2
The three compiled-program caches cap independently and clear wholesale, and compile_and_cache_regex_checked returns early on a REGEX_CACHE hit, so a pattern whose real program lives in FANCY_CACHE (its REGEX_CACHE entry being the never-match placeholder) lost that program permanently once FANCY_CACHE overflowed while the placeholder survived. lookup_fancy_regex treats a built header as authoritative and site_cache::install_programs memoizes the triple against the pattern text, so the consequence is not one bad header but every later construction of that literal. Repair the missing program before publishing and before memoizing. The same shape applies to REPEAT_MATCHER_CACHE, where the wrong answer is the linear engine's capture assignment instead of ECMA-262's.
… shipped The comment described the group-clear approach that was built first and dropped — it closes the route into the incoherent state but cannot repair a header already in it, which is why the test still failed against it. The fix that shipped repairs the header in `build_and_install_programs` before publishing it and before `site_cache::install_programs` memoizes the triple. Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2
changelog.d/README.md asks for `<PR-number>-<slug>.md`; the fragment landed unnumbered. Rename only — the entry text and the code are unchanged. Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m
… shape
`PERRY_IC_DIAG` counted misses but could not say WHY a site that primes on
every read keeps missing. Two explanations demand opposite fixes, and the miss
table cannot tell them apart:
* the receiver's shape really changed between reads (polymorphism — widen or
re-tier the ways), or
* the site was re-primed with the shape it already held (the cache holds the
right answer and nothing consulted it — priming/invalidation or IC layout).
`pic_prime_get` is the one place where both candidate answers are still live:
`prev_tok`, `token`, the four ways and `PIC_WAY_STATE` are all in registers
immediately before the write that destroys them. So the split is recorded
there, three ways:
* `same_token` — re-primed the MRU shape,
* `new_token` + `in_ways` — the token was ALREADY in one of the four ways,
so the polymorphic cache held it and the read reached the handler anyway,
* `new_token`, not in a way — a shape neither the MRU entry nor the ways had.
plus a way-state census at prime time (`fresh` / `armed` / `megamorphic`), so a
site latched off by `PIC_MEGAMORPHIC_EVICTIONS` is distinguishable from one
still trying. Reported globally and per site.
Miss rows are now keyed by the RESOLVED cache rather than by the slot that
points at it, because a prime only ever sees the resolved cache; without that
the two halves of one site would never merge into one row. A site that has
never primed keeps its slot as the key (it has no prime rows to merge with).
Diagnostic only: every probe sits behind `ic_on()`, and the values it reads are
ones the caller already has.
…ever had #5391 path 3 replaces the inline generic-get diamond with a single `js_object_get_field_ic` call in oversized modules, and the diamond's monomorphic fast-load went with it. Nothing replaced it: the helper observed typed feedback and then called `js_object_get_field_ic_miss` UNCONDITIONALLY, on every read of every heap receiver. The per-site cache was written by every read and consulted by nobody. The threshold that turns full-outlining on (4,000 callables) is met by the whole MODULE, so on a minified bundle every generic property read in the program takes that path. `nm -u` on the compiled claude-code object is the proof: it references `js_object_get_field_ic` and does not reference `js_object_get_field_ic_miss` at all — there is no inline diamond anywhere in the binary, so nothing was ever positioned to hit. Measured with `PERRY_IC_DIAG`'s prime split, one 400-character reply: 2,663,424 entries to the miss handler over 12,326 sites; 2,122,626 of them primed; and **95.2 % of those primes wrote the token the site's MRU entry already held**. The four hottest sites (`.done`, `.ambiguousAsWide`, `.value`, `.segment`, ~195k reads each) each recorded exactly ONE new-token prime and ~195k same-token primes with `PIC_WAY_STATE` still 0 — perfectly monomorphic sites, a cache holding the right answer, and the whole miss ladder walked every time. So these were never misses: they are every property read in the program. `pic_outlined_mru_hit` reads the cache the same path already writes. Its guards are `lower_generic_property_get`'s, one for one and in the same order — real heap pointer, `GC_TYPE_OBJECT`, `OBJ_FLAG_HAS_DESCRIPTORS` clear, non-zero shape stamp equal to the cached token, no `IC_SLOT_OVERFLOW_BIT`, no `TAG_HOLE` — and the raw header loads are the ones it emits, licensed by the same already-established pointer tag. Anything it declines still reaches the handler, so this only ever removes work. Word 2 (the Array-subclass named-prefix token) and the polymorphic ways are deliberately left to the handler: 2.5 % of primes between them, and each needs its own proof. `PERRY_IC_OUTLINE_FASTPATH=0` restores the old behaviour for a same-binary A/B.
…gs` job rejects Three checks were failing on PR #9802, none of them for a reason in its own diff: * `self-test-checkers` — `check_thread_locals.py` rejected the two raw `thread_local!` blocks in `hot_diag.rs`. They are not this PR's: main fixed them in 5112112, and the branch was based on 1d63fa9. Rebasing onto main is the fix; nothing here touches them. * `lint` — the fragment must be named `changelog.d/<PR-number>-<slug>.md` per `changelog.d/README.md`, so `outlined-ic-monomorphic-hit.md` did not count as a fragment at all. Renamed. * `warnings` — `cargo check -p perry --bins` builds `perry-runtime` WITHOUT `regex-engine`, and every use of `HashMap` in `regex.rs` sits inside a `#[cfg(feature = "regex-engine")]` block (the four caches and `evict_regex_cache_if_full`), so the unconditional import is an unused-import error under `-D warnings`. The import now carries the same cfg as its uses. The last one is a pre-existing defect on main — `regex.rs` is byte-identical at 1d63fa9 and at c7361c8 — surfaced by this PR only because it is one of the PRs whose `warnings` job ran to completion. It is a one-line attribute in another lane's file, kept minimal for that reason. Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m
This was referenced Sep 5, 2026
Closed
A Proxy used as an array prototype is treated as absent — its traps never run on indexed reads
#9786
Closed
@@iterator leaks as a string own key, and a well-known-symbol prototype replacement is ignored
#9788
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Merge train: 10 PRs — #9755, #9801, #9802, #9803, #9805, #9806, #9809, #9811, #9812, #9813 — plus gate follow-ups.
Gate work
PASS1_MARKED'snon_moving_snapshotwindow re-pinned after perf(gc): young-entry logs for the side-table root scanners #9755 restructuredgc/cycle.rs— the pin's own owner file, so this needed a real audit rather than a bump. perf(gc): young-entry logs for the side-table root scanners #9755's hunks are all root-scan machinery (RootScanSubphase,RootScanCycleState, the mutable-scanner iteration state), which runs before mark propagation completes, andgc/mod.rsgains only amod young_log;declaration. The bracketing is unchanged —census_pass1_if_armedinsidestep_mark_propagation,census_take_if_armed_at_full_sweep_startinsidestep_sweep— and a synchronous full mark-sweep still moves nothing between them.TRANSITION_CACHE_YOUNGandSHAPE_CACHE_YOUNGclassified. Both areYoungLog<u32>remembered sets holding transition-cache slot indices and shape-cache ids — au32cannot hold a 48-bit pointer — and the pointer-bearing entries they index are visited by registered scanners.visit_raw_mut_ptr_slot(&mut entry.keys_array)into the newobject/side_table_roots.rs.regex.rscrossed the 2000-line cap by one line, so thereplaceAll/matchAllnon-global receiver guards moved toregex/global_guards.rs, gated onregex-enginelike their siblings. Both feature configurations were checked — a--features regex-enginebuild passes while the default one fails when a moved item sits behind that gate.write_text/read_textcalls intests/test_gc_ratchet.pynow passencoding="utf-8". These run inwindows-build's GC structural audits, the first step of the job, where aUnicodeDecodeErrorwould skip the only Windows run of the perry-runtime unit tests (ci(windows):check_thread_locals.pyreads Rust sources as cp1252 — GC structural audits fail before any Windows test runs #7977).Validation
64/64 lint gates; release build;
perry-runtime,perry-codegen,perry-stdlib,perry-hir,perry-transform— all green (RUST_TEST_THREADS=1).