Skip to content

AOT: crashes and wrong answers found since #942, and faster builds of large functions - #967

Merged
Jarred-Sumner merged 5 commits into
mainfrom
claude/compile-aot-follow-up
Oct 31, 2026
Merged

Jarred-Sumner merged 5 commits into
mainfrom
claude/compile-aot-follow-up

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #942 for bun build --compile --aot and bun test --aot. The Bun half, with the tests that need Bun, is a PR in oven-sh/bun that pins this one's preview build.

Crashes of compiled programs

  • Eleven lines ended by SIGSEGV, and ten lines of the same kind aborted the compiler:

    let b = null, c = null;
    if (n) c = { k: 1.5 };
    while (b !== b) b = [b];
    if (y != b) { [b, y] = [y, b]; return c === y ? c : 1; }

    SinkAllocations analyses a function, counts, then rewrites it, and all three steps must decide alike. A helper read constants out of the function as it stood, so the rewriting saw constants that the analysis hadn't. AOT: per-global program state, mockable exports, heap-allocated generator frames, bug fixes #942 fixed one of its five callers. Now the question is answered from a table filled before anything is rewritten, and that fix is gone again.

  • try { null.x } catch (e) { e.constructor.list = { length: 3 } }, where TypeError.list was an array before: a signal, undefined, or a call of the function that had been replaced. Flow analysis took the stores it could follow for all the stores there are. A store through an object nobody knows is now looked for on every built-in class.

  • Bun.gzipSync.list = [1, 2]; Bun[JSON.parse('"gzipSync"')].list = { length: 3 }, then Bun.gzipSync.list.length: SIGSEGV. The same through Event.prototype[name], names out of Object.keys(Bun), and Bun.Glob.prototype reached by three computed names. A read under a computed key, or of prototype, off an unknown object in the program's own code now reaches the host's prototype objects.

  • A loop counter beyond int32, read from a property and used to index a typed array, trapped.

  • class Holder { held = stream[Symbol.for("nodejs.stream.readable")]; } with a ReadableStream aborted. In a function the read gave undefined. A symbol from Symbol.for() was treated like one from Symbol(), which the host can't reach.

Crashes of the compiler

  • "Floating point error" on while (a == 65536 && i0 < 9) { i0++; c = c > 0 ? d : c; }. B3VectorizeLoops divided by the number of memory accesses of a loop that has none.
  • bun test --aot on NestJS's own suite stopped with "Stopped while compiling printMessages .. in PlaceTypeTests". So did tslog's (in webpack's _getRenamedInlineModule) and a project that uses knex. A block's parameters are its explicit arguments followed by the results of the edges that enter it, such as the narrowed value of a type test. Two places indexed an edge's arguments with a parameter's index, which is out of bounds for an edge's own result. An earlier commit of this PR brought it.

Wrong answers

  • /^(?=(.*))\1$/ said "no match" for a subject with characters outside Latin-1. The pattern was taken to consume 8-bit characters only. What a lookahead captures counts once a back reference can consume it.
  • new RegExp("\\B" + text, "u"): \B matched inside a surrogate pair.
  • function make(k) { return k === 0 ? new A(0) : new B(1) } with a getter on B.prototype alone: make(0).size ran B's getter when the first A was made behind a test of both classes.
  • x.f += n where f starts on the prototype: the arm that reads f by name may run a getter, and it came back into code that relied on a guard from before the read.
  • Interface.prototype[Symbol.asyncIterator] read in a function was a different function object than Reflect.get() returned (node:readline).
  • await import("sass") in an executable threw "Cannot assign to Function.prototype.call$0". Programs out of dart2js add such names.
  • function twice(f, n) { return f(f(n)) } twice({}, 1): the error message quoted f, not f(n).
  • After delete A.prototype.twice, new A(8).twice() still answered.
  • With a setter on B.prototype alone, const o = k === 0 ? new A(k) : new B(k); o.y = 5 in a loop ran B's setter for the first A. B3 moved the load of a class's guard bits across the allocation that makes the class.
  • require("./config.json") in a .cjs file that runs while the graph loads answered the ES namespace.
  • Object.assign({}, source) stored names before symbols for a source with room for elements.
  • Closures of one function that call each other in tail position: a stack frame had the name of the function object that was called, not of the one that ran.
  • With one optional pass off, o.a was folded where o may be null, and another pass aborted the build.
  • Two runs of one executable made different caching decisions, because a table was hashed by an address.

Memory

  • The slow path of a stub has a frame as large as what it saves. Objects were kept alive longer than in plain Bun.
  • A store of "" or of a one-character Latin-1 string needs no write barrier.
  • for (let i = 0; i < n; i++) a.push(f(i)) reserved the whole array up front even where f may throw and end the loop.
  • The build: function ask_i(x) { return x.weigh() + ask_(i-1)(x) }, 800 in a chain, each given an instance of one class more. No two calls of weigh see the same classes, so the cache kept a partition of the receivers per call and key: 320,000 arms of 112 bytes. A partition is now kept packed, 24 bytes for an arm with one method and one layout, and "may this call enter a table look-up" asks class by class and keeps nothing. 189 > 166 MB, with identical output on 1.16 million functions.

Build time

A generated function of 1,000 statements builds in 7,891 M instructions, down from 12,473 M, with identical output:

  • Liveness gave up its walk by value only after a fixed number of visits, and then started over with another solver.
  • A forward dataflow kept a counter for each condition in each block.
  • The guards' analysis asked each write about each proof. In one function 4,304 writes had 28 different answers.

test/aot/build-scaling in Bun gets its first shapes with a warm body.

Speed

  • a[i] === b[j].
  • Case conversion goes sixteen characters at a step.
  • A read by name of a slot that holds a raw double.
  • The stubs' new form: 226 stepped benchmark rows take fewer instructions and none takes more.
  • let current = first; while (current) current = current.run(this) with first typed by an interface: objects that have the interface's members but none of its layouts stay in the typed loop. Effect's run loop takes 20% fewer instructions per turn.
  • The first TypeError at a place quotes the source ("undefined is not an object (evaluating 'a.b')"). For that the run time decoded every entry of the kept piece of source at each first error, and a long statement is one piece. Now once per piece. Forty places in one switch of 12,000 cases: 2.3 > 1.5 ms, in a compiled test run 4.05 > 0.59 ms. No message changes.

Windows

  • hasChanged |= std::exchange(member, copyOfClass(member)) != member compares the old value with itself where the right operand is evaluated first, as on both Windows targets. Copied classes and closure factories kept the original's types there: correct, but less precise.

Size

  • The compiler's rarely run files are built for size: 600 KB less.

Checks

  • The proof checks compare a function's identity, and are no longer skipped in forwarding code.
  • --aot-check=ClaimsOfFlow tests every claim of flow analysis where it's made.
  • Under the verifying flags SinkAllocations checks that its three steps decided alike.
  • RegExp::matchWithImageCode dereferenced a null image record when jsc runs its tests with regular expressions compiled ahead of time. That was six failing JSTests on this PR's first commit.
  • The map and --aot-report say "proven" or "missed optimization" where the final IR knows the receiver's class, not "unknown".

Changes outside aot/

22 files: b3/ and b3/air/ (branches to selects, loop rotation, the loop vectorizer, the greedy register allocator, block order, AirReleaseDeadValues, three testb3 files), runtime/ (ArrayPrototype, JSModuleLoader, JSObject, RegExp), yarr/ (YarrJIT, YarrFlatProgram) and wtf/FrequencyEstimator.h.

… large functions

Its tests that need Bun are in the Bun PR that goes with this one.

Crashes of compiled programs:

    let b = null, c = null;
    if (n) c = { k: 1.5 };
    while (b !== b) b = [b];
    if (y != b) { [b, y] = [y, b]; return c === y ? c : 1; }

ended by SIGSEGV, and ten lines of the same kind aborted the compiler. SinkAllocations analyses a function,
counts and then rewrites it, and all three must decide alike. A helper read constants out of the function as
it stood, so the rewriting saw constants that the analysis had not seen. #942 mended one of its five callers.
Now the question is answered from a table that is filled before anything is rewritten, and that mend is gone.

    try { null.x } catch (e) { e.constructor.list = { length: 3 } }    // TypeError.list was an array before

ended by a signal, gave `undefined`, or called the function that had been replaced. Flow took the stores that
it could follow for all the stores there are. A store through an object that nobody knows is now looked for on
every built-in class, and on the prototypes above crypto.subtle, Bun.s3 and a request's signal.

    class Holder { held = stream[Symbol.for("nodejs.stream.readable")]; }    // a ReadableStream

aborted, and in a function the read gave `undefined`. A symbol out of Symbol.for() was treated like one out of
Symbol(), which the host cannot reach.

Wrong answers:

  - After `delete A.prototype.twice`, `new A(8).twice()` still answered. With a setter on B.prototype alone,
    `const o = k === 0 ? new A(k) : new B(k); o.y = 5` in a loop ran B's setter for the first A. B3 moved the
    load of a class's guard bits across the allocation that makes the class.
  - `require("./config.json")` in a .cjs file that runs while the graph loads answered the ES namespace.
  - `Object.assign({}, source)` stored names before symbols for a source with room for elements.
  - Closures of one function that call each other in tail position: a stack frame had the name of the
    function object that was called, not of the one that ran.
  - With one optional pass off, `o.a` was folded where `o` may be null, and another pass aborted the build.
  - Two runs of one executable made different caching decisions: a table was hashed by an address.

Memory: the slow path of a stub has a frame as large as what it saves. Objects were kept alive longer than in
plain Bun. A store of "" or of a string of one Latin-1 character needs no write barrier.

Build time. A generated function of 1,000 statements builds in 7,891 M instructions, from 12,473 M, with the
same output: liveness gave up only after a fixed number of visits and then started over, a dataflow kept a
counter for each condition in each block, and the guards' analysis asked each write about each proof, where
4,304 writes had 28 different answers.

Speed: `a[i] === b[j]`, case conversion sixteen characters at a step, a read by name of a slot that holds a
raw double, and 226 stepped benchmark rows through the stubs' new form.

Checks: the proof checks compare a function's identity and are no longer skipped in forwarding code.
`--aot-check=ClaimsOfFlow` tests every claim of flow where it is made. Under the verifying flags
SinkAllocations checks that its three steps decided alike.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Beyond the inline findings, I also checked the for (;;) in copyLoopsForFunctionsTheyCall (AOTLoopPasses.cpp) for non-termination — after a copy, the original loop's listed callees all fail findEarlierTest and each copy has a single proven callee, so the search finds nothing more — and the new RELEASE_ASSERT in ProgramIdentifiers::uidSlowCase (AOTProgramIdentifiers.cpp:117), which only guards the VM-shared object of a multi-instance image while realm-created symbols come from the per-realm object, so it is not reached in the multi-realm path.

Extended reasoning...

This is a 538-file, ~18.7k-line change across the JSC AOT compiler, runtime, stubs and tests plus small edits in yarr, WTF and bmalloc, with no auth, crypto or injection surface. Four inline findings were posted; the loop-copying pass termination and the new identifiers assertion were also examined and ruled out as described in the body.

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

Comment on lines +3755 to +3756
for (StringID pattern : patterns.strings)
addCandidates(inputs.program, inputs.context, RegExpRequestOrigin::Candidate, pattern, flagSets, site, found);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 (optional) Hard stop removed: nothing now bounds how many predicted RegExp texts one call site compiles into the image, so a build whose pattern texts come from a large list gets an image and compile time that grow with that list, where the base stopped at 64 texts and 64 KiB of code per site. The three limits RegExpCandidatesPerSite, RegExpCandidateCodeBytesPerSite and RegExpCandidateTextLength are deleted from limits.def and every text flow or the constant walk finds is added at AOTRegExpRequests.cpp:3756 and compiled at AOTRegExpCompiler.cpp:541, with only the 2 GiB RegExpSectionSize left as a backstop. Fix: restore a per-site (or per-program) cap on predicted texts and on their code bytes, applied both to flow candidates and to texts from the constant walk, and decline the rest as before.

Why this was flagged

A program whose RegExp text is predicted from many values reaches collectCandidates and addCandidatesFromConstants in AOTRegExpRequests.cpp: a wrapper function re(s) { return new RegExp(s) } called from thousands of sites with distinct literals (AOTValueFlowResultsSinks.cpp:171 collects every literal), or list.map(s => new RegExp(s)) over a data array, whose elements are produced by elementOf at AOTRegExpRequests.cpp:1883-1886 without charging the walk budget. On the base branch takesTextsOfFlow refused a site with more than RegExpCandidatesPerSite (64) texts, add() refused the 65th alternative, and applyCandidateBudget dropped code past RegExpCandidateCodeBytesPerSite (64 KiB) per site. After the change add() at AOTRegExpRequests.cpp:538-542 always accepts, every pattern is appended at AOTRegExpRequests.cpp:3756 and :3877, and compileRegExps at AOTRegExpCompiler.cpp:532-541 compiles each one with applyCandidateBudget removed; the only remaining stop is applySectionSizeLimit at 2 GiB. No code names a bound on the number of predicted texts or their bytes per site or per program.

Verification: limits.def deletes RegExpCandidateCodeBytesPerSite (65536), RegExpCandidatesPerSite (64) and RegExpCandidateTextLength. AOTRegExpCompiler.cpp removes applyCandidateBudget entirely, and compileRegExps now goes straight to applySectionSizeLimit, whose only bound is 2 GiB (AOTRegExpCompiler.cpp:456). collectCandidates now adds every flow string unconditionally at AOTRegExpRequests.cpp:3755-3756.

// The walk for a caller of the function that holds the call is a walk in its own right: there are as many as the program has such calls, and each is as small as what that caller
// passes. The chains above one caller share its budget: there can be as many of them as there are ways through the program.
if (chain.size() == 1)
m_valuesFollowed = 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 (optional) Builds of programs with a RegExp helper that has many direct callers now spend up to callers x 16384 value walks on one site, where the base spent one budget per site. AOTRegExpRequests.cpp:385-386 resets m_valuesFollowed to 0 for every direct caller at chain depth 1, and the base's m_worlds counter (bounded by maxAlternatives) that capped the number of worlds explored per site was removed. Fix: restore a per-site total on walks (or a cap on the number of callers walked) so build time for one call site stays bounded regardless of caller count.

Why this was flagged

A helper like function re(s, f) { return new RegExp(s, f) } called from K sites, each passing its own constant, enters exploreForEachCaller (AOTRegExpRequests.cpp:375). For each DirectCall at :381 the budget is reset at :385-386 when chain.size() == 1, so the m_valuesFollowed >= m_budget cut at :387 never fires across callers; each caller may consume the full maxValuesFollowed (1 << 14, :254) and recursive chains above it. The base (saved copy at :161, :315) reset the budget once in patternsOf and additionally stopped after maxAlternatives() worlds via m_worlds; both totals are gone (m_worlds removed in the diff). This entry was dismissed as covered by the candidate at AOTRegExpCompiler.cpp:541, but that candidate was REFUTED, so no filed finding addresses the per-caller walk cost; the CONFIRMED finding at AOTRegExpRequests.cpp:3756 is about how many predicted texts get compiled, a different mechanism and fix. Every build of a program with a widely used RegExp wrapper pays this.

Verification: Triggering condition: a new RegExp(...) site in a helper with many direct callers. At AOTRegExpRequests.cpp:385-386 if (chain.size() == 1) m_valuesFollowed = 0; runs before the m_valuesFollowed >= m_budget check at :387, so that check can never cut the loop. The base reset m_valuesFollowed = 0 once per site, so per-site work goes from ~16384 walks to (number of direct callers) x 16384.

@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Preview build of 43ccc98: autobuild-preview-pr-967-43ccc98c

…crash

Crashes

- `Bun.gzipSync.list = [1, 2]; Bun[JSON.parse('"gzipSync"')].list = { length: 3 }`
  and then `Bun.gzipSync.list.length` segfaulted. So did the same through
  `Event.prototype[name]`, names out of `Object.keys(Bun)`, and
  `Bun.Glob.prototype` reached by three computed names.
  `Object.setPrototypeOf(Object.getPrototypeOf(Bun.s3), { .. })` was not seen.
  A read under a computed key, or of `prototype`, off an unknown object in the
  program's own code now reaches the host prototype objects. This replaces the
  wider rule of the previous commit, which also counted such reads in library
  code and cost `await request.json()` its typed parse.
- The compiler died with "Floating point error" on
  `while (a == 65536 && i0 < 9) { i0++; c = c > 0 ? d : c; }`.
  B3VectorizeLoops divided by the number of memory accesses of a loop that has
  none.
- A loop counter beyond int32, read from a property and used to index a typed
  array, trapped.
- `RegExp::matchWithImageCode` dereferenced a null image record when jsc runs
  its tests with regular expressions compiled ahead of time (six JSTests).

Wrong answers

- `/^(?=(.*))\1$/` said "no match" for a subject with characters outside
  Latin-1. The pattern was taken to consume 8-bit characters only: what a
  lookahead captures counts once a back reference can consume it.
- `new RegExp("\\B" + text, "u")`: `\B` matched inside a surrogate pair.
- `function make(k) { return k === 0 ? new A(0) : new B(1) }` with a getter on
  `B.prototype` alone: `make(0).size` ran `B`'s getter when the first `A` was
  made behind a test of both classes.
- `x.f += n` where `f` starts on the prototype: the arm that reads `f` by name
  may run a getter, and came back into code that relied on a guard from before
  the read.
- `Interface.prototype[Symbol.asyncIterator]` read in a function was a
  different function object than `Reflect.get()` returned (node:readline).
- `await import("sass")` in an executable threw "Cannot assign to
  Function.prototype.call$0". Programs out of dart2js add such names.
- `function twice(f, n) { return f(f(n)) } twice({}, 1)` quoted `f` instead of
  `f(n)` in the error message.
- `import * as ns`; `Reflect.setPrototypeOf(ns, ..)`, `Object.freeze(ns)`,
  `"added" in ns`: a guard for compiled objects read a module namespace.

Windows

- `hasChanged |= std::exchange(member, copyOfClass(member)) != member` compares
  the old value with itself where the right operand is evaluated first, as on
  both Windows targets. Copied classes and closure factories kept the types of
  the original there: correct, but less precise.

Memory

- `for (let i = 0; i < n; i++) a.push(f(i))` reserved the whole array up front
  even where `f` may throw and end the loop.

Faster

- `let current = first; while (current) current = current.run(this)` with
  `first` typed by an interface: objects that have the interface's members but
  none of its layouts stay in the typed loop. Effect's run loop: -20%
  instructions per turn.

Smaller

- The compiler's rarely run files are built for size: -600 KB.

Tools

- The map and `--aot-report` say "proven" or "missed optimization" where the
  final IR knows the receiver's class, instead of "unknown".
- False alarms under `--aot-validate` and `--aot-verify-each-pass`: a field
  that holds its own object, what an `await` holds, `Object.keys()` of an
  object with a computed key.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

The previous commit added a bool at the end of FlowSiteLevel. With the
Microsoft ABI the two bit-fields at its start take a unit of their own, so the
struct was already 24 bytes there and grew to 28, and the static_assert failed
every Windows build. The bool now stands behind the two uint8_t members, in
what was padding on every target.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

…by a test's own edge

`bun test --aot` on NestJS's suite stopped with "Stopped while compiling
printMessages .. in PlaceTypeTests". A block's parameters are its explicit
arguments followed by the results of the edges that enter it, such as the
narrowed value of a type test. Two places indexed an edge's arguments with a
parameter's index, which is out of bounds for an edge result. Both now ask
incomingValueOf(). An edge's own result counts as proven when a rejoin is
planned, and as unknown when the first values of loop variables are looked for.
The second commit of this pull request brought this.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

…ith a long chain of classes

Two fixes for two red tests of oven-sh/bun#47571.

The first error at a place costs the same in a longer file

Forty places inside one `switch` of 12,000 cases each throw their first
TypeError ("undefined is not an object (evaluating 'a.b')"). To quote the source
in such a message the run time decodes the entries that say what was printed in
place of the original text. It decoded every entry of the kept piece of source
at each first error, and a long statement or function is one piece. The entries
of a piece are now decoded once and kept, however many places in it throw. A
compiled test run decoded every entry of the module each time: now once.

The long file decodes 2,162 entries where it decoded about 357,000. Forty places
in one statement: 2.3 > 1.5 ms. In a test run: 4.05 > 0.59 ms. With
AddressSanitizer it was 130 to 141 ms against 2 to 3 ms in the short file, which
is what failed the-first-error-at-a-place-costs-the-same-in-a-longer-file on the
x64 ASAN lane. No message changes. The counts file has a new line,
`decoded:TextPrintedInPlace`.

A build keeps the partition of a call's receivers as words

`function ask_i(x) { return x.weigh() + ask_(i-1)(x) }`, 800 in a chain, each
given an instance of one class more. No two calls of `weigh` see the same
classes, so the cache kept one partition per call and key, each with an arm per
class: 320,000 arms of 112 bytes. The cache now keeps a partition packed: 24
bytes for an arm with one method and one layout. And "may this call enter a
table look-up" built and kept a whole partition to answer one bool: it now asks
class by class, stops at the first no and keeps nothing.

800 classes: 189 > 166 MB, 20.14 > 20.01 G instructions. The generated code is
identical on 1.16 million functions. build-scaling-4 failed on macOS arm64 with
"twice as many classes cost 2.58 times the peakBytes; allowed: 2.5". On Linux
that ratio goes from 2.29 to 1.85. Not measured on macOS.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

Comment on lines +2397 to +2399
for (const TextThatWaits& text : waiting) {
if (comesToEarlierCall && text.waitsApart)
levelsAddedToPlace = std::max<uint32_t>(levelsAddedToPlace, (text.place >> 33) - state.turnOfTexts);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit (optional): Programs whose dynamic imports wait for text on another thread can see an import() settle ahead of an earlier call it shares modules with, which the code says must not happen. AOTModules.cpp:2399 computes (text.place >> 33) - state.turnOfTexts in unsigned arithmetic; once an earlier waiting-apart call's turn has already passed (several calls were queued in one turn and are delivered one per turn), the difference wraps to about 2^32 and std::max<uint32_t> picks it over a genuinely later call's level count. Fix: only add a level count when (text.place >> 33) > state.turnOfTexts (saturate at 0), so the max reflects the latest earlier call rather than a wrapped value.

Why this was flagged

Trigger: under bun run, several import() calls are held in one turn with waitsApart set at AOTModules.cpp:2380; deliverTextThatWaits at AOTModules.cpp:2479-2495 delivers one per turn and increments turnOfTexts each time, so a call X held at turn 10 (place >> 33 == 11) can still be waiting at turn 13. A new import() W that comesToEarlierCall and also shares modules with a call Y held at turn 13 with levelsAddedToPlace 2 (place >> 33 == 15) then runs the loop at AOTModules.cpp:2397-2400. For X the subtraction (11) - 13 is uint64 and wraps; converted to uint32 by std::max<uint32_t> it becomes 0xFFFFFFFE, which beats Y's 2, so at AOTModules.cpp:2401 place += 0xFFFFFFFE << 33 wraps mod 2^64 and W lands at turn level 11 instead of 15. W is therefore delivered before Y, although the comment at AOTModules.cpp:2396 states 'What it shares with them comes no sooner for it than for them'. The base branch had no waits-apart ordering, so this is a new, intended ordering guarantee that the wrap defeats; putCallsThatWaitApartInLine at AOTModules.cpp:2410-2421 subtracts the same wrapped count so no crash results, only a wrong settlement order.

Verification: Trigger: under a host with callInLaterTurn, two or more import() calls are held apart in one turn. state.turnOfTexts is uint32_t (Source/JavaScriptCore/aot/runtime/AOTVMState.h:238) and text.place is uint64_t, so (text.place >> 33) - state.turnOfTexts at Source/JavaScriptCore/aot/runtime/AOTModules.cpp:2399 wraps when the waiting text's turn is behind the current one; std::max<uint32_t> narrows it to ~2^32-k, which beats any genuine small offset from a later call.

Jarred-Sumner added a commit to oven-sh/bun that referenced this pull request Oct 31, 2026
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.
@Jarred-Sumner
Jarred-Sumner merged commit 5869a3b into main Oct 31, 2026
88 checks passed
Jarred-Sumner added a commit to oven-sh/bun that referenced this pull request Nov 1, 2026
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.
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.

1 participant