Skip to content

fix(transform): do not beta-reduce a local arrow whose param is captured by a nested closure - #10653

Closed
proggeramlug wants to merge 3 commits into
mainfrom
wip/10567-arrow-param-capture
Closed

proggeramlug wants to merge 3 commits into
mainfrom
wip/10567-arrow-param-capture

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

A closure created inside a local arrow function, capturing that arrow's own
parameter, kept seeing the FIRST call's argument on every later call to the
same arrow — even though the arrow itself was re-invoked with a different
value each time. Only the nested-closure-into-array-callback shape was
affected; a directly-invoked inner closure and a plain function declaration
already worked correctly.

Root cause (file:line)

crates/perry-transform/src/closure_local_inline.rs — the
run/process_stmts/arrow_candidate/rewrite_calls pipeline — beta-reduces
a let f = (a, b) => <single return expr> local whose every use is a direct
call: it clones the return expression fresh per call site and hands the
clone to substitute_locals (crates/perry-transform/src/inline/substitute.rs)
with a param→argument map for that call.

substitute_locals's Expr::Closure arm (lines 72–105) substitutes into
the body and remaps captures/mutable_captures: when a captured id maps to
something other than a LocalGet — i.e. the argument is a literal — the
capture is dropped from the list ("the closure body no longer references
this id"), and the literal is baked straight into the closure's body instead.
That is correct for that one clone — but the closure keeps its original
func_id
; nothing in this path mints a fresh one for the rewritten literal.
Codegen compiles exactly one body per func_id — whichever Expr::Closure
occurrence its module-wide scan encounters first (see the analogous note in
lower_decl/class_decl.rs:238: "the generator transform... only visits
module.functions... codegen compiles ONE function body per func_id"). With
more than one call site of the same local arrow, every clone of the nested
closure shares that one func_id, so only the first-seen clone's baked-in
literal is ever compiled — every other call silently runs it too.

Confirmed with --trace hir: for

const iter = (isOpt: boolean) => [1,2].forEach(v => console.log(v, isOpt));
iter(false); iter(true);

the post-transform HIR shows the enclosing function's body containing
two ArrayForEach statements, both callback: Closure { func_id: 2, ... }
— one with Bool(false) baked into its Return, the other with Bool(true)
— sharing the identical func_id. At runtime, both calls to iter printed
false.

try_inline_simple_call/try_inline_call (the FuncRef-keyed inliner in
crates/perry-transform/src/inline/call_inliner.rs) already has a guard for
this exact class of bug (issue #858:
collect_closure_captured_local_ids + forcing such a parameter to be
materialized as a fresh Let instead of substituted as a literal) — but
closure_local_inline.rs is a separate pass (it beta-reduces a plain
local closure variable, which is never a func_candidates/FuncRef target)
and never applied that guard.

PR #10615's DeclCensus rewrite in crates/perry-hir/src/lower/shared_mutable_capture.rs
(cited in my brief as a possible sibling fix) is unrelated: that machinery
only fires on Expr::RegisterClassCaptures — i.e. a class LIFTED out of an
enclosing function — and this repro has no class at all. I confirmed by
inspection and by reproducing the bug that #10615's branch does not touch
closure_local_inline.rs and would not fix this issue; I am not closing
#10567 as a duplicate.

Fix

arrow_candidate now rejects a candidate when any of its own parameters is
read inside a closure nested in its body (reusing the existing
collect_closure_captured_local_ids helper from crates/perry-transform/src/inline/closure_analysis.rs,
the same helper #858's fix uses). Such an arrow is left as a real, per-call
closure: each invocation allocates its own closure instance whose nested
callback correctly captures that call's argument by reference — the same
path that already worked for the issue's outer/inner control.

Tests added

test-files/test_gap_10567_arrow_param_closure_capture.ts: the issue's own
repro, plus the requested variants —

  • an arrow with several params, where the captured one is neither first
    nor last;
  • nested arrows (outer → middle → inner, two closure boundaries away);
  • an arrow declared inside a class method;
  • a by-write capture control (a multi-statement arrow body never becomes
    a closure_local_inline candidate at all — this documents that the
    existing, unaffected path keeps working);
  • the plain-function controls from the original issue (outer/inner,
    twice).

Validated byte-for-byte against node --experimental-strip-types (Node
26.5.1).

Proof it fails on the baseline: checked out closure_local_inline.rs
from this branch's parent commit (68a5454396, main before this fix) with
the new test file added, rebuilt, and ran it — the repro, several-params,
nested-arrows, and arrow-in-method sections all print the FIRST call's
argument on every call:

x number false
a boolean false          <- should be true
A 1 1 false
A 1 2 false
B 2 1 false               <- should be "B 2 1 true" / "B 2 2 true"
B 2 2 false
first 10
first 20
first 10                  <- should be "second 10" / "second 20"
first 20
method 1 false
method 2 false
method 1 false            <- should be true
method 2 false
true true
A:1,B:1
false true

On this branch: every line matches Node byte-for-byte (diff against Node's
output is empty).

Validation

  • cargo test --release -p perry-transform --tests: 152 passed, 0
    failed
    .

  • Gap suite (filtered): the new test plus every existing closure/inline/forEach
    gap test that could plausibly be touched by this change, including the
    original Closure-captured numeric params read as 0 inside object-literal : Date method (@perryts/mysql MyDateTime.toDate shape) #858 regression test:
    test_gap_10567_arrow_param_closure_capture, test_issue_858_closure_numeric_capture,
    test_parity_inline_closure_capturing_local, test_gap_9090_closure_literal_identity,
    test_gap_finally_inline_nested_closure, test_gap_closures, test_closure_complex,
    test_edge_closures, test_returning_closures, test_obj_closure_call,
    test_gap_collection_foreach_member_receiver_thisarg, test_gap_foreach_live_index_read_no_skip,
    test_gap_set_map_foreach_fused_receiver, test_issue_5432_fetch_headers_foreach — all
    PASS, no regressions. (test_issue_610_foreach fails to compile on this
    host for an unrelated, pre-existing reason: it uses perry/ui, and this
    Linux box has no libperry_ui_gtk4.a built — confirmed independent of this
    change.)

  • python3 scripts/check_test_registration.py: OK (338 files checked).

  • cargo fmt --all -- --check: clean.

  • Lint (SKIP_COMPILE_GATES=1 ./scripts/run_lint_gates.sh): 76/77 gates
    passed
    ; pre-existing red: Public benchmark evidence freshness
    (benchmarks/ci_public_baseline_check.py), red on every PR in this repo,
    untouched by this change.

  • Performance (perf stat -e instructions,task-clock, 3 runs each, this
    host):

    workload baseline (median instructions) fix (median instructions) delta
    single call site, LocalGet argument (safe shape — never hits the bug; validate(isOpt) calling iter(isOpt) once per invocation, 3M invocations) 4,908,481,044 4,908,557,095 +0.002% (noise)
    exact bug shape — two literal-arg call sites of the same local arrow per invocation, 1.5M invocations (iter(false); iter(true);) 4,170,877,399 (WRONG output: 0) 4,477,146,967 (correct: 9000000) +7.3%

    The single-call-site/LocalGet-argument shape is unaffected (within noise)
    substitute_locals already preserves a LocalGet-mapped capture instead
    of stripping it, so my guard's rejection is conservative there but costs
    nothing measurable on this benchmark. The exact bug shape regresses ~7.3%
    instructions: this is the correctness cost of no longer sharing a single,
    wrongly-baked closure body across call sites with different arguments —
    the baseline number is not a valid "before" to preserve, since it is
    measuring a program that computes the wrong answer (0 instead of
    9000000). Node wall time on the same workload: 163ms (JIT-optimized
    small-loop specialization AOT native codegen does not attempt); consistent
    with prior PRs' notes on this class of micro-loop.

What I did not verify

  • Did not run the full gap suite locally — this host (perrymaster,
    shared, no working PERRY_NO_AUTO_OPTIMIZE-free auto-optimize path in
    reasonable time) is documented to stall under auto-optimize mode; relying
    on CI's sharded gap suite per the standard process, per the owner's
    standing "ignore CI, PR open is the stop condition" instruction.
  • The @noble/curves 2.2.0 end-to-end repro named in the issue — verified
    the issue's own minimal repro and the requested variants directly, not
    the full package build.
  • The separately-filed, not-reduced @noble/curves 1.2.0
    ReferenceError: Cannot access 'wnaf' before initialization noted in the
    issue is explicitly called out there as possibly a different defect; not
    investigated here.

New bug noticed (not fixed here)

While tracing substitute_locals's Expr::Closure handling
(crates/perry-transform/src/inline/substitute.rs), I noticed it is the
only caller-side mechanism that strips a capture on literal substitution
without mangling func_id — any other future caller of substitute_locals
that clones a body containing a nested closure and doesn't independently
apply the #858-style guard would reproduce this exact class of bug. Worth a
follow-up: either thread a fresh-func_id allocator through
substitute_locals itself, or add a debug_assert!/lint that every caller
of collect_closure_captured_local_ids-adjacent inlining paths applies the
same guard. Filing as future work, not attempting the broader refactor here
per the ~800-line-diff guidance.

Fixes #10567

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an issue where callbacks nested in arrow functions could reuse the argument from the first invocation.
    • Ensured nested closures consistently receive the correct values across repeated calls, including in class methods and nested functions.
    • Preserved correct behavior when parameters are updated or inner closures are invoked directly.
  • Tests

    • Added regression coverage for multiple parameters, nested arrows, repeated calls, and function declarations.

Ralph Küpper added 2 commits September 18, 2026 16:37
…red by a nested closure

closure_local_inline's beta-reduction clones the arrow's return expression
fresh per call site and substitutes each parameter with that call's
argument via substitute_locals. For a parameter read inside a NESTED
closure (e.g. (f, isOpt) => arr.forEach(([k,v]) => check(k, v, isOpt))),
substitute_locals bakes a non-LocalGet argument straight into that nested
closure's body and drops it from the closure's captures list, but never
mints a fresh func_id for the rewritten closure literal. Codegen compiles
exactly one body per func_id (whichever Expr::Closure occurrence its
module-wide scan sees first), so every call site's clone of the nested
closure keeps sharing the SAME func_id -- with more than one call site,
only the first-seen clone's baked-in argument is ever compiled, and every
other call silently runs it too.

Bail out of the beta-reduction when any parameter is captured by a nested
closure, leaving such an arrow as a real, per-call closure -- each
invocation then creates its own closure instance whose nested callback
correctly captures that call's argument by reference.
…ultiple call sites

Covers the #10567 repro (nested destructuring forEach callback), an arrow
with several params where the captured one is neither first nor last,
nested arrows (outer -> middle -> inner, two closure boundaries away),
an arrow declared inside a class method, a by-write capture control (a
multi-statement arrow body, never a closure_local_inline candidate), and
the plain-function controls from the original issue.
@proggeramlug proggeramlug added the package-audit Found by the 2026 package audit: compiling real npm packages from source instead of native bindings label Sep 18, 2026
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 5fcc97f0-c7da-4a41-acc8-968af8e78efb

📥 Commits

Reviewing files that changed from the base of the PR and between 6092204 and 2298dfa.

📒 Files selected for processing (3)
  • changelog.d/10653-arrow-param-capture.md
  • crates/perry-transform/src/closure_local_inline.rs
  • test-files/test_gap_10567_arrow_param_closure_capture.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 1 remains after this review.


📝 Walkthrough

Walkthrough

Changes

Arrow closure capture

Layer / File(s) Summary
Capture-aware arrow transform
crates/perry-transform/src/closure_local_inline.rs, changelog.d/10653-arrow-param-capture.md
arrow_candidate now rejects beta-reduction when a nested closure captures an arrow parameter. The changelog records the shared-func_id defect and the resulting instruction regression for the affected shape.
Closure capture regression coverage
test-files/test_gap_10567_arrow_param_closure_capture.ts
Tests cover repeated calls, multiple and nested parameters, class methods, parameter writes, directly invoked closures, and function declarations.

Priority: ⬆️ High

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix · Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 2298d

The fix preserves per-invocation closure captures for the reported repeated-call cases, with no remaining concrete merge-blocking risk.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 2 files. (1 skipped: 1… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main fix: preventing beta-reduction of local arrows when nested closures capture their parameters.
Description check ✅ Passed The description is detailed and covers the bug, root cause, fix, related issue, regression tests, validation results, limitations, and performance impact. It does not use every template heading, but i…
Linked Issues check ✅ Passed Issue #10567 requires each arrow invocation to provide its own parameter value to nested closures. crates/perry-transform/src/closure_local_inline.rs now rejects beta-reduction when an arrow paramet…
Out of Scope Changes check ✅ Passed The changes stay within issue #10567. They modify the affected local-arrow transform, add focused regression coverage, and add a related changelog entry. No unrelated product behavior or broad refacto…
Full details: Docstring Coverage

Explanation

Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 2 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed in merge train 222 (#10732), released as v0.5.1601 — main is now 7c5d04d0ea.

Closing rather than merging is how trains work here: the eight PRs were cherry-picked onto one tree, validated together, and landed under the train's own commit, so GitHub cannot mark this one merged even though your change is on main. git log origin/main will show your commits.

Close-keywords in a source PR body never fire under this scheme, so the issues this train resolved were closed from the train's body instead.

The tree passed: all nine cheap gates, cargo check --workspace --all-targets under -D warnings, the release build of all five pinned artifacts, every unit suite, both derived integration suites, and a 14-area gap sweep with zero unexplained regressions and every area asserted to have run a non-zero number of tests. lint completed its full 6-of-6 compile tier with no failure outside the known-red public-baseline step.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package-audit Found by the 2026 package audit: compiling real npm packages from source instead of native bindings

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A closure created inside an arrow function captures the arrow's parameter by first-call value: the second call still sees the first call's argument

1 participant