Skip to content

fix: #858 — closure-captured numeric params reach object-literal method body#866

Merged
proggeramlug merged 2 commits into
mainfrom
fix/858-closure-numeric-capture
May 16, 2026
Merged

fix: #858 — closure-captured numeric params reach object-literal method body#866
proggeramlug merged 2 commits into
mainfrom
fix/858-closure-numeric-capture

Conversation

@proggeramlug
Copy link
Copy Markdown
Contributor

Summary

Closes #858. Likely closes #859 (downstream — shop-admin SIGBUS site has identical shape; not separately verified end-to-end in this worktree).

Repro (issue body, 12 lines)

function show(label: string, d: Date) {
  console.log(label, "getTime=", d.getTime(), " iso=", d.toISOString());
}
function makeDT(y: number) {
  return { toDate(): Date {
    return new Date(Date.UTC(y, 4, 15, 17, 29, 35, 402));
  } };
}
const d = makeDT(2026).toDate();
console.log("INLINE:", d.getTime());
show("HELPER:", d);

Pre-fix perry: INLINE: -62155492224598 (that's Date.UTC(0, 4, 15, ...)y reads as 0). Node + tsx: INLINE: 1778866175402.

Post-fix: byte-identical to Node — INLINE: 1778866175402 / HELPER: ... iso= 2026-05-15T17:29:35.402Z.

Root cause

The inliner's call-site param-substitution path (try_inline_simple_call Pattern 1 and friends in crates/perry-transform/src/inline.rs) short-circuits "literal-trivial" args (Integer/Number/Bool/String/Null/Undefined per is_trivial_expr) into param_map.insert(param.id, arg.clone()) and then runs substitute_locals over the cloned function body. substitute_locals's Expr::Closure arm recurses into the closure body, substitutes the captured LocalGet(y) with the literal, and drops y from the closure's captures list.

But closures are compiled exactly once per func_id by compile_closure, keyed by the FIRST occurrence collect_closures_in_stmts saw — typically the original (uninlined) Expr::Closure inside the source function's body, which still has body: [...LocalGet(y)...] and captures: [y]. The compiled body therefore reads capture slot 0. At the call site, lower_expr consults the inlined Expr::Closure (where captures: []), so compute_auto_captures allocates zero capture slots. Reading slot 0 returns uninitialized bits — 0.0 in practice — so Date.UTC(y, 4, 15, ...) runs as Date.UTC(0, 4, 15, ...).

The bug is invisible to type checking and codegen warnings because both sides ARE internally consistent — just with each other disagreeing on the func_id ↔ capture-shape contract.

Fix

New helper collect_closure_captured_local_ids (one walk of the callee's body collecting every closure's captures / mutable_captures) drives a force-materialize rule at all six call-site code paths in inline.rs that run substitute_locals / substitute_locals_in_stmts:

  • try_inline_simple_call: fn-call Pattern 1 (single Return), fn-call Pattern 2 (Let-then-Return), single-Return method, void-method
  • try_inline_call: fn-call, method-call

For each (param, arg) pair: if param.id is in the closure-captured set AND arg is a trivial-but-not-LocalGet literal, insert a fresh Let __param_id = arg into setup_stmts and map param.id → LocalGet(fresh_id) instead of substituting the literal in place. A LocalGet → LocalGet substitution is benign because the closure body still references a local, just renumbered, and the captures list rewrites consistently.

The closure body retains its LocalGet(fresh_id) reference and captures: [fresh_id] stays non-empty — so closure-creation (lower_expr + compute_auto_captures) and compiled-body emission (compile_closure) agree on capture slot index 0.

Why this also (likely) closes #859

@perryts/mysql's makeMyDateTime(y, mo, d, h, mi, s, ms) is the exact same shape:

function makeMyDateTime(y, mo, d, h, mi, s, ms) {
  return { toDate(): Date { return new Date(Date.UTC(y, mo-1, d, h, mi, s, ms)); } };
}

Pre-fix, every numeric param routed through the same substitute-into-closure-body path; post-fix they all read correctly. The downstream SIGBUS in shop-admin is consistent with Date.UTC receiving an all-zero arg vector and producing a NaN/out-of-range Date that's then handed to a native-method dispatch expecting a valid date pointer. I called it "likely closes #859" rather than "closes #859" because shop-admin isn't part of this worktree — please flip to "Closes" once verified end-to-end, or file a follow-up if the SIGBUS still reproduces.

Validation

  • New regression test test-files/test_issue_858_closure_numeric_capture.ts (7 closure-capture shapes: method shorthand with numeric/string/multi-primitive captures, arrow-returning shape, arrow nested inside object literal value, Array.prototype.map callback, multi-stmt Pattern 2). All seven byte-identical to node --experimental-strip-types.
  • Full parity suite (./run_parity_tests.sh): 345/372 pass; all 27 parity failures + 18 compile failures pre-listed in test-parity/known_failures.json. Zero new failures.
  • cargo test --release --workspace (excluding cross-host UI crates per CLAUDE.md): green.

Files touched

  • crates/perry-transform/src/inline.rs — new helper + six call-site updates
  • test-files/test_issue_858_closure_numeric_capture.ts — new regression test
  • Cargo.toml + CLAUDE.md version bump 0.5.921 → 0.5.922
  • CHANGELOG.md## v0.5.922 — fix(transform): #858 … entry

Test plan

  • Reproduce pre-fix: INLINE: -62155492224598
  • Post-fix: INLINE: 1778866175402 (byte-identical to Node)
  • cargo build --release -p perry-runtime -p perry-stdlib && cargo build --release
  • Regression test 7/7 byte-identical to Node
  • ./run_parity_tests.sh: zero new failures
  • cargo test --release --workspace --exclude perry-ui-ios --exclude perry-ui-tvos --exclude perry-ui-watchos --exclude perry-ui-visionos --exclude perry-ui-android --exclude perry-ui-windows --exclude perry-ui-gtk4: green
  • Validate Native shop-admin signup SIGBUS in toUser → dtRequiredToIso (downstream of @perryts/mysql MyDateTime.toDate corruption) #859 closure end-to-end in shop-admin (separate environment)

…iteral method body

The inliner's call-site param-substitution path was rewriting captured
`LocalGet(y)` to a literal arg (`Integer(2026)`) inside a nested
closure's body, which empties the closure's `captures` list at the
call site. But the closure body is compiled exactly once per `func_id`
from the FIRST occurrence collected — typically the original (uninlined)
definition, which still references `LocalGet(y)` and lists `captures: [y]`.
The compiled body therefore reads capture slot 0, while the call-site
closure-creation allocates zero capture slots. The mismatch reads
garbage (0.0 in practice) for `y`, breaking the canonical
`function makeDT(y){ return { toDate(): Date { return new
Date(Date.UTC(y, ...)); } } }` shape — perry printed
`-62155492224598` (Date.UTC with year zero) instead of node's
`1778866175402`.

Fix: in all six call-site code paths in `crates/perry-transform/src/inline.rs`
that drive `substitute_locals` over a function body (try_inline_simple_call's
fn Pattern 1, fn Pattern 2, single-Return method, void-method;
try_inline_call's fn + method), pre-walk the body for closure
captures and force-materialize closure-captured params as a fresh `Let`
even when the arg is trivial — except when the arg is already a
`LocalGet` (renumbering inside the closure is harmless). The closure
body keeps its `LocalGet(fresh)` reference and `captures: [fresh]`,
so closure-creation and compiled-body codegen agree on slot index 0.

Closes #858.
Likely closes #859 (downstream — shop-admin's @perryts/mysql
`MyDateTime.toDate()` SIGBUS site has identical shape; not separately
verified end-to-end in this worktree).
Refs #793.
@proggeramlug proggeramlug merged commit 0116fc5 into main May 16, 2026
9 checks passed
@proggeramlug proggeramlug deleted the fix/858-closure-numeric-capture branch May 16, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant