Skip to content

fix(codegen): refresh local_types on var redeclaration - #10627

Closed
proggeramlug wants to merge 2 commits into
mainfrom
fix/10488-var-array-void-compare
Closed

proggeramlug wants to merge 2 commits into
mainfrom
fix/10488-var-array-void-compare

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

arr[i] === void 0 (and any other undefined-valued strict-equality compare)
against an out-of-bounds or hole read of a var-declared number array always
compiled false, and !== always compiled true — regardless of whether
the read is actually out of bounds. let/const arrays were unaffected.
This blocked decimal.js's toHexadecimal/toBinary/toOctal
(convertBase's carry-slot initialization check, arr[j + 1] === void 0).

Root cause

A hoisted var reaches crates/perry-codegen/src/stmt/let_stmt.rs's
lower_let as TWO Stmt::Lets sharing one local id: a body-entry predefine
(Any = undefined, from predefine_var_bindings_in_function_body), then the
real declaration (here, Array(Number) = [0]). The SECOND one hits the
#1803 "hoisted var redeclaration" branch (reuse the already-allocated slot
via LocalSet instead of re-allocating) and returns early — before the
normal declaration path's ctx.local_types.insert(id, refined_ty) runs.

ctx.proven_local_types (a separate map, consulted by
is_numeric_expr/stable_local_type_proof when deciding whether an array
element read is provably numeric) IS refreshed on every Let, including
redeclarations. ctx.local_types (consulted by
expr_may_return_boxed_value_from_raw_f64_fallback's static_type_of,
which is supposed to veto the numeric fast path whenever an element read
could still come back boxed/undefined) was NOT. So after the redeclaration,
is_numeric_expr said "yes, proven Array(Number)" while the
fallback-hazard guard — reading the stale Any — said "no hazard, safe to
treat as a raw double." The comparison then took the numeric fast path
(crates/perry-codegen/src/expr/compare.rs::lower_strict_eq_against_number,
a bare fcmp), which cannot represent the NaN-boxed undefined tag an
out-of-bounds/hole read actually produces (the tag IS a NaN, so fcmp is
unconditionally unordered — always false for oeq, always true for une).
let/const never hit the redeclaration branch, so their local_types
entry was always fresh and the guard already worked correctly.

Fix

crates/perry-codegen/src/stmt/let_stmt.rs: refresh ctx.local_types on
the var-redeclaration path too (only in the Some(init_expr) arm — a
bare var x; redeclaration with no initializer keeps whatever the local
already knows, matching "keep the prior value" semantics), so both
predicates agree about the local's current type at every point in the
function.

Tests added

  • test-files/test_gap_10488_var_array_void_compare.ts: every failing
    variant from the issue (void 0, an undefined-holding local, a
    property holding undefined, another out-of-bounds read, a negative
    index, a hole after .length = growth, a function-parameter index) plus
    the issue's controls (=== undefined literal, let, an in-bounds read)
    and the decimal.js convertBase repro verbatim (var, not : any
    the earlier draft of this test used arr: any and did not reproduce the
    bug; TypeScript's own inference to number[] is what triggers it).
    Validated byte-for-byte against node --experimental-strip-types (Node
    26.5.1).
    • Proof it fails on the baseline: built the same commit this branch
      forked from with only the test file added (no fix code) — every
      boolean variant inverted (varVoid false vs Node's true, etc.) and
      convertBase returned [null,15] (the carry slot never initializes,
      so undefined += 15 gives NaN, printed as null). On this branch: all
      lines match Node exactly, including convertBase [15,15].
  • crates/perry-codegen/src/stmt/let_stmt_var_redeclare_tests.rs (2 new
    unit tests, registered in stmt/mod.rs): hand-builds the exact
    two-Lets-sharing-one-id HIR shape and asserts the compiled IR for the
    void 0 compare calls the boxed js_eq helper and contains neither
    fcmp une double nor fcmp oeq double; a let-only control confirms the
    non-redeclared case already took the boxed path.

Validation

  • cargo test --release -p perry-codegen --tests: 2098 passed, 0
    failed
    (all prior tests plus the 2 new ones; the new test file's first
    assertion draft over-scoped — it initially rejected ANY fcmp in the
    compiled IR, but the boxed js_eq comparison path has its OWN internal,
    runtime-guarded fcmp fast path for the case where both operands prove
    out to be genuine untagged doubles at runtime, which is correct and
    expected; narrowed the assertion to check for the js_eq call instead of
    fcmp's absence). Ran with CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16.

  • Lint (SKIP_COMPILE_GATES=1 ./scripts/run_lint_gates.sh): 76/77 gates
    passed
    ; pre-existing red: "Public benchmark evidence freshness" (red on
    every PR in this repo, unrelated). cargo fmt --all: clean.
    python3 scripts/check_test_registration.py: OK.

  • Gap test: PERRY_SKIP_BUILD=1 ./run_parity_tests.sh --filter test_gap_104881/1 PASS, 100% parity on this branch. Fails on the
    pristine baseline (fix/10488-wip's parent commit + only the test file
    added): every boolean variant inverted (varVoid false vs Node's
    true, etc.) and convertBase returned [null,15] instead of
    [15,15].

  • Perf (perf stat -e instructions,task-clock, 3 runs each, median shown;
    baseline binary built from a sibling clone with none of this PR's
    changes, PERRY_NO_AUTO_OPTIMIZE=1 for both arms):

    workload baseline (median instructions) fix (median instructions) delta
    benchmarks/suite/04_array_read.ts (general array-read workload, does not hit the var-redeclaration pattern) 172,207,463 172,103,137 −0.06% (noise)
    issue repro scaled into a loop (arr[1] === void 0 × 20M, var-declared number array) 7,624,823,032 4,323,109,271 −43.3%

    The repro-loop case is FASTER after the fix, not slower: the baseline's
    bare fcmp path and the fix's js_eq boxed-dispatch path are not simply
    "fast vs. slow" versions of the same work — js_eq's own internal
    runtime-guarded fast path (an icmp range check proving both operands
    are genuine untagged doubles, then a plain fcmp) apparently codegens
    more efficiently for this loop shape than whatever the old
    always-numeric static path emitted. The general case
    (04_array_read.ts, no var redeclaration in play) shows no measurable
    change either way. Node wall time on the repro-loop workload: ~55ms
    (JIT-optimized loop unrolling/elision Perry's AOT codegen does not
    attempt); pre-existing gap, not something this PR changes.

  • Package check: verified the issue's exact decimal.js convertBase
    repro (the carry-slot if (arr[j + 1] === void 0) arr[j + 1] = 0; check)
    directly — did not install the decimal.js package itself; see "What I
    did not verify" below.

What I did not verify

  • Full decimal.js package install/build end-to-end
    (perry.compilePackages: ["decimal.js"]) — verified the exact
    convertBase repro from the issue body directly.
  • Did not run the full local gap suite; this change touches a narrow
    var-redeclaration path in lower_let, not a hot shared lowering path
    used by most programs — relying on CI's sharded gap suite per the
    standard process.
  • The issue's separately-noted a instanceof Dec === false decimal.js
    finding is explicitly a different bug ("covered elsewhere" per the issue
    body) — not touched here.

Fixes #10488

Summary by CodeRabbit

  • Bug Fixes

    • Fixed strict equality and inequality checks involving undefined values from var-declared numeric arrays.
    • Out-of-bounds, sparse, negative-index, and similar array reads now produce correct comparison results instead of always evaluating as true or false.
    • Fixed array expansion behavior used by decimal conversion operations, including hexadecimal, binary, and octal conversions.
  • Tests

    • Added coverage for undefined comparisons across array reads and related conversion scenarios.

A hoisted var reaches lower_let as two Stmt::Lets sharing one local
id (a body-entry predefine, then the real declaration); the second
takes the #1803 redeclaration early return before ctx.local_types is
updated. proven_local_types (consulted by is_numeric_expr) IS
refreshed on redeclaration, but local_types (consulted by
expr_may_return_boxed_value_from_raw_f64_fallback) was not, so the
two predicates disagreed about the same local: a strict-equality
compare against an out-of-bounds/hole read of a var-declared number
array took the bare-fcmp numeric fast path, which cannot represent
the NaN-boxed undefined tag such a read can produce. Refresh
local_types on the redeclaration path too.
@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: 8cee8d01-c872-4015-8ab4-313b37e68855

📥 Commits

Reviewing files that changed from the base of the PR and between 0058bab and a8f8507.

📒 Files selected for processing (5)
  • changelog.d/10627-var-array-void-compare.md
  • crates/perry-codegen/src/stmt/let_stmt.rs
  • crates/perry-codegen/src/stmt/let_stmt_var_redeclare_tests.rs
  • crates/perry-codegen/src/stmt/mod.rs
  • test-files/test_gap_10488_var_array_void_compare.ts

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


📝 Walkthrough

Walkthrough

The compiler now refreshes local_types during hoisted var redeclaration. Strict comparisons involving undefined-valued array reads therefore use boxed comparison handling. New codegen and runtime tests cover the regression and decimal.js conversion behavior.

Changes

Hoisted var comparison handling

Layer / File(s) Summary
Refresh redeclared local type
crates/perry-codegen/src/stmt/let_stmt.rs, changelog.d/10627-var-array-void-compare.md
The hoisted var redeclaration path updates local_types with the refined type. The changelog records the corrected comparison behavior.
Validate boxed comparison fallback
crates/perry-codegen/src/stmt/let_stmt_var_redeclare_tests.rs, crates/perry-codegen/src/stmt/mod.rs, test-files/test_gap_10488_var_array_void_compare.ts
Codegen tests verify that redeclared and non-redeclared numeric arrays use js_eq. Runtime tests cover undefined-valued reads, holes, index variants, and the decimal.js convertBase case.

Priority: ➖ Normal

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

Change: Bug fix · Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to a8f85

No actionable current-head risk remains; the new regression fixture will detect divergent strict-comparison results against Node.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 15 functions across 4 files. (1 skipped: … 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 identifies the primary codegen fix: refreshing local type information during var redeclaration.
Description check ✅ Passed The description is complete and directly addresses the template requirements. It explains the bug, root cause, fix, related issue, tests, validation results, performance results, and verification limi…
Linked Issues check ✅ Passed The implementation addresses issue #10488. In the hoisted-var redeclaration path, lower_let now refreshes ctx.local_types with the refined type before returning. This keeps the declared-type map…
Out of Scope Changes check ✅ Passed The changed production code, regression tests, gap test, and changelog entry all support issue #10488. The changes do not implement the separate instanceof issue or introduce unrelated behavior.
Full details: Docstring Coverage

Explanation

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

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • 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 via merge train #10710 (v0.5.1597). All source commits preserve authorship; merged main matches the validated train exactly.

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.

codegen: arr[i] === void 0 is always false for an out-of-bounds read of a var-declared number array (numeric fcmp on the undefined tag)

1 participant