Skip to content

index_get/index_set/property_set: ~20 arms make no rooting decision at all (the population the Layer 1 ledger cannot see) #7640

Closed

Description

@proggeramlug

Found while migrating the computed read/write modules onto the Layer 1 rooting API (slice 4 of #7615). Filed rather than fixed — see "Why not in slice 4" at the bottom.

The finding

Migrating the guarded arms of index_set.rs / index_get.rs / property_set.rs
made it visible that a much larger population of arms in the same files makes
no rooting decision at all. They are not ordering mistakes against the raw
API — they never call it. They lower a receiver (and sometimes a key), lower more
user code, and then use the register.

This is a distinction the Layer 1 ledger cannot see, and it is worth stating on
#7615 directly: a module listed in MIGRATED_MODULES is not an audited
module.
The ledger asserts "every rooting decision this module makes goes
through crate::rooting". It says nothing about a window with no decision.

Three of the arms below were adjacent to code slice 4 was already rewriting and
are fixed there (#7637, #7638, #7639). Everything in this issue is what is left.

A. Store side — receiver (and sometimes key) live across the value

expr/index_set.rs shape exposed
bounded-index-pair array store for (let i=0;i<a.length;i++) a[i] = {v:i}; receiver
the #5525 recv_unknown inline dyn-TA store function put(o:any,k:any){ o[k]=mk(); } receiver and key
globalThis[k] = v key (heap string); receiver MEDIUM
width-tracked TA, non-numeric index ta[k] = mk() with k: any receiver and key
TA runtime-key / TA final fallback / Uint8Array runtime-key receiver

The bounded-index one is the sharpest, because it sits immediately above the
generic array-store arm that closed exactly this window in #7341, and because
classify_for_length_hoist's body predicate (expr_preserves_array_length)
accepts Expr::Object / Expr::Array / Expr::ArraySpread values — so the
registered bounded pair and an allocating RHS coexist by design:

const a: unknown[] = new Array(1024);
for (let i = 0; i < a.length; i++) a[i] = { v: i };

Note also that is_width_tracked_typed_array_receiver keys on
receiver_class_name, a declared type. Per CLAUDE.md a declared type is a
dispatch hint, never a layout fact, so those registers can hold any heap object.

B. Read side — receiver live across the KEY (expr/index_get.rs)

index_get.rs guards two arms (the dynamic-string-key read and the last-resort
fallback, both now migrated). Ten others lower the receiver, then lower the
index — arbitrary user code, o[f()] — then use the receiver:

width-tracked TA runtime-key; TA final fallback; Uint8Array runtime-key;
Uint8Array unproven bounds; string receiver s[f()] (a heap string, very
much movable); recv_unknown inline dyn-TA get; is_array_expr && !is_numeric_expr;
numeric_index_needs_runtime_key; and both number-context entry points
(lower_numeric_index_get_for_number_context,
lower_unknown_local_index_get_for_number_context).

Two more are MEDIUM because the key is a SymbolFor (allocates the interned
symbol, a narrower window): the TA symbol-key and array symbol-key arms.

One shared change closes all of these: put [object, index] in a
rooting::with_operands_rooted group, which is what the two already-guarded
arms now are.

C. property_set.rs class-field store — an in-code claim I could not substantiate

property_set.rs says, above the sloppy class-field raw store and again above
the strict one:

the receiver's relocation across an allocating RHS is handled by the same
statepoint re-read that arm relies on.

I could not substantiate it. function/precise_roots.rs's RS4GC retype pass
rewrites a root-alloca load double into load ptr addrspace(1) → ptrtoint →
bitcast, so the addrspace(1) value is dead at the next instruction and only
the plain double crosses the statepoint — RS4GC does not relocate it. The
setter-dispatch arm has the same shape and the same absence of a guard.

Either the claim is true and should say why in terms of what RS4GC actually
relocates, or it is false and this is the hottest store path in the compiler
running unguarded.
It should not stay a sentence nobody can check.

D. A derived raw handle taken before an allocating unbox_str_handle

unbox_str_handle is not a pure mask: it calls js_get_string_pointer_unified,
which materialises an SSO value into a fresh heap StringHeader — one
allocation per SSO unbox. Several sites compute the receiver's raw untagged
pointer first and call it second, so a pointer no root can name crosses a
potential collection point:

  • index_set.rs — the arr[stringKey] arm and the globalThis arm
  • index_set.rs — inside the guarded dynamic-string-key arm: the temp root
    protects obj_box, not the obj_handle derived from it
  • index_get.rs — the same residual inside both arms that are otherwise guarded

This is the #7280 taxonomy's case (a): a pointer already unboxed to raw i64
cannot be repaired by re-reading a double from a shadow slot, so
crate::rooting structurally cannot express it. Swapping the two lines — unbox
the key first, the receiver second — closes every one at zero runtime cost
, and
is the cheapest item in this issue.

E. Callees with the same window

Called from the audited files with (object, index, value) and lowering all
three, so they can carry the identical shape and were not in slice 4's scope:
ptr_numarray_access::try_lower_num_array_guard_free_{set,get},
try_lower_proven_view_checked_store, lower_typed_array_store,
lower_buffer_store, lower_index_set_fast,
masked_window::lower_masked_window_index_get,
property_get/generic_dispatch.rs::lower_generic_property_get.

Why not in slice 4

Slice 4's rule was: fix a found bug in-slice unless the fix changes observable
behaviour or measured performance
. The three fixed there are arms whose store
already routes through a runtime helper call, so a temp root is noise beside it.
Everything in this issue is the opposite — inline store double fast paths,
guard diamonds, and the bounded-index loop store, which is the hot path. Adding
roots there is a measured change, and slice 4 was explicitly not permitted to
benchmark. Section D is the exception on cost but is a different repair shape
(re-ordering, not rooting), so it belongs with its siblings rather than smuggled
into a rooting refactor.

Suggested order

  1. D — free, mechanical, closes six sites.
  2. The bounded-index array store — deterministic repro above, sits next to an
    already-fixed twin.
  3. C — resolve the statepoint claim before touching anything else on that path.
  4. B — one shared group closes ten arms.
  5. A's typed-array arms, then E.

Activity

  1. proggeramlug commented on Aug 8, 2026

    @proggeramlug
    ContributorAuthor

    Slice 4 (#7642) is merged-ready and does not touch any of this. Cross-referencing the three that WERE fixed there, so the boundary is legible: #7637 (arr.length = f()), #7638 (unrooted heap-string keys on two array element stores) and #7639 (the polymorphic fallback + the #7201 residue in the string-key arm). All three are arms whose store already routes through a runtime helper, so the root is noise beside the call. Everything in this issue is the opposite — inline stores, guard diamonds, and the bounded-index loop store — which is why it is filed rather than folded in.

  2. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    Sections D and B are fixed in #7699. A, C and E stay open; this comment records what was done, what each arm cost, and the false positives I did not touch.

    Section D — all six sites, zero runtime cost

    Confirmed the premise: unbox_str_handle is not a mask. It calls js_get_string_pointer_unified, which materialises an SSO value into a fresh heap StringHeader — one allocation per SSO unbox.

    Four are pure statement swaps, the same two instructions in the other order:

    • index_get.rs — the dynamic-string-key arm (the raw handle inside the guarded arm, exactly as this issue described: the temp root protects obj_box, not the handle derived from it);
    • index_set.rs — the globalThis[k] = v arm (which has no rooting wrapper at all);
    • index_set.rs — the arr[stringKey] arm;
    • index_set.rs — the dynamic-string-key arm.

    Two are cross-block and could not be swapped: the handle is computed in the arm's entry block, before the symbol/string/numeric triage cond_brs, and used two blocks later inside the string sub-block that calls unbox_str_handle. Both re-derive the handle inside that block, below the key unbox — index_get.rs's iget.str and index_set.rs's str_set.

    ★ That repair had a bug in it and the corpus is what caught it. Re-deriving obj_handle in the string block shadowed the entry block's, which the numeric sibling block still uses — and a definition in the string block does not dominate it. The LLVM verifier rejected the module. What makes it worth writing down is how it presented: gc_root_dominance_corpus.sh reported 120/130 sources compiled, 10 skipped and exited 0, because MIN_COMPILED is 90. Ten sources — including test_gap_gc_index_get_receiver_rooting, i.e. the corpus entry for this very area — silently left the corpus, and the dominance verdict downstream was computed over the remainder. That is #7024's shape: the gate ran, its subject did not. Distinct names; corpus back to 130/130, 0 skipped, and both dominance arms are green at the #7679 ratchet (unrooted 11 ≤ 11, stale 0 ≤ 0, 40/40 seeded violations caught on each).

    Section B — seven arms, one shared group

    Each is now rooting::with_operands_rooted(ctx, &[object, index], …), which is what the file's two already-guarded arms are:

    arm why it is real
    string receiver s[f()] no proof gate at all; the receiver is a heap string
    recv_unknown inline dyn-TA get index only excluded from being a static string/symbol
    is_array_expr && !is_numeric_expr !is_numeric_expr does not restrict the index to a safe shape
    numeric_index_needs_runtime_key is_numeric_expr is a TYPE predicate, not an effect-free one
    lower_unknown_local_index_get_for_number_context general path index unconstrained beyond "not a static string/symbol"
    typed-array Expr::SymbolFor key SymbolFor lowers to a real js_symbol_for call — it interns, so it allocates
    array Expr::SymbolFor key same

    Where the index provably cannot collect — a literal, a plain local — operand_protection answers Reuse and the group emits nothing, so the proven-index fast paths keep the IR they had.

    Deliberately NOT "fixed": the conditional false positives

    Two arms this issue lists are not unconditional hazards, and wrapping them would have been noise:

    • the typed-array final fallback and the Uint8Array unproven-bounds arm are reached only when the index already passed numeric_index_has_integer_array_index_proof or the loop proof — literal / LocalGet / const-masked & / loop counter, none of which emits a call.

    They have a residual caveat worth its own ticket rather than a wrap here: int_range_expr_inner (range_facts.rs:342,400) also admits a nested Expr::IndexGet and a 3-arg Expr::Call to a registered clamp3 function. Those two shapes are the only way a "proven" index still emits a collect-capable call, so the right fix is to narrow the range prover, not to root every proven index.

    Likewise lower_numeric_index_get_for_number_context's masked-window, packed-f64-loop and bounded-index-pair paths: all three lower the index as an i32 slot load plus constant arithmetic. Zero emissions that can collect between the receiver and the index — false positives, left alone. Only that function's unconditional final fallback is real, and it is section A's neighbour rather than section B's, so it stays with A.

    Still open

    test-files/test_gap_7640_computed_key_windows.ts covers everything this PR touched — side-effecting index on a string receiver, non-numeric and unproven-numeric computed keys on an array, a symbol key, SSO string keys read and written, an a[stringKey] write, globalThis[k] = v, and the byte-read fast path — and matches node 26.5.1 byte for byte.

  3. proggeramlug commented on Aug 10, 2026

    @proggeramlug
    ContributorAuthor

    Status check against main @ db44b31b7, since several PRs have touched this since filing. Sections A–D are landed; E is the remainder.

    section state on main evidence
    A — store side, receiver/key live across the value closed #7736 (fix(gc): … + #7640 A/C findings); index_set.rs carries #7640 section A notes at the bounded-index, globalThis and width-tracked-TA arms
    B — read side, receiver live across the key closed index_get.rs:562 is now rooting::with_operands_rooted(ctx, &[object, index], …), the one shared change this issue proposed
    C — the unsubstantiated class-field claim closed #7736; property_set.rs:169 now states what RS4GC actually relocates instead of the sentence nobody could check
    D — raw handle taken before an allocating unbox_str_handle closed index_set.rs lines 768, 1038, 1713 and 1847 all carry #7640 section D: key first, including the cross-block half
    E — callees with the same window open see below

    For E I checked all eight named functions on main. None makes a rooting decision — zero rooting:: / with_operands_rooted calls in the 90 lines following each definition:

    function file:line
    try_lower_num_array_guard_free_get ptr_numarray_access.rs:106
    try_lower_num_array_guard_free_set ptr_numarray_access.rs:157
    try_lower_proven_view_checked_store proven_view_access.rs:312
    lower_buffer_store buffer_access.rs:450
    lower_typed_array_store buffer_access.rs:658
    lower_index_set_fast index.rs:79
    lower_masked_window_index_get masked_window.rs:153
    lower_generic_property_get generic_dispatch.rs:39

    One correction to the framing, from reading them: at least lower_index_set_fast takes arr_box / idx_double / val_double as already-lowered strings, so it never interleaves user-code lowering between them — the (object, index, value) window this issue describes lives in its callers, not in it. What it does have is the section-D shape: it derives arr_handle (raw i64, via bitcast + POINTER_MASK_I64) and then reaches a realloc path that can allocate, and a raw untagged pointer is precisely what crate::rooting structurally cannot repair.

    So E is not one shape but two, and they need different fixes — an operand group at the call sites, versus re-deriving the handle after the allocating call. Worth splitting before anyone starts, because "wrap it in with_operands_rooted" is the right answer for only some of these eight, and applying it to the rest would look like a fix while leaving the raw handle exactly as stale as before.

    Not fixing it in this pass: a wrong rooting change is worse than none here, and this needs the per-site analysis plus a gc-root-dominance run rather than a pattern-match over eight functions.

  4. proggeramlug commented on Aug 12, 2026

    @proggeramlug
    ContributorAuthor

    Re-triage against main @ 55fd197 — most of this issue has already been fixed, and the suggested order is overtaken

    Picked this up alongside #7984 and checked every claim against the tree rather than the text. Two merged PRs are ancestors of main and between them close most of what is written above, so the "Suggested order" at the bottom now starts on work that is done.

    D — done, and one of the six was not the two-line swap this issue predicts

    Four sites really were "the same two instructions in the other order", now carrying // #7640 section D comments: index_set.rs globalThis[k] = v and arr[stringKey], and the guarded dynamic-string-key arms in both files (e.g. index_set.rs:1762, index_get.rs:1448).

    The other two are the cross-block ones — index_set.rs's polymorphic-fallback str_set block and index_get.rs's str_idx block — and they could not be fixed by reordering. The receiver handle was defined in the arm's entry block and is also used by the numeric sibling block, so redefining it inside the string block is an LLVM dominance error and leaving it alone fixes nothing. Both now re-derive a distinctly-named handle inside the string block, below the key unbox, with the reason spelled out in place (index_set.rs:1898, index_get.rs:1546).

    Worth recording because this issue's cost estimate for D ("free, mechanical, closes six sites") was right for four and wrong for two, and the two it was wrong about are the ones that fail a naive fix silently.

    One correction to the premise while I was in there: whether the swap is load-bearing or merely belt-and-braces depends on which helper produces the receiver handle. unbox_to_i64 is bitcast + and, which root_reload.rs classifies as a transparent recipe and re-materialises on its own; classref_preserving_handle's dynamic branch ends in a select, which is in neither TRANSPARENT_BIN nor TRANSPARENT_CAST, so that pass structurally cannot repair it. The reorder is the only fix for the string-key arms and redundant insurance for the others.

    C — resolved, with a smaller residual that is still open

    The sentence this issue says nobody can check ("handled by the same statepoint re-read that arm relies on") is gone. Four sites in property_set.rs (:169, :384, :857, :893) now carry a corrected explanation, and this issue's reading of RS4GC was right: precise_roots.rs's retype pass leaves the ptr addrspace(1) value dead one instruction after the ptrtoint, so only a plain double crosses the statepoint and nothing is relocated. What actually protects the common case is root_reload.rs (#7280), which is independent of RS4GC and runs before either root lowering.

    The residual that stays open is narrower than section C as written: a compound receiver (this.target.x = allocPoint(n).x) is a phi over two field-get paths, not a direct root load, so root_reload.rs has nothing to re-derive from and the --stale-registers checker does not anchor on it. That is the part still worth an issue.

    (Cosmetic: property_set.rs:202 cites changelog.d/7640-abc-rooting-residue.md; the fragment that landed is 7736-rooting-residue.md.)

    What is actually left

    • section B tail — index_get.rs's width-tracked TA runtime-key, TA final fallback, Uint8Array runtime-key, Uint8Array unproven bounds, and lower_numeric_index_get_for_number_context.
    • section A tail — the #5525 recv_unknown inline dyn-TA store and the TA / Uint8Array runtime-key arms on the set side.
    • section E — the seven callees, untouched.
    • the compound-receiver residual from C above.

    Not the same family as #7984, and one sequencing note

    #7984 is a runtime defect: a value that is rooted gets resolved to the wrong stack word, because on aarch64 ELF the fast fp-chain walker and the Itanium unwinder disagree about the frame base. This issue is a codegen defect: values that are never rooted at all, so no root alloca is emitted, nothing lands in a statepoint's gc-live bundle, and the stack-map walker never sees them. Independent, and neither fix can mask the other.

    The one interaction worth stating: native/RS4GC roots are the default on aarch64 Linux (rs4gc_enabled() → NATIVE_ROOTS_TARGET_OK, helpers.rs:225), which is #7984's exact target. Closing the arms above adds tracked root slots on that target, so if #7984 turns out to be a systematic frame-base error rather than something specific to one slot shape, this work widens its exposure. That is a reason to land #7984 first, not a reason to think they are one bug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions