diff --git a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs index 6eba59ed6..10f646b22 100644 --- a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs +++ b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs @@ -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)?; diff --git a/crates/perry-hir/src/lower/expr_call/local_array_methods.rs b/crates/perry-hir/src/lower/expr_call/local_array_methods.rs index 14cdccb25..d73dfc229 100644 --- a/crates/perry-hir/src/lower/expr_call/local_array_methods.rs +++ b/crates/perry-hir/src/lower/expr_call/local_array_methods.rs @@ -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()) diff --git a/test-files/test_gap_function_ctor_array_named_methods.ts b/test-files/test_gap_function_ctor_array_named_methods.ts new file mode 100644 index 000000000..0c497d89b --- /dev/null +++ b/test-files/test_gap_function_ctor_array_named_methods.ts @@ -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(","));