Skip to content

fix(codegen): stop typing BigInt-capable bitwise results as int32 - #10540

Closed
proggeramlug wants to merge 2 commits into
mainfrom
fix/10418-bigint-bitwise-typing
Closed

proggeramlug wants to merge 2 commits into
mainfrom
fix/10418-bigint-bitwise-typing

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

& | ^ << >> compute a BigInt when both operands are BigInts, but three layers of the compiler assumed every bitwise result is an int32 Number. For BigInt operands whose type is not statically known (untyped params, BigInt(...) initializers, property/element reads, arithmetic results):

  • const x = a & b read back as 0.
  • Number(a & b) returned the BigInt unchanged.
  • A bigint-typed result reached a call as fptosi of its box (number:-2147483648).
  • (_2n << k) * _2n threw Cannot mix BigInt and other types.

This is @noble/hashes' fromBig (Number((n >> _32n) & U32_MASK64) | 0 with U32_MASK64 = BigInt(2 ** 32 - 1)). It made sha384/sha512/blake2b/blake2s/argon2 produce wrong digests, and sha3/keccak throw at module load.

Root cause

A bitwise expression is a Number only when an operand provably is not a BigInt (a mixed pair throws). Every site below skipped that check:

  1. HIR typing. crates/perry-hir/src/lower_types.rs:434 typed the five operators Number unless an operand was already BigInt. crates/perry-hir/src/analysis/value_types.rs:1682 (infer_binary_type) returned Number unconditionally. That type became the binding's stable_local_type_proof.
  2. Integer-local proofs. Admission (collect_integer_let_ids, is_int32_producing_expr) accepts every bitwise init, by design optimistically. But the disqualification judge int32_producing_deps (crates/perry-codegen/src/collectors/integer_locals.rs:763) accepted them too, so nothing pruned them. const x = a & b therefore took an int32 slot, and the store ToInt32'd the correct js_dynamic_bitand result to 0. collectors/int_valued_ta_locals.rs (rule 1 and the additive arm) had the same unconditional arm. The int32 slot also made the local "numeric" for later consumers, which is how a bigint value reached show$spec_i32(fptosi x).
  3. Number() elision. number_coerce_operand_is_already_primitive_number (crates/perry-codegen/src/expr/bigint_set.rs:37) returned true for any bitwise operand, so js_number_coerce was dropped.

Fix

  • HIR: Type::is_non_bigint_primitive() (Number/Int32/Boolean/String). The five operators infer BigInt for BigInt operands, Number when either operand is a non-BigInt primitive, and Any otherwise. >>> stays Number.
  • collectors/not_bigint_locals.rs: the existing not-BigInt fixpoint is exposed as NotBigIntFacts, with bitwise_result_is_number(e). It is computed once in collect_type_facts, ahead of the integer-local proofs, and reused for not_bigint_locals, so it is not computed twice.
  • integer_locals.rs judge and int_valued_ta_locals.rs: a BigInt-capable bitwise write is int-producing only when NotBigIntFacts proves it, or when an operand is itself an integer candidate (recording that operand's deps). Admission stays optimistic; the judge prunes.
  • bigint_set.rs: Number(a op b) is elided only with an is_provably_not_bigint operand.
  • expr/binary.rs: removing the int32 slot for unproven operands would have left a ^ b as an unconditional js_dynamic_bitxor call. The bitwise operators now take the existing lower_guarded_numeric_arith diamond that -/*// already use: tag-test both operands, inline ToInt32 <op> ToInt32 (shift count masked to 5 bits, toint32_wrap for NaN/Infinity), and the BigInt-aware helper on the cold arm. It is gated by the existing PERRY_GUARDED_ARITH and PERRY_INLINE_NONBIGINT_BITWISE knobs, both already in the object-cache key. Proven-Number operands still take the unguarded inline path as before.

Tests (fail on baseline 7661bc0, pass here)

  • test-files/test_gap_10418_bigint_bitwise_typing.ts: the issue's matrix.
    • Five operators × 16 operand shapes: literals, const/let literal, const/let BigInt(), bigint param, untyped param, module literal / module BigInt(), property and element (literal and BigInt()), (p + _0n), inline BigInt(), and mixed literal/unknown.
    • 13 consumers each: typeof, String, * 2n, ===, f(x), Number(x), let binding, the same on the bare expression, plus a closure.
    • Controls: + - * ** % on three shapes, ~, and compound assignment.
    • Module-scope bindings.
    • Number(n & M) with M = BigInt(2**32-1) (untyped and typed).
    • The @noble/curves _2n << (c1 - _1n - _1n) prelude and Number(q.y & _1n).
    • A fromBig/split/toBig 64-bit round trip over the SHA-512 constants.
    • Mixed BigInt/Number operands still throw.
    • Number controls: ToInt32 wrap, shift counts ≥ 32, >>> 0, a hash loop.
    • Baseline: 97 of 187 lines differ from Node. This branch: byte-identical (run_parity_tests.sh --filter test_gap_10418: baseline 0%, this branch 100%).
  • Unit tests:
    • perry-hir: value_types_tests::bitwise_result_is_number_only_with_a_non_bigint_operand, and tests/shape_inference.rs::bitwise_over_possible_bigints_is_not_inferred_number (source level).
    • perry-codegen: hir_facts::tests::bigint_capable_bitwise_init_is_not_an_integer_local, int_valued_ta_locals::tests::bigint_capable_bitwise_write_is_not_an_i32_write, and expr::bigint_bitwise_tests (no alloca i32 for possibly-BigInt consts and one kept for a & 3; Number(a & b) keeps js_number_coerce and Number(a & 255) does not; unproven operands get the guarded int32 arm with a masked shift count).
  • Issue repro: all six lines match Node.

Validation

All run on perrybuilder (Linux x64) against baseline 7661bc0 (prebuilt reference build of the same commit).

check result
cargo test --release -p perry-hir --tests 728 passed, 0 failed
cargo test --release -p perry-codegen --tests 2074 passed, 0 failed (includes tests/native_proof_regressions.rs, the IR-grep suite naming js_dynamic_bit*)
scripts/run_lint_gates.sh (full, incl. compile tier: -D warnings check, clippy, API-docs drift) 80/83 passed, 2 CI-only skipped. Pre-existing red: benchmarks/ci_public_baseline_check.py ("public artifact benchmark inputs changed") fails identically on a 7661bc0 snapshot.
scripts/check_file_size.sh, cargo fmt --check, check_test_registration.py ok
gap suite (PERRY_SKIP_BUILD=1 ./scripts/run_gap_tests.sh) GAP_EXIT=0, 814 pass / 6 fail of 820. The 6 are exactly the baseline's 6 known mismatches (2159_defineproperty_class_prototype, 2514_settracesigint, json_lazy_defineproperty_index, perfhooks_3088…, prop_plan_cache_invalidation, v8_2_3680plus). No new failures.

Package check: @noble/hashes 2.2.0 (compilePackages, PERRY_NO_AUTO_OPTIMIZE=1), each hash on a 3000-byte and a 300-byte input, diffed against Node 26.5.1:

  • Baseline: throws TypeError: Cannot convert a BigInt value to a number at split / keccakP at module load.
  • This branch: sha256, sha512, sha3_256, keccak_256, blake2b, blake2s and blake3 all match Node byte for byte. A second program (sha384, hmac-sha512, argon2id/argon2i/argon2d with t=2 m=64) also matches Node; the baseline gives wrong digests there.

IR (--trace llvm, no auto-optimize). The proven-Number fast path keeps its inline int32 ops:

  • In SHA-256 compress, baseline and fix emit the same and i32/xor i32/shl i32/lshr i32 counts (26/26/20/22).
  • The 8 bitwise ops over unproven operands that were unconditional js_dynamic_bit* calls are now tag-guarded, and their Numbers compute inline.
  • Untyped JS helpers (xorshift, fmix32, mix) gain the same guarded inline arm.
  • With the typing fix alone (before the guarded diamond was added), I diffed the IR of 347 non-BigInt test-files and 50 benchmarks programs that use bitwise operators, against the baseline. No IR changed apart from declaration order in 2 multi-module tests, i.e. no proven-Number site lost its fast path. The guarded diamond then deliberately changes only the bitwise ops whose operands are unproven.

Perf. perf stat -e instructions, 3 runs each, PERRY_NO_AUTO_OPTIMIZE=1 both arms, outputs identical to Node:

program baseline fix Δ Node wall
SHA-256 compress ×300k, typed helpers 18.166 G 17.941 G −1.2 % 0.23 s
same, untyped helpers 19.034 G 18.792 G −1.3 % 0.24 s
hash mix (xorshift/fmix32/crc32/fnv1a, untyped) 12.156 G 5.355 G −56 % 0.16 s
untyped ops.mix(a, b) via dynamic dispatch ×20M 72.77 G 72.17 G −0.8 % 0.13 s
benchmarks/bench_bitwise.ts 71.47 G 71.48 G +0.01 % (noise) 6.07 s
#10418 repro loop (fromBig / Number(n & M) / (_2n << k) * _2n) ×300k throws after 5.6 M 1.897 G (correct) n/a 0.09 s

Spread across runs is ≤0.02 % on every row. Compile time for the 1031-line gap test is unchanged (≈27 s vs ≈22–26 s uncached, PERRY_NO_AUTO_OPTIMIZE=1, loaded host).

Not verified / notes

  • Perf was measured with PERRY_NO_AUTO_OPTIMIZE=1 on both arms. The baseline tree is read-only, so an auto-optimize A/B was not possible there. Wall time vs Node on these microbenchmarks is far behind Node on both arms (e.g. SHA-256 1.8 s vs 0.23 s on a host at load ~100). That gap exists on the baseline too and this change does not move it.
  • macOS/aarch64 was not run. The guarded arm uses toint32_wrap, which emits fjcvtzs there.
  • @noble/curves 1.2.0 still fails at startup, on baseline and here alike, with ReferenceError: Cannot access 'wnaf' before initialization in weierstrassPoints (esm/abstract/weierstrass.js:382 references const wnaf declared at line 514, after Point.BASE = new Point(...)). This is a separate TDZ bug; a minimal class/const shape did not reproduce it. ethers was not checked.
  • Separate pre-existing bug noticed: in a module split into several codegen units, a large BigInt literal compared with === against a computed value is false. PERRY_CODEGEN_UNITS=2 with function f() { const x = 2n ** 70n; return x === 1180591620717411303424n; } prints false on the baseline too. The gap test uses small operands and stays one unit, so it avoids this.

Fixes #10418

Summary by CodeRabbit

  • Bug Fixes
    • Corrected bitwise operators so BigInt operands preserve BigInt results instead of being incorrectly treated as 32-bit numbers.
    • Improved handling of untyped, computed, property, and element-based operands.
    • Added guarded numeric handling for operations whose operand types cannot be determined in advance.
    • Preserved fast numeric behavior when operands are known to be numbers.
    • Fixed compatibility with cryptographic operations, including SHA-2, SHA-3, Keccak, Blake2, and Argon2-related hashing.

`&` `|` `^` `<<` `>>` compute a BigInt from two BigInt operands, but the
compiler assumed every bitwise result is an int32 Number:

- HIR typed `a & b` over unknown operands `Number` (lower_types.rs,
  value_types.rs), so `stable_local_type_proof` vouched for it.
- The integer-local proofs (integer_locals.rs judge, int_valued_ta_locals.rs)
  admitted every bitwise write, so `const x = a & b` took an int32 slot and
  `ToInt32`'d the BigInt result to `0`; a `bigint`-typed binding reached a
  call as `fptosi` of its box.
- `Number(a & b)` elided `js_number_coerce` for any bitwise operand
  (bigint_set.rs), returning the BigInt unchanged.

Each site now requires an operand that provably is not a BigInt. The
not-BigInt fixpoint is exposed as `NotBigIntFacts` and computed ahead of the
integer-local proofs. With the int32 slot no longer covering unproven
operands, those operators take the existing guarded numeric diamond (tag
test, inline `ToInt32 <op> ToInt32`, BigInt-aware helper on the cold arm).
@proggeramlug proggeramlug added the package-audit Found by the 2026 package audit: compiling real npm packages from source instead of native bindings label Sep 17, 2026
@coderabbitai

coderabbitai Bot commented Sep 17, 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: c6ac4d3e-798d-4ab9-8c9c-d6aa0c973087

📥 Commits

Reviewing files that changed from the base of the PR and between 5030e6e and 400977c.

📒 Files selected for processing (17)
  • changelog.d/10540-bigint-bitwise-typing.md
  • crates/perry-codegen/src/collectors/hir_facts.rs
  • crates/perry-codegen/src/collectors/int_valued_ta_locals.rs
  • crates/perry-codegen/src/collectors/int_valued_ta_locals/tests.rs
  • crates/perry-codegen/src/collectors/integer_locals.rs
  • crates/perry-codegen/src/collectors/not_bigint_locals.rs
  • crates/perry-codegen/src/collectors/spec_abi_sites.rs
  • crates/perry-codegen/src/expr/bigint_bitwise_tests.rs
  • crates/perry-codegen/src/expr/bigint_set.rs
  • crates/perry-codegen/src/expr/binary.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-hir/src/analysis/value_types.rs
  • crates/perry-hir/src/analysis/value_types_tests.rs
  • crates/perry-hir/src/lower_types.rs
  • crates/perry-hir/src/types.rs
  • crates/perry-hir/tests/shape_inference.rs
  • test-files/test_gap_10418_bigint_bitwise_typing.ts

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


📝 Walkthrough

Walkthrough

The compiler now preserves BigInt results for &, |, ^, <<, and >>. HIR inference, local analyses, and code generation use non-BigInt proofs. Unproven operands use guarded numeric lowering. New tests cover compiler IR and end-to-end BigInt behavior.

Changes

BigInt bitwise result handling

Layer / File(s) Summary
HIR bitwise type inference
crates/perry-hir/src/types.rs, crates/perry-hir/src/analysis/*, crates/perry-hir/tests/shape_inference.rs
Bitwise results now infer as BigInt, Number, or Any based on operand proofs. >>> remains Number.
Collector proofs and fact sharing
crates/perry-codegen/src/collectors/*
NotBigIntFacts is shared with integer-local and typed-array analysis. BigInt-capable bitwise writes are excluded from integer proofs.
Bitwise code generation
crates/perry-codegen/src/expr/*
Unproven operands use guarded arithmetic with native integer operations and BigInt-aware helpers. Proven Number operands retain direct lowering.
Regression coverage
crates/perry-codegen/src/expr/bigint_bitwise_tests.rs, test-files/test_gap_10418_bigint_bitwise_typing.ts, changelog.d/10540-bigint-bitwise-typing.md
Tests cover IR allocation, coercion, guarded lowering, operand shapes, consumers, compound assignment, mixed operands, and package-derived BigInt helpers.

Priority: ➖ Normal

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

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant HIR
  participant NotBigIntFacts
  participant Collectors
  participant Codegen
  HIR->>NotBigIntFacts: classify operand facts
  NotBigIntFacts->>Collectors: provide bitwise result proofs
  Collectors->>Codegen: select integer or dynamic representation
  Codegen->>Codegen: lower guarded or direct bitwise operation
Loading

Merge Risk: ⚪ Minimal · up to 40097

The changed BigInt bitwise paths and mixed-operand behavior are consistently covered, with no actionable merge risk identified.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.32% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 173 functions across 16 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 and concisely describes the main change: preventing BigInt-capable bitwise results from being typed as int32.
Description check ✅ Passed The description is complete and directly explains the root cause, implementation, related issue, tests, validation results, performance impact, and known limitations. It uses different headings from t…
Linked Issues check ✅ Passed Issue #10418 requires BigInt-safe handling for &, |, ^, <<, and >>. The HIR changes infer BigInt, Number, or Any from operand facts instead of forcing Number. The codegen changes rem…
Out of Scope Changes check ✅ Passed The changes stay within issue #10418. The new fact-flow plumbing, collector refactoring, guarded lowering, optimized Number paths, and regression tests directly support BigInt-aware bitwise code gener…
Full details: Docstring Coverage

Explanation

Docstring coverage is 28.32% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 173 functions across 16 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 via merge train #10559 (v0.5.1592). 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.

BigInt & | ^ << >> results are typed as int32/number: const x = a & b yields 0, Number(a & b) returns the BigInt

1 participant