Skip to content

AOT: Array methods are undefined when accessed via Map.get(...).field #655

Description

@proggeramlug

Summary

Under perry compile, calling Array.prototype methods on an array reached through Map.get(k)!.field returns garbage (or never dispatches) for shift, splice, pop, slice, unshift. push is special-cased and works. length = writes are silently ignored.

The same array, aliased to a single-identifier local first, dispatches the same methods correctly. So the issue is specifically the chained Map.get(...).field.method() call site, not Array.prototype itself.

Found while running @perryts/redis under AOT — drivers and pools that store per-connection FIFO state in module-level Map<id, State> (the pattern recommended by your own AOT guidance for closure-capture safety) hit this on every reply.

Environment

  • perry 0.5.772
  • macOS 26.4 / Apple M1 Max

Minimal repro

// repro.ts
interface State { pending: number[]; }

const m = new Map<number, State>();
m.set(1, { pending: [10, 20, 30] });
const s = m.get(1)!;

console.log('typeof shift:  ', typeof s.pending.shift);   // undefined
console.log('typeof splice: ', typeof s.pending.splice);  // undefined
console.log('typeof pop:    ', typeof s.pending.pop);     // undefined
console.log('typeof slice:  ', typeof s.pending.slice);   // undefined

const x = s.pending.shift();
console.log('shift ret:', x, 'len after:', s.pending.length);
// Actual:   shift ret: [object Object] len after: 3   ← length unchanged, return is wrong
// Expected: shift ret: 10                len after: 2

s.pending.length = 0;
console.log('after length=0:', s.pending.length);
// Actual:   3   ← write ignored
// Expected: 0

// Workaround: alias to local first
const arr = s.pending;
console.log('aliased typeof shift:', typeof arr.shift);  // still 'undefined'
console.log('aliased shift ret:', arr.shift());          // 10  ← actually works
console.log('back at struct:', s.pending);               // [ 20, 30 ]  ← mutation visible
$ perry compile repro.ts -o repro && ./repro
typeof shift:   undefined
typeof splice:  undefined
typeof pop:     undefined
typeof slice:   undefined
shift ret: [object Object] len after: 3
after length=0: 3
aliased typeof shift: undefined
aliased shift ret: 10
back at struct: [ 20, 30 ]

Behavior matrix

Call form Works?
arr.shift() where arr is a local number[] yes (typeof still 'undefined' but call dispatches)
this.field.shift() (class instance field) yes
m.get(k)!.field.shift() no — returns wrong, no mutation
m.get(k)!.field.push(x) yes (special-cased)
m.get(k)!.field.length = n no — silently ignored

Impact

Per the AOT-guidance pattern in CLAUDE.md files across the perryts ecosystem ("store state in module-level Map<id, State> and use named module-level handlers"), every driver that follows this pattern has FIFO/queue logic doing s.pending.shift() or s.idle.shift(). Every reply path mutates a Map-stored array, so this isn't an edge case — it's the hot path.

Workaround in @perryts/redis: alias each array to a local before calling mutating methods. This is mechanical but easy to regress on, and length = N truncation has no workaround at all (we replace the array reference instead).

Notes

push is silently special-cased to dispatch correctly even on the broken path. So the symptom is "every other Array mutation is silently broken in a way push isn't", which is hard to spot without a test that reads back length or uses one of the affected methods.

Activity

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