Skip to content

refactor(codegen): Layer 1 rooting migration slice 6 — the multi-point re-read scope (#7615) - #7651

Merged
proggeramlug merged 5 commits into
mainfrom
refactor/7615-layer1-slice6-rooted-group
Aug 8, 2026
Merged

refactor(codegen): Layer 1 rooting migration slice 6 — the multi-point re-read scope (#7615)#7651
proggeramlug merged 5 commits into
mainfrom
refactor/7615-layer1-slice6-rooted-group

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Slice 6 of the Layer 1 rooting migration (#7615). Slices 1–5 are merged; #7648 was slice 5.

The API gap first, because that was the assignment

Slice 5 left three lower_call/ modules unmigrated and recorded why: "three of
them need a re-read at more than one point, and every with_operands_rooted*
form has exactly one. The concrete missing combinator is the variadic/rest shape
(per-element re-reads between allocating pushes)."

The first half of that is right and the second half is not, and it matters.
Only one of the three modules is variadic. Reading all three:

module why one re-read point cannot serve
console_promise.rs's dynamic closure call consumes the group in two instructions with an allocating step between them: receiver + callee feed js_closure_unbox_callee_checked_rebind (which clones a this-capturing closure), the arguments feed js_closure_callN below it
mod.rs's lower_rest_call_args_rooted re-read points are a loop, not a point — one per js_array_push_f64
func_ref.rs the release must post-dominate four block-splitting specialized-ABI diamonds, ~450 lines below the lowering

The common request is not "variadic". It is one temp-root scope, re-readable
at any number of caller-chosen points
. The variadic case is that scope with an
accumulator array in it, which is why RootedGroup carries both operands and
arrays rather than there being a second type.

group.lower(ctx, expr, collects)?;      // lower and root; returns an INDEX, never the register
group.adopt(ctx, expr, &value, collects); // root a value the caller emitted itself
group.reread(ctx, i)?;                  // re-read HERE, as many times as needed
group.begin_array(ctx, cap) / push_array / read_array;

Two entry points, argued rather than assumed

with_rooted_group owns the release like every other combinator in the file.
open_rooted_group hands the scope back — which the rest of rooting.rs
deliberately refuses to do — so it needs a justification, and the justification
is that the two halves of guard mismanagement are not equally dangerous:

  • an early or mis-ordered release is a use-after-free (a truncate is a stack
    cut: truncating the wrong slot drops everything above it, which is how a
    saved receiver becomes the number 0);
  • a forgotten release is over-retention — the slot stays bound, the object
    stays live, the code is merely conservative.

RootedGroup removes the dangerous half by construction, for both entry
points
: not Clone, release consumes it, and no way to obtain the slot
index. Escaping leaves only the safe half writable. That is strictly better than
the Option<String> slot index it replaces, which the caller could truncate
anywhere. Inverting control in func_ref.rs instead would not remove the hazard,
it would relocate it into a 450-line closure.

implicit_this_save / implicit_this_restore moved into crate::rooting
rather than being re-exported, so the pair has one spelling — two would be the
drift that produced #7114.

Modules migrated

Three, all load-bearing on the committed source (each named expr::temp_root
before this change): lower_call/mod.rs, lower_call/func_ref.rs,
lower_call/console_promise.rs.

early_branches.rs, method_override.rs and both property_get dispatchers
are now clean of the escape hatch as a side effect of the implicit_this move,
and are deliberately not listed: a ledger line asserts that a module makes
every rooting decision through this API, and nobody has read those four for
windows with no decision at all. That is slice 4's "listed ≠ audited"
distinction.

Live bugs

All three behavioural ones are byte-for-byte A/B'd against node 26.5.1 (the
.node-version oracle) with a baseline compiler built from main in a separate
target dir, same PERRY_GC_MOVING_LOOP_POLLS on both arms.

console.dir(obj, { depth: 0 }, side("dir3"));
console.time("t", side("time2"));   console.timeEnd("t");
console.table([{ a: 1 }], ["a"], side("table3"));
node main this branch
console.dir(x, y, f()) side:dir3 then { a: 1 } { a: 1 } then side:dir3 matches node
console.time(l, f()) side:time2 (never printed) matches node
console.table(a, b, c) renders the table [ { a: 1 } ] [ 'a' ] 1 matches node

diff of node's output against this branch's is empty; against main it is
four hunks.

  1. console.dir sequenced side effects after its own print (console.table / console.dir hold operand 0 unrooted across operand 1's lowering (lower_call/console_promise.rs) #7649's non-GC
    arm, which the issue flagged as unverified — now verified). It lowered
    args[2..] below js_console_dir_with_options. Node evaluates the whole
    argument list before invoking anything.

  2. console.time / timeEnd / timeLog / count / countReset dropped
    their surplus arguments entirely
    — not resequenced, never lowered. A new
    find, same root cause.

  3. console.table(a, b, c) stopped being console.table. The arity gate was
    args.len() == 1 || args.len() == 2, so three arguments fell through to the
    generic multi-arg console.log arm. Node ignores surplus arguments; it does
    not switch renderer.

  4. console.table / console.dir hold operand 0 unrooted across operand 1's lowering (lower_call/console_promise.rs) #7649's rooting half, demonstrated in IR rather than at runtime. With
    console.table(makeRows(), [churn(300)]), main emits

    %r1 = call double @perry_fn_..._makeRows()          ; operand 0, a bare register
    %r2 = call double @perry_fn_..._churn$spec_i32(300) ; user code: allocates, polls
    call void @js_console_table_with_properties(double %r1, double %r22)

    root_reload structurally cannot repair this — a call result has no slot to
    be re-read from (GC: #7154's residual is NOT fixed — the loop-polls config is red 0/30, and stock zod alone fails 5/40 #7280 taxonomy (c) + (d)). This branch emits the root store
    above churn and reloads below it.

    The runtime fault is arrangement-dependent and I could not reproduce one.
    Under PERRY_GC_MOVING_LOOP_POLLS=1 + PERRY_GC_ZEAL=1 (copied_objects=6005
    confirmed via PERRY_GC_DIAG=1, so the subject was live), plus
    PERRY_GC_PROTECT_FROMSPACE=1 …_DEPTH=800, main printed the correct table
    and exited 0. The IR is the evidence the window exists; a TypeError would
    only have been evidence it is reachable in one arrangement. Stated here rather
    than claimed away.

Zero cost where the window cannot collect — measured, not asserted

A probe exercising direct calls, rest calls, arguments, new, method dispatch,
dynamic closure calls, Map.set and eight console.* arms, compiled both arms
with --trace llvm. Normalising SSA numbering and block labels, the entire
semantic delta is 35 removed lines and 0 added: two unconditional
temp_root_push_double / re-read / clear sequences that were protecting values
expr_is_known_non_pointer_shadow_value proves are not heap references — a
double from b.scale(2) and the literal true in console.assert. Both arms
bypassed the shared operand_protection decision; routing them through it drops
the traffic. Corpus-wide the root-store count goes 9846 → 9799.

Runtime output over the same probe: main and this branch identical.

Tests

lower_call/console_rooting_tests.rs, five tests, asserting on IR ordering
rather than slot counts (a count lets the other operand's rooting pay for the
assertion). Each asserts by callee name that the arm under test was reached, so
a shape measured over a lowering that never ran cannot pass.

Sabotage-verified against the pre-fix source, with the file reverted to its
HEAD content and only the moved implicit_this path repointed so it still
compiles: error[ count 0, Running unittests present (so the plant
compiled and the binary ran), 4 of 5 red. The fifth is the zero-cost pin,
which correctly passes in both arms.

Ledger sabotage, one arm per newly-listed module — a compiling
temp_root_push_double / temp_root_truncate pair planted in each:

module error[ binary ran ledger red names the lines
lower_call/mod.rs 0 yes yes yes
lower_call/func_ref.rs 0 yes yes yes
lower_call/console_promise.rs 0 yes yes yes

Gates

All 20 lint-job commands enumerated from .github/workflows/test.yml20/20
pass
. Plus cargo fmt --all -- --check, cargo test -p perry-codegen --lib
(711 pass), cargo test -p perry-runtime --lib (1909 pass, 3 ignored),
cargo check --all-targets (clean).

scripts/gc_root_dominance_check.py over the real
gc_root_dominance_corpus.sh corpus (149 modules, 2452 functions), in the
gate's own mode:

--moving-only --allowlist … --seeded-violations 40
  main:        0 violations, 9846 root stores
  this branch: 0 violations, 9799 root stores
  seeded: 40 planted, 40 caught, 0 MISSED

--unrooted-allocas: 6 violations, 0 moving-reachable, identical in both
arms
(the known #7210 residue).

⚠️ --seeded-violations is unreachable without --moving-only. The
seeded arm runs only after the real check returns 0, and the un-filtered mode
reports 171 violations (all non-moving, the
js_object_alloc_class_inline_keys → js_gc_declare_typed_shape_layout class)
on main and on this branch alike. So a run without --moving-only never
exercises the "can this gate still fail?" arm and prints nothing about it —
the same shape as CLAUDE.md's hazard-4 note about --unrooted-allocas
accepting the flag and silently doing nothing. Not fixed here; recorded.

Gap tests A/B'd (node vs main vs this branch): 7 console tests and 15
call-shape / GC-rooting tests. All base-vs-fix: same, all node-vs-fix: match, except test_gap_console_methods, which differs from node in both
arms by timer-microsecond values only.

Campaign note: the "24 modules" number is misleading

rg -l "temp_root::" crates/perry-codegen/src reports 24, but 14 of them make
no rooting decision
: five only construct TempRootPool::default(), three only
call a predicate (expr_may_trigger_gc, expr_is_inert_primitive,
operand_is_reloadable), and six only used the implicit_this pair this PR
moved. After this change the modules with real un-migrated rooting decisions are:
lower_call/new.rs, expr/child_proc.rs, expr/dyn_extern_i18n.rs,
expr/fs_await.rs, expr/math_simple.rs, expr/proxy_reflect.rs,
expr/static_field_meta.rs. new.rs is the big one and now unblocked —
RootedGroup is exactly its refresh_rooted_args shape — but it is 1988 lines
against the 2000-line cap, so it wants its own slice.

Closes #7649.

https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

Summary by CodeRabbit

  • Bug Fixes

    • Fixed evaluation order for console methods so all arguments are evaluated reliably.
    • Corrected console.table behavior across supported argument counts.
    • Preserved side effects from extra console arguments.
    • Improved reliability of function, method, and cross-module calls during memory allocation.
    • Fixed argument handling for Promise.try and Array.fromAsync.
  • Documentation

    • Added release documentation covering migration details, validation results, and known runtime-gate observations.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6850bde6-d2b6-41e6-9a3e-92f04fec3f6a

📥 Commits

Reviewing files that changed from the base of the PR and between 3822763 and aceba5f.

📒 Files selected for processing (1)
  • crates/perry-codegen/src/rooting.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-codegen/src/rooting.rs

📝 Walkthrough

Walkthrough

The PR adds RootedGroup for multi-point operand rooting, migrates call and console lowering from temporary roots, relocates implicit-this helpers, and adds IR tests for evaluation order, dispatch, rereads, and root traffic.

Changes

Rooted call lowering

Layer / File(s) Summary
RootedGroup infrastructure
crates/perry-codegen/src/rooting.rs, crates/perry-codegen/src/expr/temp_root.rs, changelog.d/...
Adds multi-point operand and accumulator rooting, rereads, release scopes, and implicit-this helpers. Removes the former implicit-this abstraction and documents migration validation.
Call argument migration
crates/perry-codegen/src/lower_call/mod.rs, crates/perry-codegen/src/lower_call/extern_func.rs, crates/perry-codegen/src/lower_call/func_ref.rs
Migrates direct, rest, cross-module, and function-reference calls to shared rooted argument groups, rereads, and unified release handling.
Console and closure lowering
crates/perry-codegen/src/lower_call/console_promise.rs, crates/perry-codegen/src/lower_call/console_rooting_tests.rs
Roots console, promise, native-method, and closure-call operands across allocations. Preserves argument evaluation and surplus-argument effects. Adds IR tests for rooting, dispatch, ordering, and root traffic.
Implicit-this call-site migration
crates/perry-codegen/src/lower_call/early_branches.rs, crates/perry-codegen/src/lower_call/method_override.rs, crates/perry-codegen/src/lower_call/property_get/*
Updates typed, fallback, override, dynamic-dispatch, and static-dispatch paths to use the rooting-based implicit-this helpers.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7648 — Migrates related lower_call modules to shared rooting APIs.
  • PerryTS/perry#7627 — Migrates call and operand lowering to shared rooting abstractions.
  • PerryTS/perry#6972 — Provides earlier temporary-root argument handling replaced by this migration.

Sequence Diagram(s)

sequenceDiagram
  participant CallLowering
  participant RootedGroup
  participant ExpressionLowering
  participant RuntimeDispatch
  CallLowering->>RootedGroup: create rooted operand scope
  RootedGroup->>ExpressionLowering: lower arguments left to right
  ExpressionLowering-->>RootedGroup: return lowered operands
  RootedGroup->>RootedGroup: reread operands after allocations
  RootedGroup->>RuntimeDispatch: dispatch rooted arguments
  RuntimeDispatch-->>CallLowering: complete call lowering
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the Layer 1 rooting migration slice and its multi-point re-read scope.
Description check ✅ Passed The description provides a detailed summary, implementation changes, linked issue, test evidence, and validation results, despite omitting some template headings.
Linked Issues check ✅ Passed The PR adds multi-point rooting, migrates console_promise.rs, and fixes console.table and console.dir rooting and evaluation-order issues required by #7649.
Out of Scope Changes check ✅ Passed The changes support the Layer 1 rooting migration and related console, Promise, and Array.fromAsync rooting objectives; no unrelated code changes are evident.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ 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 refactor/7615-layer1-slice6-rooted-group

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/perry-codegen/src/rooting.rs (1)

717-722: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Correct the AccArray opacity claim, or brand the handle.

AccArray is a bare usize index and is Copy. Nothing ties it to the group that produced it, so a handle from one RootedGroup can index another group's accs and select the wrong slot, or panic on an out-of-range index. Both groups are pub(crate) in the same crate, so the compiler does not prevent this. Either soften the doc claim or add an invariant that ties the handle to its group.

♻️ Option: state the actual guarantee
 /// A handle on one accumulator array inside a [`RootedGroup`].
 ///
-/// Opaque and `Copy`: it indexes the group's own list, so it cannot name a slot
-/// belonging to a different group or outlive the release.
+/// Opaque and `Copy`: it indexes the group's own list, and the release consumes
+/// the group, so a handle cannot outlive the scope. It is NOT branded per
+/// group — pass a handle only to the group that returned it.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub(crate) struct AccArray(usize);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/rooting.rs` around lines 717 - 722, Correct the
documentation on AccArray to state only the guarantee provided by its usize
index representation: it identifies an accumulator slot but is not tied to or
validated against a particular RootedGroup and must not be used across groups.
Do not claim group ownership or lifetime enforcement unless the handle is
redesigned to carry a group-specific invariant.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/perry-codegen/src/rooting.rs`:
- Around line 717-722: Correct the documentation on AccArray to state only the
guarantee provided by its usize index representation: it identifies an
accumulator slot but is not tied to or validated against a particular
RootedGroup and must not be used across groups. Do not claim group ownership or
lifetime enforcement unless the handle is redesigned to carry a group-specific
invariant.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b4b04431-973e-42f3-bfcf-655d03de5414

📥 Commits

Reviewing files that changed from the base of the PR and between c8394bf and 20a1ec4.

📒 Files selected for processing (12)
  • changelog.d/7651-layer1-slice6-rooted-group.md
  • crates/perry-codegen/src/expr/temp_root.rs
  • crates/perry-codegen/src/lower_call/console_promise.rs
  • crates/perry-codegen/src/lower_call/console_rooting_tests.rs
  • crates/perry-codegen/src/lower_call/early_branches.rs
  • crates/perry-codegen/src/lower_call/extern_func.rs
  • crates/perry-codegen/src/lower_call/func_ref.rs
  • crates/perry-codegen/src/lower_call/method_override.rs
  • crates/perry-codegen/src/lower_call/mod.rs
  • crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs
  • crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs
  • crates/perry-codegen/src/rooting.rs
💤 Files with no reviewable changes (1)
  • crates/perry-codegen/src/expr/temp_root.rs

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Follow-up commit: two more windows, found by auditing the arms the migration did not have to touch

console_promise.rs is now listed in the ledger, and slice 4's "listed ≠ audited"
note says a ledger line means "makes no ordering mistake against the raw API",
not "has no rooting bugs". So I read the rest of the module. Two windows with
no rooting decision at all (#7640's class), both now fixed:

1. Promise.try(cb, ...extra)#7154's accumulator shape verbatim.

current_arr was a raw *mut ArrayHeader threaded through the push loop in a
bare SSA register, holding the only reference to everything pushed so far while
the next argument's expression — arbitrary user code — was lowered. callback
sat in a second bare register across js_array_alloc, every push, and every one
of those lowerings. This is the same defect slice 5 found in
namespace_call.rs's rest path and called "the most serious of the four".

Compiled from HEAD of this branch's first commit vs the fix, same compiler
build otherwise:

; before
%r55 = invoke i64 @js_array_alloc(i32 3)
%r58 = invoke double @..._churn(...)                 ; user code: allocates, polls
%r60 = invoke i64 @js_array_push_f64(i64 %r55, double %r58)   ; %r55 from above the churn
%r63 = invoke double @..._churn(...)
%r65 = invoke i64 @js_array_push_f64(i64 %r60, double %r63)   ; %r60 from above the churn
%r68 = invoke double @..._churn(...)
%r70 = invoke i64 @js_array_push_f64(i64 %r65, double %r68)   ; %r65 from above the churn

; after — the accumulator is re-read from its root between every push
%r68 = invoke double @..._churn(...)
%r70 = <load from the root slot>
%r71 = invoke i64 @js_array_push_f64(i64 %r70, double %r68)
%r78 = invoke double @..._churn(...)
%r80 = <load from the root slot>
%r81 = invoke i64 @js_array_push_f64(i64 %r80, double %r78)

2. Array.fromAsync(input, mapFn, thisArg) held each operand in a bare
register across the others' lowering — #7280 taxonomy (c), which root_reload
structurally cannot repair. One re-read point serves (all three feed the single
js_array_from_async), so this is the plain with_operands_rooted form.

Neither faults at runtime in the arrangements triedPERRY_GC_ZEAL=1 on a
PERRY_GC_MOVING_LOOP_POLLS=1 build prints x|y|z / 2,4,6 and exits 0 in both
arms, matching node. The IR ordering is the evidence, per the same reasoning as
#7649's rooting half above.

Array.fromAsync deliberately keeps take(3), so this stays a pure rooting
fix.
While reading these arms I found that Promise.resolve / reject /
all / race / allSettled / any and Array.fromAsync all fail to
evaluate their surplus arguments
Promise.resolve(x, sideEffect()) never
runs sideEffect. That is the same node-visible defect as console.time's
above, across seven more arms, and it wants its own change with its own oracle
A/B rather than being smuggled into a rooting slice. Recorded in the changelog
fragment.

Gates re-run after this commit: 20/20 lint commands, cargo test -p perry-codegen --lib 711 pass, gc_root_dominance_check.py --moving-only --seeded-violations 40 → 0 violations, 40/40 seeded caught.

Ralph Küpper added 5 commits August 8, 2026 17:50
…t re-read scope (#7615)

Slice 5 could not take three `lower_call/` modules and named the reason: they
need a re-read at more than one point, and every `with_operands_rooted*` form
has exactly one. Its hypothesis for the missing combinator was the variadic/rest
shape. That turned out to be one instance of it rather than the shape itself.

`crate::rooting::RootedGroup` is ONE temp-root scope — already-lowered operands
and mutable accumulator arrays together — re-readable at any number of
caller-chosen points and released once, for the whole stack. The rest/variadic
case is the one that also holds an array.

Two entry points, and the asymmetry is argued in the source: `with_rooted_group`
owns the release; `open_rooted_group` hands the scope back for the one shape
where the release must post-dominate blocks the lowering does not lexically
contain (`func_ref.rs`'s four block-splitting specialized-ABI diamonds). What
escaping leaves writable is only the SAFE half of guard mismanagement — the
scope is not `Clone`, `release` consumes it, and there is no way to obtain the
slot index, so an early or mis-ordered truncate is unwritable and a forgotten
one is over-retention.

`implicit_this_save` / `implicit_this_restore` MOVED into `crate::rooting`
rather than being re-exported, so the pair has one spelling.

Migrated and listed in the ledger, all three load-bearing on the committed
source: `lower_call/mod.rs`, `lower_call/func_ref.rs`,
`lower_call/console_promise.rs`.

Three live bugs in the `console.*` arms, all A/B'd byte-for-byte against node
26.5.1 (#7649 and two siblings the audit found):

  * `console.dir(x, y, sideEffect())` lowered `args[2..]` AFTER the call that
    prints, so the side effect ran second;
  * `console.time(label, sideEffect())` never lowered the surplus arguments at
    all, so the side effect never happened;
  * `console.table(a, b, c)` fell through to the generic multi-arg console.log
    arm and printed the array instead of a table.

Plus #7649's rooting half, demonstrated in IR: `console.table`/`console.dir`
held operand 0 in a bare SSA register across operand 1's lowering.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
…sync's operand chain (#7615)

Two windows the slice-6 audit found in arms the migration did not have to
touch. Promise.try threaded a raw *mut ArrayHeader through its push loop in a
bare SSA register while the next argument -- arbitrary user code -- was
lowered, and held the callback in another bare register across all of it:
#7154's accumulator shape verbatim, and the same defect slice 5 found in
namespace_call.rs's rest path. Array.fromAsync held each of its three operands
across the others' lowering (#7280 taxonomy (c)).

Array.fromAsync keeps take(3) so this stays a pure rooting fix: the Promise.*
statics and fromAsync alike fail to EVALUATE their surplus arguments, which is
a separate node-visible defect.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
#7615)

Review caught the doc claiming a handle 'cannot name a slot belonging to a
different group'. It is a bare usize index and both types are pub(crate), so
nothing enforces that. State what the handle DOES buy — it is not a slot index,
so it cannot be truncated or mis-ordered — and say plainly that it is not
branded per group.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1369

Three node-visible bugs, all reproduced independently

Built a baseline from main myself in a separate target dir, wrote my own repros, A/B'd against node 26.5.1:

main this PR node
console.dir({a:1},{depth:0},f()) { a: 1 } then SIDE EFFECT SIDE EFFECT then { a: 1 } matches the PR
console.time("t", g()) no side effect at all SIDE EFFECT g matches the PR
console.table([{a:1}],["a"],1) [ { a: 1 } ] [ 'a' ] 1 the actual table matches the PR

The second is the one I'd have missed reviewing this by eye: the argument was never lowered, so the evaluation silently vanished. And the third is a good demonstration that an arity gate is a behavioural decision — == 1 || == 2 quietly turned console.table into console.log for every three-argument call.

The combinator, and slice 5's hypothesis being wrong

Slice 5 said the gap was "the variadic/rest shape". You checked and only one of its three blocked modules is variadic — the other two want re-reads at two instructions and at four block-splitting diamonds ~450 lines apart. Generalising to "one temp-root scope, re-readable at any number of caller-chosen points, released once" is the right abstraction, and it is better for having been derived from all three rather than from the one that suggested itself.

The two-entry-point argument is the part I'd have pushed back on if it were asserted rather than argued: an early or mis-ordered release is a use-after-free, a forgotten one is over-retention, so RootedGroup killing the dangerous half by construction (not Clone, release consumes, no way to reach the slot index) is what makes open_rooted_group acceptable. Inverting control in func_ref.rs would have relocated the hazard into a 450-line closure, not removed it.

Declining to list early_branches.rs, method_override.rs and the two property_get dispatchers — escape-hatch-clean as a side effect of the implicit_this move, but unaudited for windows with no decision — is exactly right. A ledger padded with modules that never had a decision to make is worth less than a short honest one.

Verified independently

Ledger sabotage: I planted a compiling temp_root_push_i64 call in all three newly-listed modules. error[ count 0, Running unittests present, ledger FAILED naming the planted lines. (My first attempt didn't compile — the check caught it, which is the whole point of the redirect fix you found.)

Gates: 20/20 from the lint job enumerated out of test.yml, cargo fmt clean, cargo test -p perry-codegen --lib 711 passed.

Cost: 35 lines removed, 0 added, corpus root stores 9846 → 9799 — two unconditional roots on provably non-pointer values that bypassed operand_protection. A migration that removes work while closing windows is the outcome this API was designed for.

Your three corrections, all verified

  1. "24 modules" was my number and it was misleading. 14 make no rooting decision. Real remaining: new.rs plus six expr/ modules. I'll use your list.
  2. --seeded-violations is inert without --moving-only — confirmed by reading main(): the seeded arm sits after if remaining: return 1, so a red check skips its own self-test, and un-filtered the corpus is red on main anyway. CI passes --moving-only so it is consistent there; the hazard is that a human running it the obvious way gets no self-check and no warning. Broader than the correction I made after slice 5.
  3. The stale Cargo.lock was mine — the perf(gc): answer arena valid-pointer membership from the census runs (#7592) #7646/refactor(codegen): Layer 1 rooting migration slice 5 — the timer and namespace-call lowerings (#7615) #7648 bumps staged the lock without a cargo invocation between the sed and the git add, so all 76 workspace entries kept the old version. Fixed on main directly, and I now regenerate before staging.

new.rs at 1988/2000 lines being its own slice is right, and the Promise.* / Array.fromAsync surplus-argument evaluation bug is correctly a separate change.

@proggeramlug
proggeramlug force-pushed the refactor/7615-layer1-slice6-rooted-group branch from aceba5f to 7afaad5 Compare August 8, 2026 15:58
@proggeramlug
proggeramlug merged commit 6d5b0cc into main Aug 8, 2026
@proggeramlug
proggeramlug deleted the refactor/7615-layer1-slice6-rooted-group branch August 8, 2026 15:58
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.

console.table / console.dir hold operand 0 unrooted across operand 1's lowering (lower_call/console_promise.rs)

1 participant