Skip to content

fix(hir): stop the Array fast paths from hijacking same-named prototype methods#6397

Merged
proggeramlug merged 1 commit into
mainfrom
fix/array-name-hijack-prototype-methods
Jul 14, 2026
Merged

fix(hir): stop the Array fast paths from hijacking same-named prototype methods#6397
proggeramlug merged 1 commit into
mainfrom
fix/array-name-hijack-prototype-methods

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

The bug

A method installed the classic way — Fn.prototype.push = function (...) — is hijacked by the Array fast paths whenever its name collides with an Array builtin (push / pop / shift / unshift). The user's method never runs, the call returns a bogus array length, and — worse — js_array_push_f64 writes the receiver's ObjectHeader as an ArrayHeader, corrupting the instance so its fields subsequently read undefined. shift() even leaks a raw NaN-boxed double (2.12e-314) into user code.

function Q() { this.n = 0; }
Q.prototype.push = function (x) { this.n++; return "Q.push:" + x; };

new Q().push("a");   // node: "Q.push:a"   perry: 0  — and `n` now reads undefined

Class methods and object-literal methods were never affected, which is why this survived: it only bites the Fn.prototype.<name> = fn idiom — every queue, stack, ring-buffer and linked-list written that way, plus most minified/transpiled bundles.

Root cause — two gaps

Local receiver. is_user_class_instance knew about class decls, interfaces and imported classes, but not function-constructors. new Q() types its instances Named("Q"), which is neither Any nor Unknown, so it sailed past the wall-49 guard straight into the array block.

Property receiver. The #5139 deferral only inspected a bare identifier receiver. this._commands.push(c) had no gate at all and committed to the eager array.push_single arm.

The fix

push now folds only when the receiver is provably an array; otherwise it defers to js_native_call_method, which selects on the receiver's real runtime shape (real array → dense helper, growth via the #233 forwarding pointer; object → its own method). The rest of the mutator family already reached that dispatch for property receivers.

The fast paths are preserved where they matter. arr.push(x) on a typed array local still folds to Expr::ArrayPush, and this.items.push(v) on a declared items: number[] field still folds to array.push_single (infer_type_from_expr resolves the field through class_field_types). Only receivers that cannot be proven to be arrays defer — exactly the population where correctness was at risk.

How it was found

Compiling a real Next.js + MySQL app. This is the shape denque uses, which is how it reached mysql2: every this._commands.push(cmd) was silently dropped, so the command queue always reported empty and a real query never issued its command.

Test

test_gap_function_ctor_array_named_methods — local / property / parameter receivers, a denque-shaped ring buffer (push must enqueue so shift can dequeue), and the preserved real-array cases. Byte-identical to node.

Gap suite: no new untriaged failures (verified on a branch of main carrying only this and the two sibling fixes; the two failures the runner flags as "new" — test_gap_fs_fd_2749, test_gap_handle_band_object_ops — reproduce byte-identically on pristine main and are pre-existing).

Summary by CodeRabbit

  • Bug Fixes

    • Prevented array methods such as push, pop, shift, and unshift from being incorrectly applied to array-like objects and class instances.
    • Preserved correct behavior for custom collection implementations, including ring buffers and typed-array fields.
    • Improved safety when handling property-based receivers that are not confirmed to be real arrays.
  • Tests

    • Added coverage for constructor-based array-like objects and genuine array behavior.

…pe methods

A method installed the classic way — `Fn.prototype.push = function (...)` — was
hijacked by Perry's Array fast paths whenever its name collided with an Array
builtin (`push`/`pop`/`shift`/`unshift`). The user's method never ran, the call
returned a bogus array length, and `js_array_push_f64` wrote the receiver's
ObjectHeader *as an ArrayHeader*, so the instance was corrupted and its fields
subsequently read `undefined`. `shift()` even leaked a raw NaN-boxed double
(`2.12e-314`) into user code.

    function Q() { this.n = 0; }
    Q.prototype.push = function (x) { this.n++; return "Q.push:" + x; };
    new Q().push("a");   // node: "Q.push:a"   perry: 0, and `n` is now undefined

Two gaps, both closed here:

* Local receiver — `is_user_class_instance` knew about class decls, interfaces
  and imported classes, but not function-constructors, so a local typed
  `Named("Q")` was not `Any`/`Unknown` and sailed past the wall-49 guard into
  the array block.
* Property receiver — the #5139 deferral only inspected a *bare identifier*
  receiver. `this._commands.push(c)` had no gate at all and committed to the
  eager `array.push_single` arm. `push` now folds only when the receiver is
  provably an array; otherwise it defers to `js_native_call_method`, which
  selects on the receiver's real runtime shape. The rest of the mutator family
  already reached that dispatch for property receivers.

The fast paths are preserved where they matter: `arr.push(x)` on a typed array
local still folds to `Expr::ArrayPush`, and `this.items.push(v)` on a declared
`items: number[]` field still folds to `array.push_single` (`infer_type_from_expr`
resolves the field through `class_field_types`). Only receivers that cannot be
proven to be arrays now defer — exactly the population where correctness was at
risk.

This is the shape denque uses, which is how it reached mysql2: every
`this._commands.push(cmd)` was silently dropped, so the queue always reported
empty and a real MySQL query never issued its command. It bites any queue,
stack, ring-buffer or linked-list written in the `Fn.prototype.<name> = fn`
idiom, and most minified/transpiled bundles.

Found while compiling a real Next.js + MySQL app; `test_gap_function_ctor_array_named_methods`
covers local/property/parameter receivers, a denque-shaped ring buffer, and the
preserved real-array cases. Byte-identical to node.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The HIR array-method fast paths now require stronger receiver classification before folding push and other array-named methods. Regression tests cover constructor instances, property receivers, ring buffers, real arrays, and typed array fields.

Array lowering safety

Layer / File(s) Summary
Property receiver push guard
crates/perry-hir/src/lower/expr_call/array_only_methods.rs
push uses array intrinsic lowering only for receivers proven to be arrays; other property receivers retain dynamic dispatch.
Named constructor fast-path guard
crates/perry-hir/src/lower/expr_call/local_array_methods.rs
Named receivers backed by constructor-like functions are excluded from local array-method folding.
Array-like receiver regression coverage
test-files/test_gap_function_ctor_array_named_methods.ts
Tests constructor prototype methods, property and any receivers, denque behavior, real arrays, and typed array fields.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: andrewtdiz, thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the core fix to Array fast-path lowering for prototype methods.
Description check ✅ Passed The description covers the bug, root cause, fix, and test evidence, though it doesn't use the template's exact section headings.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/array-name-hijack-prototype-methods

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/perry-hir/src/lower/expr_call/array_only_methods.rs (1)

714-715: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider including Type::Tuple in the provably array check.

The provably_array check currently verifies if the receiver is Type::Array or Type::Generic { base: "Array", .. }. If a receiver is statically typed as a Tuple (e.g. [number, string]), this check will evaluate to false and bail out to dynamic dispatch. Since tuples are array-like and other parts of the codebase (such as line 486 in this file and checks in local_array_methods.rs) allow them in array fast paths, consider adding Type::Tuple(_) to this check to maintain optimal performance for tuples.

♻️ Proposed refactor
-                    let provably_array = matches!(recv_ty, Type::Array(_))
+                    let provably_array = matches!(recv_ty, Type::Array(_) | Type::Tuple(_))
                         || matches!(&recv_ty, Type::Generic { base, .. } if base == "Array");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-hir/src/lower/expr_call/array_only_methods.rs` around lines 714
- 715, Update the provably_array check in the array method lowering path to also
match Type::Tuple(_), preserving the existing Type::Array and generic Array
cases so statically typed tuples use the array fast path instead of dynamic
dispatch.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/perry-hir/src/lower/expr_call/array_only_methods.rs`:
- Around line 714-715: Update the provably_array check in the array method
lowering path to also match Type::Tuple(_), preserving the existing Type::Array
and generic Array cases so statically typed tuples use the array fast path
instead of dynamic dispatch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 620ea1b7-a6ed-4834-906f-e3f82a6c70e9

📥 Commits

Reviewing files that changed from the base of the PR and between 205473f and 05de71e.

📒 Files selected for processing (3)
  • crates/perry-hir/src/lower/expr_call/array_only_methods.rs
  • crates/perry-hir/src/lower/expr_call/local_array_methods.rs
  • test-files/test_gap_function_ctor_array_named_methods.ts

@proggeramlug

Copy link
Copy Markdown
Contributor Author

The test_gap_fs_fd_2749 failure here is not caused by this PR — the test is flaky and it red-lit #6397, #6398 and #6399 today.

It races the libuv threadpool: the three callback-form fd mutators are dispatched concurrently, so their callbacks fire in completion order. The gap harness compares perry against a freshly run node oracle, and the oracle itself is nondeterministic — across 40 runs, node --experimental-strip-types produced 4 distinct outputs, emitting fchown closed cb before futimes closed cb on roughly one run in six. Perry is deterministic here (always submission order), so it disagrees with the oracle whenever the oracle takes the minority path.

Fix is up as #6400 (buffer the three callback results, flush in a fixed order; assertions and expected output unchanged). Once that lands, rebase or rerun and this should go green.

Note test_gap_fs_fd_2749 is the only untriaged failure on this PR — everything else it reports is already in known_failures.json.

@proggeramlug proggeramlug merged commit 6169ed7 into main Jul 14, 2026
48 of 50 checks passed
@proggeramlug proggeramlug deleted the fix/array-name-hijack-prototype-methods branch July 14, 2026 13:06
proggeramlug added a commit that referenced this pull request Jul 14, 2026
…ol (#6400)

The three callback-form fd mutators are dispatched to the libuv threadpool
concurrently, so their callbacks fire in completion order, which is not
deterministic. The gap harness compares perry against a freshly run node
oracle -- and the *oracle itself* is unstable: across 40 runs of
`node --experimental-strip-types` this test produced 4 distinct outputs,
emitting `fchown closed cb` before `futimes closed cb` on roughly one run
in six. Perry is deterministic here (always submission order), so the test
red-lit at random on unrelated PRs -- it took out #6397, #6398 and #6399
today, and cost a full bisect before the oracle was suspected.

The test is about the error surface (err.code / err.syscall) of each fd
mutator, not about libuv's scheduling. Buffer the three callback results and
flush them in a fixed order once all three have landed. Every assertion is
preserved and the emitted 10 lines are byte-for-byte what they were; only the
print order is pinned.

After: node emits 1 distinct output across 60 runs, perry 1 across 40, and the
two are byte-identical.

Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant