Repository navigation
index_get/index_set/property_set: ~20 arms make no rooting decision at all (the population the Layer 1 ledger cannot see) #7640
Description
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— thearr[stringKey]arm and theglobalThisarmindex_set.rs— inside the guarded dynamic-string-key arm: the temp root
protectsobj_box, not theobj_handlederived from itindex_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
- D — free, mechanical, closes six sites.
- The bounded-index array store — deterministic repro above, sits next to an
already-fixed twin. - C — resolve the statepoint claim before touching anything else on that path.
- B — one shared group closes ten arms.
- A's typed-array arms, then E.
Activity
proggeramlug commentedon Aug 8, 2026
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.test(codegen): cover the three repaired computed-store arms from HIR (#…
refactor(codegen): migrate the computed read/write modules onto the L…
proggeramlug commentedon Aug 9, 2026
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_handleis not a mask. It callsjs_get_string_pointer_unified, which materialises an SSO value into a fresh heapStringHeader— 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 protectsobj_box, not the handle derived from it);index_set.rs— theglobalThis[k] = varm (which has no rooting wrapper at all);index_set.rs— thearr[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 callsunbox_str_handle. Both re-derive the handle inside that block, below the key unbox —index_get.rs'siget.strandindex_set.rs'sstr_set.★ That repair had a bug in it and the corpus is what caught it. Re-deriving
obj_handlein 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.shreported120/130 sources compiled, 10 skippedand exited 0, becauseMIN_COMPILEDis 90. Ten sources — includingtest_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:s[f()]recv_unknowninline dyn-TA getis_array_expr && !is_numeric_expr!is_numeric_exprdoes not restrict the index to a safe shapenumeric_index_needs_runtime_keyis_numeric_expris a TYPE predicate, not an effect-free onelower_unknown_local_index_get_for_number_contextgeneral pathExpr::SymbolForkeySymbolForlowers to a realjs_symbol_forcall — it interns, so it allocatesExpr::SymbolForkeyWhere the index provably cannot collect — a literal, a plain local —
operand_protectionanswersReuseand 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:
numeric_index_has_integer_array_index_proofor 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 nestedExpr::IndexGetand a 3-argExpr::Callto a registeredclamp3function. 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
root_reloaddoes rematerialise a rawi64handle throughptrtoint+and POINTER_MASKwhen the chain roots in a spilledload ptr addrspace(1), which is not what GC: #7154's residual is NOT fixed — the loop-polls config is red 0/30, and stock zod alone fails 5/40 #7280's taxonomy entry (a) says and may be most of the answer C is looking for. Measured, not assumed — the A/B is in that PR.(object, index, value)shape.range_facts.rscaveat above.test-files/test_gap_7640_computed_key_windows.tscovers 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, ana[stringKey]write,globalThis[k] = v, and the byte-read fast path — and matches node 26.5.1 byte for byte.fix(codegen): root new's non-class ctor args and the computed-key win…
fix(codegen): the cross-block section-D handles need distinct names (#…
fix(codegen): the lowering-window cluster — push order, member update…
fix(gc): close #7210's two remaining unrooted-alloca sites + #7640 A/…
fix(gc): close #7210's two remaining unrooted-alloca sites + #7640 A/…
fix(gc): close #7210's two remaining unrooted-alloca sites + #7640 A/…
fix(gc): close #7210's two remaining unrooted-alloca sites + #7640 A/…
proggeramlug commentedon Aug 10, 2026
Status check against
main@db44b31b7, since several PRs have touched this since filing. Sections A–D are landed; E is the remainder.mainfix(gc): … + #7640 A/C findings);index_set.rscarries#7640 section Anotes at the bounded-index,globalThisand width-tracked-TA armsindex_get.rs:562is nowrooting::with_operands_rooted(ctx, &[object, index], …), the one shared change this issue proposedproperty_set.rs:169now states what RS4GC actually relocates instead of the sentence nobody could checkunbox_str_handleindex_set.rslines 768, 1038, 1713 and 1847 all carry#7640 section D: key first, including the cross-block halfFor E I checked all eight named functions on
main. None makes a rooting decision — zerorooting::/with_operands_rootedcalls in the 90 lines following each definition:try_lower_num_array_guard_free_getptr_numarray_access.rs:106try_lower_num_array_guard_free_setptr_numarray_access.rs:157try_lower_proven_view_checked_storeproven_view_access.rs:312lower_buffer_storebuffer_access.rs:450lower_typed_array_storebuffer_access.rs:658lower_index_set_fastindex.rs:79lower_masked_window_index_getmasked_window.rs:153lower_generic_property_getgeneric_dispatch.rs:39One correction to the framing, from reading them: at least
lower_index_set_fasttakesarr_box/idx_double/val_doubleas 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 derivesarr_handle(rawi64, viabitcast+POINTER_MASK_I64) and then reaches a realloc path that can allocate, and a raw untagged pointer is precisely whatcrate::rootingstructurally 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-dominancerun rather than a pattern-match over eight functions.proggeramlug commentedon Aug 12, 2026
Re-triage against
main@ 55fd197 — most of this issue has already been fixed, and the suggested order is overtakenPicked this up alongside #7984 and checked every claim against the tree rather than the text. Two merged PRs are ancestors of
mainand between them close most of what is written above, so the "Suggested order" at the bottom now starts on work that is done.9965eb3f6) — all six section D sites, plus 7 of the 10 section B arms.ed58ec633) — 3 of the 5 section A arms, and it resolves section C.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 Dcomments:index_set.rsglobalThis[k] = vandarr[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-fallbackstr_setblock andindex_get.rs'sstr_idxblock — 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_i64isbitcast+and, whichroot_reload.rsclassifies as a transparent recipe and re-materialises on its own;classref_preserving_handle's dynamic branch ends in aselect, which is in neitherTRANSPARENT_BINnorTRANSPARENT_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 theptr addrspace(1)value dead one instruction after theptrtoint, so only a plaindoublecrosses the statepoint and nothing is relocated. What actually protects the common case isroot_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 aphiover two field-get paths, not a direct root load, soroot_reload.rshas nothing to re-derive from and the--stale-registerschecker does not anchor on it. That is the part still worth an issue.(Cosmetic:
property_set.rs:202citeschangelog.d/7640-abc-rooting-residue.md; the fragment that landed is7736-rooting-residue.md.)What is actually left
index_get.rs's width-tracked TA runtime-key, TA final fallback,Uint8Arrayruntime-key,Uint8Arrayunproven bounds, andlower_numeric_index_get_for_number_context.#5525recv_unknowninline dyn-TA store and the TA /Uint8Arrayruntime-key arms on the set side.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-livebundle, 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.