Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions crates/perry-hir/src/lower/expr_call/array_only_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,30 @@ pub(super) fn try_array_only_methods(
return Ok(Err(args));
}
}
// The guard above only inspects a *bare identifier* receiver. A property
// receiver (`this._commands.push(c)`, `h.q.push(c)`) had no gate at all
// and committed to the eager `array.push_single` native arm, which threads
// the receiver through `js_array_push_f64` — that helper writes
// `(*arr).length` and the element slot with no shape check, so a plain
// object receiver has its ObjectHeader overwritten as an ArrayHeader: the
// user's `push` never runs, the call yields a bogus length, and later field
// reads on the object come back `undefined`. denque (mysql2's command
// queue) is exactly this shape — `Denque.prototype.push` reached via
// `this._commands` — so every queued command was silently dropped.
//
// Fold only when the receiver is *provably* an array; otherwise defer to
// the runtime `js_native_call_method` dispatch, which selects on the
// receiver's real shape (real array → dense helper, growth via the #233
// forwarding pointer; object → its own method). The rest of the mutator
// family already reaches that dispatch for property receivers.
if method_name == "push" {
let recv_ty = crate::lower_types::infer_type_from_expr(&member.obj, ctx);
let provably_array = matches!(recv_ty, Type::Array(_))
|| matches!(&recv_ty, Type::Generic { base, .. } if base == "Array");
if !provably_array {
return Ok(Err(args));
}
}
match method_name {
"reduce" if !args.is_empty() && !recv_is_class => {
let array_expr = lower_expr(ctx, &member.obj)?;
Expand Down
11 changes: 11 additions & 0 deletions crates/perry-hir/src/lower/expr_call/local_array_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,17 @@ pub(super) fn try_local_array_methods(
ctx.lookup_class(name).is_some()
|| ctx.is_interface_type(name)
|| is_imported_class_name(name)
// A `function Q() {…}` used as a constructor (`new Q()`)
// types its instances `Named("Q")`, but it is not a class
// decl, so `lookup_class` misses it. Its methods live on
// `Q.prototype` (registered via
// `Expr::RegisterFunctionPrototypeMethod`), and when one of
// them shares an Array name — `Q.prototype.push`, the shape
// denque uses for mysql2's command queue — the array fast
// path folded `q.push(x)` to `Expr::ArrayPush`, read the
// instance's ObjectHeader as an ArrayHeader (silently
// corrupting it) and never ran the method.
|| ctx.functions_index.contains_key(name.as_str())
}
Some(Type::Generic { base, .. }) => {
!builtin_generic_bases.contains(&base.as_str())
Expand Down
99 changes: 99 additions & 0 deletions test-files/test_gap_function_ctor_array_named_methods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// A `function Q() {}` constructor whose prototype defines methods that share a
// name with an Array builtin (`push`/`pop`/`shift`/`unshift`). The array fast
// paths must not hijack them: the instance is a plain object, not an array.
// This is the shape denque uses (mysql2's command queue).

function Q(this: any) {
this.n = 0;
}
Q.prototype.push = function (this: any, x: any) {
this.n++;
return "Q.push:" + x;
};
Q.prototype.pop = function (this: any) {
return "Q.pop";
};
Q.prototype.shift = function (this: any) {
return "Q.shift";
};
Q.prototype.unshift = function (this: any, x: any) {
return "Q.unshift:" + x;
};

// receiver: local
const q: any = new (Q as any)();
console.log("local push :", q.push("a"));
console.log("local pop :", q.pop());
console.log("local shift :", q.shift());
console.log("local unshift:", q.unshift("b"));
console.log("local field n:", q.n);

// receiver: property (`this._commands.push(cmd)` — mysql2's shape)
function Holder(this: any) {
this.q = new (Q as any)();
}
Holder.prototype.add = function (this: any, x: any) {
return this.q.push(x);
};
const h: any = new (Holder as any)();
console.log("field via method:", h.add("c"));
console.log("field direct :", h.q.push("d"));
console.log("field n :", h.q.n);

// receiver: parameter (type is `any`)
function useIt(dq: any) {
return dq.push("e");
}
console.log("param :", useIt(q));
console.log("param n :", q.n);

// A denque-shaped ring buffer: push must actually enqueue so shift can dequeue.
function Deq(this: any) {
this._head = 0;
this._tail = 0;
this._capacityMask = 3;
this._list = [, , , ,];
}
Deq.prototype.size = function (this: any) {
return this._head === this._tail
? 0
: this._head < this._tail
? this._tail - this._head
: this._capacityMask + 1 - (this._head - this._tail);
};
Deq.prototype.push = function (this: any, item: any) {
const t = this._tail;
this._list[t] = item;
this._tail = (t + 1) & this._capacityMask;
return this.size();
};
Deq.prototype.shift = function (this: any) {
const head = this._head;
if (head === this._tail) return undefined;
const item = this._list[head];
this._list[head] = undefined;
this._head = (head + 1) & this._capacityMask;
return item;
};

const dq: any = new (Deq as any)();
console.log("deq push ret :", dq.push("cmd1"));
console.log("deq size :", dq.size());
console.log("deq shift :", String(dq.shift()));
console.log("deq empty :", String(dq.shift()));

// Real arrays must keep working (the fast path is preserved for them).
const arr: number[] = [1, 2];
arr.push(3);
console.log("array :", arr.join(","), arr.length);

class WithField {
items: number[] = [];
add(v: number) {
this.items.push(v);
return this.items.length;
}
}
const wf = new WithField();
wf.add(7);
console.log("typed field :", wf.add(8), wf.items.join(","));