Skip to content

aot: read the first name of a --define value through its binding - #47718

Closed
robobun wants to merge 2 commits into
mainfrom
robobun/4854365e/aot-define-value-binding
Closed

robobun wants to merge 2 commits into
mainfrom
robobun/4854365e/aot-define-value-binding

Conversation

@robobun

@robobun robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Related to #40857

Question for a maintainer. Where bun --define reads no binding (return NAME; const target = 1), --aot now does what the spelled name does. Should it stop with TS100050?

Problem

  • bun build --compile --aot --define NAME=target reads globalThis.target where target is a binding. Plain Bun reads the binding. With no such global the executable throws ReferenceError: target is not defined.
  • Cause: Folder::try_decide_define (src/sema/fold/folder_names.rs) makes Decision::GlobalPath from the value's text, and lowering reads its first name from the global object.

Fix

  • The binder looks up the first name where the define applies and records it as an implicit reference. The fold carries the record in Decision::NamedPath.
  • Lowering reads, calls and stores through that binding, for one-name and dotted values, in JavaScript and TypeScript. A free first name keeps Decision::GlobalPath.
  • Verified: 306 new programs under test/aot/parallel (285 fail on main) and test/aot/build-flags.test.ts. Self-reviewed: 42 concerns raised, 36 addressed.

Background

  • An implicit reference is the binder's record of a name that no expression spells, such as a JSX factory.
  • run-parallel.ts now passes a program's --define flags to its plain reference run.
  • Considered: write the name into the tree and bind again (right for one shape of four), and stop on every bound name.

Downsides

  • New build errors: a call of eval, feature() or require.resolve through a define, a store to a CommonJS name.
  • 3,400 lines of compiler code in 80 files, +59,136 bytes of text.
  • No such define: identical IR and machine code, -0.004% instructions over 301 modules (noise 0.005%).
Notes

The question, with a program

globalThis.target = "the global";
function read() {
  return NAME;
  const target = "the binding";
}
console.log(read());

bun --define NAME=target prints the global: its transpiler removes the declaration behind return. The spelled program (return target;) prints the same under bun and throws ReferenceError: Cannot access 'target' before initialization under --aot. With this change the defined name throws the same error under --aot. 30 of the new programs record such a difference with "Plain Bun prints another output" and an /* Output: */ block, as 41 programs on main do for spelled names.

An earlier version of this change stopped the build at such places. It needed about 640 lines that predict what the transpiler of bun run removes, and it stopped builds that are right on main. It is gone.

The same question holds for --define RUN=eval: plain Bun prints a direct eval, main's --aot makes an indirect one, which is right only when the text needs no local. This change stops the build where RUN is called by its name.

What a defined name does now

The first name of the value, where the defined name is written --aot on main --aot now
Not declared the global the global, same code as main
var, let, const, function, class, parameter, catch parameter, import the global, or ReferenceError the binding, as the spelled name
arguments, require, module, exports, __filename, __dirname ReferenceError, or undefined under typeof what the function or the file has
A name that with or a direct eval may declare the global looked up when the code runs
Any of these with a dotted value (ROOT=holder.value) the global the member of the binding

Stores, ++, compound assignment, destructuring targets, delete of a member, calls (a dotted value keeps its holder as this), typeof and new go through the same binding. require, createRequire and Worker reached through a define load the file as the spelled name does.

The stops

All have the form TS100050: --aot does not support --define <key>=<value> where <key> <reason>:

  • is called: the value or the key is eval and the place is called by its name, the value is the resolve, call or apply of a require (or module.require.bind), or the value is feature of bun:bundle.
  • is stored to: the value is module, exports, require, __filename or __dirname.
  • is stored to in strict mode, is deleted in strict mode: plain Bun gives a SyntaxError for the printed text.

A program that stops carries a /* Plain Bun prints: */ block, and run-parallel.ts compares it. One more stop has no program: lowering reports the define where a function cannot reach the storage of the binding. No input was found that gets there.

Costs, measured

User-mode instructions from the CPU's counter (perf_event_open, the process with all threads and each thread alone), release builds of main 3f3a9ff and of this commit, runs in turns. valgrind, perf, bloaty and strace are not installed where this ran. The program has 301 modules. Each has one typeof USED in a function that declares target.

bun build --compile --aot --aot-threads=1 main this PR
No define, the process 9,403.17M 9,402.83M -0.004% (runs of one build differ by 0.005%)
No define, the thread of the front end 2,853.90M 2,853.58M -0.01%
--define USED=absentName, front end 2,858.42M 2,859.42M +0.03%
--define USED=target, front end 2,857.17M 2,859.14M +0.07%
  • Text: 140,286,972 to 140,346,108 bytes (size). File: +61,440 bytes.
  • test/aot/tools/same-output.ts --before <main> --after <this PR> prints IDENTICAL: 289 programs (IR and machine code).
  • Lines against main: production +3,427 -554 in 80 files, Rust tests +3,476, test/ +4,129 in 455 files.

What ran

  • bun bd test: test/aot/build-flags.test.ts 16 pass, test/aot/names-of-bindings-in-minified-executables.test.ts 12 pass, test/aot/spies-through-helpers.test.ts 8 pass. A release build of main fails 6, 6 and 2 of them.
  • test/aot/run-parallel.ts with a release build over every program under test/aot/parallel: 7,431 pass, 12 skip, 30 fail. A sample of 16 of the 30, at least one of each folder, fails the same way with main's build and main's runner on this machine.
  • cargo test -p bun_sema -p bun_sema_parser, cargo test -p bun_aot --lib and cargo test -p bun_aot --test ir_tests pass, but for two --lib tests and two IR files that nest very deeply. Those fail on main too in a debug test build.
  • 27 programs that a review wrote against plain Bun with the same defines: 26 equal, 1 stops (RUN=eval is called). Main: none equal.

Not addressed from the self-review

  • Error codes of their own for the stops. The stops keep TS100050 with the reason in the message.
  • A form for the fuzzer that writes a name through a define, and two forms for test/aot/tools that compare a define with the spelled name. test/aot/fuzzer-judging.test.ts fails 2 tests on a debug build of main, so this PR does not change it.
  • A first name that is a reserved word in some places (yield, await, let, static).
  • The JSX factory below with or a direct eval, which reads the global on main in the same way.
  • Four shapes in which a define hands a loader on (for example REQ=require passed to a function that calls it with a variable) build and then fail to load the file. The spelled programs do the same on main.
  • bun test --aot --define IT=test for a name of the test runner. An earlier version rewrote the key in the tree. That part is out of this PR.
  • From the code review: a define at a member that the text also reads off a module, where the member is no callee. The finder of required files reads the spelled member there. No program was found where that changes what the executable loads.
  • From the code review: typeof require under a define whose key is require folds to "function", as on main.

Order and history


no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/aot/names-of-bindings-in-minified-executables.test.ts, test/aot/build-flags.test.ts

A --define whose value is a name or a dotted path (NAME=target,
ROOT=holder.value) was read from the global object under --aot, also
where the file binds the first name. Plain Bun reads the binding.

The binder looks the first name up in scope at each expression that
such a define can replace and records it as an implicit reference
(ImplicitReferenceKind::DefineHead), as it does for a JSX factory. The
fold puts the record into a new decision, Decision::NamedPath. With no
record the decision stays Decision::GlobalPath, so a first name that is
free is read from the global object as before.

Lowering reads, calls, stores to and deletes through the recorded
binding, in JavaScript and TypeScript, for one-name and dotted values:
a defined name does what the same name does when the program spells it
there. The readers that count the uses of a binding count a define head
where the fold applied the define, and the finders of required files
and of Worker files follow a define as they follow the spelled name.

The build stops with TS100050 only where the compiler cannot do what
the spelled text does: a call of eval, of require.resolve and its
like, or of feature() through a define, a store to a name of CommonJS,
and a store or delete that strict code forbids.

A file in which the fold applied no define head takes the path that it
took before: lowering and the scan of import sites keep one flag for
each file.

test/aot/run-parallel.ts starts the plain reference run of a program
with the --define flags of its Flags line, and compares a program that
stops with TS100050 with what plain Bun prints.
@github-actions github-actions Bot added the claude label Nov 2, 2026
@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

How this was reproduced, on main at 3f3a9ff (release build, Linux x64):

// ident.js
const target = "the binding of the file";
globalThis.target = "the global";
console.log(NAME);
bun --define NAME=target ident.js
# the binding of the file
bun build --compile --aot --define NAME=target ident.js --outfile aot && ./aot
# the global

A dotted value does the same. With const holder = { value: "the binding of the file" }, a global holder and console.log(ROOT, holder.value.length), --define ROOT=holder.value prints the global 23 from the --aot executable and the binding of the file 23 from plain Bun.

With this PR both executables print what plain Bun prints.

CI: every test that is red on this PR is red on main too, for example test/aot/engine/native-6.test.ts, test/bundler/compile-aot-size.test.ts and the mock tests under test/aot. A release build of main at 3f3a9ff fails the ones that run here in the same way. No red test comes from this change.

PR: #47718

@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:18 AM PT - Nov 2nd, 2026

❌ @robobun, your commit c31b115 has 42 failures in Build #136817 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47718

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

bun-47718 --bun

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

Beyond the inline findings, I also checked the new stored-name collection for define paths at assignment targets in src/aot/load/worker_files.rs (find_name_stored_through_define) — it can only add stored names, so it errs toward keeping more worker code reachable, never toward dropping a file; and the TS2588/TS2632 const/import-assignment check in src/sema/check/aot/early_errors/declarations.rs for stores routed through a define, which does not bypass the existing run-time TypeError.

Extended reasoning...

The change is a ~10k-line compiler PR across src/sema and src/aot that teaches the fold, binder, import-site scanner and lowering to follow --define paths whose head is a binding of the file, plus hundreds of fixture tests; it touches no auth, crypto or injection surface. Four confirmed findings about wrong-file bundling and missed stops are posted inline, so a human review is already signalled; this note only records two adjacent concerns examined and ruled out.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/sema/import_sites/alternatives.rs Resolved
Comment thread src/sema/bind/runtime/define_heads.rs Resolved
Comment thread src/sema/import_sites/made_requires.rs Resolved
Comment thread src/aot/lower/call.rs Resolved
…ead only below its key

The finder of required files reads the pushes to a list in the order of
the text. A define names the list between the identifiers that spell
it, so the finder sorts the places that name the list.

The binder records the first name of a define value only where the
accesses above the root spell the whole key of the define. DefineHead
has the member names of the key. A file that writes only the root of a
key, such as process.argv for the key process.env.A, gets no record, no
use of arguments and no mark for require.

The readers of call sites take the decisions of the fold. Where a
define puts a name or a path in place of a callee, they read that name
or path. They do not read the member that the text spells there. So
require("node:url") under --define require=load is no request for a
module, and import.meta.require(..) under --define
import.meta.require=load calls load. The finders of module members, of
resolver answers, of changed files and of lists that stay as written
ask with the decisions.

The build stops with TS100050 at a call that is written eval(..) under
a --define of the name eval, also where the value names a global.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Comment thread src/sema/fold/folder_operators.rs Resolved
@robobun

robobun commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

The findings of the first review pass are handled in c31b115:

  • The finder of required files reads the pushes to a list in the order of the text, also where a define names the list between its spelled names. Program: define-value-is-a-list-pushed-to-through-both-names.
  • The binder records the first name of a define value only where the accesses above the root spell the whole key. process.argv gets no record and no use of arguments under --define process.env.A=arguments.length. Program: define-is-not-applied-where-only-its-root-is-written.
  • A define at the callee of a request is read in place of the spelled callee (--define require=load, --define import.meta.require=load). The createRequire example does not occur, because a define does not replace a name that the file binds. The thread has the details.
  • A call that is written eval(..) under a define of the name eval stops the build in a module too.

Two notes stay open and are listed under "Not addressed" in the PR notes: a define at a member that is no callee, and typeof require under a define whose key is require. Both are answered in their threads.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Closing: #47942 rewrites the front end of the ahead-of-time compiler and its tests for the engine's new compiler (oven-sh/WebKit#997, merged), so this no longer applies and would conflict with it. If the problem still shows once that is in, please open a new pull request against main.

@robobun

robobun commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator Author

Understood. When #47942 is in, I will run the two programs of the description against main again. If the --aot executable still prints the global, I will open a new pull request against main with a fix for the new front end.

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