Skip to content

ModuleLoader: record a non-Error fetch rejection as a fetch failure - #262

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/502a9a64/fetch-rejection-non-error-instance
Jun 30, 2026
Merged

ModuleLoader: record a non-Error fetch rejection as a fetch failure#262
Jarred-Sumner merged 1 commit into
mainfrom
farm/502a9a64/fetch-rejection-non-error-instance

Conversation

@robobun

@robobun robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

What

A top-level dynamic import() whose embedder fetch rejects with a value that is not an ErrorInstance poisons its module registry entry. Every later importer of the same file that goes through hostLoadImportedModule (a static import, or a dynamic import inside another module's graph load) then hangs forever.

Bun hits this constantly: a single-message transpile failure rejects the fetch with a BuildMessage, which is a plain JSDestructibleObject. No import attributes, no concurrency, no unusual input required:

// bad.ts contains exactly `import {` (one parser error -> a single BuildMessage)
// other.ts contains `import "./bad.ts"`
await import("./bad.ts").catch(() => {});
await import("./other.ts"); // never settles

On Bun 1.4.0 the second import() never resolves or rejects and the process never exits (timeout 10 bun run.mjs exits 124).

Why

moduleLoadTopSettled's rejected branch (JSMicrotask.cpp) gates the entire fetch/evaluation-error classification on dynamicDowncast<ErrorInstance>(errorValue):

if (auto* error = dynamicDowncast<ErrorInstance>(errorValue)) {
    auto failure = JSModuleLoader::getErrorInfo(globalObject, error);
    if (failure.isEvaluationError(specifier, type))
        entry->setEvaluationError(globalObject, error);
    else
        entry->setFetchError(globalObject, error);
}
intermediatePromise->reject(vm, errorValue);

For a non-ErrorInstance value, nothing is recorded. moduleLoadTopRejected then runs, finds entry->fetchError() null, and calls entry->setEvaluationError(errorValue).

That leaves the entry at EvaluationFailed with no fetch, module, or load promise: the top-level rejection path never reaches the inner loadModule/hostLoadImportedModule that would have attached them. The next hostLoadImportedModule of the same key finds a non-New entry with no fetchError() and no loadPromise(), skips the re-fetch because the status is not New, and calls ensureModulePromise(), which creates a fresh pending fetchPromise. Nothing ever settles it.

Fix

Add the else branch: a non-ErrorInstance rejection value is still a fetch failure, so record it with setFetchError.

This is safe by construction. moduleLoadTopSettled reacts to the promise JSModuleLoader::loadModule passes it, which is always a fetch result: either a fresh fetch() or a cached entry->ensureFetchPromise(). The only thing that rejects an entry's fetch promise is setFetchError, and provideFetch only fulfills it, so every rejection that reaches this microtask is a fetch failure. The existing isEvaluationError check is about ErrorInstances that carry stage metadata from attachErrorInfo; a value that cannot carry that metadata cannot be an evaluation error here.

It also matches the sibling path. The nested-import reaction, moduleRegistryFetchSettled, already calls setFetchError unconditionally and only gates the optional attachErrorInfo metadata on the value being an ErrorInstance:

JSValue errorValue = arguments[1];
if (auto* error = dynamicDowncast<ErrorInstance>(errorValue))
    JSModuleLoader::attachErrorInfo(globalObject, error, ...);
entry->setFetchError(globalObject, errorValue);

With the entry at FetchFailed, moduleLoadTopRejected rejects with the cached fetchError() instead of calling setEvaluationError, and later importers take hostLoadImportedModule's fetchError() short circuit and reject instead of parking on a promise nothing settles.

Not a duplicate of #258

#258 fixes a different bug in the same function (the module map key collapsing every host-defined import attribute onto one entry). It threads the attribute into the ensureRegistered call here but keeps the ErrorInstance gate, and this hang reproduces with no import attributes at all, so the two are independent. The diffs apply cleanly together.

Verification

Built JSCOnly (debug, asserts, ASAN) from this branch and linked it into a Bun debug build.

  • The repro above: both import()s reject with the BuildMessage, the process exits 0.
  • Without the fix the second import() never settles (10s timeout).
  • A Bun regression test is in the companion oven-sh/bun PR (linked once its preview artifact exists); it fails with a 5000ms test timeout on released Bun and passes on this build.

In moduleLoadTopSettled's rejected branch, the entire fetch/evaluation-error
classification is gated on dynamicDowncast<ErrorInstance>. Bun rejects a
single-message transpile failure with a BuildMessage, which is a plain
JSDestructibleObject, so nothing is recorded on the registry entry at all.
moduleLoadTopRejected then falls through its fetchError() check and records
the value with setEvaluationError.

That leaves the entry at EvaluationFailed with no fetch, module, or load
promise. The next importer of the same key that goes through
hostLoadImportedModule (a static import, or a dynamic import inside another
module's graph load) finds a non-New entry with no fetchError and no
loadPromise, creates a fresh pending fetchPromise via ensureModulePromise,
and waits on it forever. Nothing ever settles it.

  // bad.ts contains "import {" (exactly one parser error, so the
  // rejection value is a BuildMessage, not an AggregateError)
  await import("./bad.ts").catch(() => {});
  await import("./other.ts"); // other.ts statically imports bad.ts; hangs

This microtask reacts to the embedder fetch promise, so every rejection that
reaches it is a fetch failure by construction: the promise is either a fresh
fetch() or a cached entry->ensureFetchPromise(), and the only rejector of an
entry's fetch promise is setFetchError. Add the else branch so a non-Error
rejection value is recorded the same way. This matches what the sibling
nested-import path already does: moduleRegistryFetchSettled calls
setFetchError unconditionally and only gates the attachErrorInfo metadata on
the value being an ErrorInstance.

With the entry at FetchFailed, moduleLoadTopRejected rejects with the cached
fetchError() instead of calling setEvaluationError, and later importers take
hostLoadImportedModule's fetchError short circuit instead of parking on a
promise nothing settles.

Independent of #258 (the import-attribute module map key):
that change threads the attribute into ensureRegistered here but keeps the
ErrorInstance gate, and this hang reproduces with no import attributes at
all. The two apply cleanly together.
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 64ef46f5-dae9-4ae2-bf7f-b3873a923a6c

📥 Commits

Reviewing files that changed from the base of the PR and between 0d9ee56 and 661592f.

📒 Files selected for processing (1)
  • Source/JavaScriptCore/runtime/JSMicrotask.cpp

Walkthrough

This change extends the rejection-handling logic in moduleLoadTopSettled within JSMicrotask.cpp. A new else branch handles cases where the load rejection value is not an ErrorInstance, recording it as the module entry's fetch error instead of leaving it unhandled.

Changes

Module Fetch Error Categorization

Layer / File(s) Summary
Non-ErrorInstance rejection handling
Source/JavaScriptCore/runtime/JSMicrotask.cpp
Adds an else branch in moduleLoadTopSettled to set the module entry's fetch error via entry->setFetchError(...) when the rejection value is not an ErrorInstance, supplementing the existing ErrorInstance classification logic.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is substantive, but it does not follow the required template and omits the Bugzilla link, reviewed-by line, and changed-path details. Rewrite the description to match the template: bug title, Bugzilla link, Reviewed by line, brief fix explanation, and bullet list of changed paths/functions.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: non-Error fetch rejections are recorded as fetch failures.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

The companion Bun PR is oven-sh/bun#33149: it pins WEBKIT_VERSION at autobuild-preview-pr-262-661592f9 and adds the regression test described above (test/js/bun/resolve/concurrent-dynamic-import.test.ts, a 5000ms timeout on released Bun, passes with this branch).

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

LGTM — minimal else-branch that brings moduleLoadTopSettled's non-ErrorInstance rejection handling into parity with moduleRegistryFetchSettled.

Extended reasoning...

Overview

This PR adds a single else branch (5 lines including comment) to moduleLoadTopSettled in Source/JavaScriptCore/runtime/JSMicrotask.cpp. When the embedder fetch promise rejects with a value that is not an ErrorInstance, the code previously recorded nothing on the registry entry; the new branch calls entry->setFetchError(globalObject, errorValue) so the entry transitions to FetchFailed instead of being left in a state that later causes hostLoadImportedModule to park forever on a fresh pending promise.

Security risks

None. This is internal module-loader error-state bookkeeping. The change only affects which ModuleRegistryEntry status/error slot a rejection value is stored in; it does not introduce new data flow, parsing, allocation, or trust boundaries.

Level of scrutiny

Low. The change is mechanical and mirrors the existing sibling reaction moduleRegistryFetchSettled (lines ~908–913), which already calls setFetchError unconditionally on the JSValue and only gates the optional attachErrorInfo metadata on ErrorInstance. setFetchError's signature is (JSGlobalObject*, JSValue), so passing the raw errorValue is type-correct, and moduleLoadTopRejected already has a non-ErrorInstance branch when reading entry->fetchError() back (line ~1164–1165), so the downstream path handles the value shape.

Other factors

The PR description gives a precise causal chain (non-Error rejection → no fetchError recorded → moduleLoadTopRejected falls through to setEvaluationError → entry at EvaluationFailed with no fetch/load promise → next importer's ensureModulePromise() creates a never-settled promise) that I verified against the surrounding code in JSMicrotask.cpp and ModuleRegistryEntry.cpp. The author built JSCOnly debug+ASAN and confirmed the repro and fix, with a companion Bun regression test. The bug-hunting system found no issues, and there are no prior reviewer comments on the timeline.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
661592f9 autobuild-preview-pr-262-661592f9 2026-06-30 20:36:16 UTC

@Jarred-Sumner
Jarred-Sumner merged commit 1aa707c into main Jun 30, 2026
95 of 96 checks passed
Jarred-Sumner pushed a commit to oven-sh/bun that referenced this pull request Jul 1, 2026
Bumps `vendor/WebKit` to upstream `WebKit/WebKit@d81bcc3d833c`
(2026-06-30). 887 upstream commits since the last Bun sync point
(`b5ba38a21e17`, 2026-06-16); 117 touch `Source/JavaScriptCore`, 54
touch `Source/WTF`, 10 touch `Source/bmalloc`.

The fork-side work is split across two merge PRs:

- oven-sh/WebKit#261: `b5ba38a21e17` to `ce481c4cfedb`, merged as
`0d9ee56ba4f6`.
- oven-sh/WebKit#263: `ce481c4cfedb` to `d81bcc3d833c`, based on the
fork's current `main`, so it also carries oven-sh/WebKit#257 and
oven-sh/WebKit#262, which landed on the fork between the two merges and
which fix real Bun bugs (see below).

> [!NOTE]
> oven-sh/WebKit#263 has landed on `main` as `c9ad5813fd23`, and
`WEBKIT_VERSION` now points at that commit's release,
`autobuild-c9ad5813fd23bd8b98b0738abc3d037ec716aa92`. Nothing else is
pending on the WebKit side. It was landed as a squash rather than a
merge commit, but `c9ad5813fd23`'s git tree is byte-identical to the
preview head `f652829d78`'s tree (verified: both are `470eee60f5`), so
the final artifacts are built from exactly the source all of the
verification below ran against. The squash does drop the upstream parent
link, so whoever does the next fork upgrade will need a merge-base fixup
like the one that followed oven-sh/WebKit#251.

Fixes #32793

## Bun changes required by this upgrade

### From `b5ba38a2 -> ce481c4c`: three builtin rewrites

Upstream removed three bytecode intrinsics that Bun's built-in JS
modules use (https://bugs.webkit.org/show_bug.cgi?id=318076 and
https://bugs.webkit.org/show_bug.cgi?id=318028). Bumping the version
without these rewrites aborts on every `Buffer.from()` call:

```
ASSERTION FAILED: Private symbol not found: tryGetByIdWithWellKnownSymbol(value, "toPrimitive");
```

and on `new console.Console(stream).table(...)` (the global
`console.table` is native and was unaffected).

- `src/js/builtins/JSBufferConstructor.ts`:
`$tryGetByIdWithWellKnownSymbol(value, "toPrimitive")` becomes an
ordinary `value[Symbol.toPrimitive]` read, which is exactly what Node's
`lib/buffer.js` does. The removed intrinsic never invoked accessors, so
this is also a Node-compat improvement: a getter-defined
`Symbol.toPrimitive` is now honored.
`test/js/node/buffer-from-symbol-to-primitive.test.ts` covers it and
fails on the released Bun.
- `src/js/builtins/ConsoleObject.ts`: `$isMapIterator` /
`$isSetIterator` become `require("node:util/types").isMapIterator /
isSetIterator`, which is exactly what Node's
`internal/console/constructor.js` does.
`test/js/node/console/console-table-iterators.test.ts` is a snapshot
test that passes on both the released Bun and this branch, proving the
rewrite is behavior-preserving (there was no prior coverage for
`Console#table` on a Map/Set iterator).
- `src/js/builtins.d.ts`: the declarations for all ten removed
intrinsics are deleted so nothing reaches for them again.

### From oven-sh/WebKit#257: `jest` fake-time code must use the new
`NaN` sentinel

oven-sh/WebKit#257 changed the "no override is active" sentinel on
`JSGlobalObject::overridenDateNow` from `-1` to `NaN` (see
`JSGlobalObject::jsDateNow()`). Bun's reset paths still wrote `-1`,
which the new semantics read as a *live* override frozen at `-1` ms, so
`jest.useRealTimers()` and `setSystemTime()` with no argument left
`Date.now()` stuck at `1969-12-31T23:59:59.999Z`.
`test/js/bun/test/test-timers.test.ts` ("we can go back in time") caught
this against a Bun built from the #263 artifact.

- `src/runtime/test_runner/timers/FakeTimers.rs`: `CurrentTime::clear`
(the `jest.useRealTimers()` path) writes `f64::NAN` instead of `-1.0`.
- `src/jsc/bindings/JSMockFunction.cpp`, `JSMock__jsSetSystemTime`: the
reset value becomes `PNaN`. The old `>= 0` / `std::isnormal()` gating
existed only to serve the `-1` sentinel and silently dropped every
pre-epoch and zero override (a bug the #257 review called out); with the
`NaN` sentinel every real timestamp is a valid override, so
`setSystemTime(-1)` and `setSystemTime(new Date("1960-01-01"))` now
work. A new test in `test/js/bun/test/test-timers.test.ts` covers the
pre-epoch, zero, `-1`, and no-argument-reset cases and fails on Bun
1.4.0.
- Deleted `JSMock__jsUseRealTimers` from `JSMockFunction.cpp`: nothing
registers it (`jest.useRealTimers` is the Rust `use_real_timers` in
`FakeTimers.rs`), and it was the third place that would have needed the
sentinel change.

`ce481c4c -> d81bcc3d` itself (the 84 upstream commits) requires
nothing: no bytecode intrinsics changed, `JSType.h` did not change, and
the only WebCore code-generator change is to a helper Bun does not use.

## Bun bugs fixed by the fork-side commits this carries

- oven-sh/WebKit#257: `Date.now()` is tagged `DateNowIntrinsic`, so a
hot call site is inlined by the DFG/FTL as a `DateNow` node, and
`operationDateNow` returned the wall clock instead of reading
`JSGlobalObject::overridenDateNow`. A `setSystemTime()` override
therefore silently wore off as soon as a `Date.now()` call site tiered
up (#32793). `test/regression/issue/32793.test.ts` spawns the loop from
that issue and asserts the override never diverges; on the released Bun
it diverges at iteration 99, and it also fails against a build of this
branch's previous pin (`0d9ee56ba4f6`, which predates #257), so the test
isolates exactly this commit.
- oven-sh/WebKit#262: a top-level `import()` whose embedder fetch
rejects with a value that is not an `ErrorInstance` (a single-message
transpile failure rejects with a `BuildMessage`) poisoned its module
registry entry, and every later importer of the same file hung forever.
The Bun-side regression test lives in #33149, which also pins a WebKit
preview; once this lands, #33149 reduces to its test plus its
`scripts/sync-webkit-source.ts` improvement.

## Why the regression tests are spawned fixtures

Without the three builtin rewrites, a Bun built against this WebKit
aborts on the first `Buffer.from()` call:

```
ASSERTION FAILED: Private symbol not found: tryGetByIdWithWellKnownSymbol(value, "toPrimitive");
```

`Buffer.from` is fundamental enough that this takes down the `bun test`
runner itself, before any test file finishes loading and before the
JUnit reporter can write anything. So
`buffer-from-symbol-to-primitive.test.ts` and
`console-table-iterators.test.ts` live in two small files of their own
and spawn a child Bun instead of calling the affected APIs in process: a
child abort becomes an ordinary `exitCode`/`stdout` assertion failure,
whereas an in-process call takes the parent test runner down with it and
reports nothing. Their fail-before is "this branch minus the `src/js/`
rewrites", which is a deterministic SIGABRT:

```sh
git checkout $(git merge-base HEAD origin/main) -- src/js/   # keep scripts/ (the WEBKIT_VERSION bump)
bun bd test test/js/node/buffer-from-symbol-to-primitive.test.ts
# => ASSERTION FAILED: Private symbol not found: tryGetByIdWithWellKnownSymbol(...)  (SIGABRT)
git checkout HEAD -- src/js/
```

`test/js/bun/test/test-timers.test.ts` is the equivalent fail-before for
the sentinel change: with the `src/` diff reverted but the new WebKit
kept, both "we can go back in time" and the new `setSystemTime` test
fail.

`test/regression/issue/32793.test.ts` is different from all of the
above: its fix lives entirely in the vendored engine, selected by
`scripts/build/deps/webkit.ts` rather than by anything under `src/`. So
its before/after is a function of which WebKit artifact is linked, not
of the `src/` diff: it fails on the released Bun (1.4.0), fails against
this branch built with the previous pin `0d9ee56ba4f6`, and passes
against this branch built with the #263 artifact.

## Verification

All of the following were run against a debug Bun built from this branch
and the `autobuild-preview-pr-263-f652829d` artifact.

- The four test files this PR adds or modifies
(`test/js/bun/test/test-timers.test.ts`,
`test/regression/issue/32793.test.ts`,
`test/js/node/buffer-from-symbol-to-primitive.test.ts`,
`test/js/node/console/console-table-iterators.test.ts`): 5 tests, all
pass. Every one fails in the right "before" state as described above.
- Every test file in the repo that exercises `setSystemTime` /
`useFakeTimers` / `useRealTimers` / `advanceTimers*` (the blast radius
of the sentinel change):
`test/js/bun/test/fake-timers/fake-timers.test.ts`,
`test/js/bun/test/fake-timers/sinonjs/fake-timers.test.ts`,
`test/js/bun/cron/in-process-cron.test.ts`,
`test/regression/issue/25869.test.ts`,
`test/regression/issue/26284.test.ts`, and the five
`test/js/third_party/jsonwebtoken/*.test.js` suites. 249 pass, 1 todo, 0
fail.
- One more file turned out to be in that blast radius:
`test/js/bun/test/fake-timers/sinonjs/issue-207.test.ts`. The sentinel
change makes pre-epoch and negative clock values work, so its three
long-`.failing` tests ("should floor negative now values", "should floor
negative start times", "should handle ticks on the negative side of the
Epoch") started passing, which Bun's runner reports as a failure until
the stale marker is removed. `d2309c92c2` removes them; all ten tests in
the file pass against this branch.
- `test/js/node/buffer.test.js`: 539 pass, 1 pre-existing skip, 0 fail.
`test/js/node/console/console.test.ts`: 7 pass. Neither file is modified
by this PR.
- oven-sh/WebKit#263's preview build produced all 43 platform artifacts
with no job failures.

## Upstream changelog

<details>
<summary><code>ce481c4c</code> -> <code>d81bcc3d</code> (the new hop in
this revision)</summary>

Memory safety and security:

- `TypedArray.from()` out-of-bounds read via resizable `ArrayBuffer`
resize/transfer inside the `mapFn` callback
- Stale structure bit in `SlowPutArrayStorage`
- Do not cache property absence on dictionary structures
- Missing `codeBlock->m_lock` in `repatchGetBySlowPathCall`
- Insert a write barrier for `MultiPutByOffset` when it can reallocate
storage
- Unconditionally keep `OMGOSREntryCallee` alive while updating its
callsites
- Disallow defining private names on WasmGC objects
- Size limit on Yarr-generated code

Correctness:

- DFG spread did not account for `cellButterflyOnlyAtomStringsStructure`
- YarrJIT `negativeOffsetIndexedAddress` discarded the adjusted base
register
- `DFGArgumentsEliminationPhase::removeViaKill` did not reset the node
scan index between `InlineCallFrame`s

Other:

- An `ArrayStorage`-specific `GetByVal` DFG operation
- ES2022 private method support in the Web Inspector
- WTF: `TinyLRUCache::findIfCached` / `insert`

</details>

<details>
<summary>Security / correctness fixes (<code>b5ba38a2</code> ->
<code>ce481c4c</code>)</summary>

- Use-after-free after Wasm memory grow via a stale pointer folded by
DFGConstantFoldingPhase
- Keep `JSWebAssemblyMemory` alive from wasm-originated `JSArrayBuffer`s
(UAF)
- Use-after-free of `StreamingCompiler::m_ticket`
- `BBQCallee` kept alive between callsite collection and repatch
(upstreams and extends oven-sh/WebKit#210)
- `Array.from(arguments)` fast path bails out past
`MAX_STORAGE_VECTOR_LENGTH`
- `JSArray::fastFlat` bounds-checked against the maximum legal length
- `Array#flat` fast path was dropping prototype-backed holes in nested
arrays
- `Array.from(map.keys())` fast path was ignoring `Symbol.iterator`
overrides
- `RegExp#@@split` crash on a huge flags string
- `LiteralParser` takes the slow path if the original structure changes
- Missing exit-profile bails on some DFG nodes
- `IteratorClose` in `forEachInIterable` uses the Map/Set's own realm's
iterator structure
- YARR: JIT backreference with a non-BMP character was skipping the next
match start position
- Wasm Memory64: fix parsing of data segment init expressions
- Track `customSlotBase` for `CustomAccessorGetter` /
`CustomAccessorSetter`
- Set `MayStoreHole` appropriately; `"entries"` ArrayIterator emits
`ExitOK` before `NewArray`
- `poisonedDeadOSRExitValue` made crashier and on by default in debug
builds

</details>

<details>
<summary>Language features and spec alignment (<code>b5ba38a2</code> ->
<code>ce481c4c</code>)</summary>

- BigInt Math proposal implemented
- Temporal: spec-alignment passes across `PlainDate`, `PlainTime`,
`Duration`, `Instant`, plus a unified `ParseISODateTime`
- `Intl.PluralRules` reflects `compactDisplay` in its skeleton and
`resolvedOptions()`

</details>

<details>
<summary>Performance (<code>b5ba38a2</code> ->
<code>ce481c4c</code>)</summary>

JavaScript-visible fast paths:

- `JSON.stringify` space/indentation argument handled by
`FastStringifier`
- Fast path for the `RegExp#flags` getter; cached named-capture `groups`
Structure on `RegExp`
- Map/Set iterator `next` and `%RegExpStringIteratorPrototype%.next`
moved to C++
- int32 fast path in `parseInt` for short decimal strings
- `NumericStrings` int cache lookup inlined into DFG/FTL
`ToString(Int32)`
- `Object#isPrototypeOf` uses the `InstanceOf` DFG node
- `Object.freeze` / `seal` / `preventExtensions` no longer allocate
`ArrayStorage` for `NonArray` objects
- Map/Set `set` + `delete` churn no longer expands then immediately
shrinks
- Avoided an intermediate `StringImpl` allocation feeding
`StringBuilder` into `makeString`
- Wasm `intoCharCodeArray` bulk-copies 8-bit strings; unnecessary
watchpoints avoided
- `DFG::GetByVal` widens to `SpecBytecodeTop` instead of forcing an OSR
exit on an empty value profile

YARR (the RegExp engine):

- `latin1Table` for efficient Char8 filtering, reused for Char16 and the
interpreter
- `StringList` optimization when captures are unused; atom fast path
enabled for unicode patterns

JIT compile time (a large batch aimed at DFG/FTL/B3/Air throughput):

- Semi-NCA algorithm for `WTF::Dominators`; reverse post-order in
`WTF::Liveness` and B3 `ReduceStrength`; `SparseBitVector` in
`DFGLivenessAnalysis`
- Unified `SSACalculator`; dominance frontiers computed once per graph
- `AirFixObviousSpills`, `AirEliminateDeadCode`, `Air::RegLiveness`, and
the greedy register allocator all got cheaper
- B3 CSE reimplemented without `B3::Variable`; `B3::Value::effects` uses
a constant table
- `ReduceStrength` avoids fixed-point iteration; `InferSwitch` disabled
in B3 for Wasm
- Fixup-inserted `RegExp` primordial `TryGetById` chains replaced with a
single `CheckStructure`
- Parser: create save points only when actually needed
- Baseline/LLInt profile `op_unsigned` overflow and report int32
overflow to the DFG

</details>

<details>
<summary>WTF, bmalloc, and the build system (<code>b5ba38a2</code> ->
<code>ce481c4c</code>)</summary>

- `SaturatedArithmetic.h` and its APIs renamed to `Saturating` (Bun does
not use these directly)
- Checked arithmetic helpers now require two or more arguments
- `OrderedHashTable` load-factor policy aligned with `HashTable`
- `JSON::Value::dump` no longer corrupts large string values via
`PrintStream` truncation
- Structured clone: the remaining terminal JS value tags moved from
WebCore into JSC (`CloneSerializerBase.h` / `StructuredCloneTags.h`).
Bun keeps its own fork of `SerializedScriptValue.cpp`, so this is
additive.
- Workaround for a GCC 14+ false `-Wuninitialized` in `Variant`;
`[[msvc::no_unique_address]]` used on MSVC
- WTF / libpas exception codes changed to `0xbb08`
- libpas: per-heap tagging policy rework; `pas_deallocate` split into
inline-only and casual variants; PGO applied to WTF/bmalloc
- CMake: prefix headers expanded, headermap generation reworked, stale
staged headers pruned, and the Darwin platform block in `BPlatform.h` /
`pas_platform.h` no longer excludes CMake builds (restored in the fork,
see oven-sh/WebKit#261; without it the Linux-to-macOS cross lane fails
to link `gigacageEnabledForProcess`)

</details>
robobun added a commit to oven-sh/bun that referenced this pull request Jul 1, 2026
…same file

Regression test for the hang fixed by oven-sh/WebKit#262, which is in the
current WEBKIT_VERSION.

  // bad.ts contains "import {" (one parser error -> a BuildMessage)
  await import("./bad.ts").catch(() => {});
  await import("./other.ts"); // other.ts: import "./bad.ts"; never settled

A top-level dynamic import whose transpile fails with a single parser error
rejects with a BuildMessage, which is not an ErrorInstance. JSC's
moduleLoadTopSettled gated its fetch-error classification on
dynamicDowncast<ErrorInstance>, so nothing was recorded and
moduleLoadTopRejected fell back to setEvaluationError. That left the module
registry entry with no fetch, module, or load promise; the next importer of
the same file that goes through hostLoadImportedModule (a static import, or
a dynamic import inside another module's graph load) then parked forever on
a freshly created fetchPromise nothing settles.
liooil pushed a commit to liooil/poly that referenced this pull request Aug 7, 2026
Bumps `vendor/WebKit` to upstream `WebKit/WebKit@d81bcc3d833c`
(2026-06-30). 887 upstream commits since the last Bun sync point
(`b5ba38a21e17`, 2026-06-16); 117 touch `Source/JavaScriptCore`, 54
touch `Source/WTF`, 10 touch `Source/bmalloc`.

The fork-side work is split across two merge PRs:

- oven-sh/WebKit#261: `b5ba38a21e17` to `ce481c4cfedb`, merged as
`0d9ee56ba4f6`.
- oven-sh/WebKit#263: `ce481c4cfedb` to `d81bcc3d833c`, based on the
fork's current `main`, so it also carries oven-sh/WebKit#257 and
oven-sh/WebKit#262, which landed on the fork between the two merges and
which fix real Bun bugs (see below).

> [!NOTE]
> oven-sh/WebKit#263 has landed on `main` as `c9ad5813fd23`, and
`WEBKIT_VERSION` now points at that commit's release,
`autobuild-c9ad5813fd23bd8b98b0738abc3d037ec716aa92`. Nothing else is
pending on the WebKit side. It was landed as a squash rather than a
merge commit, but `c9ad5813fd23`'s git tree is byte-identical to the
preview head `f652829d78`'s tree (verified: both are `470eee60f5`), so
the final artifacts are built from exactly the source all of the
verification below ran against. The squash does drop the upstream parent
link, so whoever does the next fork upgrade will need a merge-base fixup
like the one that followed oven-sh/WebKit#251.

Fixes #32793

## Bun changes required by this upgrade

### From `b5ba38a2 -> ce481c4c`: three builtin rewrites

Upstream removed three bytecode intrinsics that Bun's built-in JS
modules use (https://bugs.webkit.org/show_bug.cgi?id=318076 and
https://bugs.webkit.org/show_bug.cgi?id=318028). Bumping the version
without these rewrites aborts on every `Buffer.from()` call:

```
ASSERTION FAILED: Private symbol not found: tryGetByIdWithWellKnownSymbol(value, "toPrimitive");
```

and on `new console.Console(stream).table(...)` (the global
`console.table` is native and was unaffected).

- `src/js/builtins/JSBufferConstructor.ts`:
`$tryGetByIdWithWellKnownSymbol(value, "toPrimitive")` becomes an
ordinary `value[Symbol.toPrimitive]` read, which is exactly what Node's
`lib/buffer.js` does. The removed intrinsic never invoked accessors, so
this is also a Node-compat improvement: a getter-defined
`Symbol.toPrimitive` is now honored.
`test/js/node/buffer-from-symbol-to-primitive.test.ts` covers it and
fails on the released Bun.
- `src/js/builtins/ConsoleObject.ts`: `$isMapIterator` /
`$isSetIterator` become `require("node:util/types").isMapIterator /
isSetIterator`, which is exactly what Node's
`internal/console/constructor.js` does.
`test/js/node/console/console-table-iterators.test.ts` is a snapshot
test that passes on both the released Bun and this branch, proving the
rewrite is behavior-preserving (there was no prior coverage for
`Console#table` on a Map/Set iterator).
- `src/js/builtins.d.ts`: the declarations for all ten removed
intrinsics are deleted so nothing reaches for them again.

### From oven-sh/WebKit#257: `jest` fake-time code must use the new
`NaN` sentinel

oven-sh/WebKit#257 changed the "no override is active" sentinel on
`JSGlobalObject::overridenDateNow` from `-1` to `NaN` (see
`JSGlobalObject::jsDateNow()`). Bun's reset paths still wrote `-1`,
which the new semantics read as a *live* override frozen at `-1` ms, so
`jest.useRealTimers()` and `setSystemTime()` with no argument left
`Date.now()` stuck at `1969-12-31T23:59:59.999Z`.
`test/js/bun/test/test-timers.test.ts` ("we can go back in time") caught
this against a Bun built from the #263 artifact.

- `src/runtime/test_runner/timers/FakeTimers.rs`: `CurrentTime::clear`
(the `jest.useRealTimers()` path) writes `f64::NAN` instead of `-1.0`.
- `src/jsc/bindings/JSMockFunction.cpp`, `JSMock__jsSetSystemTime`: the
reset value becomes `PNaN`. The old `>= 0` / `std::isnormal()` gating
existed only to serve the `-1` sentinel and silently dropped every
pre-epoch and zero override (a bug the #257 review called out); with the
`NaN` sentinel every real timestamp is a valid override, so
`setSystemTime(-1)` and `setSystemTime(new Date("1960-01-01"))` now
work. A new test in `test/js/bun/test/test-timers.test.ts` covers the
pre-epoch, zero, `-1`, and no-argument-reset cases and fails on Bun
1.4.0.
- Deleted `JSMock__jsUseRealTimers` from `JSMockFunction.cpp`: nothing
registers it (`jest.useRealTimers` is the Rust `use_real_timers` in
`FakeTimers.rs`), and it was the third place that would have needed the
sentinel change.

`ce481c4c -> d81bcc3d` itself (the 84 upstream commits) requires
nothing: no bytecode intrinsics changed, `JSType.h` did not change, and
the only WebCore code-generator change is to a helper Bun does not use.

## Bun bugs fixed by the fork-side commits this carries

- oven-sh/WebKit#257: `Date.now()` is tagged `DateNowIntrinsic`, so a
hot call site is inlined by the DFG/FTL as a `DateNow` node, and
`operationDateNow` returned the wall clock instead of reading
`JSGlobalObject::overridenDateNow`. A `setSystemTime()` override
therefore silently wore off as soon as a `Date.now()` call site tiered
up (#32793). `test/regression/issue/32793.test.ts` spawns the loop from
that issue and asserts the override never diverges; on the released Bun
it diverges at iteration 99, and it also fails against a build of this
branch's previous pin (`0d9ee56ba4f6`, which predates #257), so the test
isolates exactly this commit.
- oven-sh/WebKit#262: a top-level `import()` whose embedder fetch
rejects with a value that is not an `ErrorInstance` (a single-message
transpile failure rejects with a `BuildMessage`) poisoned its module
registry entry, and every later importer of the same file hung forever.
The Bun-side regression test lives in #33149, which also pins a WebKit
preview; once this lands, #33149 reduces to its test plus its
`scripts/sync-webkit-source.ts` improvement.

## Why the regression tests are spawned fixtures

Without the three builtin rewrites, a Bun built against this WebKit
aborts on the first `Buffer.from()` call:

```
ASSERTION FAILED: Private symbol not found: tryGetByIdWithWellKnownSymbol(value, "toPrimitive");
```

`Buffer.from` is fundamental enough that this takes down the `bun test`
runner itself, before any test file finishes loading and before the
JUnit reporter can write anything. So
`buffer-from-symbol-to-primitive.test.ts` and
`console-table-iterators.test.ts` live in two small files of their own
and spawn a child Bun instead of calling the affected APIs in process: a
child abort becomes an ordinary `exitCode`/`stdout` assertion failure,
whereas an in-process call takes the parent test runner down with it and
reports nothing. Their fail-before is "this branch minus the `src/js/`
rewrites", which is a deterministic SIGABRT:

```sh
git checkout $(git merge-base HEAD origin/main) -- src/js/   # keep scripts/ (the WEBKIT_VERSION bump)
bun bd test test/js/node/buffer-from-symbol-to-primitive.test.ts
# => ASSERTION FAILED: Private symbol not found: tryGetByIdWithWellKnownSymbol(...)  (SIGABRT)
git checkout HEAD -- src/js/
```

`test/js/bun/test/test-timers.test.ts` is the equivalent fail-before for
the sentinel change: with the `src/` diff reverted but the new WebKit
kept, both "we can go back in time" and the new `setSystemTime` test
fail.

`test/regression/issue/32793.test.ts` is different from all of the
above: its fix lives entirely in the vendored engine, selected by
`scripts/build/deps/webkit.ts` rather than by anything under `src/`. So
its before/after is a function of which WebKit artifact is linked, not
of the `src/` diff: it fails on the released Bun (1.4.0), fails against
this branch built with the previous pin `0d9ee56ba4f6`, and passes
against this branch built with the #263 artifact.

## Verification

All of the following were run against a debug Bun built from this branch
and the `autobuild-preview-pr-263-f652829d` artifact.

- The four test files this PR adds or modifies
(`test/js/bun/test/test-timers.test.ts`,
`test/regression/issue/32793.test.ts`,
`test/js/node/buffer-from-symbol-to-primitive.test.ts`,
`test/js/node/console/console-table-iterators.test.ts`): 5 tests, all
pass. Every one fails in the right "before" state as described above.
- Every test file in the repo that exercises `setSystemTime` /
`useFakeTimers` / `useRealTimers` / `advanceTimers*` (the blast radius
of the sentinel change):
`test/js/bun/test/fake-timers/fake-timers.test.ts`,
`test/js/bun/test/fake-timers/sinonjs/fake-timers.test.ts`,
`test/js/bun/cron/in-process-cron.test.ts`,
`test/regression/issue/25869.test.ts`,
`test/regression/issue/26284.test.ts`, and the five
`test/js/third_party/jsonwebtoken/*.test.js` suites. 249 pass, 1 todo, 0
fail.
- One more file turned out to be in that blast radius:
`test/js/bun/test/fake-timers/sinonjs/issue-207.test.ts`. The sentinel
change makes pre-epoch and negative clock values work, so its three
long-`.failing` tests ("should floor negative now values", "should floor
negative start times", "should handle ticks on the negative side of the
Epoch") started passing, which Bun's runner reports as a failure until
the stale marker is removed. `d2309c92c2` removes them; all ten tests in
the file pass against this branch.
- `test/js/node/buffer.test.js`: 539 pass, 1 pre-existing skip, 0 fail.
`test/js/node/console/console.test.ts`: 7 pass. Neither file is modified
by this PR.
- oven-sh/WebKit#263's preview build produced all 43 platform artifacts
with no job failures.

## Upstream changelog

<details>
<summary><code>ce481c4c</code> -> <code>d81bcc3d</code> (the new hop in
this revision)</summary>

Memory safety and security:

- `TypedArray.from()` out-of-bounds read via resizable `ArrayBuffer`
resize/transfer inside the `mapFn` callback
- Stale structure bit in `SlowPutArrayStorage`
- Do not cache property absence on dictionary structures
- Missing `codeBlock->m_lock` in `repatchGetBySlowPathCall`
- Insert a write barrier for `MultiPutByOffset` when it can reallocate
storage
- Unconditionally keep `OMGOSREntryCallee` alive while updating its
callsites
- Disallow defining private names on WasmGC objects
- Size limit on Yarr-generated code

Correctness:

- DFG spread did not account for `cellButterflyOnlyAtomStringsStructure`
- YarrJIT `negativeOffsetIndexedAddress` discarded the adjusted base
register
- `DFGArgumentsEliminationPhase::removeViaKill` did not reset the node
scan index between `InlineCallFrame`s

Other:

- An `ArrayStorage`-specific `GetByVal` DFG operation
- ES2022 private method support in the Web Inspector
- WTF: `TinyLRUCache::findIfCached` / `insert`

</details>

<details>
<summary>Security / correctness fixes (<code>b5ba38a2</code> ->
<code>ce481c4c</code>)</summary>

- Use-after-free after Wasm memory grow via a stale pointer folded by
DFGConstantFoldingPhase
- Keep `JSWebAssemblyMemory` alive from wasm-originated `JSArrayBuffer`s
(UAF)
- Use-after-free of `StreamingCompiler::m_ticket`
- `BBQCallee` kept alive between callsite collection and repatch
(upstreams and extends oven-sh/WebKit#210)
- `Array.from(arguments)` fast path bails out past
`MAX_STORAGE_VECTOR_LENGTH`
- `JSArray::fastFlat` bounds-checked against the maximum legal length
- `Array#flat` fast path was dropping prototype-backed holes in nested
arrays
- `Array.from(map.keys())` fast path was ignoring `Symbol.iterator`
overrides
- `RegExp#@@split` crash on a huge flags string
- `LiteralParser` takes the slow path if the original structure changes
- Missing exit-profile bails on some DFG nodes
- `IteratorClose` in `forEachInIterable` uses the Map/Set's own realm's
iterator structure
- YARR: JIT backreference with a non-BMP character was skipping the next
match start position
- Wasm Memory64: fix parsing of data segment init expressions
- Track `customSlotBase` for `CustomAccessorGetter` /
`CustomAccessorSetter`
- Set `MayStoreHole` appropriately; `"entries"` ArrayIterator emits
`ExitOK` before `NewArray`
- `poisonedDeadOSRExitValue` made crashier and on by default in debug
builds

</details>

<details>
<summary>Language features and spec alignment (<code>b5ba38a2</code> ->
<code>ce481c4c</code>)</summary>

- BigInt Math proposal implemented
- Temporal: spec-alignment passes across `PlainDate`, `PlainTime`,
`Duration`, `Instant`, plus a unified `ParseISODateTime`
- `Intl.PluralRules` reflects `compactDisplay` in its skeleton and
`resolvedOptions()`

</details>

<details>
<summary>Performance (<code>b5ba38a2</code> ->
<code>ce481c4c</code>)</summary>

JavaScript-visible fast paths:

- `JSON.stringify` space/indentation argument handled by
`FastStringifier`
- Fast path for the `RegExp#flags` getter; cached named-capture `groups`
Structure on `RegExp`
- Map/Set iterator `next` and `%RegExpStringIteratorPrototype%.next`
moved to C++
- int32 fast path in `parseInt` for short decimal strings
- `NumericStrings` int cache lookup inlined into DFG/FTL
`ToString(Int32)`
- `Object#isPrototypeOf` uses the `InstanceOf` DFG node
- `Object.freeze` / `seal` / `preventExtensions` no longer allocate
`ArrayStorage` for `NonArray` objects
- Map/Set `set` + `delete` churn no longer expands then immediately
shrinks
- Avoided an intermediate `StringImpl` allocation feeding
`StringBuilder` into `makeString`
- Wasm `intoCharCodeArray` bulk-copies 8-bit strings; unnecessary
watchpoints avoided
- `DFG::GetByVal` widens to `SpecBytecodeTop` instead of forcing an OSR
exit on an empty value profile

YARR (the RegExp engine):

- `latin1Table` for efficient Char8 filtering, reused for Char16 and the
interpreter
- `StringList` optimization when captures are unused; atom fast path
enabled for unicode patterns

JIT compile time (a large batch aimed at DFG/FTL/B3/Air throughput):

- Semi-NCA algorithm for `WTF::Dominators`; reverse post-order in
`WTF::Liveness` and B3 `ReduceStrength`; `SparseBitVector` in
`DFGLivenessAnalysis`
- Unified `SSACalculator`; dominance frontiers computed once per graph
- `AirFixObviousSpills`, `AirEliminateDeadCode`, `Air::RegLiveness`, and
the greedy register allocator all got cheaper
- B3 CSE reimplemented without `B3::Variable`; `B3::Value::effects` uses
a constant table
- `ReduceStrength` avoids fixed-point iteration; `InferSwitch` disabled
in B3 for Wasm
- Fixup-inserted `RegExp` primordial `TryGetById` chains replaced with a
single `CheckStructure`
- Parser: create save points only when actually needed
- Baseline/LLInt profile `op_unsigned` overflow and report int32
overflow to the DFG

</details>

<details>
<summary>WTF, bmalloc, and the build system (<code>b5ba38a2</code> ->
<code>ce481c4c</code>)</summary>

- `SaturatedArithmetic.h` and its APIs renamed to `Saturating` (Bun does
not use these directly)
- Checked arithmetic helpers now require two or more arguments
- `OrderedHashTable` load-factor policy aligned with `HashTable`
- `JSON::Value::dump` no longer corrupts large string values via
`PrintStream` truncation
- Structured clone: the remaining terminal JS value tags moved from
WebCore into JSC (`CloneSerializerBase.h` / `StructuredCloneTags.h`).
Bun keeps its own fork of `SerializedScriptValue.cpp`, so this is
additive.
- Workaround for a GCC 14+ false `-Wuninitialized` in `Variant`;
`[[msvc::no_unique_address]]` used on MSVC
- WTF / libpas exception codes changed to `0xbb08`
- libpas: per-heap tagging policy rework; `pas_deallocate` split into
inline-only and casual variants; PGO applied to WTF/bmalloc
- CMake: prefix headers expanded, headermap generation reworked, stale
staged headers pruned, and the Darwin platform block in `BPlatform.h` /
`pas_platform.h` no longer excludes CMake builds (restored in the fork,
see oven-sh/WebKit#261; without it the Linux-to-macOS cross lane fails
to link `gigacageEnabledForProcess`)

</details>
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