Skip to content

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

Description

@proggeramlug

Summary

collect_pointer_typed_locals's expr_value_type classifies Expr::Uint8ArrayGet as Type::Number unconditionally, 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:

PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off

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.

Activity

  1. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    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.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:

    Let { id: 1, name: "it", ty: Any, mutable: false,
          init: Some(Uint8ArrayGet { array: LocalGet(0),
                                     index: SymbolFor(String("@@__perry_wk_iterator")) }) }
    

    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.

  2. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    Fixed by #7699 (commit 9965eb3f6), verified in current code:

    collectors/pointer_locals.rs:263  Expr::Uint8ArrayGet { index, .. } if index_is_definitely_numeric(index) => Some(Type::Number)
    collectors/pointer_locals.rs:266  Expr::Uint8ArrayGet { .. } => None
    

    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.

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