Skip to content

js_printer: keep a local NaN, Infinity or undefined from capturing a printed constant in bun run and --no-bundle - #42067

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/f5347175/printer-nan-shadow
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/f5347175/printer-nan-shadow

Conversation

@robobun

@robobun robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • print_ast, the transpiler entry point, looks for a bound symbol named undefined, NaN or Infinity in the file and sets the printer option from that. The bundler still sets it from its renamer.
  • With the option off, or inside a with body (esbuild's withNesting), the printer writes 0 / 0, 1 / 0 and void 0, in parentheses where precedence needs them.
  • Other files print as before, except that non-minified output now writes an infinite literal as Infinity, like the bundler. The runtime transpiler cache version moves to 30.
  • Verified: test/bundler/transpiler/transpiler.test.js (new block; 7 tests fail on 1.4.3), plus the suites listed in the notes.

Background

  • The parser folds +"abc", void 0, 1e999, an unbound NaN identifier, and under bun run also 0 / 0, into constant nodes with no symbol. The printer must spell the value.
  • bun run prints with minify_syntax, which already writes void 0 and 1 / 0; only NaN was exposed there.
  • Every binding the transpiler prints has a Symbol in the file's table: declarations from the parser, or exports that data loaders (TOML keys) synthesize. Unresolved references are Kind::Unbound.
Notes
  • Supersedes js_printer: guard undefined/NaN/Infinity identifiers against local shadows #36122 and js_printer: print 0 / 0 and void 0 where a local binding or with object shadows NaN / undefined #36124 (July 27). Both conflict with main since renamer: reserve NaN, Infinity and undefined so inlined constants reach the globals #41909 landed the renamer half of js_printer: guard undefined/NaN/Infinity identifiers against local shadows #36122. js_printer: print 0 / 0 and void 0 where a local binding or with object shadows NaN / undefined #36124 used the same symbol-table check; this version adds the with handling, the property-key case and tests for each door.
  • Why a symbol scan in print_ast and not a parser flag: the parser is not the only producer of bindings on this path. Bun.Transpiler().transformSync(src, "toml") (and JSON, YAML, JSON5) turns top-level keys into var bindings in transpiler.rs without the parser, so a TOML file with keys NaN / Infinity and a nan / inf value printed var NaN = "n", x = NaN. A class expression's own name also bypasses declare_symbol. The scan covers every producer. Cost: about 4 ns per symbol (0.2 ms for 50k symbols), once per file.
  • The ledger rows: { class NaN {} String(0 / 0) } gave the class source, switch (1) { case 1: let NaN = "local"; console.log(0 / 0) } gave local, and the macro face const g = (NaN = nan()) => NaN printed (NaN = NaN) => NaN (a TDZ error under bun run); it now prints (NaN = 0 / 0) => NaN. A function-level const NaN only looked handled because DCE dropped the unused binding; a kept let NaN captured there too.
  • Parentheses: 0 / 0 and 1 / 0 are wrapped at Level::Multiply and above (x / (0 / 0), (0 / 0).toFixed(), 2 ** (0 / 0)), void 0 at Level::Prefix and above, as before.
  • A NaN or Infinity property key that would print as 0 / 0 / 1 / 0 prints computed (number_property_key_must_be_computed).
  • With minify_identifiers the transpiler path runs MinifyRenamer, which reserves these names since renamer: reserve NaN, Infinity and undefined so inlined constants reach the globals #41909, but a named export export let NaN is must_not_be_renamed, so the scan is used there too.
  • Identifiers written inside a with body in the source stay identifiers (the parser does not fold them there), so with_nesting only affects synthesized constants.
  • Not handled, same as esbuild: sloppy-mode direct eval("var NaN = 1") introducing the binding at runtime.
  • NaN bits, measured on x86-64 with new Function (no Bun transpile) in both Bun and Node: f64[0] = 0 / 0 and -(0 / 0) store 7ff8000000000000, the same as the NaN global; only a runtime z / z gives the x86 default fff8…. On aarch64 the hardware default NaN is 7ff8… already. So Float64Array / Buffer.writeDoubleBE output does not change.
  • transpiler.test.js "numeric property keys that overflow to Infinity": the whitespace-only minifier now prints {Infinity:1} (valid), so that block uses minify: { whitespace, syntax } to keep covering the computed [1/0] form, and gains a let Infinity; variant for the non-minified fallback.
  • The new tests evaluate the printed code with new Function, which hands it to JavaScriptCore without a second pass through Bun's transpiler, so the --no-bundle door is checked on its own.
  • Self-reviewed before opening: the review found the data-loader producer and asked for the module-level export const NaN case; both are addressed above.
  • Suites run locally with the debug build: test/bundler/transpiler/transpiler.test.js, test/bundler/bundler_edgecase.test.ts, test/bundler/bundler_minify.test.ts, test/bundler/bundler_loader.test.ts, test/bundler/esbuild/{default,dce,lower}.test.ts, test/js/bun/transpiler/, test/js/bun/toml/, test/bundler/transpiler/{decorators,ts-use-define-for-class-fields}.test.ts.

…ding in the file captures it

The printer writes a folded NaN as the identifier `NaN`, and without
minify-syntax an EUndefined as `undefined`. The bundler renamer
reserves those names (#41909), but `bun run`, `bun build --no-bundle`
and `Bun.Transpiler` do not rename, so a block-level `let NaN`, a
parameter named `undefined`, a class named `NaN` and the like
captured the printed value.

`print_ast`, the transpiler entry point, now checks the file's symbols
for a bound `undefined`, `NaN` or `Infinity`. Only when there is
none does the printer use the names; otherwise it writes `void 0`,
`0 / 0` and `1 / 0`. Inside a `with` body it always uses those
forms, as esbuild does. Unshadowed non-minified output now prints an
infinite literal as `Infinity` instead of `1 / 0`, like the bundler.
@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 12 days. After that, they cost $0.25 per reviewed file.

Or wait 37 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: aa975df6-d8ef-4384-98f9-b9441761a0a8

📥 Commits

Reviewing files that changed from the base of the PR and between d745f03 and a21943e.

📒 Files selected for processing (5)
  • src/bundler/LinkerContext.rs
  • src/bundler/linker_context/postProcessJSChunk.rs
  • src/js_printer/lib.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • test/bundler/transpiler/transpiler.test.js

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

@github-actions github-actions Bot added the claude label Sep 8, 2026
@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:22 AM PT - Sep 8th, 2026

@robobun, your commit a21943e is building: #113184

@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on 1.4.3 (f42e98025) with the ledger lines: for (let NaN = 0; NaN < 1; NaN++) console.log(0 / 0, +"abc", Math.min(0 / 0, 5)) prints 0 0 0 under bun run, { class NaN {} console.log(String(0 / 0)) } prints the class source, and bun build --no-bundle prints console.log(undefined) inside { let undefined = "U"; ... }. The new block in test/bundler/transpiler/transpiler.test.js covers Bun.Transpiler (three configs), bun run, bun run with a module-level export const NaN, and bun build --no-bundle; 7 tests fail with USE_SYSTEM_BUN=1 and pass on this branch.

@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #36124. That PR was rebased onto main earlier today with the same symbol-table approach plus the with handling, and its CI is already green, so it is the one to review. The two extra test cases from here are noted on #36124.

@robobun robobun closed this Sep 8, 2026

@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.

I reviewed this PR and didn't find any bugs. Because it changes the default printed form of infinite literals for the transpiler path and reworks the precedence/wrapping logic in print_number, a human look at the output-format shift would still be worthwhile.

What was reviewed:

  • print_number NaN/Infinity branches — wrapping at Level::Multiply matches the property-access/** cases in the tests; -1 / 0 and -(0/0) still evaluate to the right values unwrapped.
  • print_ast symbol scan — filters Kind::Unbound so a bare reference to global NaN doesn't trip it; covers non-parser producers (TOML loader, class-expression names) as claimed.
  • with_nesting — incremented only around the SWith body, not the head expression; reset via the struct init in Printer::init.
  • Cache version bump to 30 covers the changed output per REVIEW.md's cache-key rule.
Extended reasoning...

Overview

This PR fixes a correctness bug in the JS printer where folded constants (NaN, Infinity, undefined) were emitted as bare identifiers even when a local binding with that name existed, causing the local to capture the constant. The fix renames has_run_symbol_renamer → global_value_properties_unshadowed, adds a symbol-table scan in print_ast to set it for the transpiler path, adds a with_nesting counter so with bodies always fall back to arithmetic forms, and teaches print_number to emit 0 / 0 / 1 / 0 (and print_undefined to keep void 0) when the names are unsafe. The runtime transpiler cache version is bumped to 30. Five files changed: two bundler linker sites (mechanical rename), the printer, the cache version, and transpiler.test.js.

Security risks

None. This is a code-generation correctness fix with no auth, crypto, network, or filesystem surface. The only input is the AST/symbol table already produced by the parser; no new untrusted-input parsing is introduced.

Level of scrutiny

Medium-high. The JS printer is on the hot path for every file Bun runs or builds, and the change alters default output for the non-bundled transpiler: infinite literals that previously printed as 1 / 0 now print as Infinity when no shadowing binding exists (the existing "numeric property keys that overflow to Infinity" test block was updated to reflect this). That is a user-visible output-format change beyond the shadowed-name bugfix itself, and the precedence/wrapping rework in the Infinity branch (by_name boolean replacing the old has_run_symbol_renamer || minify_syntax condition) deserves a maintainer's eye even though it tests out correctly. The new per-file symbols_for_source.iter().flatten().any(...) scan is O(symbols) on every print_ast call; the PR description quantifies it (~4 ns/symbol) but a reviewer familiar with the printer's perf budget should confirm that's acceptable.

Other factors

The test coverage is unusually thorough: let/class/switch/label/param/class-expression-name/with binding forms, TOML data-loader synthesized bindings, the module-level export const NaN = 0 / 0 TDZ case, parenthesization at ** and property-access precedence, and end-to-end bun run / bun build --no-bundle spawns evaluated via new Function to bypass a second transpile. Tests follow harness conventions (bunExe/bunEnv/tempDir, await using, Promise.all on pipes, stdout/stderr asserted before exitCode, test.concurrent for the subprocess cases). The cache-version bump satisfies REVIEW.md's "any change to cached/serialized output bumps the format version constant" rule. The bug hunt exited on dry_streak with no findings and no ruled-out candidates. No CODEOWNERS cover the changed paths. I checked the print_space_before_identifier() call that now precedes 0 / 0 / (0 / 0) — it's conservative (may add an unneeded space) but never produces wrong adjacency, and the .x / ** follow-on cases are wrapped at Level::Multiply so no 0.x ambiguity arises.

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.

2 participants