Skip to content

js_parser: keep a literal division that yields Infinity on the right of equality comparisons - #39024

Open
robobun wants to merge 1 commit into
mainfrom
farm/5f05e034/transpiler-infinity-eq-reorder
Open

robobun wants to merge 1 commit into
mainfrom
farm/5f05e034/transpiler-infinity-eq-reorder

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.Transpiler (and bun build --no-bundle) output is not a fixed point when an equality compares a literal with an infinite number: "a" != Infinity transpiles to "a" != 1 / 0, and transpiling that output again gives 1 / 0 != "a".
  • Same for ==, === and !==, for -Infinity, 1e999 and -1e999, for string, bigint and require.main left operands, and for inlined enum members whose value is infinite. Found by the transpiler fixed-point fuzzer in test: add seeded differential fuzz oracles for node:path, Bun.Glob, source maps and the transpiler #39006, which keeps infinities out of its literal pool until this lands.
  • Two pieces that do not compose:
    • print_number (src/js_printer/lib.rs) prints an infinite ENumber as 1 / 0 whenever the symbol renamer has not run, because Infinity may be shadowed (ReferenceError: Cannot access uninitialized variable. #7263).
    • The visit pass moves literal operands of equality comparisons to the right-hand side (src/js_parser/visit/visit_binary.rs, is_primitive_to_reorder in src/js_parser/scan/scan_side_effects.rs). Pass 1 sees EString != ENumber and leaves it alone; pass 2 parses 1 / 0 as an EBinary, which the predicate does not count as a literal, so it swaps.
  • Output order only; the values are the same either way.

Fix

Background

  • The transpiler is parse, visit (the pass that folds and rewrites expressions), print. Bun.Transpiler runs that pipeline without the bundler's symbol renamer, so the printer cannot rename a local Infinity out of the way and spells the value 1 / 0 instead; the bundler runs the renamer and prints Infinity.
  • Infinity, NaN and undefined that resolve to the globals are replaced with ENumber / EUndefined nodes during the visit (src/js_parser/defines_table.rs), which is why "a" != NaN round-trips: its printed form reads back as the same node kind.
  • The equality reorder exists so later pattern matching (typeof x === "undefined", x == void 0, constant comparisons) only has to look at one side. It is unconditional, not a minify-only optimization, so it runs on every pass.
  • Arithmetic on literals is only folded when minify_syntax or inlining is on (should_fold_typescript_constant_expressions in src/js_parser/parse/parse_entry.rs), which is why 1 / 0 stays an EBinary in the default transform path.
Fuzzer runs and the bun build --no-bundle probe

Released binary (1.4.0) with "Infinity" and "1e999" added to NUMBERS in the #39006 fuzzer:

seed 1: iteration 72    "lone \uD800 surrogate" != 1 / 0        ->  1 / 0 != "lone \uD800 surrogate"
seed 2: iteration 578   -("tab\tand\\backslash" != 1 / 0)       ->  -(1 / 0 != "tab\tand\\backslash")
seed 3: iteration 726   `new...` == 1 / 0 (multi-line template)  ->  1 / 0 == `new...`
seed 4: 1500 programs without hitting the pattern

Debug build with this change, same fuzzer: seeds 1 to 4 at 1500 programs each and the default seed at 3000 programs, all pass.

bun build --no-bundle on

y = "a" != Infinity;
z = 1 / 0 != x;

before: y = "a" != 1 / 0; / z = 1 / 0 != x;, after: y = "a" != 1 / 0; / z = x != 1 / 0;. A real bundle of the same input still prints "a" != Infinity and the user's 1 / 0 text unchanged, only the operand order of the second line moves, matching how Infinity != x was already printed.

…of equality comparisons

The transpiler prints an infinite number as "1 / 0" because "Infinity" may be
shadowed. The visit pass moves literal operands of ==, !=, === and !== to the
right-hand side, but it did not count that division as a literal, so
transpiling the transpiler's own output swapped the operands:
"a" != Infinity became "a" != 1 / 0 and then 1 / 0 != "a".

Teach is_primitive_to_reorder that a division of two number literals whose
value is infinite stands for a number literal.
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed.

  • Reproduced on the released binary (1.4.0) with new Bun.Transpiler({ loader: "js" }): transformSync('y = "a" != Infinity;') gives y = "a" != 1 / 0;, and transforming that again gives y = 1 / 0 != "a";. The new describe("equality comparisons against an infinite number") block in test/bundler/transpiler/transpiler.test.js fails 15 of 22 cases on the released binary and passes with this branch.
  • Fix is the one match arm in src/js_parser/scan/scan_side_effects.rs; see the PR description for why the reorder predicate is the right place.

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 19 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fa3fd744-6a43-42e2-841c-73e88e321249

📥 Commits

Reviewing files that changed from the base of the PR and between 88a6398 and 9cb21b1.

📒 Files selected for processing (2)
  • src/js_parser/scan/scan_side_effects.rs
  • test/bundler/transpiler/transpiler.test.js

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — narrow, well-tested fixed-point fix for the equality-operand reorder.

What was reviewed:

  • The new BinDiv arm only fires when extract_numeric_values succeeds, which is limited to ENumber/EInlinedEnum(ENumber) operands — both already side-effect-free in this predicate, so the reorder stays safe.
  • Checked the sole caller in visit_binary.rs: swap runs only for ==/!=/===/!==, so treating 1/0 as a literal on either side is value-preserving.
  • Confirmed -1 / 0 works because UnNeg folds to ENumber(-1.0) unconditionally during visit before the reorder check runs.
  • Tests cover the fixed-point property directly (transform twice), plus negative cases (1/2, 0/0, x/0) that must not reorder.
Extended reasoning...

Overview

Adds one match arm to SideEffects::is_primitive_to_reorder in src/js_parser/scan/scan_side_effects.rs so that a literal division evaluating to ±Infinity (1 / 0, -1 / 0) counts as a reorderable primitive, matching the ENumber it stands in for. Adds a 22-case describe block to test/bundler/transpiler/transpiler.test.js asserting that transpiler output is a fixed point for equality comparisons against infinite numbers.

Security risks

None. The change is a pure predicate over already-parsed AST nodes. extract_numeric_values only returns Some for ENumber and EInlinedEnum wrapping an ENumber — no user code, no allocation, no I/O.

Level of scrutiny

Low-to-medium. This is an output-stability fix in the transpiler's visit pass: the emitted code was already semantically correct (equality is commutative), only the operand order oscillated between passes. The predicate is called from exactly one site (visit_binary.rs:183-184), gated to the four equality operators, and the new arm is strictly narrower than the existing ENumber arm it mirrors. I traced extract_numeric_value to confirm it cannot match anything with side effects, and traced visit_expr.rs to confirm -1 is folded to ENumber(-1.0) before the reorder check, so the -1 / 0 test cases are sound.

Other factors

The test coverage is thorough: every operator, both signs and both spellings of infinity, three literal left-operand kinds, a shadowed-Infinity file, an inlined enum, minified and plain output, negative cases proving finite/NaN/non-literal divisions are untouched, and a runtime new Function check that the reordered code evaluates to the same values. Each case asserts transformSync(transformSync(x)) === transformSync(x). The PR description explains and rules out the two alternative fix locations (folding 1/0 in visit; printing Infinity) with references to the specific issues that constrain them, and reports fuzzer verification. hideFromStackTrace is already imported in the test file. No outstanding reviewer comments.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:22 AM PT - Aug 15th, 2026

❌ @robobun, your commit 9cb21b1 has some failures in Build #98021 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 39024

That installs a local version of the PR into your bun-39024 executable, so you can run:

bun-39024 --bun

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant