Skip to content

AOT: hangs, wrong answers, failed builds; bun test --aot follows #44833's mocks - #47605

Open
Jarred-Sumner wants to merge 75 commits into
mainfrom
aot-follow-up-1031
Open

Jarred-Sumner wants to merge 75 commits into
mainfrom
aot-follow-up-1031

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

The newest push (22621ef9) is a compile check on every platform, nothing more. It pins the preview build of oven-sh/WebKit#982. It still carries a list of the test files that CI runs (test/aot/every-build.txt); that list is going away, because skipping tests in CI defeats their purpose. The version of this branch that merges runs every test file it contains.

Known to fail in this push:


Follow-up to #47316 for bun build --compile --aot and bun test --aot. It includes #47571, which is closed in favor of this one: that pull request's description lists its fixes, and what is listed here came after it. Against main: 4,617 files, +667,172 −174,752; all but 240 of them are under src/aot, test/aot and docs, and those 240 are listed under "Not AOT-specific". main is merged in, up to #44833 and #47070.

Pins WebKit to autobuild-preview-pr-972-cf0e99d2, the preview build of oven-sh/WebKit#972. That pull request and oven-sh/WebKit#971, both merged, have the engine's half of what is listed here; the stored programs and tests for both come with this one. Before this merges the pin moves to the merge commit of oven-sh/WebKit#974, which has the engine fixes that landed since.

Measured on a build of this branch against main's compiler: of 1,255 small programs and test projects, each built by itself, main gets 930 right and this branch 1,102. 175 that are wrong on main are right here. Three that are right on main were wrong on that build; two of them are fixed by later commits here and the third is the deliberate rule below. Four more that are right on main and wrong here have been found since, outside those programs; they are listed first under "known faults against main". Of the 89 bun test --aot projects among them main gets 17 right and this branch 59. Of 56 known miscompilations (an executable at default flags that gives a wrong answer or crashes) main has 53 and that build had 11; later commits here fix six of those.

Not for merging yet. The list below has to be empty first.

Not for merging yet: known faults against main

Right under main's bun test --aot or in an executable built by main, and wrong on this branch:

  • An executable: function Meter(size) { this.size = size; this.n = 0; } function Sub(size) { Meter.call(this, size); this.extra = "x"; } Sub.prototype = Object.create(Meter.prototype); new Sub(2); const o = Reflect.construct(Meter, [9], Sub); then o.extra is "x" for undefined and "extra" in o is true for false, without an error; a larger program of the same shape segfaults. main is right. Fixed in the engine; the fix comes with the next pin.
  • An executable: a.mjs import { fromB } from "./b.mjs"; export const fromA = "A" + fromB;, b.mjs import { fromA } from "./a.mjs"; export const fromB = "B"; export const later = () => fromA;, and const [x, y] = await Promise.all([import("./a.mjs"), import("./b.mjs")]); fails with "ReferenceError: Cannot access 'fromB' before initialization": of two import() calls made in one turn the second one's graph runs first. Without a cycle only the order differs, without an error: h.mjs import "./i.mjs"; console.log("h runs");, i.mjs console.log("i runs");, and import("./h.mjs"); await import("./i.mjs"); console.log("main goes on"); prints i runs, main goes on, h runs for i runs, h runs, main goes on. main's compiler, an ordinary --compile executable and node are right. Open; the fix is in the engine.
  • bun test --aot: const NAME = "mocked" as const; vi.mock("./dep", () => ({ name: NAME })); import { name } from "./dep"; fails with "ReferenceError: Cannot access 'NAME' before initialization". Plain bun test and main's bun test --aot pass. Open.
  • An executable: import buffer from "node:buffer"; JSON.parse("0", () => process.getBuiltinModule)("node:buffer").atob = mine; console.log(buffer.atob("YQ=="), buffer.atob === mine); prints a false for mine true, without an error. main and AOT: more of bun test --aot, module loading fixes, faster ES5 classes #47571 are right. Open.
  • Buffer.isEncoding = .. in a package that product code imports: "Cannot assign to Buffer.isEncoding because built-in objects are read-only". See "A deliberate rule".

Found on this branch or on #47571 while it was being put together and fixed in its later commits, each seen right again on a build of the branch:

  • Executables: function f(a) { var get = function () { return a; }; var a = "V"; return arguments[0]; } returned the argument. Object.assign(require("node:crypto").webcrypto, { kept: 3 }) was not seen through the global crypto in a function. JSON.parse("0", () => Reflect.set)(buffer, "atob", mine) on node:buffer, then buffer.atob(..) in a function, called the built-in. The compiler aborted on while (c === b && fuel-- > 0) { .. } where the test narrows a value that never changes in the loop.
  • Test runs, any form: a lowercase JSX tag named like an import (hono's jsx-renderer test), a hoisted mock in a file with classic JSX ("React is not defined"), a test file's own Module._resolveFilename, a mock without a factory after one with a factory, what a second vi.mock() leaves out, a mock factory that throws and is imported again, vi.doUnmock() inside a test, a name that a barrel re-exports from a mocked CommonJS file, and a test file that did not load ("Export named 'f' not found in module ../stars.js") where f comes through two levels of export * and the file spies on computed names.
  • Test runs compiled as several programs (a large suite, or bun test --aot --parallel=2 --aot-memory-limit=1KB): the same export name read off two required ES modules gave the first module's value for the second, a mocked module with export * from "a-package" threw "is not loaded", a ring of export * with a package in it never finished, and a CommonJS file that threw kept throwing for the next test file.

Known, and on main too:

Executables, default flags, each seen on a build of this branch:

  • The compiler aborts on function tidy(list) { const copy = [...list]; const result = { values: copy, changed: 0 }; for (let i = 0; i < copy.length; i++) { if (copy[i] < 0) { result.values[i] = Math.abs(copy[i]); result.changed++; } } return result; } called with [1, -2, 3, 5]: the build ends with "abort() called".
  • const blank = { cells: [0, 1, 2].map(() => ["-", "-", "-"]) }; const board = structuredClone(blank); board.cells[0][0] = "o"; then board.cells[0][0] + board.cells[1][0] + board.cells[2][0] gives ooo for o--: a store into one row of the clone shows in every row.
  • A copy made by [...list], a store through a second name of it, then a read: function sum(given, own) { const copied = [...given]; const target = own ? copied : others; let total = 0; for (let i = 0; i < copied.length; i++) { total += copied[i]; target[(i + 1) & 3] = 1.5; } return total; } gives a wrong total for sum([1, 2, 3, 4], true).
  • class Shape { get area() { return 7; } } JSON.stringify([new Shape()], ["area"]) throws "'area' was left out of the executable" for [{"area":7}].
  • A getter that only Reflect.get() reads, in a function that was also called with a Proxy: Object.defineProperties(Array.prototype, Object.getOwnPropertyDescriptors({ get extra() { return 41; } })), then Reflect.get([1, 2], "extra") throws "'extra' was left out of the executable".
  • import.meta.main = "mine"; console.log(import.meta.main) prints true for mine.
  • String(f) for const f = (a) => true ? { a } : 0 gives (a) => { a }, which is a different function, for (a) => ({ a }).

Test runs:

  • vi.mock(pkg, async importOriginal => ..) on two or more workspace packages that are read through an unmocked barrel package, in a run built as one program. Inquirer.js's packages/demo/demo.test.ts: plain 7 pass; main, AOT: more of bun test --aot, module loading fixes, faster ES5 classes #47571 and this branch 0. One commit here brought it to 5 of 7 and a later one, which makes import() wait for an async mock factory, back to 0.
  • In the same project a member of node:util that compiled code imports by name is left out of the image in some runs: "'styleText' was left out of the executable".
  • What resetModules() leaves behind for the next test file differs from plain, here and on main, in different lines.
  • vi.doUnmock("./k.js") while a plugin's async onLoad is still loading another import: the crash is fixed here, the values are still the first mock's.

Crashes

  • TypeError.list = [1, 2]; JSON.parse("0", () => Reflect.set)(e.constructor, "list", { length: 3 }) with e a caught error, then TypeError.list.length in a function: the compiled program segfaulted. The same happened with a hidden Object.assign or Object.defineProperty.
  • Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)) with a key object whose toString() returns "a" and then "b": plain prints 3, compiled code segfaulted.
  • An --aot executable aborted before its first statement on a kernel with 64 KB pages (RHEL 8 aarch64, Oracle's UEK, the 64k kernels for NVIDIA Grace). Fixed for the ordinary arm64 Linux builds; the hardened arm64 builds keep the 16 KB layout and still abort there, as on main.
  • vi.doUnmock("./k.js") while a plugin's async onLoad is still loading another import segfaulted under bun test --aot. The crash is fixed; the import still gets the first mock's values where plain gives the file's, and twelve test.failing cases record that.
  • bun test --aot aborted under validateExceptionChecks, which CI's ASAN lane sets, wherever a mock was installed: vi.mock("./mod", () => ({ value: fake })), and in a layered run vi.doMock("./m08.js", async () => { await sleep(80); return { value: "mocked" }; }); import("./m08.js").
  • Bun.argv[0] = 5; process.argv[0]?.length segfaulted, and Bun.argv.extra = 1 read as undefined through process.argv.extra in a function. They are one array, and the table of built-ins described them apart.

Miscompilations

  • The code that creates, fills and stores a module variable's value is deferred until the variable is first read, and an assignment to an object created by that initializer was deferred with it. After Object.freeze(Object.prototype) an assignment to an inherited name fails: a TypeError in strict code, silently dropped in sloppy code. Plain did the store before the freeze and compiled code did it after. main has this bug too.
  • Object.freeze(Object.prototype); enum Kind { toString, valueOf, plain }: bun run fills an enum by assignments, which fail for a name that a frozen Object.prototype has. Compiled code defined the members instead.
  • A loop's stores to a variable were delayed to the loop's exit edges, even across an operation that throws only when memory runs out. That error can be caught, and then the loop is left through none of those edges.
  • A Map or Set lookup with the same key on every iteration was hoisted out of the loop, even when a try covered the lookup but not the loop's preheader, and even when the loop might not run: for (let i = 0; i < 0; i++) map.has(rope) threw a RangeError.
  • A loop that does const list = [], pushes, reads this.state.type, reads list[0] and stores the list into a node: a value read back out of an open object was taken for this iteration's list.
  • const app = { db: new Bun.sql(":memory:") }; if (!app.db) console.log("no database"); took the wrong arm, and typeof [made][0] was "undefined" for "function". The table of built-ins had no construct signature for Bun.sql, so the construction produced no value.
  • In a CommonJS file, var exports; (function (exports) { exports["A"] = "a"; })(exports || (exports = {}));: whoever required the file got {} for {"A":"a"}. What tsc and esbuild print for an enum was taken as one object literal over a var that something else had already filled: the wrapper's parameter, a function declared in a block, the head of a for-in or for-of loop, or a parameter with a default.
  • With "jsx": "react", "jsxFactory": "h": const early = <div />; var x = 1; function h() { return x ? "t" : "f"; } console.log(early, <div />); printed "t t" for "f t". A classic JSX element calls its factory without spelling it, so the factory was taken to run first after x was filled.
  • In an ES module, (say("a"), require)("node:path") never called say(); nor did (say("a") && require)("./dep.cjs") or (say("a"), require.resolve)("./dep.cjs"). The callee was recognized as require through the comma and never lowered for its effect.

Hangs and failed builds

  • esbuild's transformSync() and buildSync() never returned in a compiled program.
    var threads; try { threads = require("worker_threads") } catch {}, then
    var start = given => new given.Worker(__filename, ..) and start(threads): the
    search for the files that Workers start from knew the module behind an import, a
    direct require() and a const, not behind a variable that is assigned or a
    parameter. The Worker could not be loaded, and the caller slept in Atomics.wait().
  • A package that says new Worker(__filename) got this executable can't load a module from a dynamic path, in an executable and in a test run built as one program.
  • mock.module("node:os", ..) in a.test.js, await import("node:os") in b.test.js: the
    promise never settled and the test timed out. Since bun test: Vitest compatibility #44833 plain bun test takes a
    file's mocks away when the file ends, and nobody told the engine.
  • internal compiler error (LinkAndEmit): UndefinedLayout, in two ways. child.ts has
    import "unbuilt", which can't be resolved, and export class Child extends Base, and
    another file says await import("./child"): in a test run no test file ran. Or a package
    with "sideEffects": false has export class Unused extends Base {} beside
    export const used = 1, and main.ts imports used and, with import type, Unused.
    The layout of a class whose statement is not compiled named a parent that nothing defines.
  • A build that never ended in the checker: a conditional type such as
    T extends infer R ? R : never was weighed by how it is written, not by what T
    stands for.
  • import.meta.glob() was not expanded under --aot. It is now, by Bun's own parser:
    lazy, eager, import: "name", ?raw, Object.keys() of it, in a CommonJS file.

Wrong answers of compiled programs

  • bottom[name], with class Top { [name]() {} }, class Middle extends Top and
    class Bottom extends Middle, had its type if Bottom was asked about first and no
    type if Middle was. Which came first went by the order of the files.
  • Object.getOwnPropertyDescriptor(require("node:_http_agent"), "globalAgent") showed a
    getter and a setter. It is a value made at load, as in plain Bun.
  • export { default as t } from "./outside.txt" with { type: "toml" }, for a file that
    is not in the program, lost its type, while import kept it.
  • In a .mjs or .mts file this at the top is undefined. The name alone decides: a
    .cjs file in a package with "type": "module" still exports through this.
  • A stack trace taken while then is read by await thenable listed the async function,
    which has suspended by then. A class whose decorated members need a constructor was
    shown as new Task (unknown:1:11), not at its class.
  • for (3 times) try { require("./t.cjs") } catch {} where t.cjs throws: the body ran once and the first error was thrown three times. It now runs again each time, as in node and bun run.
  • main.mjs imports ./m2.mjs and ./a.cjs; m2.mjs imports ./b.cjs; b.cjs requires ./a.cjs between two logs; a.cjs uses nothing of CommonJS. a.cjs ran first instead of when b.cjs required it.
  • require("./m3.mjs"); Object.keys(require("./d.json")) where m3.mjs imports d.json: plain a,default, compiled a.
  • Object.isExtensible(require("./e3.mjs")) after an import of the same file, in a layered run and for a module left out by --external: plain true, compiled false.
  • package.json says "sideEffects": false and a file says import "./setup": a test run built as one program dropped setup and said nothing, while a layer ran what bun build --compile drops. Both now do what plain does.
  • const o = Object.create(Blob.prototype, {}); o.constructor = { get name() { return "Stored" } }; o.text(): plain throws ERR_INVALID_THIS; compiled code threw "'name' was left out of the executable". The same for Buffer.concat(v) when v isn't an array.
  • piscina's new Worker(join(__dirname, "runner.js"), { workerData: filename }) with require(workerData).default in the worker didn't find the project's file. threads.js's await spawn(new Worker("./job.cjs")) failed with "this executable can't load a module from a dynamic path".
  • A Worker file that nothing reachable can start is no longer put into the executable.
  • EventEmitter.prototype._maxListeners = 100 had no effect on http.ServerResponse or on a server's requests and responses: the AOT text of node:http stored undefined where events.ts has ??=.

bun test --aot

  • vi.mock() and jest.mock() are hoisted above the imports, as bun test: Vitest compatibility #44833 does for plain
    bun test. With import { captured } from "./side"; vi.mock("./mod", ..), where
    side.ts imports mod.ts, mod.ts ran and captured was the real value.
  • require() of a mocked module answers the object that the factory gave, for node:
    names too.
  • mock.module("node:dns", f1); await import("node:dns"); mock.module("node:dns", f2); await import("node:dns") answered the first mock again where a run is built in layers.
    Five mocks in a row gave 1, 1, 3, 4, 5.
  • A compiled run of one empty test held 166 functions of the test runner's own code. It
    holds 2 again. process.mainModule follows the test file.
  • A file of the project that writes on a built-in (Buffer.isEncoding = ..) now throws in
    the test run as it does in the executable. A green run said nothing about that line.
  • A file that only Workers load has no expect or test, as in plain Bun.
  • // require("./x.cjs") in a comment no longer decides whether x.cjs runs as CommonJS.
  • A test that writes state.js and imports it afterwards gets the new text in more forms:
    a path from process.cwd(), and a helper in another file that takes the path or joins
    a name to its own folder.
  • A type imported from a module in a layer below no longer costs the importing file its
    types.
  • --shard with --timings spreads the files that are held apart for their memory over
    the shards, and the walk ahead of the deal asks for each name of a folder once.
  • /** @vitest-environment jsdom */ (also happy-dom, @jest-environment, --environment=, bunfig's environment): the file didn't run: "Cannot load '(internal)': it isn't in this executable". With jsdom 25 and happy-dom 17 it now gets its window, under --isolate too. Both packages evaluate text, so a run with such a file is built as an open program: about 5.5 s more per run.
  • Fake timers replace the globals. Compiled code had bound setTimeout and friends to what the process started with, so useFakeTimers()'s own functions were silently refused and the real ones consulted the fake clock: a socket's own timeout never fired, and advanceTimersByTime() fired the library's timers.
  • jest.doMock("./lib"); lib.f() called the real function, and import sum from "./sum"; jest.mock("./sum"); jest.mocked(sum) at the top of a test file got the real function: a mock without a factory arrived one step late.
  • export const a = 0 of a mocked module read as 0 in every importer, whatever the factory returned.
  • Two export * barrels, one of them mocked. Without hoisting (mock.module() in a test, then await import("./first.js")) main prints real mocked where plain prints mocked mocked. main has this bug too.
  • mock.module("./other.ts", () => ({ v: "mocked v" })); (await import("./by-name.ts")).renamed with export { v as renamed } from "./other.ts": "Cannot access 'renamed' before initialization".
  • A require() that a mock had answered kept that answer for the rest of the run, even after mock.restore() and vi.doUnmock(), so a later test passed or failed depending on what an earlier file had mocked.
  • A CommonJS file behind a mocked ES module still ran.
  • jest.clearAllMocks() and friends also end the mocks that are still waiting in the engine.
  • import("./user.js").then(..); await vi.dynamicImportSettled() didn't wait for the import.
  • vi.resetModules() keeps packages and jest.resetModules() doesn't, as in plain.
  • A mock of a built-in module installed before the module loads takes effect.
  • import {} from "./m" and import "./m" are hoisted below vi.mock() like every other import.
  • A file in a __mocks__ folder is compiled with the program, and require() finds a module mocked by a CommonJS file there.
  • vi.importActual() and jest.requireActual(): the original next to its mock loads in a compiled run, may import files of the program, and works with a name computed at run time.
  • A test file named on the command line that doesn't match *.test.* (bun test --aot ./tests/latest.ts), and a helper that writes a file and then require()s it.
  • What only the preloads import is treated as the tests' own, in a layered run too. A preload that can't be compiled no longer ends the run silently.
  • --isolate=modules runs compiled. In a layered run the files share one global object, as in plain.
  • --globals=vitest in a run built in parts; globalThis.describe; vi === vitest; vitest.mock() is hoisted.
  • vitest's assert and should are the project's own chai.
  • test.extend(): fixtures, JSX in the body, its errors.
  • A file that both a Worker and a test file load reads expect, test and the like from the realm it runs in.
  • A test file that can't load (const broken = ;) is written to the JUnit report as a failed case, as plain does on main now.
  • typeof console.timeStamp is "undefined" in a test run, as in bun test, Jest and Vitest. React's development build checks for it.
  • The runner's own filters and the DOM formatting of toMatchSnapshot() accept an object of compiled code.
  • A plain run keeps its workers: --aot --shard had changed how files are dealt to them.
  • vi.mock("./lib.cjs") without a factory, with __mocks__/lib.cjs beside it, in a run compiled as two layers: one binary gave two to five different outputs in ten runs, because no layer's build compiled the folder's file. Of 48 such projects 8 gave varying output before and none does now.

Build time and memory

  • What a file uses of the library's functions was kept in four sets of 131,131 bits, each
    cleared and walked for every file. They are two sorted lists now. The two blocks of a
    module that is set aside are compressed in one go, not one by one.
  • class C1 extends C0 { m1() {} }, 800 in a row: every inherited method was compared
    with itself in the class above, 380 of 540 MB.
  • A build that is not a test run walked every expression of every file for writes on
    built-ins and threw the result away.
  • map.delete(k); map.set(k, v) took a new slot in the table's index each time and never freed the old one, so every lookup of the key read every slot it had left behind: an iteration cost the size of the table. A deletion now frees the slot. It costs 5 more instructions on a set and on a delete; see "Numbers that went up".
  • new Worker(new URL("./w.js", import.meta.url)) inside a package, the way piscina, tinypool, workerpool and threads.js start theirs: under bun test --aot the file was compiled into no layer and ran in the interpreter. A loop of 3e8 iterations took 10,812 ms against plain's 320 ms.
  • class Below extends mix(Base): the whole caller was walked for every such call. At 3,200 classes that step took 8,306.6 M instructions and now takes 119.7 M. The generated code is byte-identical.
  • Three more build steps that grew with the square of the program: choosing representations around a web of values, finding who reads a parameter that is passed on, and loads by a computed name, which now cost by the package.

Speed

  • A sloppy function that only reads arguments and has a closure over a parameter
    (Babel's _objectSpread) no longer makes the arguments object at every call.
  • Array.from(map) and Array.from(map.entries()): 672 instructions an entry, now 132.
  • 65 host functions have a signature that says what they answer. Without one, a call
    counted as making an instance of every host class.

bun check

  • function f({ a: { a: .. b } }) {} nested N deep took time quadratic in N: 20,000 deep took 23.6 s of processor time. Each element of the pattern asked whether the pattern is ambient, which walks up through all its parents. It now asks the parameter that was just found (symbols.rs). Same answers: nothing between a pattern and its parameter can be ambient. test/cli/check/nested-parameter.test.ts.
  • value.someName on a FromSchema<Schema> (json-schema-to-ts) with a type parameter for Schema: bun check never finished. It now reports TS2589 and TS2590 where tsc does, in about 5 s (tsc 6.0.2: 13.5 s). test/cli/check/schema-types-limit.test.ts.
  • A this parameter without an annotation keeps what it was assigned, and one that nothing gives a type is still reported. test/cli/check/unannotated-this.test.ts.

A deliberate rule

Under bun test --aot, a package that your own source files import is held to the same rule as in an executable: it can't replace a built-in, so a project whose product code depends on such a package sees the failure in its tests and not first in production. A package that only test files import, like a mocking library, may still do so. One visible consequence: nock's own test suite no longer loads compiled (in nock's repository nock is the product, and its dependency @mswjs/interceptors assigns Headers.prototype.set), while a project that uses nock from its tests behaves as before.

Tests

  • heap-limit: each of the eleven cases that use up the address space runs twenty times
    at each limit, and one run of the twenty may end in a malloc that is not allowed to
    fail. Plain Bun dies there every time.
  • The stored programs that print what the collector lets go are each built alone. What an
    executable with many entry points copies into what goes by everything it holds, so two
    of them failed in CI by the company they were dealt. Both are stored with that company.
  • The test of how a run is cut to fit its memory is skipped under sanitizers, like its
    eleven siblings.
  • A stepped idiom fails when it reads more than 2% and more than 2 instructions over its
    pin. It was held exactly. Two pins that had been raised go back to where they were.
  • A stepped row can be marked "todo": it is held to a higher number, and fails once its
    pins are met again. Two EventEmitter.emit idioms carry it, and "owed" in the counted
    pins: the fix for the compiler's abort costs them five times their instructions until
    the narrower fix is in the engine.
  • Two rows of the flow-schedule test are restated to what they measure.
  • Six stored programs, pass-conditions/carried-word-*, each built alone: a loop adds to
    holder.n, stores to other.n once, and under holder instanceof Other calls a
    function, a method or a setter that writes n too, with holder and other one
    object. B3's new pass promoteLoopMemory lost the store. The engine has the pass
    switched off for now, and these guard its return.
  • Four more counted idioms are marked "owed". Their pins stay where they are.
  • small-programs-stay-closed.test.ts: one program moves from the programs that stay closed to the programs that open at one call.
    const withDefaults = options => ({ verbose: false, ...options }) is called with the bun module and with the program's own
    { build }. A literal made by one function is one object for all its callers, so the build called later may be Bun.build.
    The test now says where it opens: Bun.build, line 6. That costs such a program size and speed. No answer changes.
  • perf/idiom-counts.json: the allCells and allBytes pins are new here. everyday-downleveled/async.awaiter-helper, untyped,
    holds what arm64 reads (21,550 and 1,005,376; Linux x64 reads 19,164 and 935,680): the file keeps the larger of the two.

May be red in CI

Expected red in the first round:

  • test/bundler/compile-aot-size.test.ts, x64 glibc: "hello world maps no more bytes than it did" is over its bound on a trimmed build close to this branch: 68,558,093 bytes for 68,440,000, and 26,213,645 for 25,880,000 with --trim. The --trim bound was already over in this PR's last round (26,113,163). The three bounds are restated once, from this round's own log, in a commit of their own.
  • test/aot/servers/a-test-run-compiles-what-an-executable-compiles-whatever-the-order-of-the-rules-of-flow-hono.test.ts: a test run and an executable compile 98% of hono's functions alike, and the test asks for 99%. Seen on a build close to this branch, not on this branch's own; it came with the fix for a setter made with Object.defineProperty that stores through this. The floor is not lowered.

Not known:

  • x64-asan, by time: the five files that test/aot/mock-without-default.test.ts and test/aot/a-question-to-require-cache-costs-the-same-in-a-larger-program.test.ts were dealt to take 71 to 147 s each under ASAN at home, but on four starved cores one of them reached 310 of its 360 s.
  • test/aot/heavy-test-files-run-apart.test.ts, "a compiled run writes by how much each file's worker grew": one of five runs at home lost a memory sample, on a machine at three times its capacity. Alone it passed four of four.
  • Most of the new files under test/aot have not been run under ASAN at home, and nobody has built this branch for Windows or macOS at home.

Red in the last round and fixed here: test/aot/build-scaling/build-scaling-3.test.ts (see "Numbers that went up"), test/aot/heap-limit.test.ts on Alpine x64, and mock-hides-packages, mock-ends-in-test and mock-import-original, which needed more time than a lane gives a file and are dealt to 26 files.

Marked, not red

  • Gone since the first commits of this PR: the two "todo" marks in idiom-steps.linux-x64.json (the emitter idioms; the narrower fix is in) and six of the eight "owed" marks in idiom-counts.json. Their causes are fixed, and a mark left in place would fail.
  • In idiom-counts.json: what-a-function-nobody-knows-returns-map (one more 48-byte cell per iteration: what a call passes to a small function is released after its last read, and the iteration's array literal is now allocated), built-in/stream-new-readable (+3 branches since host functions got signatures that say what they return), everyday-collections/map-group-by (3,000 more arrivals; its branches fell from 85,000 to 57,000 and that pin was lowered) and, since a merge of main, everyday-small-programs/gzip-round-trip (+2 accesses by name: src/js/node/zlib.ts takes two functions out of require("node:util/types")).
  • In idiom-counts-in-a-very-large-program.json, a new file: two-values-are-compared-again-and-again (+1 branch each time the loop is entered: a comparison that is the same in every iteration is hoisted behind a copy of the loop's first test, because in front of a loop that never runs it could throw where the source does not) and iterator-helpers (+14 branches per iteration; the cause has not been shown, and the mark says so).
  • idiom-steps.linux-x64.json has 35 "owed" marks, one more than before. 34 share one cause: an exit from a cycle that is rarely taken isn't treated as a loop exit. The new one: remainders and quotients of whole numbers stay in integers (125.45 against 117.73).
  • A tightening: everyday-pins.linux-x64.json now holds 1,836 idioms (1,267 before) to 2% over their pins. It was 10%. 106 idioms read between 2% and 10% over when the bound was tightened; they carry "owed" and stay held at the old bound. Each mark says what it read and that nobody has looked for the cause yet. That test needs a hardware counter and skips itself on debug, ASAN, musl and non-Linux builds.
  • A stepped row fails when it reads more than 2% and more than 2 instructions over its pin.

Numbers that went up

42 pinned numbers go up with the last commits, in six files under test/aot/perf. 1,006 go down in the same files.

how many idioms by how much why
26 map-set-and-delete, set-add-and-delete, map-lru +3% to +13% of the instructions of an iteration The fix for map.delete(k); map.set(k, v), which never freed a slot of the table's index, so an iteration cost the size of the table. A delete and a set now each take 5 more instructions.
6 collection/map-get-or-default-in-function 0.5 to 6 instructions, 1,000 to 2,000 branches A wrong answer was fixed: hoisted in front of a loop that may not run, the lookup threw an out-of-memory error that the source never throws. It now sits behind a copy of the loop's first test.
8 what-a-function-nobody-knows-returns-map, a-generator-driven-to-its-end-by-a-loop-that-unwraps-small-objects +3 and +1 instructions What a call passes to a small function is released after its last read, so its memory can be reused.
2 gzip-round-trip in a very large program 16,073 to 19,563 The pin was first taken while a change was in that was later removed because a build never finished.

Two more, each in its own commit. The budget of "awaited calls in one array literal" in test/aot/build-scaling/budgets.linux-x64.json goes from 2,294 M to 2,427 M instructions: an empty program with a top-level await got 133 M dearer to build when main was merged into this branch, because such a program makes the build lower all of the runtime's own TypeScript, and that grew. What 2,000 awaits add has not changed. The header of test/aot/parallel/host-objects/import-meta-printed.mjs asks for at most 72 emitted functions where it asked for 8: since the fix for import.meta.env.DEV typed as a string in a test run, env is an object of unknown shape in every build, so printing import.meta brings in util.inspect. It has a TODO: back to 8 once the table says what env is outside a test run.

behind-the-jit-in-a-very-large-program.linux-x64.json is not a file of pins. It lists the idioms known to be slower than the JIT in that form; the tests read only its keys, and an idiom on it is excused from the comparison with the JIT, not from its pin. The list grew: 442 new names, 117 removed.

From the first commits of this PR:

  • build-scaling/budgets.linux-x64.json, "texts that move up one place in every turn of a loop": 10,724 to 13,044 M
    instructions, 205 to 329 MB. The test's program is twice as large as it was (4,000 texts for 2,000).
  • The same file, "parts that two loops join to the key of a table": 11,481 to 14,903 M instructions, 259 to 309 MB. The old
    budget was taken while a cap kept the second loop from being copied. The cap is gone. The build still grows no faster than the
    program: 1.95 times for twice the input.
  • perf/idiom-steps-called-once.linux-x64.json, decode-uri-component and encode-uri-component, typed and untyped: 127 to 159.
    32 instructions that a process pays once, at the first input that needs the real function. They came with a change that
    saves 59 KB in one program.
  • perf/idiom-steps.linux-x64.json, made-and-kept.es5, typed and untyped: 41 to 43. A wrong answer was fixed: a test is no
    longer believed over the first object of a constructor function.

Left out on purpose

  • test/aot/frameworks/apps/sdks/ and test/aot/frameworks/sdks.test.ts, 13 files: programs that use two vendors' SDKs the way their docs do.
  • One entry in the list of suites in test/aot/runner-suites.test.ts.
  • A change to the inliner for loops in functions that run once: 901 rows got faster and 49 slower, and 105 pins would have gone up. It is being narrowed.
  • Two fixes to plain Bun found along the way. They get pull requests of their own.
  • Treating the bare word require in a test file as no reason to open a test run: it broke Module._resolveFilename hooks and packages that store a loader function, so it was removed again (the seventh commit on top of the cut).

Not AOT-specific

C and C++ were checked mechanically: from each changed file outside the AOT folders every #if ENABLE(AOT) region was taken out, an #else arm kept, and the result compared with main's.

what plain Bun compiles files
identical BunAOTCompile.h, BunPlugin.cpp, BunPlugin.h, JSMockFunction.cpp, ViUtils.cpp, bindings.cpp
more, nothing changed BunAOTCompile.cpp (+1), BunAOTRuntime.cpp (+1), InternalModuleRegistry.h (+1), JSFunctionParametersSource.cpp (+7: a stub that returns nothing), highway_strings.cpp (+169: the SIMD kernels for path.posix)
its own lines changed BunCompiledModules.cpp (+7 −2), BunCompiledModules.h (+5 −2), BunProcess.cpp (+11 −5), ImportMetaObject.cpp (+1 −1), InternalModuleRegistry.cpp (+6 −2), JSCommonJSModule.cpp (+2 −1), ZigGlobalObject.cpp (+11 −13), node/NodeTimers.cpp (+4 −2)

The eight files where plain Bun's own lines change:

  • ZigGlobalObject.cpp (+11 −13). (1) bun test --isolate: Bun__didCreateNextTickQueue() returns early once the thread has drained its first process.nextTick queue (a new thread-local, didDrainFirstNextTickQueueOfThread), so only a thread's first queue waits for the running microtask to end. A process.nextTick() queued while the second test file's imports load runs after that file's body again, as before Compile TypeScript to machine code ahead of time: bun build --compile --aot #45631 and as in 1.4.4. (2) The native helper behind require.cache loses its no-argument form, reads its argument with argument(0), and answers undefined where isHiddenFromRequireCache() has no answer; in plain Bun the stubs it calls still answer false. (3) Inside if (hasCompiledModules()) where the module loader fetches, a block that built a rejected promise became RETURN_IF_EXCEPTION(scope, nullptr); plain Bun does not enter that if.
  • BunProcess.cpp (+11 −5). process.release is locked only where compiled modules do not stand in for files; the lock does nothing without compiled code. this._write is not a function takes its description of the value from errorDescriptionForValue(), with an exception check; those bare streams exist only where a built-in was trimmed away. _destroy is added to a table of names. The getter that bun test: Vitest compatibility #44833 gave process.mainModule under bun test is installed in compiled test runs too (if (isBunTest)), and begins with if (Bun::hasCompiledModules()): one branch more in plain Bun.
  • InternalModuleRegistry.cpp (+6 −2), node/NodeTimers.cpp (+4 −2). The condition that stood in requireOnBehalfOf() is a function of its own, DeclaredModuleLoad::canLoad(). Bun__FakeTimers__loadNodeTimers() asks it first and names the module it loads ("node:timers", "node:timers/promises"), not "bun:test", as the one on whose behalf it loads. For plain Bun: two calls and two branches more for each useFakeTimers().
  • JSCommonJSModule.cpp (+2 −1). require.cache is made by the function it had before AOT: bun test --aot on large suites, module loading fixes, fuzzer fixes #47316; where compiled modules are loaded another function, with the traps for them, is chosen. One branch when the cache is first made.
  • ImportMetaObject.cpp (+1 −1). Before a resolved path that ends in .node goes on to errorIfCodeOutsideImageIsRefused(), Bun::letsTestRunLoadAddon() is asked. In plain Bun that is a stub that answers false, and the path goes on as before.
  • BunCompiledModules.cpp (+7 −2), BunCompiledModules.h (+5 −2). The half of the file for a build without the compiler: four new empty stubs (requireCompiledModuleForTestRunner, letsTestRunLoadAddon and its extern "C" form, willResetModules), an include, and two stubs whose signatures follow the compiled half (isHiddenFromRequireCache() returns std::optional<bool>, requireCacheListsCompiledFiles() takes no argument). They answer what they answered.

From #47571:

  • bun test --isolate: a process.nextTick() queued while the second test file's imports load runs after that file's body again, as before Compile TypeScript to machine code ahead of time: bun build --compile --aot #45631 and as in 1.4.4.
  • bun test --parallel: a worker waiting for its next file sleeps instead of polling.
  • bun test: the memory reservation while tests run is removed (581 lines).
  • bun test --isolate: a FileSink closes its descriptor before close returns instead of later on the work pool.
  • require.cache is the function it was before the first AOT PR. The traps for compiled programs are a separate function.
  • bun check: a hash set past 16 type parameters, the narrowing index is kept instead of dropped whole, the regular form of a literal is computed once, a discarded task explains no error.
  • bun check and bun lint --type-aware, five more changes, none of which moves an answer: the same errors on seven folders of TypeScript, eight
    folders under bun lint --type-aware and four written probes, on CI's builds of main and of this branch. Whether what is being computed may be
    cached is answered from a memo keyed by the frames' serial numbers, not by a scan of the stack of queries (check/context.rs).
    are_call_statements_idle() remembers how many statements in front are idle (check/flow.rs). exporting_modules() works in chunks of
    1,024, and a task that is discarded leaves off where the next begins (check/errors_declaration_emit.rs, task.rs). hir::Fixed is a
    pointer, a u32 and a tag in 16 bytes where it was an enum of a box, a &mut and a &, with unsafe in Deref, DerefMut, Drop, Send
    and Sync (hir.rs). Under bun lint --type-aware, a function expression with a contextual signature and no written return type gets its first
    look in the task of its own file even where a return type is stored already (check/expr.rs).
  • path.posix.dirname, basename and extname of Latin-1 strings use SIMD kernels.
  • bun check and builds on POSIX: a thread that runs out of stack says what it was compiling (a 512 KB signal stack per thread).
  • Release link: on Windows the bitcode is no longer compiled twice for a weak alias that only native objects name.
  • Compiled into plain Bun and not reached there: two stubs that answer false (one asked in ImportMetaObject.cpp before a .node file is refused), an early return inside if (hasCompiledModules()) where the module loader fetches, the lock on process.release (it does nothing without compiled code), and the wording of this._write is not a function for the bare streams that exist only where a built-in was trimmed away.

Since #47571:

  • node/NodeTimers.cpp, InternalModuleRegistry.{h,cpp}, BunProcess.cpp: Bun__FakeTimers__loadNodeTimers() names the module it loads, not "bun:test", as the one on whose behalf it loads; the condition that stood in requireOnBehalfOf() is a function of its own; the getter that bun test: Vitest compatibility #44833 gave process.mainModule under bun test is installed in compiled test runs too; _destroy is added to a table of names. For plain Bun: two calls and two branches more for each useFakeTimers(), one branch more in that getter. These three have not changed since the first commit of this PR.
  • RemovedFeature.cpp, which the first commit touched, is main's text again: the function moved into BunAOTRuntime.cpp.

Rust has no such check, so these were read.

  • src/runtime/test_runner/test_context_parameter.rs, ScopeFunctions.rs, test_context.rs, test_context_fixtures.rs: test.extend() chooses fixtures by the properties a callback destructures, which it reads from the function's source. Where a function has no source it answered "absent"; it now asks a cold of_compiled(), which gets a compiled function's text from the engine. For a plain function without source (native, bound) that call returns nothing and the answer is "absent" as before. The new case TextLeftOut, an error for a build without function source, only comes out of of_compiled().
  • src/runtime/test_runner/jest.rs, jest.bind.ts: Bun.jest(path) takes a second, loose boolean that compiled files pass; length stays 1. It matters only where a compiled program is running.
  • src/runtime/test_runner/dom_format.rs: one more alternative in a matches!, a cell type that no object of plain Bun has. environment.rs: has_window(), which only the preparation of a compiled run calls.
  • src/runtime/cli/test_command.rs, test/ShardsByReach.rs: everything added is inside if let Some(aot) = ctx.bundler_options.aot or in functions only that branch calls. Three reads of isolate_modules, here and in test/parallel/runner.rs, became run_compiled::test_files_share_global(ctx), which is isolate_modules && !IS_ONE_PROGRAM; that flag is stored only where a compiled program has been loaded, so a plain run gets the same answer and one relaxed load more.
  • src/runtime/jsc_hooks.rs: a false became compiled_entry_point_imports_vitest(path), which begins with is_compiled_module(path): false in a plain run.
  • src/runtime/cli/test/Timings.rs, test/parallel/runner.rs: bun test --update-timings keeps the "memory" and "workers" entries it read; the number of workers is recorded only for a compiled parallel run.
  • src/runtime/cli/test/parallel/Coordinator.rs, parts.rs: how the compiled parts of a run are dealt to workers, and a warning when the temporary directory has no room for them. Only bun test --aot --parallel has parts.
  • src/bundler/ParseTask.rs: loader.is_jsx() || topts.react_compiler_filter.is_some(). compile_command/configuration.rs sets that filter only for the build of a compiled test run with [test] reactCompiler; bun build never sets it. With it bun test --aot compiles the components that plain bun test compiles. Before, it compiled none and said nothing.
  • src/runtime/cli/run_compiled.rs, Arguments.rs, compile_options.rs, src/jsc/compiled_program.rs, src/options_types/aot_options.rs, context.rs, src/bundler/aot/options.rs, components.rs, src/standalone_graph/executable_request.rs: how a compiled run is started and what it is told: its test files and preloads, which Workers run apart from the program, a developer flag --aot-cpu, an error message.
  • src/js_parser/lower/import_meta_glob.rs: two thread-locals that nothing sets outside --aot.
  • src/sort_by_number is a new crate (lib.rs, Cargo.toml), with Cargo.toml and Cargo.lock: a sort by a numeric key that takes less code than sort_by_key. Its users: src/exe_format (cut_choice.rs, executable_memory_filter.rs, image.rs, pe_ranges.rs), src/trim (apply.rs, edges.rs, icu_data.rs, table.rs), src/sema/components.rs, types.rs. Same order, same output.
  • src/codegen/builtins-manifest.ts, src/codegen/builtins/host/*.ts, src/builtin_sources/lib.rs, src/js/ahead-of-time/node/_http_agent.ts, test/internal/builtins-manifest.test.ts: the table that tells the compiler what Bun's built-ins do. Nothing of it runs in plain Bun.
  • src/sema/check outside check/aot, which bun check runs too: see "bun check" above for the three changes that bun check can see. The rest is false unless the build is a layer of a compiled test run or the question comes from lowering.
  • The other files under src/sema: the search for the files a program loads (import_sites), the walk over a program's code (program), check/aot, bind, file_analysis, fold, driver/compile, module_format, resolve, hir.rs, atom.rs.
  • scripts/build/deps/webkit.ts: the pin. test/parallel-allowlist.json: four new test files. test/cli/check, test/internal/trim: tests.
All 240 paths outside `src/aot`, `test/aot` and `docs`
  • ./: .prettierignore (+1 −0), Cargo.lock (+9 −0), Cargo.toml (+6 −0)
  • scripts/build/deps/: webkit.ts (+1 −1)
  • scripts/build/: rust.ts (+2 −0)
  • scripts/build/trim/: coff-graph.ts (+6 −5), linker-coff.ts (+14 −11), linker-elf.ts (+20 −3)
  • src/builtin_sources/: lib.rs (+18 −0)
  • src/bun_core/: current_work.rs (+79 −3)
  • src/bundler/: ParseTask.rs (+1 −1)
  • src/bundler/aot/: components.rs (+4 −2), options.rs (+27 −3)
  • src/codegen/: builtins-manifest.ts (+3 −1), replacements.ts (+1 −0)
  • src/codegen/builtins/host/: buffer.builtins.ts (+3 −1), bun-syntax.builtins.ts (+8 −12), features-web.builtins.ts (+0 −1), globals.builtins.ts (+41 −27), handwritten-classes.builtins.ts (+7 −1), marks.builtins.ts (+0 −1), modules.builtins.ts (+12 −9), native-bindings.builtins.ts (+3 −3), native-code.ts (+16 −0), native-functions.builtins.ts (+19 −1), node-crypto-bindings.builtins.ts (+12 −7), operand-changes.builtins.ts (+30 −3), process.builtins.ts (+4 −0), runtime-dependencies.builtins.ts (+8 −10), typed-results.builtins.ts (+10 −0), web-constructors.builtins.ts (+45 −14)
  • src/crash_handler/: stopped_builds.rs (+39 −5)
  • src/exe_format/: Cargo.toml (+1 −0), cut_choice.rs (+3 −4), executable_memory_filter.rs (+2 −1), image.rs (+2 −1), pe_ranges.rs (+2 −1)
  • src/highway/: lib.rs (+50 −0)
  • src/io/: PipeWriter.rs (+17 −0)
  • src/js/ahead-of-time/node/: _http_agent.ts (+6 −62), _http_incoming.ts (+1 −1), _http_server.ts (+2 −2)
  • src/js/: builtins.d.ts (+4 −2)
  • src/js/builtins/: CommonJS.ts (+75 −8)
  • src/js/node/: async_hooks.ts (+7 −4)
  • src/js_parser/lower/: import_meta_glob.rs (+79 −0)
  • src/jsc/: VirtualMachine.rs (+19 −2), bindgen.rs (+20 −0), compiled_program.rs (+25 −11), web_worker.rs (+21 −1)
  • src/jsc/bindings/: BunAOTCompile.cpp (+12 −0), BunAOTCompile.h (+1 −0), BunAOTRuntime.cpp (+35 −0), BunCompiledModules.cpp (+164 −30), BunCompiledModules.h (+5 −2), BunPlugin.cpp (+220 −4), BunPlugin.h (+3 −0), BunProcess.cpp (+11 −5), ImportMetaObject.cpp (+1 −1), InternalModuleRegistry.cpp (+7 −2), InternalModuleRegistry.h (+1 −0), JSCommonJSModule.cpp (+2 −1), JSFunctionParametersSource.cpp (+39 −0), JSMockFunction.cpp (+9 −0), ViUtils.cpp (+45 −0), ZigGlobalObject.cpp (+27 −13), bindings.cpp (+23 −0), highway_strings.cpp (+187 −0)
  • src/jsc/bindings/node/: NodeTimers.cpp (+37 −2)
  • src/node_path/: lib.rs (+4 −4), tidy.rs (+80 −2)
  • src/options_types/: aot_options.rs (+36 −1)
  • src/resolver/: standalone_module_graph.rs (+49 −2)
  • src/runtime/cli/: Arguments.rs (+2 −1), compile_options.rs (+12 −0), mod.rs (+3 −2), run_compiled.rs (+401 −37), test_command.rs (+850 −73)
  • src/runtime/cli/compile_command/: build.rs (+11 −5), configuration.rs (+26 −0)
  • src/runtime/cli/test/: MemoryOfJob.rs (+7 −10), ShardsByReach.rs (+408 −0), Timings.rs (+33 −17)
  • src/runtime/cli/test/parallel/: Coordinator.rs (+62 −207), Frame.rs (+2 −2), Reservation.rs (+0 −297), parts.rs (+80 −122), runner.rs (+16 −21)
  • src/runtime/: jsc_hooks.rs (+8 −1)
  • src/runtime/node/: path.rs (+46 −17)
  • src/runtime/test_runner/: ScopeFunctions.rs (+4 −1), dom_format.rs (+1 −1), environment.rs (+8 −0), jest.bind.ts (+10 −2), jest.rs (+39 −4), test_context.rs (+3 −1), test_context_fixtures.rs (+5 −1), test_context_parameter.rs (+43 −4)
  • src/runtime/webcore/: FileSink.rs (+3 −0)
  • src/sema/: Cargo.toml (+1 −0), atom.rs (+6 −0), components.rs (+2 −1), file_analysis.rs (+27 −5), fold.rs (+14 −0), hir.rs (+117 −32), import_sites.rs (+54 −3), program.rs (+131 −68), types.rs (+2 −1)
  • src/sema/bind/: binder.rs (+1 −0), closed_module.rs (+2 −1), mod.rs (+13 −14), runtime.rs (+1 −0), static_referent.rs (+25 −1)
  • src/sema/bind/runtime/: queries.rs (+46 −2), references.rs (+12 −0), stability.rs (+4 −17), stores.rs (+31 −0)
  • src/sema/bind/source_tests/: same_tables.rs (+2 −2), static_referent.rs (+139 −3)
  • src/sema/bind/source_tests/support/: mod.rs (+1 −0)
  • src/sema/bind/static_referent/: stored_by_calls.rs (+312 −0), walk.rs (+419 −22)
  • src/sema/check/aot/: answers.rs (+8 −1), class_shape.rs (+19 −0), constructor_evidence.rs (+158 −18), dead_code.rs (+27 −10), inferred_from_use.rs (+139 −27), kept_answers.rs (+0 −3), left_out_bodies.rs (+11 −3), mod.rs (+5 −5), object_members.rs (+37 −4), program_symbols.rs (+1 −0), prototype_links.rs (+67 −7), queries.rs (+20 −6), type_relations.rs (+19 −0)
  • src/sema/check/aot/type_shape/: recursive_conditionals.rs (+45 −16), walk.rs (+1 −1)
  • src/sema/check/: class_members_by_name.rs (+21 −0), commit.rs (+97 −2), context.rs (+31 −14), decl.rs (+5 −2), errors.rs (+39 −2), errors_assign.rs (+4 −1), errors_call.rs (+13 −2), errors_declaration_emit.rs (+57 −19), errors_jsx.rs (+4 −0), errors_modules.rs (+2 −0), errors_names_and_exports.rs (+3 −1), errors_statements.rs (+6 −3), explain_relation.rs (+11 −0), expr.rs (+47 −8), flow.rs (+34 −26), infer.rs (+74 −60), instantiate.rs (+183 −15), mod.rs (+72 −1), relate.rs (+90 −4), shape.rs (+26 −2), shortcuts.rs (+1 −0), symbols.rs (+5 −2), task.rs (+4 −0), unions.rs (+10 −2)
  • src/sema/driver/: compile.rs (+43 −0), lib.rs (+78 −13)
  • src/sema/driver/compile/: check.rs (+21 −3), effects.rs (+14 −0), load.rs (+58 −17), roles.rs (+7 −2), threads.rs (+20 −0)
  • src/sema/driver/compile/tests/: first_look.rs (+94 −0), inference_order.rs (+37 −5), load.rs (+130 −19), mod.rs (+1 −0), queue.rs (+281 −1), support.rs (+2 −0)
  • src/sema/erasure/: type_ranges.rs (+47 −4)
  • src/sema/file_analysis/: analyze.rs (+10 −4), tests.rs (+1 −0)
  • src/sema/file_analysis/source_tests/: import_sites.rs (+391 −13), module_format.rs (+11 −1), type_ranges.rs (+13 −0)
  • src/sema/file_analysis/source_tests/support/: mod.rs (+4 −2)
  • src/sema/fold/: define_trie.rs (+5 −0), folder.rs (+1 −0), folder_calls.rs (+4 −1), folder_commonjs.rs (+1 −0), inputs.rs (+2 −0), run_order.rs (+24 −9), string_affixes.rs (+17 −4)
  • src/sema/import_sites/: alternatives.rs (+606 −22), call_sites.rs (+277 −29), changed_files.rs (+228 −26), made_requires.rs (+105 −10), parameters.rs (+147 −8), scan.rs (+4 −0), tests.rs (+2 −1)
  • src/sema/module_format/: decide.rs (+13 −5), tests.rs (+1 −0)
  • src/sema/program/: code_file.rs (+18 −2), code_finish.rs (+2 −0), code_graph.rs (+13 −1), code_imports.rs (+20 −5), code_load.rs (+13 −0), code_walk.rs (+300 −3), retire.rs (+121 −41)
  • src/sema/program/code_file/: tests.rs (+1 −1)
  • src/sema/program/code_file/tests/: analysis.rs (+1 −1)
  • src/sema/program/code_finish/: tests.rs (+1 −1)
  • src/sema/program/code_types/: tests.rs (+1 −1)
  • src/sema/program/code_walk/: only_imported.rs (+13 −5)
  • src/sema/program/retire/: tests.rs (+122 −0)
  • src/sema/resolve/: code.rs (+5 −2)
  • src/sema/resolve/code/: options.rs (+4 −0)
  • src/sema/resolve/code/tests/: host_defaults.rs (+1 −0)
  • src/sort_by_number/: Cargo.toml (+10 −0), lib.rs (+260 −0)
  • src/standalone_graph/: compiled_program.rs (+92 −22), executable_file.rs (+18 −15), executable_request.rs (+79 −2)
  • src/trim/: Cargo.toml (+1 −0), apply.rs (+3 −2), edges.rs (+2 −1), icu_data.rs (+2 −1), table.rs (+5 −3)
  • test/bundler/: what-an-executable-can-call.ts (+16 −1)
  • test/cli/: aot-refused-changes-in-the-test-summary.test.ts (+2 −2)
  • test/cli/check/: literals-nested-in-one-another.test.ts (+5 −0), nested-parameter.test.ts (+49 −0), references-beside-the-flow-depth-limit.test.ts (+130 −0), schema-types-limit.test.ts (+51 −0), unannotated-this.test.ts (+92 −0)
  • test/cli/check/schema-types-limit/: LICENSE (+21 −0), schema.d.ts.txt (+358 −0)
  • test/: expectations.txt (+4 −0), parallel-allowlist.json (+13 −0)
  • test/internal/: build-trimmable-config.test.ts (+10 −0), builtins-manifest.test.ts (+12 −1)
  • test/internal/trim/: link-coff-freestanding.test.ts (+23 −0), link-elf.test.ts (+48 −1), toy-program.ts (+6 −1)
  • test/js/node/path/: parts-of-paths-of-every-shape.test.ts (+110 −0)

Follow-up to #47316 for `bun build --compile --aot` and `bun test --aot`.
Goes with oven-sh/WebKit#967; WEBKIT_VERSION is pinned to its preview build until it merges.

bun test --aot:
- Tests can replace `Math`, `JSON`, `Reflect` or `process` on the global object: `global.Math = mockMath`, `globalThis.process = undefined`.
- Tests can pose as another runtime: redefining or assigning `process.versions`, `process.release`, `process.isBun`.
- `swap(Date, { now() { return 5 } })`, where a helper in another file copies the members in a `for...in` loop, no longer fails with "Cannot assign to Date.now".
- In layered runs, built-ins reached through another file stay writable: `export { getPrototypeOf } from "./tools.js"`, `export const above = x => Object.getPrototypeOf(x)`, a static field, a `Map`.
- Names that `mock.module()` adds or leaves out are seen through `export *`.
- A `mock.module()` factory that throws rejects the import the way `bun test` does, also for a module that is not on disk.

Module loading:
- `require.cache[key].exports` of a file required as a statement is its exports, not `undefined`. The entry of an ES module shows all of its exports.
- `require.cache[key].children` and `.parent` are filled for files required at the top level of a test file.
- Imports with nothing to run (`export const value = 7`) are listed in `Object.keys(require.cache)`.
- `require("./config.json")` in a CommonJS file returns the data while an ES module that imports the same file is still loading. It returned the ES namespace.
- `const name = "bun"; (await import(name)).default === Bun` answers what Bun answers.

Crashes:
- `mock.module("./below.ts", () => { throw .. })` followed by imports of two files above it aborted in builds with assertions.
- When the source text an error message quotes failed its own check, the build stopped with an internal compiler error. Now that one file loses the quote.
- `function kind(x) { switch (x) { case 3: .. } } kind([2])` aborted with "FrameCellEscaped" in builds with validation on.

Wrong answers:
- `function pick(i) { return i % 2 ? null : { a: 1 } } function read(o) { return o.a }`: `read(null)` returned 1 instead of throwing.
- ES5 classes that share a method: after `delete A.prototype.twice`, `new A(8).twice()` returned 16 instead of throwing.
- `try { null.x } catch (e) { e.constructor.extra = 41 }`: `TypeError.extra` was `undefined`.
- `Object.getPrototypeOf(crypto.subtle).extra = 41`: `SubtleCrypto.prototype.extra` was `undefined`. Same for `AbortSignal.prototype` and `Bun.S3Client.prototype`.
- `class Tags extends Set { constructor(values) { super(values) } }` calls an overridden `add`. Same for `Map`, `WeakMap`, `WeakSet`.
- `Array.from(new Map(..))` after the class's iterator was deleted treats the object as array-like.
- `Object.assign({}, source)` kept the wrong key order for a source with a hidden index property and a symbol key.

Faster:
- Classes that Babel compiled to ES5 are compiled as classes: `_createClass`, `_defineProperties`, `_classCallCheck`, `_inherits` and `_typeof`, written into the file or imported from `@babel/runtime`, Babel 7.18 through 7.24.
- `var p = Object.create(A.prototype); p.constructor = B; B.prototype = p`: 254 to 25 instructions per iteration.
- `util.inherits = function (ctor, superCtor) {..}` in another file: 712 to 19.
- `const f = table[key]; for (..) t += f(..)` checks once, before the loop, which function it got.
- `out[key] = value` that adds a property is cached per site. `holder[key] = value` checks the field's type once per kind of value.
- `for (const [key, value] of [["a", 1]])` and `const [text, count] = ["abc", n]`: each position has its own type.

Smaller:
- The names of captured variables are written once for closures that capture the same ones.
- The registers around a slow-path call are saved by one shared stub. A caller that reads no floating-point register after a call saves none.

Build time and memory:
- `let picked = s0; if (n > 1) picked = s1; ..` with 2,000 or more branches.
- A linked package reached through a computed `import()` no longer causes a second build.

Tests and CI:
- A test builds each stored program under several orders of the analysis rules, thread counts and checker passes, and fails if the generated code differs.
- The instruction counts in `test/aot/perf` are exact, with no tolerance.

Not AOT-specific:
- `bun test --isolate`: a `process.nextTick()` queued while the second test file's imports load runs after that file's body again, as before #47316.
- `bun test --parallel`: a worker waiting for its next file sleeps instead of polling.
- `bun test`: the memory reservation while tests run is removed (581 lines).
- `bun test --isolate`: a `FileSink` closes its descriptor before `close` returns instead of later on the work pool.
- `require.cache` is the function it was before the first AOT PR. The traps for compiled programs are a separate function.
- `bun check`: a hash set past 16 type parameters, the narrowing index is kept instead of dropped whole, the regular form of a literal is computed once, a discarded task explains no error.
- `path.posix.dirname`, `basename` and `extname` of Latin-1 strings use SIMD kernels.
Pins oven-sh/WebKit#967 at 43ccc98c356a5d1ff47392dde1ced58b51c5d20c.

The build

- `var holder = { other: 1 }; const kept = holder.link; holder.link = kept;
  holder.link(Child, Base)`: `bun build --compile --aot` never ended. So with
  `const original = A.make; .. A.make = original`. The walk from a name to what
  it stands for forgot the names it had visited when it went on to a stored
  member. It now remembers each store it has followed. The previous commit
  brought this.
- The exported variables of a build's module records are looked up on the
  calling thread again. The previous commit put that on threads, which gained
  nothing on a large application.
- `bun_aot` is built at opt-level "s": Bun is 475 KB smaller.

`bun test --aot`

- Product code finds only what an executable would find. With
  `exports.load = name => require(dir + name)` in `src/` or in a package, a test
  that has it load a file the build didn't see now fails as the executable
  would. Files of the tests load what they like, native addons load wherever
  the call stands, and `--aot-allow-code-from-disk` turns it off.
- `delete require.cache[require.resolve("../src/state.js")]` and then a written
  `import("./state.js")` in product code runs the module again.
- `--shard=k/n` deals the files by what they reach, so that a shard builds
  less. Every shard computes the same deal on any machine. Plain
  `bun test --shard` is unchanged.
- The parts of a run are built slowest first.
- `silence(console, levels)` and `spyAll(console, wanted)`, where a helper in
  another file stores or spies under a computed name, no longer fail with
  "Cannot assign to console.debug".
- `--files-from=/dev/stdin` failed every file in a layered run.
- One file that let go of its `module` or `require` made the run keep every
  export of every file. In express that was 838 functions and 78 files.
- `--aot-memory-limit` says what it bounds: the build, not the tests that run.

Module loading

- Followed at build time: `require(__filename.replace("main.cjs", "first.cjs"))`,
  `replaceAll`, `__filename.slice(0, -8) + "third.cjs"`,
  `import(import.meta.url.replace(/module\.mjs$/, "ninth.mjs"))`.
- `new Worker(file)` in a file loaded from disk by an executable built with
  `--aot-allow-code-from-disk` died on `globalThis.console = ..`.

Wrong answers

- `String(f)` in a `.ts` file: `() => <Foo>{}` read `() => {}` and
  `return<number>a` read `returna`.

From the review of the previous commit

- A `spyOn()` inside a template string with `${..}` for a name was missed:
  "Cannot spy on 'total'".
- The memory of a worker's children was counted in pages of 4 KB.
- A failed write into the program below was ignored. Three of our own tests
  opened that file read-only, so their writes had been failing silently.
- A preload that a test file imports back was scanned twice.
- A test that can't make its folder says skipped.

Tests

- The stage-log test used `O_DIRECT` by number, which is `O_DIRECTORY` on
  arm64. It listens on a datagram socket now.
- A helper asked `.todo` of `test.skipIf(true)`, which failed eight files on
  the ASAN lane.
- A stack-frames program cut its path at the first forward slash, which failed
  on Windows.
- Five `gc/` programs are marked todo on Windows x64.
- The thread pool's unit tests ran dozens of builds on a pool sized for one.
- The test of how a run's parts are cut asked for the order in which three
  builds end, which is a race. It asks for the cut.
- 113 new files, most of them stored programs compared with plain Bun.
Pins WebKit at 5869a3b791e68fd8e0e8059ff21e278760bcf4c6 (oven-sh/WebKit#967,
merged) in place of the preview build.

Five files conflicted with #44833 and #47070. In all five, main's lines stand
as written.

Fixes for what this branch brought

- `bun test --aot --shard=k/n`: each shard dealt the test files by, among other
  things, its own `--parallel`. Two runners started with different `--parallel`
  computed different deals. With `--parallel=1` on one and `--parallel=16` on
  the other, 21 of 40 test files ran on neither, and both runs reported
  success. Found in review of this PR. Every shard now computes the deal from
  what all of them share: the paths, the sizes, what the files reach, and the
  timings file. How many files run at once is read from the timings file
  (`"workers"`, written by a compiled `--parallel` run under
  `--update-timings`). `--parallel` has no say. Each shard prints one dim line
  with a hash of the whole deal, so a mismatch can be seen.
- 10,000 functions that call one another in a ring, each handing on what it
  got: `bun build --compile --aot` overflowed the stack under
  AddressSanitizer, and `bun test --aot` did so in release builds. The walk
  that finds which values a name may stand for recurses once per function of
  the ring. Where the stack runs low it now goes on in a thread with a fresh
  stack. 100,000 functions build.

Build time

- The checker walked, for every reference to a symbol, all call statements that
  mention it. `all.push({..})` 20,000 times in one function takes 2.6% fewer
  instructions to build. No answer of the checker changes. `bun check` shares
  the file.
- `bun_aot` is built at its own optimization level again, not at "s". The
  smaller Bun cost up to 0.85% more instructions a build.
- The front end keeps a tree that it will be asked for again instead of parsing
  the file anew. The output is identical.

The first two turn `build-scaling-6` green on Alpine x64.

Following #44833 (`bun test`: Vitest compatibility)

- An import with a query follows main's rule: `raw` among the `&` parameters is
  the file's text, `url` is the file loader.
- Which file is the entry point of a test run is asked of the runner, not
  compared with the main module's path.
- Tests that state what plain `bun test` does now state what it does on main: a
  name that a mock leaves out is `undefined`, not a SyntaxError; a factory that
  failed throws again at each load; `spyOn(namespace, "absent")` throws a
  TypeError.
- `bun test --aot` doesn't match plain `bun test` on module mocks yet. Each such
  case says so behind a `TODO(bun)` and stays a comparison with plain, so it
  goes red the day the two agree: a name that only a mock has, a name that a
  mock leaves out, the order of two link errors where a file may be mocked, the
  default of a mock that has none, a load above or behind a mock whose factory
  failed.
- `compile-trim-capabilities`: its reader of machine code took every `e8` and
  four bytes for a call, also in the middle of another instruction
  (`80 bb e8 96 00 00 01` is `cmpb $1, 0x96e8(%rbx)`). It now asks that an
  instruction begins there.
- The test that compares the functions of a test run with those of the
  executable leaves out the map's "outlined" rows: `bun:test` now brings its
  fake timers into every test run.
- Two tests of the engine's own suites are listed as failing: they count the
  entries of a table that has three more since #44833. Their fix is in the
  engine and comes with its next commit.

Tests

- `a-line-of-the-stage-log-goes-out-in-one-write` is a todo where the running
  glibc is 2.37 or newer. The engine's long log lines still go out in pieces
  there. The fix is in the engine and comes with its next commit.
The source lint for byte searches asks for it.
Binary size

- `bun_aot` is built at opt-level "s" again. The merge commit had taken that
  out, and bun-linux-x64-musl came out 516 KB larger than main's build, 4 KB
  over what the binary-size check allows. At "s" Bun is 475 KB smaller and a
  build takes 0.15 to 0.4% more instructions. `build-scaling-6` keeps its room
  from the checker fix in the merge commit: its shape takes 13,882 M
  instructions against a limit of 14,164 M.

Tests

- `heavy-test-files-run-apart` listed the keys of the timings file that a
  compiled run writes. Since the shard fix in the merge commit, a `--parallel`
  run also writes `"workers"`. The test expects the key, and that it is 2 under
  `--parallel=2`.
- `counted-idioms-2` and `-3`: since main was merged, a gzip round trip makes 2
  more property accesses by name per turn (108 against a pin of 106), and
  `Map.groupBy` takes 3 more branches (85 against 82). Typed and untyped alike,
  on every platform; a build without the merge does not. The cause is not known
  yet. The pins stay where they are. Each of the two rows carries an `"owed"`
  mark with the count it is held to: the test fails if the count rises further,
  and it fails once the pin is met again, so the mark cannot be forgotten.
- ``o.nothing(String.raw`a\nb字`)``: since #44833 the TypeError of plain Bun
  quotes its own lowering of a tagged template that holds letters outside ASCII
  (`String.raw(__rawTemplate_..`). Compiled code quotes the source. The two
  lines move out of the test of error texts into a program of their own, marked
  as a todo.
- `what-require-answered-behind-a-mock-of-a-built-in-module-..` states what
  plain `bun test` does on main: where nothing is loaded when the mock is made,
  `require()` returns the object that the factory made, or its `default` where
  the object says `__esModule`.
…4833's mocks

Pins WebKit to autobuild-preview-pr-971-cc1ea906 (oven-sh/WebKit#971). That pull request
has the engine's half of what is listed here and its own list of miscompilations and
crashes; their stored programs and tests come with this commit.

Hangs and failed builds:

- esbuild's `transformSync()` and `buildSync()` never returned in a compiled program.
  `var threads; try { threads = require("worker_threads") } catch {}`, then
  `var start = given => new given.Worker(__filename, ..)` and `start(threads)`: the
  search for the files that Workers start from knew the module behind an import, a
  direct `require()` and a `const`, not behind a variable that is assigned or a
  parameter. The Worker could not be loaded, and the caller slept in `Atomics.wait()`.
- A package that says `new Worker(__filename)` got `this executable can't load a module
  from a dynamic path`, in an executable and in a test run built as one program.
- `mock.module("node:os", ..)` in a.test.js, `await import("node:os")` in b.test.js: the
  promise never settled and the test timed out. Since #44833 plain `bun test` takes a
  file's mocks away when the file ends, and nobody told the engine.
- `internal compiler error (LinkAndEmit): UndefinedLayout`, in two ways. `child.ts` has
  `import "unbuilt"`, which can't be resolved, and `export class Child extends Base`, and
  another file says `await import("./child")`: in a test run no test file ran. Or a package
  with `"sideEffects": false` has `export class Unused extends Base {}` beside
  `export const used = 1`, and main.ts imports `used` and, with `import type`, `Unused`.
  The layout of a class whose statement is not compiled named a parent that nothing defines.
- A build that never ended in the checker: a conditional type such as
  `T extends infer R ? R : never` was weighed by how it is written, not by what `T`
  stands for.
- `import.meta.glob()` was not expanded under `--aot`. It is now, by Bun's own parser:
  lazy, eager, `import: "name"`, `?raw`, `Object.keys()` of it, in a CommonJS file.

Wrong answers of compiled programs:

- `bottom[name]`, with `class Top { [name]() {} }`, `class Middle extends Top` and
  `class Bottom extends Middle`, had its type if `Bottom` was asked about first and no
  type if `Middle` was. Which came first went by the order of the files.
- `Object.getOwnPropertyDescriptor(require("node:_http_agent"), "globalAgent")` showed a
  getter and a setter. It is a value made at load, as in plain Bun.
- `export { default as t } from "./outside.txt" with { type: "toml" }`, for a file that
  is not in the program, lost its type, while `import` kept it.
- In a `.mjs` or `.mts` file `this` at the top is `undefined`. The name alone decides: a
  `.cjs` file in a package with `"type": "module"` still exports through `this`.
- A stack trace taken while `then` is read by `await thenable` listed the async function,
  which has suspended by then. A class whose decorated members need a constructor was
  shown as `new Task (unknown:1:11)`, not at its `class`.

`bun test --aot`:

- `vi.mock()` and `jest.mock()` are hoisted above the imports, as #44833 does for plain
  `bun test`. With `import { captured } from "./side"; vi.mock("./mod", ..)`, where
  side.ts imports mod.ts, mod.ts ran and `captured` was the real value.
- `require()` of a mocked module answers the object that the factory gave, for `node:`
  names too.
- `mock.module("node:dns", f1); await import("node:dns"); mock.module("node:dns", f2);
  await import("node:dns")` answered the first mock again where a run is built in layers.
  Five mocks in a row gave 1, 1, 3, 4, 5.
- A compiled run of one empty test held 166 functions of the test runner's own code. It
  holds 2 again. `process.mainModule` follows the test file.
- A file of the project that writes on a built-in (`Buffer.isEncoding = ..`) now throws in
  the test run as it does in the executable. A green run said nothing about that line.
- A file that only Workers load has no `expect` or `test`, as in plain Bun.
- `// require("./x.cjs")` in a comment no longer decides whether x.cjs runs as CommonJS.
- A test that writes state.js and imports it afterwards gets the new text in more forms:
  a path from `process.cwd()`, and a helper in another file that takes the path or joins
  a name to its own folder.
- A type imported from a module in a layer below no longer costs the importing file its
  types.
- `--shard` with `--timings` spreads the files that are held apart for their memory over
  the shards, and the walk ahead of the deal asks for each name of a folder once.

Build time and memory:

- What a file uses of the library's functions was kept in four sets of 131,131 bits, each
  cleared and walked for every file. They are two sorted lists now. The two blocks of a
  module that is set aside are compressed in one go, not one by one.
- `class C1 extends C0 { m1() {} }`, 800 in a row: every inherited method was compared
  with itself in the class above, 380 of 540 MB.
- A build that is not a test run walked every expression of every file for writes on
  built-ins and threw the result away.

Speed:

- A sloppy function that only reads `arguments` and has a closure over a parameter
  (Babel's `_objectSpread`) no longer makes the arguments object at every call.
- `Array.from(map)` and `Array.from(map.entries())`: 672 instructions an entry, now 132.
- 65 host functions have a signature that says what they answer. Without one, a call
  counted as making an instance of every host class.

Tests:

- `heap-limit`: each of the eleven cases that use up the address space runs twenty times
  at each limit, and one run of the twenty may end in a `malloc` that is not allowed to
  fail. Plain Bun dies there every time.
- The stored programs that print what the collector lets go are each built alone. What an
  executable with many entry points copies into what goes by everything it holds, so two
  of them failed in CI by the company they were dealt. Both are stored with that company.
- The test of how a run is cut to fit its memory is skipped under sanitizers, like its
  eleven siblings.
- A stepped idiom fails when it reads more than 2% and more than 2 instructions over its
  pin. It was held exactly. Two pins that had been raised go back to where they were.
- A stepped row can be marked `"todo"`: it is held to a higher number, and fails once its
  pins are met again. Two `EventEmitter.emit` idioms carry it, and `"owed"` in the counted
  pins: the fix for the compiler's abort costs them five times their instructions until
  the narrower fix is in the engine.
- Two rows of the flow-schedule test are restated to what they measure.
- Six stored programs, `pass-conditions/carried-word-*`, each built alone: a loop adds to
  `holder.n`, stores to `other.n` once, and under `holder instanceof Other` calls a
  function, a method or a setter that writes `n` too, with `holder` and `other` one
  object. B3's new pass promoteLoopMemory lost the store. The engine has the pass
  switched off for now, and these guard its return.
- Three more counted idioms are marked `"owed"`. Their pins stay where they are.
@Jarred-Sumner
Jarred-Sumner requested a review from alii as a code owner November 1, 2026 04:02
@robobun

robobun commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 7:26 PM PT - Nov 1st, 2026

❌ @Jarred-Sumner, your commit 22621ef has 2 failures in Build #136666 (All Failures):

  • 📦 Binary size — 2 over 0.50 MB
  • targetthis build canary: main #136408
    sizeΔ
    bun-darwin-aarch64118.32 MB117.96 MB+376.9 KB
    bun-darwin-x64128.42 MB128.23 MB+197.1 KB
    bun-linux-aarch64135.25 MB135.56 MB-320.0 KB
    bun-linux-x64134.37 MB134.17 MB+212.0 KB
    bun-linux-aarch64-musl120.65 MB120.65 MB+0.1 KB
    bun-linux-x64-musl122.91 MB122.55 MB+368.1 KB
    ❌ bun-linux-aarch64-android127.24 MB126.68 MB+576.2 KB
    bun-linux-x64-android134.29 MB133.81 MB+488.1 KB
    bun-freebsd-x64130.65 MB130.32 MB+340.1 KB
    bun-freebsd-aarch64133.41 MB133.54 MB-131.9 KB
    bun-windows-x64143.00 MB142.93 MB+64.0 KB
    ❌ bun-windows-aarch64128.21 MB127.60 MB+625.5 KB

    Add [skip size check] to the commit message if this increase is intentional.

  • test/aot/what-this-lane-can.test.ts - code 1 on 🐧 13 x64-asan

🧪   To try this PR locally:

bunx bun-pr 47605

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

bun-47605 --bun

Comment thread scripts/build/deps/webkit.ts Outdated
* From https://github.com/oven-sh/WebKit releases.
*/
export const WEBKIT_VERSION = "4d45755fed4aeef19e95beb90d3d0c884a2812bd";
export const WEBKIT_VERSION = "autobuild-preview-pr-971-cc1ea906";

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.

🔴 Anyone building Bun from this branch gets a hard 404 on the WebKit download once the oven-sh/WebKit PR behind it merges or closes. WEBKIT_VERSION at scripts/build/deps/webkit.ts:6 is pinned to the preview tag "autobuild-preview-pr-971-cc1ea906", and scripts/build/download.ts:320 documents that GitHub deletes such preview releases at that point. Fix: before merge, point WEBKIT_VERSION at the merged main sha's autobuild- release (the repo's own upgrade-webkit procedure says this bump happens before the bun PR merges), so every autobuild-preview-pr-* pin is gone from the tree.

Why this was flagged

The base pins WEBKIT_VERSION to the commit sha 4d45755fed4aeef19e95beb90d3d0c884a2812bd, a permanent release. The diff changes scripts/build/deps/webkit.ts:6 to "autobuild-preview-pr-971-cc1ea906". scripts/build/download.ts:318-337 states that autobuild-preview-pr-* releases only exist while the WebKit PR is open and that GitHub deletes the release when it merges or closes, so every build then 404s and raises the BuildError "WebKit preview release is gone". .claude/commands/upgrade-webkit.md:34 says the pin must be bumped to the merge commit's autobuild- before the bun PR merges. Nothing else in the diff does that bump, so main would carry a pin that the next WebKit merge invalidates for all developers and CI at once.

Verification: normal — triggers once the oven-sh/WebKit PR #971 behind the preview tag merges or closes, after which every prebuilt-WebKit build 404s. The diff changes scripts/build/deps/webkit.ts:6 from the permanent sha pin (base) to "autobuild-preview-pr-971-cc1ea906". scripts/build/download.ts:318-337 documents that GitHub deletes the preview release when the PR merges or closes.

Comment thread src/runtime/cli/test/Timings.rs Resolved
Comment thread src/aot/load/named_loads.rs Resolved

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

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

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/jsc/bindings/RemovedFeature.cpp — nit: a mimalloc idle hook for compiled programs is placed in the removed-feature file. Bun__didFinishFirstCollectionOfCompiledProgram at src/jsc/bindings/RemovedFeature.cpp:168 has nothing to do with removable features; move it beside the other Bun__AOT__ runtime hooks (src/jsc/bindings/BunAOTRuntime.cpp) so the next reader finds it.

    Why this was flagged

    The diff adds extern declarations of mi_on_thread_idle_start/mi_on_thread_idle_end and a Bun__didFinishFirstCollectionOfCompiledProgram function to src/jsc/bindings/RemovedFeature.cpp:762-769, a file whose other contents describe removed features. The repository review guidance asks that new code be placed in the module that owns the feature. No runtime consequence; maintainability only.

    Verification: nit. Trigger: any reader/maintainer of the removed-feature bindings. The diff adds Bun__didFinishFirstCollectionOfCompiledProgram at src/jsc/bindings/RemovedFeature.cpp:165-172, a mimalloc idle-sweep hook; it uses nothing from the file. src/jsc/bindings/BunAOTRuntime.cpp holds the other Bun__AOT__* runtime hooks. No runtime consequence, maintainability/placement only.

  • 🟡 test/aot/programs-with-the-same-code-under-nine-schedules.txt:2571 — nit: a maintainer running the same-code tool over the full list gets a "not built" report for a fixture this PR deletes. The new list test/aot/programs-with-the-same-code-under-nine-schedules.txt:2571 names library-modules/global-agent-of-the-module-is-an-accessor.cjs, which the diff removes from test/aot/parallel/. Fix: drop the stale line (and any other deleted fixture) from the list so every listed program exists.

    Why this was flagged

    The diff deletes test/aot/parallel/library-modules/global-agent-of-the-module-is-an-accessor.cjs and adds test/aot/programs-with-the-same-code-under-nine-schedules.txt, whose line 2571 still lists that path. test/aot/tools/same-code-under-every-schedule.ts:223 pushes a program into notBuilt when its first build yields nothing, and test/aot/the-code-of-a-program-does-not-follow-the-schedule-of-flow-rules.test.ts:48 expects notBuilt to be []. Today the test samples every 202nd entry (index 2570 is not sampled), so the test passes, but a run of the tool with --from, or any edit that shifts the sample, reports the deleted fixture as "it does not build". On the base branch the list does not exist, so there is no stale entry.

    Verification: nit — triggers when a maintainer runs the tool with --from test/aot/programs-with-the-same-code-under-nine-schedules.txt. Line 2571 reads library-modules/global-agent-of-the-module-is-an-accessor.cjs, which this PR deletes from test/aot/parallel/. test/aot/tools/same-code-under-every-schedule.ts:266 statSync(path) throws ENOENT, so the whole run aborts before any build.

…ntry

- `everyday-microtasks-and-promises/async-resource-bind` is marked `"owed"`: it reads
  14,000 branches in a thousand turns against a pin of 13,000, like the two idioms marked
  in the previous commit. The pin stays where it is.
- `everyday-downleveled/async.awaiter-helper`, untyped: `allCells` 19,164 to 21,550 and
  `allBytes` 935,680 to 1,005,376. The previous commit introduced both pins with what
  Linux x64 reads. arm64 allocates more, and the file holds the larger of the two.
- `programs-with-the-same-code-under-nine-schedules.txt` listed
  `library-modules/global-agent-of-the-module-is-an-accessor.cjs`, which does not exist.
- `small-programs-stay-closed.test.ts`: one program moves from the programs that stay
  closed to the programs that open at one call. `const withDefaults = options => ({
  verbose: false, ...options })` is called with the `bun` module and with the program's
  own `{ build }`. A literal made by one function is one object for all its callers, so
  the `build` called later may be `Bun.build`. The test says where it opens: `Bun.build`,
  line 6.

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

Code review found no new issues

No new issues were found in this update; 3 findings from earlier reviews are still open above.

Still open from earlier reviews (3):

  • 🔴 scripts/build/deps/webkit.ts:6 — Anyone building Bun from this branch gets a hard 404 on the WebKit download once the oven-sh/WebKit PR behind it merges…
  • 🔴 src/aot/load/named_loads.rs:257 — Users who run bun test --aot on a test file that bun test accepts without a .test/.spec suffix, or whose suffix is not…
  • 🔴 src/runtime/cli/test/Timings.rs:316 — Teams sharding a compiled test run lose the "workers" count from the timings table after any plain bun test --update-ti…

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

Jarred-Sumner added a commit to oven-sh/WebKit that referenced this pull request Nov 1, 2026
Under `bun test --aot`, two test files each mock the same ES module before it loads
(`vi.mock("./dep.js", ..)` hoisted, or `mock.module()` in a test ahead of the `import()`). A third
file, which mocks nothing, read the second file's mock, by name and through the namespace object,
and dep.js never ran. Plain `bun test` runs dep.js and reads the real values.

When the first mock ended, its module still counted as having begun to load, because that goes by
the group it was loaded with, which is never taken back. So the second mock's factory didn't wait for
the first import. It ran at once, and its values were laid by name over a module whose body had
never run.

Modules::hasBegunToLoad() now answers no for such a module. The bit it reads is set only where a
mock that stood in place of a module ends, so an executable never gets here. The test comes with
oven-sh/bun#47605: test/aot/mock-second-file.test.ts.
Jarred-Sumner added a commit to oven-sh/WebKit that referenced this pull request Nov 1, 2026
…s, a bounds test on split() (#972)

* AOT: miscompilations and crashes found since #967, builds that never ended, faster loops

Wrong answers of compiled programs:

- A list of numbers with one `undefined` in it, iterated by two loops. The first adds
  the numbers up, the second counts the `undefined`s: it counted 0 instead of 1.
- In a generator, a value read before a `yield*` and used after it was wiped when a
  parameter that held an object was set to `null` or `undefined` in the same loop: 12
  instead of 22. A second program never ended.
- A `RegExp` made from text out of a list was compiled from the text the build had
  predicted, even when the list was read under two names in one step and then changed.
- `main.mjs` imports `m.mjs`, which imports `x.cjs`, which calls `require()` on `m.mjs`
  under a name made at run time: `main.mjs` ran after `x.cjs` had ended, not before the
  `require()` returned.
- An `import()` made while other `import()` calls were waiting, and whose graph shares a
  module with one of them, was held back behind all of them.

Crashes of compiled programs:

- A segfault in eleven lines: a loop reads and stores a static field that holds an
  object, after the field was deleted while a Proxy was on the class's prototype chain
  and was defined again through the trap.
- A segfault at address 0 after an out-of-memory error that the program catches: a rope
  of 2^30 two-byte characters that cannot be flattened, ahead of a short loop in a `try`
  with `catch` and `finally`.

Builds that failed or never ended:

- Thirteen lines did not build in two minutes: two nested loops of eight iterations, a
  swap in a `try`, an xor in its `finally`. B3's `Value::returnsBool()` recursed into both
  operands without remembering what it had asked, so its cost was exponential.
- One function spent 214 of 246 seconds in type propagation: its queue was last in,
  first out.
- The compiler aborted on `for (;;) { if (source.take === never) return ..; const got =
  source.take(..); .. }`. A loop that reads the same property of an unchanging object on
  every iteration gets a version that asks ahead of the loop, and a type test on that read
  was turned into a cast of the copy. Where both sides of the test's branch leave the
  loop, the branch still hands on what the test proved, so its condition has to stay a
  test.
- Fourteen lines stopped the build with `InvalidGuard`: a read of a typed array at the
  top of an iteration relied on the bounds check at the end of the previous one, and the
  loop's counter was then replaced by a constant.
- Quadratic time in large functions: three places in Air's register and stack
  allocators, two in the ahead-of-time compiler's own passes (one is flow analysis), and the
  lookup of a regular expression's symbols (arm64: twice as many alternatives cost 9.5
  times as much, now 1.74 times).

`bun test --aot`, following oven-sh/bun#44833:

- An `import` that reaches a mock whose factory threw gets the error that the loader
  kept. `require()` of a mocked module returns what the factory returned. A later mock
  of a CommonJS file doesn't change what an earlier `require()` returned.
- The engine's own tests no longer depend on how many built-ins Bun has.

Speed:

- New B3 pass, promoteLoopMemory: `a[k] += v[j]` and `o.total += x` reloaded on every
  iteration what the previous one had stored. The word is read once ahead of the loop
  and carried in a Phi. The stores stay where they are. Off by default; only the ahead-of-time
  compiler's policy turns it on.
- `map.has(k)` followed by `map.get(k)` searches once. Keys that are objects are searched
  inline in loops.
- A generator function made for each call: 2,585 to 175 instructions. tslib's
  `__awaiter`: 3,876 to 1,441 per iteration.
- Helpers that Babel and TypeScript emit: `Reflect.construct` behind tests, a built-in
  bound and called in one function, descriptors read and defined again, `for..in` copies.
- Remainders and quotients of whole numbers stay integers. A loop polls with one
  faulting load on arm64, macOS x64 and Windows arm64 too.

* AOT: a library function that a test run leaves out is refused, not aborted on

`import { test, vi } from "vitest"; test("t", () => { vi.useFakeTimers(); })` under
`bun test --aot` ended in `abort()`. The code that throws "was not compiled into this
executable" looked the function up in the table of built-ins to ask whether it is an
accessor's getter, and a function that the table leaves out has no record there. It asks
`contains()` first now, so the error is thrown.

* AOT: a member copied off `require("bun")` under a computed key is followed to its call

`bun = require("bun"); copy = {}; copy[key] = bun[key]; copy.plugin(options)`: the build
now sees that `copy.plugin` may be `Bun.plugin`. The test, `plugin-copy-late-mark.ir`,
comes with Bun's half.

* AOT: two segfaults: a store through a caught error's `constructor.prototype`, and to a method found on `Bun.s3`

`TypeError.prototype.list = [1, 2]`, then `catch (e) { put(e.constructor.prototype, { length: 3 }) }`
with `put(where, what) { where.list = what }`, then `TypeError.prototype.list.length` read in
a function: the read took the list for what the build had seen stored by name, and the
program ended in a segfault. The same with `Bun.S3Client.prototype.file.list = [1, 2];
Bun.s3.file.list = { length: 3 }`. Where no list was involved, the read answered
`undefined`. Both stores are now seen by a read that names the object.

* AOT: promoteLoopMemory is not switched on yet

The pass and its tests stay in B3. The ahead-of-time compiler's policy no longer turns
it on.

What broke, in a loop:

    holder.n = (holder.n + 1) | 0;
    if (j === when) other.n = 50;
    if (holder instanceof Other) bump(holder);

with `function bump(o: Other) { o.n = (o.n + o.k) | 0 }`. Where `holder` and `other` are
one object, a `Plain`, the store of 50 was lost: the function answered 7:29 instead of
55:317. The copy of `bump` in the loop never runs for that object, but its access named a
narrower heap for the word, and the pass let that one heap decide that nobody else
writes it.

* AOT: fix crashes and miscompilations found since #971, free Map/Set index slots, turn promoteLoopMemory back on

Stacked on #971. The stored programs and tests for everything listed here come with oven-sh/bun#47605.

Crashes and failed builds:

- `TypeError.list = [1, 2]; JSON.parse("0", () => Reflect.set)(e.constructor, "list", { length: 3 })`
  with `e` a caught error, then `TypeError.list.length` in a function: the compiled program
  segfaulted. The same happened with a hidden `Object.assign` or `Object.defineProperty`, and when
  the writer was called as a member of a hidden object. A write through a value the compiler can't
  identify is now also applied to built-in objects, even when flow analysis sees no path to them.
- `Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key))` with a
  `key` object whose `toString()` returns "a" and then "b": plain prints 3, compiled code
  segfaulted. With methods the wrong one ran; with getters it threw "'a' was left out of the
  executable".
- Every `--aot` executable aborted before its first statement on a kernel with 64 KB pages
  (RHEL 8 aarch64, Oracle's UEK, the 64k kernels for NVIDIA Grace).
- The compiler aborted with "Stopped while compiling run in ConvertCountersToIntegers" on
  `for (..) { if (j === w) other.n = j; holder.n = (holder.n + 1) | 0; if (holder instanceof Q) bump(holder); }`.
- `vi.doUnmock("./k.js")` while a plugin's async `onLoad` is still loading another import
  segfaulted under `bun test --aot`.
- `bun test --aot` aborted under `validateExceptionChecks`, which CI's ASAN lane sets, when a mock was
  installed in a layered run:
  `vi.doMock("./m08.js", async () => { await sleep(80); return { value: "mocked" }; }); import("./m08.js")`.

Miscompilations:

- The code that creates, fills and stores a module variable's value is deferred until the variable
  is first read. An assignment to a plain object or function created by that initializer was
  deferred with it. But a program may call `Object.freeze(Object.prototype)` or
  `Object.defineProperty(Object.prototype, "toString", { writable: false })`, and after that an
  assignment to an inherited name fails: a TypeError in strict code, silently dropped in sloppy
  code. Imports run first and the main module hardens afterwards, so plain did the store before the
  freeze and compiled code did it after. main has this bug too.
- A loop's stores to a variable were delayed to the loop's exit edges, even across an operation
  that throws only when memory runs out (`text.charCodeAt()` on a rope). That error can be caught,
  and then the loop is left through none of those edges.
- A Map or Set lookup with the same key on every iteration was hoisted out of the loop, even when a
  `try` covered the lookup but not the loop's preheader, and even when the loop might not run:
  `for (let i = 0; i < 0; i++) map.has(rope)` threw a RangeError.
- A loop that does `const list = []`, pushes, reads `this.state.type`, reads `list[0]` and stores the
  list into a node at the end of the iteration: a value read back out of an open object was taken
  for this iteration's list. Also with the push on one arm of an `if`.
- `seen.push([a])` in one loop and `for (const kept of seen) show(kept)` in the next: the inner
  list was attributed to `show()`.
- `const o = Object.create(Blob.prototype, {}); o.constructor = { get name() { return "Stored" } }; o.text()`:
  plain throws ERR_INVALID_THIS ".. received an instance of Stored"; compiled code threw "'name' was
  left out of the executable". The same for `Buffer.concat(v)` when `v` is an object that isn't an array.
- `const { module } = await WebAssembly.instantiate(..)`: the compiler didn't know what the promise resolves with.

Modules:

- `for (3 times) try { require("./t.cjs") } catch {}` where t.cjs throws: the body ran once and the
  first error was thrown three times. It now runs again each time, as in node and `bun run`.
- `Object.isExtensible(require("./e3.mjs"))` after an import of the same file: plain true, compiled false.
- `require("./m3.mjs"); Object.keys(require("./d.json"))` where m3.mjs imports d.json: plain `a,default`, compiled `a`.
- Loading a file that only a plugin can provide, under a name computed at run time.
- A test registers `Bun.plugin` with an `onLoad` that matches slow.js and then does `await import("./user.js")`,
  where user.js imports k.js and slow.js: "Cannot access 'x' before initialization" for k.js's export.

Quadratic behavior:

- `map.delete(k); map.set(k, v)` took a new slot in the table's index each time and never freed the
  old one, so each iteration cost the size of the table. A deletion now frees the slot when no
  search can miss it. This costs 5 more instructions on a set and on a delete.
- `class Below extends mix(Base)`: the whole caller was walked for every such call. At 3,200 classes
  that step went from 8,306.6 M instructions to 119.7 M; the generated code is byte-identical.
- Two more build steps that grew with the square of the program: choosing representations
  around a web of values, and finding who reads a parameter that is only passed on.

Speed:

- The AOT policy turns promoteLoopMemory on again, with the fix for the aliased store that got it
  turned off in #971. What guards it: 14 stored programs that each gave a wrong answer with an
  earlier version of the pass, a fuzzer family written for it, 8,815 programs with no differing
  result, and a review by someone who didn't write it and tried to break it: 0 of 7,728 programs wrong.
- The low word of a sum that was built from doubles is computed as a 32-bit sum.

Outside aot/:

13 files. The JIT compiles b3/ too; the changes below run there only where noted.

- b3/B3PromoteLoopMemory.cpp and its testb3 cases: the pass. It runs only when
  `loops.promotesMemory` is set: false by default, set only by the AOT policy.
- b3/B3LikelyPaths.{h,cpp}: how the nearest common dominator over likely paths is found. Used by
  hoistLoopInvariantValues() under `loops.hoistsOnlyFromLikelyPaths`: false by default, set only by the AOT policy.
- b3/B3ReduceStrength.cpp and testb3_bun_reductions.cpp: `Trunc(Op(a, b))` becomes
  `Op(Trunc(a), Trunc(b))` when this walk built the operation from doubles. Under
  `narrowsIntegers`: false by default, set only by the AOT policy.
- b3/B3LoopRangeChecks.cpp, b3/B3Policy.h: a switch that lets a test sabotage the compiler
  (`omitsLimitOfClampedBound`, false by default) and a client callback (null by default).
  Loops are versioned only under `loops.versions`: set only by the AOT policy.
- b3/B3CompilationReport.h: 16 more counters in a Procedure's report. That is 64 more bytes per
  Procedure, in the JIT too; nothing there increments them.
- heap/HeapSubspaceTypes.h: an #include under the existing `#if ENABLE(AOT)`, for the
  generators' own subspace.
- llint/LLIntEntrypoint.cpp, runtime/VM.cpp: inside `#if ENABLE(AOT)`: code that runs in the
  interpreter is also counted in a process without an image, under an option that tests set.
- runtime/ExceptionHelpers.cpp: inside `#if ENABLE(AOT)`: a clearer error for a global function
  declaration that reuses the name of a global that compiled code treats as a constant.

* AOT: a bounds test on the result of split() or splice() compared against the wrong length

    function f(h: string | null): string[] {
      if (h == null) return [];
      const accepted: { type: string; count: number }[] = [];
      for (const part of h.split(",")) {
        const [type, ...params] = part.split(";").map(v => v.trim());
        accepted.push({ type, count: params.length });
      }
      return accepted.map(e => e.type);
    }

Built with --aot-validate, f("a/b") and f("c/d, e/f") returned [] instead of ["a/b"] and
["c/d", "e/f"]: the whole for-of loop was removed. No error, exit code 0. main has this bug too.

When only the first few elements and the length of an array returned by split, match, exec or splice
are used, allocation sinking never builds the array. It becomes a virtual object whose shape counts
only the elements that are kept separately. evaluateBoundsTest() compared an index against that
count as if it were the array's length. Nothing in this function reads a constant index, so the
count was 0, every index was "out of bounds", the edge into the loop body was dead, and the body
held the only use that would have asked for the real array.

For such an array, an index past the shape's count is now unknown instead of false.

The wrong answer has only been seen under --aot-validate, where a type check keeps the array alive.
At default flags, iterating over split()'s result becomes a loop over indexOf and substring and no
array is made. But the rule itself was reached at default flags too, so a wrong answer there can't
be ruled out for earlier builds.

The same shape with splice() was wrong as well. The generated code of ten servers and the
TypeScript compiler benchmark is unchanged at default flags. The tests come with oven-sh/bun#47605:
test/aot/parallel/strings/split-list-walked.ts and test/aot/parallel/arrays/spliced-list-walked.ts.

* AOT: include what AOTValueFlowSolveTestCommands.cpp uses

The file uses StringPrintStream and makeString() without including their headers. It compiled only
where the unified build put it next to a file that does. The -aot lanes failed with "unknown type
name 'StringPrintStream'" and "use of undeclared identifier 'makeString'".

* AOT: don't name a variable `small` in AOTValueFlowCoreTests.cpp

The Windows SDK's rpcndr.h has `#define small char`, so `ValueSets small(1u << 10);` didn't compile
on the Windows -aot lanes ("cannot combine with previous 'type-name' declaration specifier").

* AOT: a module whose mock ended before its body ran has not begun to load

Under `bun test --aot`, two test files each mock the same ES module before it loads
(`vi.mock("./dep.js", ..)` hoisted, or `mock.module()` in a test ahead of the `import()`). A third
file, which mocks nothing, read the second file's mock, by name and through the namespace object,
and dep.js never ran. Plain `bun test` runs dep.js and reads the real values.

When the first mock ended, its module still counted as having begun to load, because that goes by
the group it was loaded with, which is never taken back. So the second mock's factory didn't wait for
the first import. It ran at once, and its values were laid by name over a module whose body had
never run.

Modules::hasBegunToLoad() now answers no for such a module. The bit it reads is set only where a
mock that stood in place of a module ends, so an executable never gets here. The test comes with
oven-sh/bun#47605: test/aot/mock-second-file.test.ts.

* AOT: check for an exception after a mock in place is taken back

AOT::didEndMock() had no throw scope, and went on after setMockInPlace(null), which can throw. Under
`validateExceptionChecks`, which CI's ASAN lane sets, `bun test --aot` aborted in two test files:
"setMockInPlace @ AOTModules.cpp .. unchecked as of captureExports @ AOTModuleLinking.cpp".

    // a.test.js
    vi.mock("./dep.js", () => ({ value: "mock" }));
    // the mock ends with its test file; b.test.js then imports ./dep.js

* AOT: build the same program at one thread as at many

The `typescript` package built at `--aot-threads=1` and at 4 gave two different executables from the
same front-end output. One line in one function of 16,981 differed: for one property read, the
predicted receivers were empty at one thread and listed at two or more. No proof differed, and 4, 4
again and 16 threads agreed, so it was never run to run.

findOnAllThreadsAndDo(), in the pass that computes predictions after the solver's rounds, was the only
place in the flow analysis with a test for exactly one thread. With one worker each item of a wave
ran in place and saw what the items before it had done. With more, all steps of a batch are
computed from the state at its start and replayed in order. Both end at a fixpoint, but the pass has
caps, so not always at the same one.

One thread now takes the same path as many. Batch and share sizes don't depend on the thread count.
At two or more threads nothing changes. Six thread counts from 1 to 32, eight times each: one hash,
the one that 4 and 16 threads gave before. test/aot/same-program-however-the-threads-fall.test.ts in
oven-sh/bun covers it.

* AOT: a read copied for another kind of array storage kept its proof across a store through a second name

    function clean(src, dst, n) {
      for (let i = 0; i < n; i++) {
        if (src[i] < 0) dst[i] = NaN; // a bad sample
        else dst[i] = src[i] * 2;
      }
    }
    clean("1,2,3,4,5,6".split(","), [], 6);
    const samples = [1.5, -1.5, 2.5, 3.5, 4.5, 6.5];
    clean(samples, samples, 6);
    console.log(samples.join(" "));   // 3 NaN 5.5 7 9 13, should be 3 NaN 5 7 9 13

Silent, at default flags, in plain JavaScript and in TypeScript. main has this bug.

PlanArrayCapacities copies the reads behind a test of an array's kind of storage for the other kind,
as far as the test's proof goes. addReadsOfOtherStorage() copied the stores between those reads with
them and never asked whether the copy's proof outlives such a store. A store through another operand
that may be the same array ends it.

The guards are now asked again behind each copy, and a copy that may lose its proof is taken back.
New: --aot-sabotage=CopiedReadsKeepTheirProof, the statistics ReadsCopiedForOtherStorage and
CopiesOfReadsTakenBack, two IR tests. The 30 stored programs come with oven-sh/bun#47605.

* AOT: SplitBehindEntryChecks kept value ids across a retyping that renumbers the values

    function outer(p: any, parent: number | null): string {
      const head = parent === null ? "" : "<a " + parent + ">" + "<b " + parent + ">";
      return head + (p as Kind).name;
    }
    // called hot as outer(given, null)

An assertion build of the compiler aborted on `id.index() < values.size()`. A release build read
outside the function's table of values. No release build has been seen to give other code.

typeValuesFromNever() may cut dead blocks, and then it compacts the function and gives every value a
new id. sayWhatOperandsOfFormerChecksHaveProven() went on with the ids it had taken before. The
retyping is now handed a Renumbering: the checks' ids are mapped, and those cut with the dead code
are dropped. The bound at the head of that function is a RELEASE_ASSERT.

* AOT: the second import() of a CommonJS file whose mock factory throws asks the factory again

    mock.module("./lib.cjs", () => { throw new Error("boom " + ++n); });
    // import(), require(), import(), require(), each caught

Under `bun test --aot` as one program the errors read boom 1, 2, 1, 3. Plain `bun test` has 1, 2, 3, 4.

An import() of a CommonJS file begins at the file's ES face. The walk then comes to the file itself,
which isn't the root, so the throw counted as "behind an import statement" and the error was kept on
the file for the loader. The file behind the root's face now counts as the root.

* AOT: a mock factory that takes importOriginal and fails is asked again at the next import

In plain `bun test` a factory that takes `importOriginal`, sync or async, is laid over the original.
Where it fails the loader keeps no record: the next import asks it again, and the module that imports
the mocked one then loads. A factory without the parameter keeps its error. Compiled code kept the
error for both kinds.

deferMock() is now told which kind the mock is, and runMocksBelow() keeps no error for the first kind.
Where such a factory's promise rejects a turn later, the mock waits for the next load again instead of
being taken away. The test comes with oven-sh/bun#47605: test/aot/mock-factory-retry.test.ts.

* AOT: vi.doUnmock() in a test, behind a hoisted vi.mock() of a module that never ran

    vi.mock("./dep.js", () => ({ value: "mock", fn() { return "mock-fn"; } }));
    import { value, fn } from "./dep.js";
    test("t", () => { vi.doUnmock("./dep.js"); fn(); value; });

Under `bun test --aot` the imported function answered `real-fn` silently, and consts, `ns.value` and
`Object.keys(ns)` threw "before initialization". In plain `bun test` whoever was linked keeps the mock.

An ended mock that stood in place of a module is now let go when a load next comes to that module.
At the end of the test file, while a graph is being loaded, and for .cjs and library modules it is let
go at once, as before. So it is where a module that a require() has already answered imports the mocked
module: a compiled require() of a module that has run asks nobody, so no load would ever come by.

* AOT: `export * from "<a CommonJS package>"` in a mocked module, in layered test runs

    // lib.mjs
    export * from "driver"; export let v = 1;
    // a.test.js
    mock.module("./lib.mjs", () => ({ v: "mock v" })); await import("./lib.mjs");

In a layered `bun test --aot` run (`--parallel=2 --aot-memory-limit=1KB` is enough) this failed with
`TypeError: The module ".../driver/index.js" is not loaded`. The same error came in a second test file
that only imports a user of a module which the first file had mocked. A regression of this branch.

Three places went behind the `export *` of a mocked module to take names, and
evaluateModulesOwedBelow() ran a module that a mock had kept from running without asking the loader for
what only that module imports. A mock that came ahead of the load now stands for the whole module.

* AOT: the first require() of an ES module gave its namespace object a Structure shared with others

    import { x } from "./a.mjs";                       // read hot
    const all = ["./a.mjs", "./b.mjs", "./c.mjs"].map(p => import.meta.require(p));
    function readX(ns) { return ns.x; }                // over the three objects, hot

In a layered `bun test --aot` run readX() came to answer a's x for b. Silent. A regression of this
branch, found in review.

willRequireESModule() set the Structure that all extensible namespace objects share, also on an object
that had a Structure of its own, and hasOwnStructure() went on answering true. A property cache keyed
on the shared Structure then carried the first module's binding to the next. Such an object now gets a
new extensible Structure of its own.

* AOT: the compiler aborted on `o.self = o` where o may be a subclass of Map

    class C extends Map {}
    function see(o) { return o.self; }
    function run(o = new C()) { see(o); o.self = o; }
    run({}); run();

"Stopped while compiling run in PlaceTypeTests." Also over Set, WeakMap, WeakSet, Array and Error.
main has this bug.

Behind a store's tests, rewriteTestedPropertyAccess() takes the last TypeTest for the test of the stored
value, which says nothing about the receiver, and leaves it out. Here the stored value is the
receiver, so its only test was left out and the access couldn't be resolved. The test is now left out
only where the receiver hasn't passed it too.

* AOT: an import() waits for the async factory of a mock below it

    vi.mock("./a", async importOriginal => ({ ...(await importOriginal()), a: "mocked-a" }));
    import { a } from "./a";      // and user.ts: export const atLoad = a;

Under `bun test --aot` as one program: "Cannot access 'a' before initialization", or the real value,
and the next test file "never finished loading". Which of the three came went by when a thread had read
the original from disk. A hoisted vi.mock() turns the test file's import statements into such import()
calls, so Vitest's commonest partial mock met it. main has this bug.

startImport() called the waiting mock's factory, held the import for one turn and delivered it whatever
the factory had done by then. Only the loader's own fetch waited. Where factories wait, the import
now goes on behind them, and is rejected with what one of them throws.

* AOT: a bounds test on the kept prefix of split()'s result compares against the real length

Follows "a bounds test on the result of split() or splice() compared against the wrong length". That
commit made an index past the kept elements unknown instead of false. The other side had the same
flaw: an index inside the shape's count was taken for in bounds, though the array may be shorter than
the number of elements that the function reads.

The test becomes `position < the array's real length`. New: --aot-sabotage=ListsOfBuiltinsEndWithWhatIsKept
and the statistics ListsOfBuiltinsNeverBuilt and ObjectsOfBuiltinsNeverBuilt. It costs 0.15% on one
router idiom.

* AOT: three IR tests check the new counters of arrays and objects that are never built

* AOT: a ring of `export *` with a package in it hung the link of a layered test run

    // index.ts: export * from "./a"; export * from "./b"; export * from "pkg";
    // a.ts, b.ts: export * from "./index";
    import { missing } from "./index";

In a layered `bun test --aot` run the link never ended (485,000 frames when it was killed). It should
throw the SyntaxError for the missing export, as it does as one program and in an executable. A
regression of this branch, found in review.

Modules::isMockedInItsImage() called itself for every dynamic `export *` with no set of visited modules.
It is one loop with a BitVector now, and asks each module once.

* AOT: a name that a barrel hands on from a mocked CommonJS file is the mock's

    // barrel.js: export { name } from "./lib.cjs";
    vi.mock("./lib.cjs", () => ({ name: "mock" }));
    import { name } from "./barrel.js";

Under `bun test --aot` as one program `name`, `ns.name`, Reflect.get and `(await import("./barrel.js")).name`
read `undefined`. Object.getOwnPropertyDescriptor and a spread of the same object were right.

findNamespaceExport() leaves a member to the namespace object where the module that declares the variable
may be mocked. The names of a CommonJS file are variables of its ES face, and the mark sits on the file,
so the read was bound to a variable that nothing fills while the mock stands. Both are asked now.

* AOT: a function of a built-in module replaced by a writer the build can't see

    import found from "node:module";
    JSON.parse("0", () => Reflect.set)(found, "SourceMap", mine);
    function use() { return found.SourceMap(); }
    use();      // plain: mine's result; compiled: SIGSEGV

With `buffer.atob`, `Buffer`, `Blob` and all of `node:util/types` the write went through, a read saw it,
and the call by the module's name silently reached the original. main has this bug.

Flow analysis reasoned that start-up locks every member of a built-in that no statement writes. Nobody
locks the object of a module, and nobody should: stubs and instrumentation replace such members. A
holder that is a module's object, has no lock under the name and has escaped to code nobody sees now also
answers what was stored through the unknown object, so the call is tested and not bound.

* AOT: a variable read stale behind a method picked by a name known only at run time

    const handlers = {
      open(done)  { return { run() { done({ ok: true  }); } }; },
      close(done) { return { run() { done({ ok: false }); } }; },
    };
    const type = JSON.parse('{"type":"open"}').type;
    let result = null;
    handlers[type](r => { result = r; }).run();
    console.log(JSON.stringify(result));      // null, should be {"ok":true}

Silent, at default flags. Neither the verifier nor --aot-validate sees it; --aot-check=MovedLoads does.
main has this bug.

In cold code Select fuses a property read and a call into CallMethod. That operation's effects are
"unknown code", without its known callees. "Unknown code" is what functions entered by unknown code
do, and a function whose callers flow analysis all knows isn't among those. So B3 was told that the
call doesn't write the variable's word, and forwarded the earlier store to the load.

The read and the call are now fused only where the call's effects lie within those of unknown code.
New: --aot-sabotage=FusedCallsEnterOnlyUnknownCode.

* AOT: a setter laid by Object.defineProperty that stores through `this`

    import "node:util";
    class C { z = 0; }
    function run(given) {
      const o = given ?? new C();
      Object.defineProperty(o, "p", { set(v) { this.z = v; } });
      o.p = 1;
      return o.z;
    }
    console.log(run(), run([]));      // 0 1, should be 1 1

Silent. Where a TypeScript class declares `z?: number` the executable aborted. Twenty built-in modules
arm it by being imported anywhere in the program. main has this bug.

The library's text holds a function that lets accessors escape, so the setter escapes, rightly. For a
store with several receivers the setters found are then {unknown}, which covers the known setter, and the
branch that hands each receiver to the setters on its own chain asked the covered view and was skipped.
The instance was neither handed to the setter nor made to escape, so its field counted as stored once.

For a store, where unknown stands among the setters found, the receivers that haven't escaped are now told
apart, and each is handed to the setters laid on its own chain. A getter that writes through `this` and is
run by a spread had the same hole in the rule for copies. What flow analysis hands the optimizer is
unchanged on express, fastify and the typescript package.

* AOT: the test of a branch that passes on a proof stays in the branch's block

    function f(x, n) {
      let fuel = 3, b = x, c = 1n, e = x, a = x;
      const gt = { get v() { a = b; return 1; } };
      for (let i = 0; i < n; i++) c = e;
      while (c === b && fuel-- > 0) { gt.v; }
      b++;
      return e + 1;
    }
    f(1, 2); f("a", 2);

"Stopped while compiling f in SimplifyControlFlow", and the step that the message advises to switch off
isn't the one at fault. main has this bug; a neighbour with a value out of Map.get built on main and
aborted on this branch.

HoistLoopInvariantReads lifted the loop's test out of the block of its Branch, which passes a proof on
its taken edge. PlaceTypeTests then copied a part of the loop, and the SSA repair turned the Branch's
condition into a block parameter, which proves nothing. Of 58 varied forms 23 didn't build and none
built and answered wrongly. Such a test is no longer hoisted. Those loops now make an unchanging test in
every iteration: 1,031 functions of the public corpus change, 3,361 bytes fewer.

* AOT: a class is no longer recognized by its prototype's own `constructor` property

    const Base = t => class { constructor() { this.a = t; } };
    const Heir = S => class extends S { constructor() { super(); this.b = 5; } };
    for (let i = 0; i < 2; i++) {
      const D = Heir(Base(i));
      delete D.prototype.constructor;
      console.log(i, JSON.stringify(new D()));      // the second iteration: SIGSEGV
    }

With class fields it is silent: `{"a":1}` for `{"a":1,"b":5}`. Besides `delete`: Reflect.deleteProperty, an
accessor, Object.defineProperties, and a mixin helper that copies another prototype's own properties. Of
3,432 generated forms 472 were wrong, 164 by SIGSEGV. main has this bug.

From a class's second evaluation on, where no member guard is left, its class object and prototype object
are left out of the table of holders. Asked later whose prototype an object is,
ClassTable::classOfHolderLeftOut() read the object's own `constructor`. With that gone the answer was
"nobody's", and the base's constructor kept the object it had allocated in the base's shape.

A prototype object that is left out now names its class object under a private name of the class table:
read-only, not enumerable, not deletable, and seen by nothing that a program can ask.

* AOT: include what AOTCallRules.cpp uses

matchMethodCall() now asks context.build().isSabotaged(..), and BuildContext was an incomplete type there. Bun's build
precompiles a prefix header that brings AOTBuildContext.h in; WebKit's own CMake build does not.
Jarred-Sumner added a commit to oven-sh/WebKit that referenced this pull request Nov 1, 2026
…cross a store through a second name

    function clean(src, dst, n) {
      for (let i = 0; i < n; i++) {
        if (src[i] < 0) dst[i] = NaN; // a bad sample
        else dst[i] = src[i] * 2;
      }
    }
    clean("1,2,3,4,5,6".split(","), [], 6);
    const samples = [1.5, -1.5, 2.5, 3.5, 4.5, 6.5];
    clean(samples, samples, 6);
    console.log(samples.join(" "));   // 3 NaN 5.5 7 9 13, should be 3 NaN 5 7 9 13

Silent, at default flags, in plain JavaScript and in TypeScript. main has this bug.

PlanArrayCapacities copies the reads behind a test of an array's kind of storage for the other kind,
as far as the test's proof goes. addReadsOfOtherStorage() copied the stores between those reads with
them and never asked whether the copy's proof outlives such a store. A store through another operand
that may be the same array ends it.

The guards are now asked again behind each copy, and a copy that may lose its proof is taken back.
New: --aot-sabotage=CopiedReadsKeepTheirProof, the statistics ReadsCopiedForOtherStorage and
CopiesOfReadsTakenBack, two IR tests. The 30 stored programs come with oven-sh/bun#47605.
Jarred-Sumner added a commit to oven-sh/WebKit that referenced this pull request Nov 1, 2026
…n at the next import

In plain `bun test` a factory that takes `importOriginal`, sync or async, is laid over the original.
Where it fails the loader keeps no record: the next import asks it again, and the module that imports
the mocked one then loads. A factory without the parameter keeps its error. Compiled code kept the
error for both kinds.

deferMock() is now told which kind the mock is, and runMocksBelow() keeps no error for the first kind.
Where such a factory's promise rejects a turn later, the mock waits for the next load again instead of
being taken away. The test comes with oven-sh/bun#47605: test/aot/mock-factory-retry.test.ts.
…ince #47571

The engine's half is oven-sh/WebKit#972. The pull request's description has what is still wrong,
what may be red in CI, the pins that went up and every file outside src/aot, test/aot and docs.

Crashes:

- `TypeError.list = [1, 2]; JSON.parse("0", () => Reflect.set)(e.constructor, "list", { length: 3 })` with `e` a caught error, then `TypeError.list.length` in a function: the compiled program segfaulted. The same happened with a hidden `Object.assign` or `Object.defineProperty`.
- `Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key))` with a `key` object whose `toString()` returns "a" and then "b": plain prints 3, compiled code segfaulted.
- Every `--aot` executable aborted before its first statement on a kernel with 64 KB pages (RHEL 8 aarch64, Oracle's UEK, the 64k kernels for NVIDIA Grace).
- `vi.doUnmock("./k.js")` while a plugin's async `onLoad` is still loading another import segfaulted under `bun test --aot`. The crash is fixed; the import still gets the first mock's values where plain gives the file's, and twelve `test.failing` cases record that.
- `bun test --aot` aborted under `validateExceptionChecks`, which CI's ASAN lane sets, wherever a mock was installed: `vi.mock("./mod", () => ({ value: fake }))`, and in a layered run `vi.doMock("./m08.js", async () => { await sleep(80); return { value: "mocked" }; }); import("./m08.js")`.

Miscompilations:

- The code that creates, fills and stores a module variable's value is deferred until the variable is first read, and an assignment to an object created by that initializer was deferred with it. After `Object.freeze(Object.prototype)` an assignment to an inherited name fails: a TypeError in strict code, silently dropped in sloppy code. Plain did the store before the freeze and compiled code did it after. main has this bug too.
- `Object.freeze(Object.prototype); enum Kind { toString, valueOf, plain }`: `bun run` fills an enum by assignments, which fail for a name that a frozen `Object.prototype` has. Compiled code defined the members instead.
- A loop's stores to a variable were delayed to the loop's exit edges, even across an operation that throws only when memory runs out. That error can be caught, and then the loop is left through none of those edges.
- A Map or Set lookup with the same key on every iteration was hoisted out of the loop, even when a `try` covered the lookup but not the loop's preheader, and even when the loop might not run: `for (let i = 0; i < 0; i++) map.has(rope)` threw a RangeError.
- A loop that does `const list = []`, pushes, reads `this.state.type`, reads `list[0]` and stores the list into a node: a value read back out of an open object was taken for this iteration's list.

Hangs and failed builds:

- esbuild's `transformSync()` and `buildSync()` never returned in a compiled program.
  `var threads; try { threads = require("worker_threads") } catch {}`, then
  `var start = given => new given.Worker(__filename, ..)` and `start(threads)`: the
  search for the files that Workers start from knew the module behind an import, a
  direct `require()` and a `const`, not behind a variable that is assigned or a
  parameter. The Worker could not be loaded, and the caller slept in `Atomics.wait()`.
- A package that says `new Worker(__filename)` got `this executable can't load a module
  from a dynamic path`, in an executable and in a test run built as one program.
- `mock.module("node:os", ..)` in a.test.js, `await import("node:os")` in b.test.js: the
  promise never settled and the test timed out. Since #44833 plain `bun test` takes a
  file's mocks away when the file ends, and nobody told the engine.
- `internal compiler error (LinkAndEmit): UndefinedLayout`, in two ways. `child.ts` has
  `import "unbuilt"`, which can't be resolved, and `export class Child extends Base`, and
  another file says `await import("./child")`: in a test run no test file ran. Or a package
  with `"sideEffects": false` has `export class Unused extends Base {}` beside
  `export const used = 1`, and main.ts imports `used` and, with `import type`, `Unused`.
  The layout of a class whose statement is not compiled named a parent that nothing defines.
- A build that never ended in the checker: a conditional type such as
  `T extends infer R ? R : never` was weighed by how it is written, not by what `T`
  stands for.
- `import.meta.glob()` was not expanded under `--aot`. It is now, by Bun's own parser:
  lazy, eager, `import: "name"`, `?raw`, `Object.keys()` of it, in a CommonJS file.

Wrong answers of compiled programs:

- `bottom[name]`, with `class Top { [name]() {} }`, `class Middle extends Top` and
  `class Bottom extends Middle`, had its type if `Bottom` was asked about first and no
  type if `Middle` was. Which came first went by the order of the files.
- `Object.getOwnPropertyDescriptor(require("node:_http_agent"), "globalAgent")` showed a
  getter and a setter. It is a value made at load, as in plain Bun.
- `export { default as t } from "./outside.txt" with { type: "toml" }`, for a file that
  is not in the program, lost its type, while `import` kept it.
- In a `.mjs` or `.mts` file `this` at the top is `undefined`. The name alone decides: a
  `.cjs` file in a package with `"type": "module"` still exports through `this`.
- A stack trace taken while `then` is read by `await thenable` listed the async function,
  which has suspended by then. A class whose decorated members need a constructor was
  shown as `new Task (unknown:1:11)`, not at its `class`.
- `for (3 times) try { require("./t.cjs") } catch {}` where t.cjs throws: the body ran once and the first error was thrown three times. It now runs again each time, as in node and `bun run`.
- main.mjs imports `./m2.mjs` and `./a.cjs`; m2.mjs imports `./b.cjs`; b.cjs requires `./a.cjs` between two logs; a.cjs uses nothing of CommonJS. a.cjs ran first instead of when b.cjs required it.
- `require("./m3.mjs"); Object.keys(require("./d.json"))` where m3.mjs imports d.json: plain `a,default`, compiled `a`.
- `Object.isExtensible(require("./e3.mjs"))` after an import of the same file, in a layered run and for a module left out by `--external`: plain true, compiled false.
- `package.json` says `"sideEffects": false` and a file says `import "./setup"`: a test run built as one program dropped setup and said nothing, while a layer ran what `bun build --compile` drops. Both now do what plain does.
- `const o = Object.create(Blob.prototype, {}); o.constructor = { get name() { return "Stored" } }; o.text()`: plain throws ERR_INVALID_THIS; compiled code threw "'name' was left out of the executable". The same for `Buffer.concat(v)` when `v` isn't an array.
- piscina's `new Worker(join(__dirname, "runner.js"), { workerData: filename })` with `require(workerData).default` in the worker didn't find the project's file. threads.js's `await spawn(new Worker("./job.cjs"))` failed with "this executable can't load a module from a dynamic path".
- A Worker file that nothing reachable can start is no longer put into the executable.

bun test --aot:

- `vi.mock()` and `jest.mock()` are hoisted above the imports, as #44833 does for plain
  `bun test`. With `import { captured } from "./side"; vi.mock("./mod", ..)`, where
  side.ts imports mod.ts, mod.ts ran and `captured` was the real value.
- `require()` of a mocked module answers the object that the factory gave, for `node:`
  names too.
- `mock.module("node:dns", f1); await import("node:dns"); mock.module("node:dns", f2);
  await import("node:dns")` answered the first mock again where a run is built in layers.
  Five mocks in a row gave 1, 1, 3, 4, 5.
- A compiled run of one empty test held 166 functions of the test runner's own code. It
  holds 2 again. `process.mainModule` follows the test file.
- A file of the project that writes on a built-in (`Buffer.isEncoding = ..`) now throws in
  the test run as it does in the executable. A green run said nothing about that line.
- A file that only Workers load has no `expect` or `test`, as in plain Bun.
- `// require("./x.cjs")` in a comment no longer decides whether x.cjs runs as CommonJS.
- A test that writes state.js and imports it afterwards gets the new text in more forms:
  a path from `process.cwd()`, and a helper in another file that takes the path or joins
  a name to its own folder.
- A type imported from a module in a layer below no longer costs the importing file its
  types.
- `--shard` with `--timings` spreads the files that are held apart for their memory over
  the shards, and the walk ahead of the deal asks for each name of a folder once.
- `/** @vitest-environment jsdom */` (also happy-dom, `@jest-environment`, `--environment=`, bunfig's `environment`): the file didn't run: "Cannot load '(internal)': it isn't in this executable". With jsdom 25 and happy-dom 17 it now gets its window, under `--isolate` too. Both packages evaluate text, so a run with such a file is built as an open program: about 5.5 s more per run.
- Fake timers replace the globals. Compiled code had bound `setTimeout` and friends to what the process started with, so `useFakeTimers()`'s own functions were silently refused and the real ones consulted the fake clock: a socket's own timeout never fired, and `advanceTimersByTime()` fired the library's timers.
- `jest.doMock("./lib"); lib.f()` called the real function, and `import sum from "./sum"; jest.mock("./sum"); jest.mocked(sum)` at the top of a test file got the real function: a mock without a factory arrived one step late.
- `export const a = 0` of a mocked module read as 0 in every importer, whatever the factory returned.
- Two `export *` barrels, one of them mocked. Without hoisting (`mock.module()` in a test, then `await import("./first.js")`) main prints `real mocked` where plain prints `mocked mocked`. main has this bug too.
- `mock.module("./other.ts", () => ({ v: "mocked v" })); (await import("./by-name.ts")).renamed` with `export { v as renamed } from "./other.ts"`: "Cannot access 'renamed' before initialization".
- A `require()` that a mock had answered kept that answer for the rest of the run, even after `mock.restore()` and `vi.doUnmock()`, so a later test passed or failed depending on what an earlier file had mocked.
- A CommonJS file behind a mocked ES module still ran.
- `jest.clearAllMocks()` and friends also end the mocks that are still waiting in the engine.
- `import("./user.js").then(..); await vi.dynamicImportSettled()` didn't wait for the import.
- `vi.resetModules()` keeps packages and `jest.resetModules()` doesn't, as in plain.
- A mock of a built-in module installed before the module loads takes effect.
- `import {} from "./m"` and `import "./m"` are hoisted below `vi.mock()` like every other import.
- A file in a `__mocks__` folder is compiled with the program, and `require()` finds a module mocked by a CommonJS file there.
- `vi.importActual()` and `jest.requireActual()`: the original next to its mock loads in a compiled run, may import files of the program, and works with a name computed at run time.
- A test file named on the command line that doesn't match `*.test.*` (`bun test --aot ./tests/latest.ts`), and a helper that writes a file and then `require()`s it.
- What only the preloads import is treated as the tests' own, in a layered run too. A preload that can't be compiled no longer ends the run silently.
- `--isolate=modules` runs compiled. In a layered run the files share one global object, as in plain.
- `--globals=vitest` in a run built in parts; `globalThis.describe`; `vi === vitest`; `vitest.mock()` is hoisted.
- vitest's `assert` and `should` are the project's own chai.
- `test.extend()`: fixtures, JSX in the body, its errors.
- A file that both a Worker and a test file load reads `expect`, `test` and the like from the realm it runs in.
- A test file that can't load (`const broken = ;`) is written to the JUnit report as a failed case, as plain does on main now.
- `typeof console.timeStamp` is `"undefined"` in a test run, as in `bun test`, Jest and Vitest. React's development build checks for it.
- The runner's own filters and the DOM formatting of `toMatchSnapshot()` accept an object of compiled code.
- A plain run keeps its workers: `--aot --shard` had changed how files are dealt to them.

Build time and memory:

- What a file uses of the library's functions was kept in four sets of 131,131 bits, each
  cleared and walked for every file. They are two sorted lists now. The two blocks of a
  module that is set aside are compressed in one go, not one by one.
- `class C1 extends C0 { m1() {} }`, 800 in a row: every inherited method was compared
  with itself in the class above, 380 of 540 MB.
- A build that is not a test run walked every expression of every file for writes on
  built-ins and threw the result away.
- `map.delete(k); map.set(k, v)` took a new slot in the table's index each time and never freed the old one, so every lookup of the key read every slot it had left behind: an iteration cost the size of the table. A deletion now frees the slot. It costs 5 more instructions on a set and on a delete; see "Numbers that went up".
- `new Worker(new URL("./w.js", import.meta.url))` inside a package, the way piscina, tinypool, workerpool and threads.js start theirs: under `bun test --aot` the file was compiled into no layer and ran in the interpreter. A loop of 3e8 iterations took 10,812 ms against plain's 320 ms.
- `class Below extends mix(Base)`: the whole caller was walked for every such call. At 3,200 classes that step took 8,306.6 M instructions and now takes 119.7 M. The generated code is byte-identical.
- Three more build steps that grew with the square of the program: choosing representations around a web of values, finding who reads a parameter that is passed on, and loads by a computed name, which now cost by the package.

Speed:

- A sloppy function that only reads `arguments` and has a closure over a parameter
  (Babel's `_objectSpread`) no longer makes the arguments object at every call.
- `Array.from(map)` and `Array.from(map.entries())`: 672 instructions an entry, now 132.
- 65 host functions have a signature that says what they answer. Without one, a call
  counted as making an instance of every host class.

bun check:

- `function f({ a: { a: .. b } }) {}` nested N deep took time quadratic in N: 20,000 deep took 23.6 s of processor time. Each element of the pattern asked whether the pattern is ambient, which walks up through all its parents. It now asks the parameter that was just found (`symbols.rs`). Same answers: nothing between a pattern and its parameter can be ambient. `test/cli/check/nested-parameter.test.ts`.
- `value.someName` on a `FromSchema<Schema>` (json-schema-to-ts) with a type parameter for `Schema`: `bun check` never finished. It now reports TS2589 and TS2590 where tsc does, in about 5 s (tsc 6.0.2: 13.5 s). `test/cli/check/schema-types-limit.test.ts`.
- A `this` parameter without an annotation keeps what it was assigned, and one that nothing gives a type is still reported. `test/cli/check/unannotated-this.test.ts`.
test/internal/bindings-without-the-compiler.test.ts failed with

    a Bun built without the compiler defines every function that BunCompiledModules.h declares
    received ["willResetModules"]

The call in BunPlugin.cpp moved inside `#if ENABLE(AOT)` and the empty definition in the `#else` half of
BunCompiledModules.cpp was deleted with it, but the header still declares the function. The empty
definition is back next to the others.
prettier and rustfmt on nine files of the previous commit. No other change.
test/aot/the-default-time-limit-never-lowers-what-the-runner-gave.test.ts checks that no test file in
test/aot gets hooks with a time limit of its own from "bun:test". mock-sweep.test.ts imported `afterAll`
from there. It now imports it from ./harness/default-timeout like every other file.
… splice()

Two programs for the engine fix in oven-sh/WebKit#972: a `for-of` over `h.split(",")` whose body
destructures `part.split(";")`, and the same with `splice()`. Built with --aot-validate the whole loop was
removed and the function returned []. Both fail on a build without the engine fix and pass with it.
This was referenced Nov 2, 2026

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants