You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
codegen: pointer_locals types Expr::Uint8ArrayGet as Number unconditionally — a symbol-keyed read binds a heap value into a local with no shadow slot #6998
collect_pointer_typed_locals's expr_value_type classifies Expr::Uint8ArrayGet as Type::Numberunconditionally, with no regard for the key kind (crates/perry-codegen/src/collectors/pointer_locals.rs:232):
Expr::Number(_)
| Expr::Integer(_)
| Expr::Uint8ArrayLength(_)
| Expr::Uint8ArrayGet{ .. }// <-- every key kind, including symbol keys
| Expr::BufferLength(_)
| Expr::BufferIndexGet{ .. }
... => Some(Type::Number),
But Uint8ArrayGet is not always numeric. PR #6997's analysis enumerated three heap-capable arms of that node — a symbol key, an unproven key routed to js_typed_array_index_get_dynamic, and an i32-context js_object_get_index_polymorphic arm — and gated its own rooting decision on the lowering rather than the node kind for exactly this reason.
Consequence
const x = u8[Symbol.iterator] binds a heap value into a local that the collector has classified as non-pointer. A non-pointer local gets no shadow slot, so it is not a GC root: the value is live in the program and invisible to the collector. Same class as #6951, rarer shape.
Note the classification is also what sizes the shadow frame, so this is adjacent to #6995 (frame sized by HashMap len while indices come from a counter) — both are ways the shadow-slot map can end up not describing the frame it is used with. They are separate defects; fixing one does not fix the other.
Status — verified statically, reachability NOT verified
I confirmed by reading origin/main (aa1c15028) that the match arm is unconditional. I have not exhibited a TypeScript program that produces a symbol-keyed Uint8ArrayGet bound to a local, and that is the first thing to establish. The obvious candidate is const it = u8[Symbol.iterator], but AST→HIR lowering may route symbol keys to a different node before this collector sees them — check before assuming.
The soundness principle PR #6997 established should be applied here too: decide from the lowering, not from the node kind or the declared type. Perry does not enforce type annotations at runtime, so a declared type is never sufficient evidence that a value cannot be a pointer.
Verification guidance
A reproducer must be checked on the evacuating precise-roots arm, where a missing root actually bites:
with [gc-copy-minor] copied_objects=N, N > 0. A green run where nothing moved proves nothing (#6942/#6946/#6950). Also note #6993: the per-PR matrix arms cannot reach this configuration, so PR CI would not catch a regression here even once a reproducer exists.
Found while fixing #6996 (PR #6997), which deliberately declined to copy the pattern. Filed separately rather than folded in.
Reachability — the thing this issue said to establish first — is established
Not argued: read off --print-hir. lower/expr_member/member_tail.rs folds every non-STRING key on a Uint8Array/Buffer-typed local onto this node, and a symbol key is not a string, so const it = u8[Symbol.iterator] gives exactly:
A dynamic key reaches it too: const k: any = "byteLength"; u8[k] gives Uint8ArrayGet { index: LocalGet(4) }.
The fix
expr_value_type's arm answers Some(Type::Number) only for a structurally numeric key — the same index_is_definitely_numeric proof the IndexGet typed-array arm next to it already uses — and None otherwise.
Structural on purpose. A number-declared index local is not evidence, because Perry does not enforce annotations, and expr_is_known_non_pointer_shadow_value's sharper three-part test needs an FnCtx this collector runs before. None is the conservative direction: the local keeps a slot the collector rewrites harmlessly.
Both directions are pinned, and the sabotage arm was run — restoring the unconditional Number turns a_symbol_keyed_uint8array_read_keeps_its_shadow_slot red while a_numeric_keyed_uint8array_read_still_pays_no_slot (the #6996 cost this must not reintroduce) stays green.
★ The runtime consequence is currently masked by a second, behavioural bug
Filed as #7700, and it is worth more than this fix. Five other collectors and type_analysis/numeric.rs make the same unconditional "a Uint8ArrayGet is a number" assumption, and they force the i32-context lowering — so a non-numeric key on a typed-array-typed local reads a byte instead of the property:
node 26.5.1
perry
typeof u8[Symbol.iterator]
function
number
const k: any = "byteLength"; u8[k]
4
0
u8.tag = {…}; const k: any = "tag"; u8[k]
{"kind":"buffer"}
0
expr/arrays_finds.rs's JS-value lowering handles all three key kinds correctly; the value never reaches it.
So no heap value binds into such a local today, which is why this classification bug has never bitten. It is a latent-soundness fix that becomes load-bearing the moment #7700 is repaired — which is the order the two should land in. No gap test was added here for that reason: the behavioural one would be red, and a red gap test is not acceptance.
with a #6998: comment at line 240 reproducing this issue's own reasoning. A symbol-keyed read no longer binds a heap value into a local classified Number, so it gets a shadow slot.
Filing the residue rather than folding it in here: that fix's changelog flags a separate, still-open defect — five sibling collectors plus type_analysis/numeric.rs still assume Uint8ArrayGet is always numeric. That is new scope discovered by the fix, not part of this issue as written, and it should not ride along on a closed ticket.
Activity
proggeramlug commentedon Aug 9, 2026
Fixed in #7699.
Reachability — the thing this issue said to establish first — is established
Not argued: read off
--print-hir.lower/expr_member/member_tail.rsfolds every non-STRING key on aUint8Array/Buffer-typed local onto this node, and a symbol key is not a string, soconst it = u8[Symbol.iterator]gives exactly:A dynamic key reaches it too:
const k: any = "byteLength"; u8[k]givesUint8ArrayGet { index: LocalGet(4) }.The fix
expr_value_type's arm answersSome(Type::Number)only for a structurally numeric key — the sameindex_is_definitely_numericproof theIndexGettyped-array arm next to it already uses — andNoneotherwise.Structural on purpose. A
number-declared index local is not evidence, because Perry does not enforce annotations, andexpr_is_known_non_pointer_shadow_value's sharper three-part test needs anFnCtxthis collector runs before.Noneis the conservative direction: the local keeps a slot the collector rewrites harmlessly.Both directions are pinned, and the sabotage arm was run — restoring the unconditional
Numberturnsa_symbol_keyed_uint8array_read_keeps_its_shadow_slotred whilea_numeric_keyed_uint8array_read_still_pays_no_slot(the #6996 cost this must not reintroduce) stays green.★ The runtime consequence is currently masked by a second, behavioural bug
Filed as #7700, and it is worth more than this fix. Five other collectors and
type_analysis/numeric.rsmake the same unconditional "aUint8ArrayGetis a number" assumption, and they force the i32-context lowering — so a non-numeric key on a typed-array-typed local reads a byte instead of the property:typeof u8[Symbol.iterator]functionnumberconst k: any = "byteLength"; u8[k]40u8.tag = {…}; const k: any = "tag"; u8[k]{"kind":"buffer"}0expr/arrays_finds.rs's JS-value lowering handles all three key kinds correctly; the value never reaches it.So no heap value binds into such a local today, which is why this classification bug has never bitten. It is a latent-soundness fix that becomes load-bearing the moment #7700 is repaired — which is the order the two should land in. No gap test was added here for that reason: the behavioural one would be red, and a red gap test is not acceptance.
fix(codegen): pointer_locals stops typing every Uint8ArrayGet as a Nu…
fix(codegen): the lowering-window cluster — push order, member update…
proggeramlug commentedon Aug 9, 2026
Fixed by #7699 (commit
9965eb3f6), verified in current code:with a
#6998:comment at line 240 reproducing this issue's own reasoning. A symbol-keyed read no longer binds a heap value into a local classifiedNumber, so it gets a shadow slot.Filing the residue rather than folding it in here: that fix's changelog flags a separate, still-open defect — five sibling collectors plus
type_analysis/numeric.rsstill assumeUint8ArrayGetis always numeric. That is new scope discovered by the fix, not part of this issue as written, and it should not ride along on a closed ticket.