Repository navigation
Layer 1 campaign: migrate perry-codegen's lowerings onto the rooting-by-construction API (88 modules, 262 hazard sites) #7615
Description
Layer 1 of the GC-correctness plan (docs/engine-plan.md, docs/src/internals/rfc-rooting-by-construction.md) is the only track that was never started. This issue is its campaign map: the ordered inventory of perry-codegen modules to migrate onto the crate::rooting combinators, one slice per PR.
Routing: linked from #7294. Design: #7459 (the RFC's own constructor was E0499, corrected). First lowering: #7461. Template slice: the PR that opens this issue.
What "migrated" means
Two properties, both checked by cargo test -p perry-codegen --lib rooting:::
- The module names no
expr::temp_rootsymbol. That module is the raw, order-sensitive API — push / get / set / truncate, plus guards the caller must remember to release — and every bug in the gc(layer 3): from-space quarantine catches 55 stale dereferences across the gap suite — the instrument is in CI but aimed at one synthetic fixture #7341 family was an ordering mistake against it. It is the escape hatch the RFC's step 3 says to#[deny]per module. Rust has no attribute that denies calling apub(crate)function from one module, so the deny is a test over the module's own source, inlined byinclude_str!. - No raw heap pointer exists as a value the lowering can hold across a collection point.
rooting::call_rootedreturns a slot, never a register;rooting::call_with_rootsre-reads each slot as part of emitting the consuming call;rooting::with_operands_rootedowns the release on every path out, including?.
A slice finishes by adding its modules to MIGRATED_MODULES in crates/perry-codegen/src/rooting.rs. Removing a line is a regression, not a cleanup.
Acceptance per slice
- Emitted IR byte-identical (normalised for register/label renumbering) on a program set that exercises the module's lowerings, or every difference itemised and justified — a difference is normally a real rooting bug the migration found, which is the point.
gc-root-dominancecorpus green in both gated modes, including--seeded-violations 40.cargo test -p perry-codegen --lib,-p perry-runtime --no-fail-fast.- The gap-test families that touch the module's lowerings, identical failure set on both arms.
Honest statement of what this buys
Measured on the template slice by sabotaging the migrated module four ways:
| reintroduced shape | compiles? | caught by |
|---|---|---|
#7192 in the borrow form (RootingEmitter, the RFC's design) |
NO — E0499 | rustc, via compile_fail doctest |
hold the call_with_roots result across a later lowering |
yes | nothing |
the verbatim pre-#7453 code, via a bare ctx.block().call |
yes | nothing — see below |
reach back into expr::temp_root |
yes | the ledger test |
| hold the operand guard so it can be released on one arm (#7462) | yes | the ledger test |
So: on the real emitter the API does not make the bug fail to compile. FnCtx has no interior mutability, so the borrow-carrying Raw<'e> cannot be built on it (#7459/#7461 established this). What the migration buys is that the correct form is the only one the API can express, that the release cannot be branch-conditional, and that the escape hatch is denied per module. Getting an actual compile error needs the RefCell'd emitter of the RFC's step 1 — a separate, wider change.
Ordering
Hazard density first, because that is where the bug lives. haz counts sites where a raw-pointer binding is used after an intervening emission — the #7453/#7192 shape, measured on the emitter's own source (heuristic, over-reports; it is a worklist, not a verdict). raw counts .call(I64, "js_*") sites, which by perry's convention return a raw heap pointer (NaN-boxed values come back as DOUBLE). root counts existing rooting references, i.e. how much of the module is already hand-rooted.
Whole campaign: 88 modules, 694 raw sites, 262 hazard sites, ~79k lines.
Slice 0 — DONE (the template)
History: #7453 #7461 #7462 #7463 #7464 #7465
1 modules · 669 lines · 11 raw sites · 7 hazard sites
| module | lines | raw | haz | root | issues named in the file |
|---|---|---|---|---|---|
expr/url_main.rs |
669 | 11 | 7 | 4 | #7463 #7462 #7461 #7459 #7453 #7341 |
Slice 1a — array methods (largest hazard population)
1 modules · 1200 lines · 37 raw sites · 40 hazard sites
| module | lines | raw | haz | root | issues named in the file |
|---|---|---|---|---|---|
lower_array_method.rs |
1200 | 37 | 40 | 0 | #6229 #4091 #3148 #2814 #2808 #2805 |
Slice 1b — array search/copy methods
2 modules · 1564 lines · 40 raw sites · 26 hazard sites
| module | lines | raw | haz | root | issues named in the file |
|---|---|---|---|---|---|
expr/arrays_finds.rs |
1204 | 28 | 23 | 0 | #7590 #6996 #6229 #6088 #5067 #4091 |
expr/array_methods.rs |
360 | 12 | 3 | 0 | #2838 #2836 #2816 #2013 #1312 #1177 |
Slice 2 — instance methods + collections
3 modules · 3209 lines · 60 raw sites · 40 hazard sites
| module | lines | raw | haz | root | issues named in the file |
|---|---|---|---|---|---|
expr/instance_misc1.rs |
1776 | 36 | 17 | 13 | #7210 #7154 #6364 #6301 #6003 #5834 |
expr/logical_collections.rs |
1052 | 24 | 14 | 21 | #7200 #7198 #7154 #6951 #5560 #5559 |
lower_call/property_get/map_set.rs |
381 | 0 | 9 | 49 | #6970 #2872 #2856 #2830 |
Slice 3 — literal accumulators (#7211 / #7280 home; NOT IR-identical)
History: #6951 #7211 #7280 #7602
4 modules · 1776 lines · 18 raw sites · 7 hazard sites
| module | lines | raw | haz | root | issues named in the file |
|---|---|---|---|---|---|
expr/objects_arrays_lit.rs |
116 | 5 | 1 | 15 | #7280 #6971 |
expr/array_literal.rs |
269 | 3 | 0 | 8 | #6951 #5391 #1146 #1098 |
expr/object_literal.rs |
437 | 2 | 0 | 21 | #6951 #1098 |
expr/array_push.rs |
954 | 8 | 6 | 0 | #7590 #7574 #7511 #7469 #6904 #5459 |
Slice 4 — computed reads/writes (#7206 home)
History: #7206
9 modules · 6189 lines · 10 raw sites · 8 hazard sites
| module | lines | raw | haz | root | issues named in the file |
|---|---|---|---|---|---|
expr/index_get.rs |
1404 | 0 | 1 | 8 | #7574 #7192 #7154 #6904 #6750 #6132 |
expr/index_get/guarded_array.rs |
— | — | — | — | (no raw-pointer site; listed for completeness) |
expr/index_get/inline_dyn_typed_array.rs |
— | — | — | — | (no raw-pointer site; listed for completeness) |
expr/index_set.rs |
1755 | 7 | 0 | 32 | #7590 #7341 #7207 #7201 #7154 #6011 |
expr/index_set_typed_array.rs |
— | — | — | — | (no raw-pointer site; listed for completeness) |
expr/property_get.rs |
1737 | 3 | 0 | 0 | #7153 #7128 #6710 #6588 #6003 #5924 |
expr/property_get/globalget.rs |
427 | 0 | 6 | 0 | #6674 #4033 #3527 #2904 #1374 #1373 |
expr/property_get/helpers.rs |
866 | 0 | 1 | 0 | #7480 #7153 #5093 |
expr/property_set.rs |
— | — | — | — | (no raw-pointer site; listed for completeness) |
Slice 5 — call / construct paths (#7192 / #7207 home)
14 modules · 11138 lines · 70 raw sites · 9 hazard sites
| module | lines | raw | haz | root | issues named in the file |
|---|---|---|---|---|---|
lower_call/new.rs |
1989 | 4 | 0 | 17 | #7552 #7536 #7535 #7532 #7525 #7512 |
lower_call/new_ctor_args.rs |
400 | 3 | 0 | 0 | #6538 #6537 #5437 #740 #672 |
expr/call_spread.rs |
547 | 27 | 5 | 0 | #6475 #653 |
expr/this_super_call.rs |
1272 | 3 | 1 | 0 | #6710 #6326 #5437 #5137 #5127 #1787 |
expr/super_method.rs |
442 | 6 | 1 | 0 | #1126 #774 |
expr/new_dynamic.rs |
758 | 4 | 1 | 0 | #5437 #5253 #4699 #3680 #3663 #3142 |
lower_call/property_get/dynamic_dispatch.rs |
1416 | 4 | 0 | 4 | #7211 #7143 #7142 #6927 #5437 #5391 |
lower_call/property_get/static_dispatch.rs |
387 | 5 | 1 | 2 | #7211 #5703 #1788 #1787 #915 #912 |
lower_call/property_get/number_string.rs |
238 | 6 | 0 | 0 | #3146 #2864 |
lower_call/property_get/promise_chain.rs |
172 | 3 | 0 | 0 | #1030 #1008 #973 |
lower_call/func_ref.rs |
922 | 1 | 0 | 5 | #7240 #7211 #7154 #4785 #3576 #1816 |
lower_call/early_branches.rs |
985 | 1 | 0 | 6 | #7211 #6369 #6328 #5030 #3576 #1113 |
lower_call/scalar_method.rs |
1161 | 2 | 0 | 0 | #5094 |
lower_call/mod.rs |
449 | 1 | 0 | 32 | #7592 #7510 #7240 #7154 #6951 #6326 |
Slice 6 — strings / regex
2 modules · 2623 lines · 71 raw sites · 6 hazard sites
| module | lines | raw | haz | root | issues named in the file |
|---|---|---|---|---|---|
lower_string_method.rs |
1958 | 37 | 2 | 37 | #7128 #6971 #6951 #5906 #4871 #3987 |
expr/string_regex_proc.rs |
665 | 34 | 4 | 0 | #6666 #4091 #2789 #2788 #2013 #618 |
Slice 7 — builtins, math, misc
16 modules · 16547 lines · 171 raw sites · 32 hazard sites
| module | lines | raw | haz | root | issues named in the file |
|---|---|---|---|---|---|
lower_call/builtin.rs |
1710 | 41 | 2 | 0 | #5458 #4915 #4103 #3985 #3657 #2875 |
expr/misc_methods.rs |
1033 | 25 | 10 | 0 | #6709 #4091 #2826 #2804 #2770 #1401 |
expr/math_simple.rs |
1255 | 16 | 1 | 14 | #6970 #4091 |
expr/os_uri_dates.rs |
512 | 23 | 5 | 0 | #2851 #2796 #1187 |
expr/env_clones.rs |
422 | 19 | 5 | 0 | #4546 #3917 #2013 #1123 #630 #600 |
expr/bigint_set.rs |
1372 | 15 | 5 | 0 | #4091 #2796 |
expr/compare.rs |
629 | 4 | 2 | 0 | #3576 |
expr/literals_vars.rs |
865 | 10 | 0 | 0 | #7055 #6369 #6003 #1380 #1343 #915 |
expr/static_field_meta.rs |
828 | 5 | 2 | 18 | #7211 #7154 #6951 #6654 #6523 #6438 |
expr/static_method.rs |
390 | 5 | 0 | 0 | #5924 #5922 #4831 #4656 #2582 #894 |
expr/helpers.rs |
636 | 3 | 0 | 0 | #7469 #6921 #6011 #4511 #2905 #2875 |
expr/binary.rs |
730 | 1 | 0 | 13 | #6951 #6904 #6884 #5525 |
expr/pod_record.rs |
549 | 1 | 0 | 0 | — |
expr/index.rs |
643 | 1 | 0 | 0 | #7396 #6904 #5094 #1098 |
expr/i32_fast_path.rs |
1906 | 1 | 0 | 0 | #7232 #6593 #5466 #1098 |
expr/mod.rs |
3067 | 1 | 0 | 4 | #7603 #7590 #7573 #7480 #7469 #7404 |
Slice 8 — leaves: crypto, native, ext, fetch
27 modules · 14252 lines · 174 raw sites · 82 hazard sites
| module | lines | raw | haz | root | issues named in the file |
|---|---|---|---|---|---|
lower_call/options/fetch.rs |
1287 | 50 | 1 | 0 | #4915 #2638 #1688 #1649 #1211 #1099 |
lower_call/options/abort.rs |
152 | 2 | 1 | 0 | #1099 #1097 |
lower_call/options/mod.rs |
100 | 2 | 0 | 0 | #1099 #1097 |
lower_call/extern_func.rs |
1799 | 15 | 0 | 0 | #7210 #7154 #6087 #5812 #5626 #5621 |
lower_call/extern_timers.rs |
276 | 14 | 0 | 15 | #7210 #2013 #1671 #1213 #665 |
lower_call/namespace_call.rs |
384 | 8 | 6 | 17 | #7210 #5924 #680 #678 #636 |
lower_call/native_module_dispatch.rs |
418 | 5 | 1 | 0 | #6563 #1852 #927 #915 #605 |
lower_call/event_target.rs |
80 | 1 | 3 | 0 | — |
lower_call/console_promise.rs |
1583 | 9 | 0 | 45 | #7214 #7211 #7206 #7192 #7184 #7154 |
lower_call/ui_styling.rs |
565 | 0 | 1 | 0 | — |
lower_call/native/mod.rs |
543 | 3 | 1 | 0 | #6185 #1074 #1002 |
lower_call/native/native_tui_layout_branch.rs |
534 | 8 | 9 | 0 | #679 #634 |
lower_call/native/native_instance_branch.rs |
454 | 2 | 3 | 0 | — |
lower_call/native/native_ui_widgets_branch.rs |
454 | 4 | 2 | 0 | #1867 #1865 #610 |
lower_call/native/native_ui_appshell_branch.rs |
415 | 3 | 0 | 0 | #1495 #1280 #658 #635 |
lower_call/native/jsonwebtoken.rs |
291 | 7 | 0 | 0 | #1074 #927 |
expr/calls/crypto_kdf.rs |
304 | 5 | 23 | 0 | #3146 #2013 |
expr/calls/crypto_misc.rs |
619 | 7 | 14 | 0 | #3146 #2550 #2013 #1364 |
expr/calls/crypto_hash.rs |
352 | 6 | 3 | 0 | #3146 #2013 #1419 #1354 #1352 #1076 |
expr/calls/crypto_keys.rs |
232 | 3 | 1 | 0 | — |
expr/calls/fs.rs |
398 | 1 | 0 | 0 | #631 |
expr/child_proc.rs |
445 | 4 | 7 | 38 | #3079 #1938 #1937 #1933 #1780 |
expr/dyn_extern_i18n.rs |
1187 | 9 | 2 | 8 | #7280 #7211 #6951 #6660 #5389 #5230 |
expr/js_runtime.rs |
288 | 1 | 1 | 0 | — |
expr/native_memory.rs |
370 | 0 | 3 | 0 | — |
expr/closure.rs |
433 | 4 | 0 | 0 | #4831 #1845 #1021 |
expr/fs_await.rs |
289 | 1 | 0 | 12 | #7341 #5437 #788 |
Slice 9 — codegen/ and stmt/ tail
12 modules · 18858 lines · 29 raw sites · 5 hazard sites
| module | lines | raw | haz | root | issues named in the file |
|---|---|---|---|---|---|
codegen/entry.rs |
1653 | 1 | 4 | 4 | #7128 #7109 #7106 #6991 #6952 #6903 |
codegen/closure.rs |
1488 | 6 | 0 | 2 | #7208 #7170 #7128 #7109 #7106 #7055 |
codegen/method.rs |
1732 | 2 | 0 | 4 | #7128 #7109 #7106 #6952 #6469 #6369 |
codegen/helpers.rs |
1704 | 2 | 0 | 0 | #7493 #7354 #7340 #7332 #7326 #7174 |
codegen/function.rs |
1080 | 1 | 0 | 2 | #7302 #7170 #7128 #7109 #7107 #7106 |
codegen/string_pool.rs |
1333 | 1 | 0 | 0 | #5957 #5592 #5391 #5042 #4880 #4850 |
codegen/arguments.rs |
112 | 3 | 0 | 0 | #7055 |
stmt/loops.rs |
7120 | 9 | 0 | 1 | #7480 #7287 #7154 #6812 #6809 #6750 |
stmt/mod.rs |
755 | 1 | 0 | 0 | #7590 #7521 #7302 #7154 #7105 #6750 |
stmt/masked_window_region.rs |
1055 | 1 | 0 | 0 | #6794 #6750 |
stmt/unused_expr.rs |
194 | 1 | 1 | 0 | #4091 |
native_value/materialize.rs |
632 | 1 | 0 | 0 | #7128 #854 |
Unassigned (add to a slice before starting it)
| module | lines | raw | haz | root |
|---|---|---|---|---|
expr/temp_root.rs |
1133 | 3 | 0 | 84 |
Terminal condition
expr/temp_root.rs becomes pub(in crate::rooting) — the raw API unreachable from any lowering, not merely uncounted. That is the same end state Layer 3 states for its raw accessor (#7457/#7458), and it is what makes the ledger permanent rather than a convention.
Related findings from the template slice
URL.canParse(input, base)andURL.parse(input, base)still carried fix(codegen): root the URL constructor's coerced string across base lowering (Layer 1) #7453's window when the module was migrated — the same three lines fix(codegen): root the URL constructor's coerced string across base lowering (Layer 1) #7453 fixed innew URL(input, base), in the two static forms nobody re-read. Fixed in the template slice.gc_root_dominance_check.pycannot see the unrooted-register shape at all — filed separately.
Activity
refactor(codegen): migrate expr/url_main.rs onto the Layer 1 rooting …
proggeramlug commentedon Aug 8, 2026
Slice 1a — DONE, in #7618
1 module · 1199 → 1346 lines · 37 raw sites · 40 hazard sites → 0.
lower_array_method.rsis migrated end to end and listed inMIGRATED_MODULES. No boundary outstanding; the module landed whole, in one PR.expr/url_main.rslower_array_method.rsexpr/arrays_finds.rs,expr/array_methods.rsRunning total: 2 of 88 modules, 48 of 694 raw sites, 47 of 262 hazard sites.
Three findings worth carrying into the remaining slices
1. The hazard-density number was measuring the right thing, but the fix is not where "40 sites" suggests. All 40 came from one structural cause:
lower_array_methodlowered the receiver above thematch, so every arm inherited the same window. Onewith_operands_rootedaround the wholematchcloses all of them. When a module's hazard count is high, look for a shared prologue before assuming 40 independent edits — the map'shazheuristic counts uses, not causes.2.
root_reload.rs(#7280) already covers part of this bug class, and the map does not account for it. Where an operand is a shadow-slotted local, #7280's post-pass re-reads it below the collection point, so the pre-migration code was not stale. Three shapes it structurally cannot cover, and those are the real content of a slice:operand_is_reloadable's documented miscompile), so it leaves a stale register. Only a temp root gives both the call-time value and a rewritten address. Confirmed in emitted IR fora.concat(reassign()).Concretely: 36 of the
load doubleinstructions this slice removed were #7280 reloads that a root read replaced. A future slice that claims "N hazard sites closed" without separating these is overclaiming. Check the baseline IR for an out-of-sequence register number (%r309after%r250) — that is #7280 having already fired.3. The ledger's escape-hatch check is vacuous for a module that never used
temp_root.lower_array_method.rsnamed it zero times before the migration, somigrated_modules_do_not_reach_past_the_rooting_apipassed the instant it was listed. The sabotage arm is what makes the listing mean something, and it was run: a real compilingtemp_root_push_double+temp_root_truncatepair injected into thereversearm turns the test red, naming both lines. Every slice should run that arm and record the result, because for most of the remaining 86 modules the ledger will otherwise pass for free.Ordering note for slice 1b and later
The structural property worth copying is not "call the combinators" — it is
lower_exprmust not appear in the migrated module. In this slice the arguments each arm consumes are declared once (lowered_arg_count) and the arms only emit, so no arm can reintroduce an unrooted operand. The asymmetry that makes a central table safe: under-counting is a codegen panic on the first program that reaches the arm; over-counting costs an evaluation JS requires anyway. That shape should transfer toarrays_finds.rsandarray_methods.rsdirectly, since they have the same "receiver + N arguments + one consuming call" skeleton.Also:
lintis red onmain, unrelated to this campaignpython3 scripts/check_test_registration.pyfails on a pristineorigin/main—test-files/test_gap_repsel_element_shape_loop_clone.tslanded in #7612 (96e034809) without being registered intest-parity/gc_repsel_corpus.txt, sogc_repsel_matrix.sh(gc-stress,gc-moving-witnesses) never runs it. A required-and-red gate means every merge is bypassing it — CLAUDE.md hazard 2. Not fixed in #7618: registering a test changes what those gates execute, which is its own change with its own measurement.refactor(codegen): migrate lower_array_method.rs onto the Layer 1 roo…
proggeramlug commentedon Aug 8, 2026
Slice 1b — DONE, in #7620
2 modules · 1562 → 1763 lines · 40 raw sites · 26 hazard sites.
expr/arrays_finds.rsandexpr/array_methods.rsare migrated end to end and listed inMIGRATED_MODULES. No boundary outstanding; both landed whole, in one PR.expr/url_main.rslower_array_method.rsexpr/arrays_finds.rs,expr/array_methods.rsexpr/instance_misc1.rs,expr/logical_collections.rs,lower_call/property_get/map_set.rsRunning total: 4 of 88 modules, 88 of 694 raw sites, 73 of 262 hazard sites.
One combinator was added, and it is the only one the campaign should need for this shape
rooting::with_operands_rooted_across(ctx, exprs, across_exprs, across, body)— root a group across a lowering step whose representation the caller picks. Its two callers areu8[i]andbuf[i], where the index goes throughlower_expr_as_i32orfptosiand the receiver is live across that choice; handing the index towith_operands_rootedwould force every such read back onto the NaN-boxed path, which is a codegen-quality regression rather than a rooting fix.Note what it is not: it never takes a bare register. The
root_i64(ctx, reg)thatrooting.rs's comment warns about would put ordering back in the author's hands (#7192); this one still owns the lowering, the re-read point and the release.with_operands_rootedis now its empty-acrosscase, sooperand_protectionstays the single decision site — the drift that produced #7114 was two helper families answering that question separately, and the campaign should not recreate it by growing a family of combinators that each re-derivecollects.Four findings for the remaining slices
1. The
hazheuristic under-counts one shape and it is the worst one. The map gives these two modules 26 hazard sites. It counts NaN-boxed operand registers used after an intervening emission.Expr::BufferSlicedoes something worse and is not in that count: it unboxes the receiver to a rawBufferHeader*and then lowersstartandend. When you audit a module, grep forunbox_to_i64/unbox_str_handleabove alower_expr, not only for reused_boxregisters.2. That raw shape is also the one #7280 provably cannot reach.
root_reloadre-reads a shadow slot into adouble. If the stale value is ani64derived above the window, there is nothing for it to rewrite. Slice 1a's finding was that #7280 already covered the shadow-slotted majority (still true — 16 of this slice's removedload doubleinstructions are exactly those reloads); this is the complement, and it is what a slice should be claiming.3. Ledger vacuity is now written into the code, not just into a PR.
escape_hatch_usesstrips comments, and every module migrated so far exceptexpr/url_main.rsnamedtemp_rootzero times beforehand — so listing it goes green instantly.MIGRATED_MODULES' doc comment now says so and states the obligation: run the sabotage arm, per module (the assert stops at the first offender, so one run cannot speak for two), and record it. Slice 1b did:arrays_finds.rs:279/:280andarray_methods.rs:59/:60, both red, both reverted.4. ★ Codegen is not run-to-run deterministic, which is a trap for exactly this campaign's evidence (#7622). Compiling one source twice with the same binary permutes
@.str.Nnumbering,js_register_function_nameorder, and — intest_gap_class_expr_dynamic_parent_ctor— whichperry_method_*symbol three call sites name. In slice 1b's 149-module IR comparison, 3 of the 9 differing functions were this and not the change. They are distinguishable only because their instruction multisets were identical AND a same-binary double compile reproduced the same permutation. A future slice that reports rawdiffoutput will report phantom regressions; one that compares multisets without checking determinism will silently dismiss a real ordering change. Both halves are needed.Also filed from this slice, not fixed in it
#7621 —
path.resolve(base, computedShortString)throwsERR_INVALID_ARG_TYPEwhere node returns the path.Expr::PathResolveJoinunboxes withunbox_to_i64, so an SSO string's inline bytes are dereferenced as aStringHeader*(#214 class; bisected by string length). Same unaudited shape inPathExtname,PathNormalize,PathResolve,PathMatchesGlob. The correct helper allocates, so the fix opens the #7213 window in the same arm — it needs its own rooting decision, and does not belong inside an IR-identical refactor.lintis still red onmainfor an unrelated reasonpython3 benchmarks/ci_public_baseline_check.pyfails on a pristineorigin/main(public artifact benchmark harness changed; regenerate it with ./benchmarks/run_public_baseline.sh). #7618 reported the same step failing on everymainrun from 2026-07-29. Becauselintis a sequence, that step's failure used to skip every later gate in the job; theif: ${{ !cancelled() }}added to the later steps means they now still speak, but the job itself stays red.refactor(codegen): migrate arrays_finds.rs + array_methods.rs onto th…
refactor(codegen): migrate instance_misc1 + logical_collections + map…
proggeramlug commentedon Aug 8, 2026
Slice 2 — DONE, in #7627
3 modules · 3207 → 3739 lines · 60 raw sites · 40 hazard sites.
expr/instance_misc1.rs,expr/logical_collections.rsandlower_call/property_get/map_set.rsare migrated end to end and listed inMIGRATED_MODULES. No boundary outstanding; all three landed whole, in one PR.expr/url_main.rslower_array_method.rsexpr/arrays_finds.rs,expr/array_methods.rsexpr/instance_misc1.rs,expr/logical_collections.rs,lower_call/property_get/map_set.rsexpr/objects_arrays_lit.rs,expr/array_literal.rs,expr/object_literal.rs,expr/array_push.rsRunning total: 7 of 88 modules, 148 of 694 raw sites, 113 of 262 hazard sites.
Two combinators added, and slice 3 already needs one of them
with_operands_rooted_across_call— the window'sacrossstep is an emittedruntime call, not a lowered expression, so there is no
Exprforany_may_trigger_gcto read and the answer is stated (conservatively) ratherthan derived. Its callers are
re.test/re.exec, whose unconditionaljs_jsvalue_to_string_coercedispatches a usertoString. Deriving the windowfrom the
stringoperand answers false for a plain local and drops the root atexactly the site #7154 faults at. The precedent for taking the answer is
temp_root::guard_store_operand_across, which has done so since #7201;operand_protectionstill decides how each operand is protected, and all threewith_operands_rooted*forms now share one implementation so the family cannotgrow three orderings.
with_rooted_accumulator— a GC-managed value the lowering keeps updatingwhile it lowers more user code. Slice 3 is the "literal accumulators" slice, so
it should reach for this rather than building a second one. The invariant it
enforces is the one a raw handle cannot: the accumulator never exists as a
register held across an emission. Consuming calls re-read it as part of being
emitted, a helper returning a fresh address publishes it straight back
(
advance), and the one point a register escapes isfinish, below the lastcollection point and above the release.
RootedSlotnow carries its ownRepr. A temp-root slot is representation-agnostic, so the
temp_root_get_i64vstemp_root_get_doublechoice used tolive at each call site, where a mismatch is a silent miscompile rather than a
type error.
Four findings for the remaining slices
1. A high
rootcount in the map means "already correct", not "alreadymigrated" — and the slice must say which.
map_set.rscarriesroot: 49andwas hand-rooted end to end by #6970; its migration is a translation, and its
IR is byte-identical.
ObjectSpreadandObject.assignare the same. Claimingthose as closed hazards would be overclaiming in the way slices 1a and 1b warned
about. What the translation genuinely buys is that the release stops being a
statement a later edit can move into a branch — the shape #7462 shipped in
URLSearchParams.delete, which is the direct sibling of these arms, so therisk is demonstrated rather than hypothetical.
2. The
hazheuristic misses a third shape: an interned key. Slice 1b foundit misses raw
unbox_to_i64receivers.Expr::WithSetshows the other end —the stale value was the property key, a load from a
__perry_init_strings_*handle global materialised above the RHS. That global is a registered root that
evacuation rewrites, so the register named from-space while the global did not:
the write landed under a garbage key. When auditing a module, look for
emit_with_key/ctx.strings.entry(..).handle_globalloads above alower_expr, not only for reused_boxregisters.3. Two accumulators in this slice were not rooted at all, and neither is in
the map's
rootcolumn:Math.min/Math.maxwith three or more argumentsthreads a raw
ArrayHeader*throughjs_array_push_f64across each remainingargument's lowering, and
fetch's static-headers build does the same withjs_object_alloc. Both are #7154'sObjectSpreadbug in arms nobody had lookedat, and both are beyond #7280 because the stale value is a raw
i64. Grep theremaining slices for
let mut current/ a handle threaded through a loop thatcontains a
lower_expr.4. #7622 is still live and the control is still mandatory. On the 149-module
corpus, compiling twice with the same binary produced 5 ordering-only
permutations; the A/B produced 16 diffs of which 5 were ordering-only, 4 in the
control set and one (
test_gap_class_ref_new_dynamic::main) not — it had to bepinned separately by compiling that one source six times with the baseline
binary, differing every time. The set of contaminated functions varies between
runs, so "it was in my control set" is not a sufficient test; the shape
(opcode multiset equal, ordering only) plus a targeted repeat is.
Also filed from this slice, not fixed in it
a[i]++(Expr::IndexUpdate) holds its re-read receiver and indexacross
js_dyn_index_get,js_to_numericandjs_numeric_stepbeforejs_dyn_index_setconsumes them. It needs a per-use re-read inside thebody rather than one group-wide re-read, i.e. an
Argthat carries an operandgroup's
Root/Reload/Reusedecision and materialises per call. The raw APIalready has the primitive (
RootedOperands::reread_one, GC: evacuating minor drops an old-to-young field[1] edge, crashing with 'value is not a function' #7154). Per thetemplate's rule that combinator arrives with its caller;
Expr::PropertyUpdatehas the same skeleton and should be done in the same change. The
operand-to-operand half is closed in refactor(codegen): migrate instance_misc1 + logical_collections + map_set onto the Layer 1 rooting API (#7615) #7627.
test_gap_fetch_request_from_node_incoming_messageSIGABRTsdeterministically (3/3) on a pristine
mainbuild and is in no allowlist. Nota rooting bug and not caused by this slice; reported because
parityistag-gated, so it has been able to hide.
lintonmainpython3 benchmarks/ci_public_baseline_check.pyis still red on pristineorigin/main, as #7618 and #7620 both reported (everymainrun since2026-07-29). Every other step of the
lintjob passes on this branch.proggeramlug commentedon Aug 8, 2026
Correction to the comment above: the dependency-scale corpus was also run for slice 2 and is green in both gated modes (81 zod modules, 12899 functions, 12908 root stores → 0 violations;
--seeded-violations 40at 40/40;--unrooted-allocas0 over 15242 gc-capable allocas). Root stores there went 12878 → 12908, and the informational--stale-registers --moving-onlyratchet is unchanged on both arms (23 curated / 115 dependency). That is the population the accumulator combinator most needed to be measured on, since zod is precisely the "builds objects field by field out of data" shape #7280 named.refactor(codegen): migrate instance_misc1 + logical_collections + map…
refactor(codegen): migrate instance_misc1 + logical_collections + map…
refactor(codegen): migrate the literal-accumulator modules onto the L…
proggeramlug commentedon Aug 8, 2026
Slice 3 — DONE, in #7636
4 modules · 1776 → 2264 lines · 18 raw sites · 7 hazard sites.
expr/objects_arrays_lit.rs,expr/array_literal.rs,expr/object_literal.rsand
expr/array_push.rsare migrated end to end and listed inMIGRATED_MODULES. No boundary outstanding; all four landed whole, in one PR.expr/url_main.rslower_array_method.rsexpr/arrays_finds.rs,expr/array_methods.rsexpr/instance_misc1.rs,expr/logical_collections.rs,lower_call/property_get/map_set.rsexpr/objects_arrays_lit.rs,expr/array_literal.rs,expr/object_literal.rs,expr/array_push.rsexpr/index_get.rs,expr/index_set.rs,expr/property_get.rs+ submodulesRunning total: 11 of 88 modules, 166 of 694 raw sites, 120 of 262 hazard
sites.
No fourth combinator, and the one place that argued for one
Slice 2 asked slice 3 to reuse
with_rooted_accumulatorrather than build asecond one. It does — the spread literal's growing array (
advance, because theaddress legitimately changes on every append), the object literal's half-built
handle on both build paths — and
with_operands_rootedcovers the arrayliteral's element group and
arr.push(...src)'s operand pair.The shape that looked like it needed a fourth: a
this-capturing methodclosure's deferred value. It is consumed by the
thispatch loop below everyremaining initializer, so its root must span exactly that suffix of the literal,
and the number of such values is data-dependent. All three
with_operands_rooted*forms lower their operand list up front, which for anobject literal would evaluate every property before storing any of them and
reorder observable side effects — so none of them can express it. But
with_rooted_accumulatoralready is "a GC value held while more user code islowered", so the answer is one nested scope per such property, which falls out as
a small recursion over the property list. Recorded here because the next slice
will hit the same question: "the list is dynamic" is not on its own a reason
for a new combinator; "the operands must be lowered interleaved with their
consumers" is what rules the operand forms out.
Four findings for the remaining slices
1. Slice 3's honest count is one repair, three translations, and the map's
rootcolumn predicted it.objects_arrays_lit.rs(root: 15),array_literal.rs(8) andobject_literal.rs(21) were all correctly rootedalready — by #7280 and #6951 — so their migrations are translations with
byte-identical IR.
array_push.rs(root: 0) named no rooting symbol at alland needed none, which is the case the ledger doc calls vacuous: only the
sabotage arm makes its listing an assertion. The
hazheuristic counted 6 sitesthere and all 6 are the same false positive — a raw
i64unboxed from areceiver that is lowered after the value and therefore has no window.
2. A high
hazon a module withroot: 0can mean "no window", not "noroot". Slice 2's finding 3 said to grep for
let mut currentthreaded througha loop containing a
lower_expr; the complement is worth stating too. Beforeconcluding a raw
unbox_to_i64is a hazard, check what is lowered after it.In both
array_push.rsarms the answer is "anExpr::LocalGet", i.e. a slotread, and a slot read placed after arbitrary user code observes what evacuation
wrote back — so the register is fresh by construction.
3. That safety can rest on an evaluation order the spec does not permit, and
then it is a bug in the other direction — #7634.
arr.push(f())/arr.push(...g())evaluate the receiver after the argument, so an argument thatreassigns the receiver's binding pushes onto the wrong array (node
[9], perry[9,2]). Filed rather than fixed: restoring spec order is one call to theexisting
with_operands_rooted_across, but it puts the receiver in a window, soevery pointer-valued push gains a temp root — a measured before/after, not a
behaviour-preserving refactor. When a slice finds an arm that is rooting-free,
ask whether it is rooting-free for a legitimate reason.
4. A branch the corpora cannot reach needs a unit test, and the way to find out
is to count, not to read.
lower_object_literal's by-name path with anon-empty property list — where the
this-patch machinery lives — is notreachable from TypeScript at all: since #809 every literal containing a
Prop::Methodlowers to a source-ordered IIFE over{}. Measured by greppingthe emitted corpus rather than by reading the HIR: over all 129 sources / 149
modules every
js_object_allocis(i32 0, i32 0). So the restructuredbranch got four HIR-built unit tests in
perry-codegen --libinstead of relyingon an A/B that structurally could not cover it. Slice 4's
index_set.rshasfifteen arms and
property_get/globalget.rssix hazard sites; count the callsites your probes actually emitted before believing the A/B covered them. In
this slice the first object-literal probe read its literals' fields back and
every one was scalar-replaced away — the A/B was green over a corpus containing
none of the subject.
Verification, and one thing that is no longer needed
Per #7625 the emitted IR is run-to-run deterministic, so the #7622 double-compile
control is retired. The A/B was measured directly: probes byte-identical in both
environments, the dependency-scale corpus (81 zod modules, 64 MB) byte-identical,
and the curated corpus 2450/2452 identical — the two differences being one
instruction pair swapping in the iterator-result literal, where the accumulator's
re-read is now fused to the emission that consumes it and therefore lands after
the value's
bitcastinstead of before. Root-store counts unchanged on bothcorpora (9846 / 12908), which is what a byte-identical translation should read.
Both gated modes green on both corpora with an empty allowlist,
--seeded-violations 40at 40/40 on each. Full evidence in #7636.
lintonmainpython3 benchmarks/ci_public_baseline_check.pyis still red on pristineorigin/main, as #7618, #7620 and #7627 all reported (everymainrun since2026-07-29). The other 26 steps of the
lintjob pass on this branch.refactor(codegen): migrate the object/array literal modules onto the …
proggeramlug commentedon Aug 8, 2026
Slice 4 — DONE, in #7642
9 modules · 6189 → 6600 lines · 10 raw sites · 8 hazard sites.
expr/index_get.rs,expr/index_get/guarded_array.rs,expr/index_get/inline_dyn_typed_array.rs,expr/index_set.rs,expr/index_set_typed_array.rs,expr/property_get.rs,expr/property_get/globalget.rs,expr/property_get/helpers.rsandexpr/property_set.rsare migrated end to end and listed inMIGRATED_MODULES. No boundary outstanding; all nine landed whole, in one PR.expr/url_main.rslower_array_method.rsexpr/arrays_finds.rs,expr/array_methods.rsexpr/instance_misc1.rs,expr/logical_collections.rs,lower_call/property_get/map_set.rsexpr/objects_arrays_lit.rs,expr/array_literal.rs,expr/object_literal.rs,expr/array_push.rsexpr/index_get.rs+ 2 submodules,expr/index_set.rs+ 1,expr/property_get.rs+ 2,expr/property_set.rslower_call/new.rs+ 13 others (call / construct paths)Running total: 20 of 88 modules, 176 of 694 raw sites, 128 of 262 hazard
sites.
Still no fourth combinator
Slice 4 is a single shape end to end — the
StoreOperandGuardfamily, which iswith_operands_rootedwhen the value is lowered bylower_exprandwith_operands_rooted_acrosswhen it is lowered bylower_value_for_dynamic_{index,property}_set/lower_value_for_optional_barrier. Nothing here argued for a new form.Five findings for the remaining slices
1. ★ A listed module is not an audited module, and this slice is where that
stopped being pedantry. Two properties, and the ledger only checks the first:
crate::rooting;index_set.rsandindex_get.rssatisfy (1) and do not satisfy (2). The act oftranslating the six guarded arms of
index_set.rsis what made it obvious that~20 arms in the same files lower a receiver, lower more user code, then use
the register, and root nothing — the bounded-index array store, the
#5525typed-array stores,
globalThis[k] = v, and ten read-side arms ofindex_get.rs. Filed as #7640. A ledger line does not mean "no rooting bugshere"; it means the module cannot make an ORDERING mistake against the raw API,
because it no longer names it. That distinction is now written into
rooting.rsnext to the ledger, so slice 5 does not have to rediscover it.2. The
hazcolumn's false positives have a single signature, and it is worthknowing before you start.
globalget.rsis credited with 6 hazard sites andall six are the same shape:
js_get_global_this_builtin_value→ctx.strings.intern(property)→format!→unbox_to_i64→load.internand
format!emit no IR. The heuristic measures source distance between abinding and its use; a window is measured in emissions. Before triaging a
hazhit, ask what was EMITTED in between, not how many lines passed — slice3's finding 2 is the complement of this ("check what is lowered after it").
3. #7201 was applied as prose and not as code, and the operand list is what
makes that unrepeatable.
guard_store_operand(ctx, operand, lowered, value)derives the window from ONE sibling. Its own doc says that is wrong — "leaves
o[f()] = 1unguarded" — and there is a three-argumentguard_store_operand_acrossfor the right answer. Two call sites inindex_set.rsused the two-argument form anyway (#7639). With an operand groupthe window is
across_collects || any_may_trigger_gc(exprs[i+1..]), i.e. aproperty of list order rather than an argument someone has to pass correctly.
Slice 5's call/construct paths have exactly this shape — callee,
this,then N arguments, each live across everything after it — so prefer one group
over a per-operand flag.
4. Count the arm's call sites, then check whether the ARM ITSELF is reachable
from TypeScript. Slice 3's finding 4 said to count before believing an A/B.
Slice 4 needed the next step. All three repaired arms were reached by the
curated corpus or by a probe, yet the IR stayed byte-identical, because
operand_protectionreturnsReusewhen the window cannot collect — so"reached" is necessary and not sufficient; you need reached with a collecting
window. And
js_array_set_length_strictis called zero times across all129 curated sources. Six hand-written probe shapes failed to reach it too, for a
structural reason worth writing down: an ordinary
o.k = v/o[k] = vassignment statement lowers to
Expr::PutValueSetand reaches the dynamic IC(
js_put_value_set_dyn_ic), not theExpr::PropertySet/Expr::IndexSetarmsin these modules. The answer was differential HIR-built unit tests: same store,
allocating RHS vs inert RHS, assert the first reserves strictly more root slots.
5. The ledger sabotage has a failure mode that reads as success. Planting
super::temp_root::…into a module one directory down (expr/property_get/,expr/index_get/) does not resolve — it iscrate::expr::temp_root::fromthere. The plant then fails to COMPILE, the test run is red for the wrong
reason, and a script that only greps for "FAILED" records it as a successful
sabotage. Four of this slice's nine modules are nested. Check for
error[E0…]before believing a red.
lintonmainpython3 benchmarks/ci_public_baseline_check.pyis still red on pristineorigin/main, as #7618, #7620, #7627 and #7636 all reported (everymainrunsince 2026-07-29). The other 23 enumerated steps of the
lintjob pass on thisbranch.
refactor(codegen): migrate the computed read/write modules onto the L…
10 remaining items
refactor(codegen): Layer 1 rooting migration slice 6 — the multi-poin…
fix(gc): Layer 1 rooting slice 7 — child_process, Proxy/Reflect, await (
fix(gc): Layer 1 rooting slice 7 — child_process, Proxy/Reflect, await (
fix(gc): Layer 1 rooting slice 7 — child_process, Proxy/Reflect, await (
docs(codegen): repoint the TempRootPool doc link after the move (#7615)
refactor(codegen): split the instance allocation out of lower_call/ne…
refactor(codegen): split string concat out of lower_string_method.rs (#…
fix(gc): Layer 1 rooting slice 8 — the raw API becomes unreachable (#…
test(gc): pin the slice-8 windows, and record two ways the pin was va…
docs(codegen): repoint the TempRootPool doc link after the move (#7615)
gc: Layer 1 rooting slice 8 — the raw API becomes unreachable (#7615) (…
proggeramlug commentedon Aug 9, 2026
Closed — the terminal condition is met, by #7670 (slice 8, v0.5.1384).
The condition as this issue stated it ("
expr/temp_root.rsgoingpub(in crate::rooting)— the raw accessor unreachable, not merely uncounted") turned out not to be expressible in Rust:pub(in path)requirespathto be an ancestor of the item (E0742), andcrate::rootingis not an ancestor ofcrate::expr::temp_root. So the file moved. It is nowcrate::rooting::temp_root, declaredmod temp_root;(private) withpub(in crate::rooting)on all 25 accessors — both, because either alone is one keyword from being undone.Verified in the merged tree, two ways: a raw call planted in a migrated module at top level (
expr/math_simple.rs) and in a nested submodule (expr/static_field_meta.rs) each fail witherror[E0603]: module temp_root is private. That is the difference between a ledger line and a guarantee.Fourteen raw entry points were deleted rather than narrowed (the
StoreOperandGuardandRootedHandlefamilies,lower_exprs_rooted,lower_operand_pair_rooted,temp_root_scope_*, …), per CLAUDE.md's kill-policy: a narrowed-but-live API is a decision nobody has made.Eight slices, and every one found live bugs — which was the point, not a side effect. A partial list:
arr.length = f()unguarded; two array element stores holding a heap-string key across the value; the polymorphico[k]=vfallback guarding neither operand; a namespace call with rest parameters printing nothing at exit 0;console.dir(x, y, f())printing before runningf;console.time("t", g())never evaluatinggat all;console.table(a,b,c)silently becomingconsole.log;child_processvalidating each argument as it lowered, soexecSync(123, sideEffect())threw before evaluatingsideEffect.What this does not claim, and the ledger says so in its own comment: a listed module cannot make an ordering mistake against the raw API, because it no longer names it. A window with no rooting decision at all is invisible to the check. #7640 (~20 such arms) and Layer 3 are the open half.
fix(codegen): root a[i]++ / o.f++'s result across the write (#7628)
fix(codegen): the lowering-window cluster — push order, member update…