Skip to content

internal/primordials: Node.js parity, generated and engine-backed - #35567

Open
robobun wants to merge 25 commits into
mainfrom
farm/caec3ad2/primordials-link-time-constants
Open

internal/primordials: Node.js parity, generated and engine-backed#35567
robobun wants to merge 25 commits into
mainfrom
farm/caec3ad2/primordials-link-time-constants

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Makes internal/primordials real. It becomes Node.js's actual primordials object — frozen, null-prototype references to the original built-ins that survive any prototype pollution or global replacement — so the ~16 builtin modules ported from Node's lib/ (node:util inspect, node:assert, errors, streams, worker messaging, quic, ...) keep working after user code tampers with Array.prototype, RegExp.prototype, Math, Error, and so on.

const { ArrayPrototypePush, SafeMap, ObjectKeys, hardenRegExp } = require("internal/primordials");
Array.prototype.push = () => { throw new Error("evil"); };
ArrayPrototypePush(list, x); // still the original push

Every value member is a $Name link-time constant materialized by JSC (from oven-sh/WebKit#341), so it is pristine no matter when the module loads — including if user code ran first. The member set and semantics are Node ≥ 25's lib/internal/per_context/primordials.js (~840 members): prototype methods and accessors are uncurried, statics are the raw functions, receiver-bound *Apply variants take (argsArray) as Node's do, and spec constants are inlined. The helpers Node builds by hand — uncurryThis, applyBind, makeSafe, the Safe* collections, the SafePromise combinators, hardenRegExp, SafeStringPrototypeSearch, SafeArrayPrototypePushApply — are ported from Node. Because Bun loads the module lazily rather than at context bootstrap, the Safe* classes are built from generated pristine descriptor records and hardenRegExp uses the raw RegExp constants, so nothing reads a live object at load; Safe collections also hand out captured safe iterators.

Generated, not hand-written. src/codegen/generate-primordials.ts runs a probe of Node's construction algorithm inside a pristine build to learn where each member's pristine value lives, then emits three artifacts: JSC's per-holder entry table (JSCPrimordialsTable.h, in the WebKit PR), src/js/internal/primordials.js, and src/js/primordials.d.ts. Rerun it when JSC's builtin surface changes; a test fails if the checked-in artifacts drift.

bun:internal-for-testing exposes primordials.audit() (the engine's per-entry manifest) and primordials.object for the tests below. Bumps WebKit to the paired build.

How did you verify your code works?

test/js/bun/util/primordials.test.ts iterates the whole manifest rather than a sample: the engine table covers every holder with 0 unavailable entries; the module's member set equals Node's algorithm run live; a differential oracle rebuilds Node's object by value in a pristine realm and compares all ~840 members (presence, typeof, arity, literal values); every entry is identity-equal to the live builtin in untouched and pre-touched globals; direct, roundabout, and structural tampering of every entry — planted before and after first load and across realms — never leaks into a primordial; plus option-off placeholders, Workers, and the $Name bundler path. Ran locally on debug and release builds; existing consumer suites (vm, assert, util-inspect, worker_threads, readline) pass unchanged.

Depends on oven-sh/WebKit#341.


[decide:webkit] gate passed · iteration 17 · 12 files touched

fails on main (without fix)
ASAN without fix: 17 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/primordials.test.ts
bun test v1.4.0 (75292f84b)

test/js/bun/util/primordials.test.ts:
256 |         if (!row.available) unavailable[unavailable.length] = row.name;
257 |         if (row.kind !== "Self" && row.key === null) missingKeys[missingKeys.length] = row.name;
258 |       }
259 |       report({ count: rows.length, unknownHolders, unavailable, missingKeys });
260 |     `);
261 |     expect(stderr).toBe("");
                         ^
error: expect(received).toBe(expected)

- ""
+ "1 | 
+ 2 |   const { primordials } = require("bun:internal-for-testing");
+ 3 |   const audit = primordials.audit;
+                     ^
+ TypeError: undefined is not an object (evaluating 'primordials.audit')
+       at /workspace/bun/[eval]:3:17
+ 
+ Bun v1.4.0-debug+75292f84b (Linux x64)
+ "

- Expected  - 1
+ Received  + 9

      at <anonymous> (/workspace/bun/test/js/bun/util/primordials.test.ts:261:20)
(fail) primordials manifest > engine table covers every holder, materializes every entry [1027.99ms]
385 |       const rows = au
... (truncated)

release without fix: 3 FAILED
bun test v1.4.0-canary.1 (c6a0d3df2)

test/js/bun/util/primordials.test.ts:
(pass) primordials manifest > engine table covers every holder, materializes every entry [50.14ms]
(pass) primordials survive tampering > pollution planted before any lazy holder exists is never captured [41.79ms]
(pass) primordials survive tampering > a foreign realm's builtin functions are never adopted, even installed before first link [40.13ms]
(pass) primordials manifest > every engine entry is identical to the live builtin in an untouched global [44.76ms]
(pass) primordials configuration > option-gated builtins that don't exist become throwing placeholders, not crashes or pollution [33.33ms]
(pass) primordials survive tampering > primitive-valued global bindings before first use don't break materialization [41.11ms]
(pass) $Name link-time constants in builtin JavaScript > untouched [34.16ms]
(pass) primordials survive tampering > direct and roundabout tampering of every entry after the builtins exist [52.47ms]
(pass) primordials survive tampering > tampered builtins still work through the primordials module [50.53ms]
(pass) primordials module load timing > tampering planted before the 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/util/primordials.test.ts
bun test v1.4.0 (75292f84b)

test/js/bun/util/primordials.test.ts:
(pass) primordials manifest > engine table covers every holder, materializes every entry [1472.46ms]
(pass) primordials manifest > every engine entry is identical to the live builtin in an untouched global [1602.40ms]
(pass) primordials manifest > src/js/primordials.d.ts is in sync with the engine's entries [1691.90ms]
(pass) primordials manifest > every member matches a reference built the way Node builds it [2304.94ms]
(pass) primordials manifest > internal/primordials has exactly Node's members for this engine [2381.72ms]
(pass) primordials survive tampering > direct and roundabout tampering of every entry after the builtins exist [2043.57ms]
(pass) primordials survive tampering > pollution planted before any lazy holder exists is never captured [2097.89ms]
(pass) primordials survive tampering > primitive-valued global bindings before first use don't break materialization [1474.27ms]
(pass) primordials survive tampering > a foreig
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 698ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/140] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[2/140] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 238 extern-C blocks audited
[3/140] gen cpp.rs (cppbind)
[4/140] gen JS modules (bundle-modules)
Preprocess modules (9093ms)
Bundle modules (44ms)
Postprocesss modules (32ms)
Bundle Functions (651ms)
Generate Code (29ms)

[9.86s] Bundled "src/js" for production
  2623 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[4/139] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

^[[1m^[[92m   Compiling^[[0m bun_alloc v0.0.0 (/workspace/bun/src/bun_alloc)
^[[1m^[[92m   Compiling^[[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
^[[1m^[[92m   Compiling^[[0m bun_libdeflate_sys v0.0.0 (/workspace/bun/src/libdeflate_sys)
^[[1m^[[92m   Compiling^[[0m bun_errno v0.0.0 (/w
... (truncated)
diff hotspot
scripts/build/deps/webkit.ts               |    2 +-
 src/codegen/generate-primordials-probe.js  |  202 ++++
 src/codegen/generate-primordials.ts        |  601 ++++++++++
 src/codegen/primordials-module-epilogue.js |  402 +++++++
 src/codegen/primordials-module-prologue.js |   21 +
 src/js/internal-for-testing.ts             |   83 +-
 src/js/internal/primordials.js             | 1632 +++++++++++++++++++++++++---
 src/js/primordials.d.ts                    |  677 ++++++++++++
 src/jsc/bindings/PrimordialsAudit.cpp      |   14 +
 src/jsc/bindings/PrimordialsAudit.h        |   11 +
 src/jsc/bindings/ZigGlobalObject.cpp       |    3 +
 test/js/bun/util/primordials.test.ts       |  991 +++++++++++++++++
 12 files changed, 4499 insertions(+), 140 deletions(-)

gate history · 1 passed · 4 rejected · iteration 17

evidence per changed file
file                                        reads  edits  tests
scripts/build/deps/webkit.ts                    4      4      0
src/codegen/generate-primordials-probe.js       1      1      0
src/codegen/generate-primordials.ts             2      3      0
src/codegen/primordials-module-epilogue.js      1      2      0
src/codegen/primordials-module-prologue.js      1      1      0
src/js/internal-for-testing.ts                  5      7      0
src/js/internal/primordials.js                  1      0      0
src/js/primordials.d.ts                         1      1      0
src/jsc/bindings/PrimordialsAudit.cpp           0      0      0
src/jsc/bindings/PrimordialsAudit.h             0      0      0
src/jsc/bindings/ZigGlobalObject.cpp            0      0      0
test/js/bun/util/primordials.test.ts            6     14      0

Bump WebKit to 6d9faf57f9 which adds 435 link-time constants named
after Node.js primordials (@ArrayPrototypePush, @ObjectDefineProperty,
@MapPrototypeGetSize, ...) captured at holder-creation time so builtin
JavaScript can call originals tamper-proof in JSC style:

    $ArrayPrototypePush.$call(array, value);
    $ObjectDefineProperty(obj, key, desc);
    $MapPrototypeGetSize.$call(map);

@-name resolution emits moveLinkTimeConstant (constant-pool slot) and
.@call in a builtin compiles to a bare op_call, so there is no lookup
overhead versus an unpolluted direct call.

Add $-prefixed typedefs for all 435 names to builtins.d.ts and a
bun:internal-for-testing probe that exercises one primordial per holder
kind (eager prototype, LUT-backed constructor, LazyClassStructure,
LazyProperty, PropertyCallback) plus a getter and a symbol-keyed
method. The test pollutes prototypes and namespace objects and verifies
the primordials still return the original behavior, including the case
where a lazy holder (Map, Math) is first touched after tampering.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

The PR adds generated JSC primordial bindings, hardened runtime wrappers, audit/test interfaces, extensive tampering and isolation tests, and changes the default WebKit prebuilt identifier.

Link-time primordials

Layer / File(s) Summary
Primordial generation and declarations
src/codegen/*, src/js/primordials.d.ts
Runtime probes and generator logic emit primordial tables, JavaScript bindings, and typed $... declarations.
Hardened primordial runtime
src/js/internal/primordials.js, src/codegen/primordials-module-*.js
Generated primordials gain safe iterators, built-in wrappers, Promise combinators, RegExp hardening, and frozen exports.
Audit and testing interface
src/jsc/bindings/*, src/js/internal-for-testing.ts
A C++ audit binding, Error primordial override, primordials test APIs, and lazy internal-module getters are added.
Tamper-resistant primordial validation
test/js/bun/util/primordials.test.ts
Child-process tests cover manifests, identity, tampering, isolation, configuration, workers, and link-time constant behavior.

WebKit artifact selection

Layer / File(s) Summary
WebKit prebuilt version
scripts/build/deps/webkit.ts
WEBKIT_VERSION now uses the autobuild-preview-pr-341-39d7bcfd identifier.

Possibly related PRs

  • oven-sh/bun#34009: Extends the WebKit prebuilt tag and identity derivation used by the updated version value.
  • oven-sh/bun#34517: Also changes the test-only exposed internal modules.
  • oven-sh/bun#35097: Changes availability of the bun:internal-for-testing surface containing the new primordial APIs.

Suggested reviewers: alii

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement the requested primordials support with protected built-ins, safe wrappers, and tests for Node.js compatibility.
Out of Scope Changes check ✅ Passed The added files and WebKit bump all support the primordials feature; no clear unrelated changes stand out.
Title check ✅ Passed The title clearly identifies the main change: generated, engine-backed Node.js primordials parity.
Description check ✅ Passed The description explains the implementation, generated artifacts, verification strategy, test results, dependency, and known merge follow-up.

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

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:04 PM PT - Aug 3rd, 2026

@dylan-conway, your commit 75292f8 has 1 failures in Build #88247 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35567

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

bun-35567 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Inconsistent behavior between CommonJS (require) and ESM (import) after modifying Object.prototype #24336 - Object.prototype['0'] setter triggers during require(), indicating Bun's CommonJS module loader does indexed assignments that primordial-safe operations would protect against
  2. require('fs') evaluates Set prototype function #18890 - Set.prototype tampering triggers during require('fs')/import('fs'), indicating Bun's internal fs module initialization iterates Sets in a way that Set-related primordials would protect

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #24336
Fixes #18890

🤖 Generated with Claude Code

Use the autobuild-preview-pr-341-6d9faf57 prerelease until the WebKit
PR merges and a main-branch autobuild is available.
Comment thread src/js/builtins.d.ts Outdated
Comment thread src/js/internal-for-testing.ts Outdated
Comment thread test/js/bun/util/primordials.test.ts Outdated
robobun added 2 commits July 25, 2026 07:30
…tins

Under bun -e the first user arg is process.argv[1], not [2], so the
tamper block was never entered and the test was vacuous. Switch to a
TAMPER env var and capture JSON.stringify/process.stdout.write/Map/
Uint8Array before tampering so the harness itself does not trip the
poisoned builtins.

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

Thanks for addressing the earlier test-harness feedback — the tamper gate now uses process.env.TAMPER, and the child script captures JSON.stringify/write/Map/Uint8Array before tampering and uses indexed iteration, so the "are tamper-proof" case is no longer vacuous. No new issues found this pass, but this is a WebKit bump (to a preview PR tag) whose actual behavior change lives in oven-sh/WebKit#341, so it still wants a human sign-off.

What was reviewed

  • Revised child script: harness values are built pre-tamper; no remaining paths through tampered Symbol.iterator/bind/JSON.stringify.
  • primordials.run() body under tampering — .next(), .length, new DataView(u8.buffer), instanceof $Promise all go through un-tampered slots.
  • prebuiltUrl/prebuiltDestDir in webkit.ts already handle autobuild-* tags, so the preview version string is well-formed for both.
  • 30s Worker-test timeout flagged and ruled out (Worker startup under debug+ASAN routinely exceeds the default).
Extended reasoning...

Overview

Four files: a one-line WEBKIT_VERSION bump to autobuild-preview-pr-341-6d9faf57, 435 declare const $...: Function typedefs appended to src/js/builtins.d.ts, a ~40-line primordials probe in src/js/internal-for-testing.ts, and a new test/js/bun/util/primordials.test.ts. The behavior change (capturing 435 link-time-constant primordials in JSGlobalObject) is entirely in the WebKit fork PR — this repo only contains typedefs, a test probe, and the version pin.

Prior feedback resolved

My earlier inline comment identified that process.argv[2] under bun -e is always undefined (making the tamper test vacuous) and that once fixed the harness would trip over its own tampered builtins. Commit e56d298 addresses both: gating moved to process.env.TAMPER, stringify/write/map/u8 captured before the tamper block, Uint8Array filled by index (no array iterator), and the for-of/destructure replaced with an indexed for. I re-traced every statement after the tamper block against the tamper list and found no remaining leak.

Security risks

None introduced by this repo's diff — .d.ts declarations have no runtime effect, and internal-for-testing.ts is CI-gated. The security-relevant surface (whether primordial capture is truly race-free against user tampering across all lazy-holder kinds) is implemented in WebKit#341 and not reviewable here.

Level of scrutiny

High, because it is a WebKit dependency bump and the version string is a preview PR tag rather than a merged commit hash. Per the repo's own guidance a dependency bump — especially to a fork of the JS engine — needs a maintainer to confirm WebKit#341 is landing and to swap the preview tag for the real autobuild-<sha> before merge. The Bun-side changes themselves are mechanical.

Other factors

  • prebuiltUrl() already special-cases autobuild- prefixes and prebuiltDestDir() slices the prefix off, so the preview tag won't produce a malformed URL or cache key.
  • The PR description already states CI will fail until the WebKit autobuild completes; that plus the preview tag are both things a human needs to coordinate.
  • comment-cop bot pings on builtins.d.ts/internal-for-testing.ts appear to be false positives (the added comments are one-liners).
  • No src/js/ builtin has been migrated to the new intrinsics yet, so there is no user-visible behavior to regress in this PR itself.

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/build/deps/webkit.ts`:
- Line 6: Update the WEBKIT_VERSION constant to reference the merged main commit
or stable tag instead of the transient autobuild-preview-pr-341-6d9faf57 value,
ensuring prebuilt fetches remain available after the PR lifecycle ends.

In `@test/js/bun/util/primordials.test.ts`:
- Line 130: Remove the explicit timeout option from the “Worker globals capture
independently” test declaration, leaving the test body and repository-level
timeout behavior unchanged.
- Around line 35-37: Extend the primordials tampering setup in the test to
poison getter-backed properties, capturing Object.defineProperty before any
tampering and using it to replace at least Map.prototype.size with a throwing
getter before primordials.run(). Also cover the exercised
TypedArray.prototype.length and DataView.prototype.byteLength getters, ensuring
the test fails if they are looked up late while preserving the existing tampered
subarray and Promise.resolve checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 12276c73-15ea-4e84-b3b1-5a2aded73fee

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and 1a9e9d4.

📒 Files selected for processing (4)
  • scripts/build/deps/webkit.ts
  • src/js/builtins.d.ts
  • src/js/internal-for-testing.ts
  • test/js/bun/util/primordials.test.ts

Comment thread scripts/build/deps/webkit.ts Outdated
Comment thread test/js/bun/util/primordials.test.ts Outdated
Comment thread test/js/bun/util/primordials.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Beyond the inline nit: confirmed e56d298 fully addresses the earlier 🔴 — the child now gates on process.env.TAMPER, captures JSON.stringify/write/map/u8 before tampering, builds the Uint8Array via indexed writes, and uses an indexed for loop; I re-walked every post-tamper line (including .next().value, .length, new DataView(u8.buffer), Reflect.getPrototypeOf) and none go through a tampered builtin. Deferring on the merge decision itself since WEBKIT_VERSION points at a preview autobuild tag for an as-yet-unmerged oven-sh/WebKit PR.

Extended reasoning...

The prior blocking finding was addressed in e56d298 and the fix is complete on inspection. This run's only new finding is a nit (missing tamper-ran canary). The PR is otherwise type declarations + a test probe + a WebKit version bump; the substantive implementation lives in oven-sh/WebKit#341, and WEBKIT_VERSION currently targets a preview autobuild tag rather than a merged commit, so a maintainer should coordinate the landing order.

Comment thread test/js/bun/util/primordials.test.ts Outdated
robobun and others added 2 commits July 25, 2026 11:13
… canary

Tamper Map.prototype.size, %TypedArray%.prototype.length,
DataView.prototype.byteLength and RegExp.prototype.source with throwing
getters so a late getter lookup in capturePrimordials would fail the
test. Route the probe's own reads of subarray().length and u8.buffer
through primordial getters so the probe does not trip the poison.

Emit out.tampered = (Array.prototype.push !== originalPush) and assert
it in the parent so a future regression in the TAMPER gate cannot pass
vacuously.

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

No issues found in the Bun-side changes, but this bumps WebKit/JSC and the substantive change (435 link-time constants, lazy-capture hooks in JSGlobalObject::init) lives in oven-sh/WebKit#341 — that plus the temporary autobuild-preview-pr-* pin needs a human sign-off.

Reviewed: the tamper-proof test now gates on process.env.TAMPER, captures stringify/defineProperty/write/map/u8 before poisoning, and asserts the tampered canary — the earlier vacuous-gate class is closed. Checked that the child harness's remaining userland calls (Reflect.getPrototypeOf, process.stdout.write, indexed for loop) don't hit anything the tamper block replaces. The primordials.run probe covers one holder per kind (eager, LUT, LazyClassStructure, LazyProperty, PropertyCallback, getter, symbol-keyed) and the getter-backed ones are now poisoned via captured defineProperty.

Extended reasoning...

Overview

Four files: a one-line WEBKIT_VERSION bump to autobuild-preview-pr-341-6d9faf57, ~435 generated declare const $Name: Function typedefs appended to src/js/builtins.d.ts, a primordials.{run,refs} probe in src/js/internal-for-testing.ts that exercises one link-time constant per JSC holder kind, and a new test/js/bun/util/primordials.test.ts that spawns child processes to verify capture, tamper-proofing (with a tampered canary asserting the poison block actually ran), lazy-holder-first-touch ordering, per-Worker capture, and PropertyCallback-holder overwrite-before-first-read.

Security risks

None introduced on the Bun side — the diff adds type declarations, a test-only probe gated behind bun:internal-for-testing, and tests. The security-relevant behavior (tamper-proof primordial capture in m_linkTimeConstants) is implemented entirely in the WebKit PR, which is not reviewable from this diff.

Level of scrutiny

High. WEBKIT_VERSION controls which JavaScriptCore build every Bun binary links against; any JSC bump is a critical-path change even when the Bun-side diff is small. The pin is also to a transient autobuild-preview-pr-* tag that the author has stated will be replaced with the merged main-branch commit once oven-sh/WebKit#341 lands — a human should confirm that hand-off before merge. The 435-name typedef list is stated to be generated from JSC_FOREACH_PRIMORDIAL_NAME; drift between it and the WebKit header would surface as a codegen/link failure, not silent misbehavior, so I did not audit it name-by-name.

Other factors

Both of my earlier findings on this PR (the process.argv[2] dead gate under bun -e, and the missing tamper canary) were addressed in e56d298 and e3f28fe; CodeRabbit's getter-poisoning and preview-pin threads are resolved. CI on 8ea828c is still building per robobun, and the PR body notes CI will fail until the WebKit autobuild completes — another reason for a human to gate the merge on the dependency landing.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Status at d7c3488 (build #81759):

No test failures. primordials.test.ts passes on every lane that ran. All bot review threads resolved; two clean review summaries from the review bot ("No further findings", deferring to maintainer for the WebKit preview pin + overridePrimordialsFromHolder in ZigGlobalObject::addBuiltinGlobals).

Only red: aarch64-android build-cpp 404'd on bun-webkit-linux-arm64-android.tar.gz because the WebKit autobuild-preview-pr-341-d0f433e1 release was still uploading when that lane ran. That asset (and windows/macos/freebsd) is now published (38/~40 assets), so the next CI run should build on every lane.

Review follow-ups applied on top of e4b4873:

  • oxlint-disable header emitted in generated primordials.d.ts
  • tamper canary in the exhaustive-tamper test
  • dropped dead primordials.refs()
  • AsyncIteratorPrototype factory gains the missing getProto hop
  • setProto/seal/freeze/reflectSet captured in the prelude so steps 1 and 3 actually run
  • exposedInternals values lazy so requiring bun:internal-for-testing no longer materializes Map/Set/WeakMap/WeakSet
  • worker exit handler so a crash surfaces immediately
  • probeBody exits via reportAndExit so TAMPER=1 shutdown cannot false-fail

Before merge: swap WEBKIT_VERSION from the preview tag to the merged main commit once oven-sh/WebKit#341 lands.

- Expose JSC's primordial manifest through `bun:internal-for-testing`
  (primordials.audit()) so tests iterate all 435 entries instead of a
  hand-picked probe.
- Generate src/js/primordials.d.ts from JSCPrimordials.h
  (src/codegen/generate-primordials-dts.ts) with real method/getter
  types, replacing the hand-written `Function`-typed block; a test pins
  the generated names to the engine's manifest.
- After Bun installs its own Error.captureStackTrace, override the
  ErrorConstructor primordial slots so `$ErrorCaptureStackTrace` is what
  user code sees.
- Rewrite test/js/bun/util/primordials.test.ts: identity of every entry
  against the live builtin (before and after use), direct and roundabout
  tampering of every key on every holder plus the holders and global
  bindings themselves, pollution planted before any lazy holder exists,
  behavior of tampered builtins through their primordials, option-off
  configuration (unavailable placeholders), and per-Worker capture.
Comment thread src/codegen/generate-primordials-dts.ts Outdated
Comment thread src/codegen/generate-primordials-dts.ts Outdated
Comment thread src/js/internal-for-testing.ts Outdated
Comment thread src/js/primordials.d.ts Outdated
Comment thread src/jsc/bindings/PrimordialsAudit.h
@dylan-conway

Copy link
Copy Markdown
Member

Pushed e4b4873 on top of the WebKit rework in oven-sh/WebKit#341 (pin bumped to autobuild-preview-pr-341-a4e834cb; CI stays red until that autobuild exists):

  • bun:internal-for-testing gains primordials.audit() — the full JSC manifest ({ name, holder, kind, key, value, available } per entry), so tests iterate all 435 primordials instead of a hand-picked probe.
  • src/js/primordials.d.ts is now generated from JSCPrimordials.h (src/codegen/generate-primordials-dts.ts) with real method/getter types, replacing the hand-written Function-typed block in builtins.d.ts; a test pins the generated names to the engine's manifest.
  • Bun replaces Error.captureStackTrace during its global setup, so the ErrorConstructor primordial slots are re-snapshotted afterwards — $ErrorCaptureStackTrace is the function user code sees.
  • test/js/bun/util/primordials.test.ts rewritten: identity of every entry against the live builtin (before and after use), direct and roundabout tampering of every key on every holder (assign / defineProperty / delete + prototype planting / setPrototypeOf / freeze / global rebinding), pollution planted before any lazy holder exists, primitive- and foreign-realm-valued global bindings, behavior of tampered builtins through their primordials, the option-off configuration (unavailable placeholders), and per-Worker capture. Verified failing on stock bun and passing on the local build (13 pass).

Follow-up (not in this PR): migrate src/js/** (starting with internal/primordials.js and its consumers) to these link-time constants; the tamper suite above is the gate for that.

Comment thread src/js/internal/primordials.js
Comment thread src/js/internal/primordials.js
Comment thread src/js/internal/primordials.js
Comment thread src/js/internal/primordials.js
Comment thread src/js/internal/primordials.js
Comment thread src/js/internal/primordials.js
Comment thread src/js/internal/primordials.js
Comment thread src/js/internal/primordials.js
Comment thread src/js/internal/primordials.js
Comment thread src/js/internal/primordials.js
Comment thread src/js/primordials.d.ts

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/codegen/primordials-module-epilogue.js`:
- Line 18: Fix all three lint violations in
src/codegen/primordials-module-epilogue.js: remove the unused ObjectCreate
destructuring entry; in the unsafe.prototype handling block, cache
unsafe.prototype locally and reuse it for the membership check, ReflectOwnKeys,
descriptor access, and else branch; in the items-processing block, cache
items.length once and reuse that local in both the if and while conditions.
Regenerate src/js/internal/primordials.js afterward.

In `@src/js/primordials.d.ts`:
- Around line 104-105: Update the nativeError() helper in
generate-primordials.ts to type the prototype holder as Error while retaining
ErrorConstructor for the constructor holder, following the part-aware pattern
used by typedArrayHolder(). Regenerate primordials.d.ts so all AggregateError
and other native error prototype declarations, plus their derived constructor
methods, use the corrected types; do not edit the generated file directly.

In `@test/js/bun/util/primordials.test.ts`:
- Around line 105-116: Update the native-error and typed-array loops that
populate holderFactories so each factory captures only name during prelude
construction and reads globalThis[name] when invoked, including resolving the
prototype from that deferred constructor. Preserve the existing Constructor and
Prototype holder keys and behavior.
- Line 734: Update the uncurryThis assertion in the primordials test to remove
the `? true : true` conditional and avoid passing `null`; invoke `p.uncurryThis`
with the appropriate Map prototype method and assert its actual behavior with a
falsifiable expectation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6f130050-aefd-44f3-ae8d-b84fcff0276a

📥 Commits

Reviewing files that changed from the base of the PR and between b8e0b84 and f7db891.

📒 Files selected for processing (9)
  • scripts/build/deps/webkit.ts
  • src/codegen/generate-primordials-probe.js
  • src/codegen/generate-primordials.ts
  • src/codegen/primordials-module-epilogue.js
  • src/codegen/primordials-module-prologue.js
  • src/js/internal-for-testing.ts
  • src/js/internal/primordials.js
  • src/js/primordials.d.ts
  • test/js/bun/util/primordials.test.ts

Comment thread src/codegen/primordials-module-epilogue.js Outdated
Comment thread src/js/primordials.d.ts Outdated
Comment thread test/js/bun/util/primordials.test.ts
Comment thread test/js/bun/util/primordials.test.ts Outdated
Comment thread src/codegen/generate-primordials.ts
Comment thread test/js/bun/util/primordials.test.ts Outdated
Comment thread src/codegen/primordials-module-epilogue.js Outdated
- generate-primordials.ts: native-error prototype holders are typed as
  Error (not ErrorConstructor) in the generated .d.ts; regenerated.
- module epilogue: drop the unused ObjectCreate destructure; regenerated.
- test: make the uncurryThis assertion falsifiable, and defer the
  holder factories' global reads to invocation so building the prelude
  does not create the lazy holders that later tests must poison first.
Comment thread src/codegen/generate-primordials.ts
Comment thread src/codegen/primordials-module-epilogue.js
The Safe* classes use `constructor(i) { super(i); }` as Node's do rather than
the implicit default constructor, whose `super(...args)` spread is
spec-observable through the array iterator protocol.
Comment thread src/codegen/primordials-module-epilogue.js Outdated
Comment on lines +280 to 282
// vendored tests need more internals. Values are lazy so merely requiring
// bun:internal-for-testing does not evaluate the whole internal-module graph.
export const exposedInternals = {

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.

🟡 The lazy-getter conversion for exposedInternals (with its comment "Values are lazy so merely requiring bun:internal-for-testing does not evaluate the whole internal-module graph") missed the sibling export const fs = require("node:fs/promises").$data; at line 340, which still eagerly pulls in internal/primordials (via internal/fs/globinternal/shared). So the prelude's require("bun:internal-for-testing") still evaluates the primordials module before the "tampering planted before the module has ever loaded" test body tampers — that test's stated precondition ("First load of internal/primordials happens now, after the tampering") is false and it cannot catch a regression where the epilogue reads live prototypes at load time. Convert fs to a lazy getter like the exposedInternals entries, and/or have the load-timing test assert its own precondition (REVIEW.md § Tests reviewers reject: "assert that setup created the precondition"; § Correctness: "Grep for every sibling site sharing the pattern").

Extended reasoning...

What the issue is

This PR converted the eight exposedInternals entries in src/js/internal-for-testing.ts from eager require(...) values to lazy getters, and added a comment (lines 280-282) explaining why: "Values are lazy so merely requiring bun:internal-for-testing does not evaluate the whole internal-module graph." That change was made specifically so the new "primordials module load timing" test can tamper prototypes before internal/primordials first loads.

But the same file still has, at line 340 (unchanged by this PR):

export const fs = require("node:fs/promises").$data;

which is an eager top-level require that transitively evaluates internal/primordials:

  • src/js/node/fs.promises.ts:5const { Glob } = require("internal/fs/glob") (top-level)
  • src/js/internal/fs/glob.ts:19const { kEmptyObject } = require("internal/shared") (top-level)
  • src/js/internal/shared.ts:1const { SafeArrayIterator } = require("internal/primordials") (top-level)

So the comment added in this PR is false, and the invariant it was added to guarantee doesn't hold.

The specific test whose precondition breaks

test/js/bun/util/primordials.test.ts — the "primordials module load timing" describe block — spawns a child whose script is prelude + body. The prelude's very first line is:

const { primordials } = require("bun:internal-for-testing");

Evaluating bun:internal-for-testing runs internal-for-testing.ts top-to-bottom, hits line 340, and — via the chain above — evaluates internal/primordials and caches it. The test body then does:

RegExp.prototype.exec = function evilExec() { return null; };
Map.prototype.get = function evilGet() { return "evil get"; };
Promise.prototype.then = function () { throw new Error("evil then"); };
// First load of internal/primordials happens now, after the tampering.
const p = primordials.object;

That comment is wrong: primordials.object is a getter that calls require("internal/primordials"), but the module is already in the require cache from the prelude. p is the instance built from a pristine environment. If a future change made makeSafeFromPristine or the OriginalRegExpPrototype* captures accidentally read Map.prototype.get / RegExp.prototype.exec at load time instead of the $Name constants, this test would still pass — the module never sees the tampering it was written to expose it to.

Why this is the sibling-site pattern REVIEW.md calls out

REVIEW.md § Correctness: "Fix the whole class in the same PR ... Grep for every sibling site sharing the pattern." This PR grepped for eager require(...) in exposedInternals and converted eight of them, but the identical pattern at line 340 (and its rationale — the added comment applies to the whole file, not just exposedInternals) was left. export const Dequeue = require("internal/fifo") at line 226 is fine — internal/fifo has no transitive requires — so fs is the one remaining leak.

REVIEW.md § Tests reviewers reject: "assert that setup created the precondition." The load-timing test asserts its intended precondition only in a comment. If it also asserted it (e.g. by checking that internal/primordials isn't already in the builtin-module cache before tampering, or by having the prelude expose a "was primordials already loaded?" bit), a false precondition would fail the test instead of silently passing.

Step-by-step proof

  1. Child process starts; bun -e receives prelude + body.
  2. Prelude line 1 executes: require("bun:internal-for-testing").
  3. Builtin bundler evaluates internal-for-testing.ts top-to-bottom. Line 340: require("node:fs/promises") runs.
  4. fs.promises.ts line 5: top-level require("internal/fs/glob") runs.
  5. glob.ts line 19: top-level require("internal/shared") runs.
  6. shared.ts line 1: top-level require("internal/primordials") runs. The module evaluates now, with a pristine RegExp.prototype.exec, Map.prototype.get, Promise.prototype.then. SafeMap, SafeSet, hardenRegExp's OriginalRegExpPrototype* locals, and SafePromise are all built from pristine constants — as intended, but not because the module is robust; because it loaded early.
  7. Prelude returns. Test body runs RegExp.prototype.exec = evilExec; Map.prototype.get = evilGet; ....
  8. const p = primordials.object → getter → require("internal/primordials") → cache hit from step 6. p.SafeMap, p.hardenRegExp, p.SafePromiseAll were all built before step 7's tampering existed.
  9. All assertions pass. But they would also pass if, hypothetically, the epilogue's OriginalRegExpPrototypeExec were changed to read RegExp.prototype.exec instead of $RegExpPrototypeExec — because at step 6 that was still the original.

Impact and fix

Nit — test-quality only, nothing user-facing breaks. The primordials module is correct today (it uses $Name constants), so the test passes for the right runtime reason; it just isn't the guard against future regressions its own comment says it is. The eager fs export is pre-existing code; only the false comment and the test that relies on it are new in this PR.

Fix: convert line 340 to a lazy getter matching the eight exposedInternals entries this PR already converted:

export const fs = {
  get $data() { return require("node:fs/promises").$data; },
}.$data;

or more simply, since fs is only consumed by tests, wrap it the same way as primordials.object:

let _fs;
export const fs = { get $data() { return _fs ??= require("node:fs/promises").$data; } };
// or just: export function fs() { return require("node:fs/promises").$data; }

(whatever shape the existing test consumers of fs accept). Independently, the load-timing test should assert its precondition so a future eager-require reintroduction fails loudly rather than silently disarming the test.

Comment on lines +265 to +279

// ───────────────────────────────────────────────────────────────────────────────
// Normalize the probe entries
// ───────────────────────────────────────────────────────────────────────────────

const entries = probeResult.entries;
for (const entry of entries) {
if (entry.kind !== "Value" || entry.valueType !== "object" || !entry.holder || !entry.key) continue;
// A constructor's `prototype` / a prototype's own object property that is itself a
// holder: reference the holder object directly rather than reading the property.
const target =
entry.key === "prototype" && entry.holder.endsWith("Constructor")
? entry.holder.replace(/Constructor$/, "Prototype")
: entry.name in holders
? entry.name

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.

🟡 FunctionPrototype is the one XPrototype entry that escapes this normalization pass: per spec typeof Function.prototype === "function", so the probe records it as kind: "Method" (not kind: "Value", valueType: "object") and the guard on line 267 skips it. Directly visible in the checked-in .d.ts: declare const $FunctionPrototype: PrimordialMethod<FunctionConstructor, "prototype"> vs every peer ($ArrayPrototype: Array<any>, $MapPrototype: Map<any, any>, …). Runtime impact is nil today (static-Method emits identically to HolderSelf, the type resolves to Function, and Function.prototype is non-configurable), but it's the sibling-site inconsistency REVIEW.md flags — broaden the guard to also match entry.key === "prototype" && entry.holder.endsWith("Constructor") regardless of kind, then regenerate.

Extended reasoning...

What the issue is

The normalization loop at generate-primordials.ts:265-279 rewrites each constructor's prototype own-property entry from a property read into { kind: "HolderSelf", holder: "XPrototype" }, so $XPrototype is captured directly as the holder object rather than via XConstructor.prototype. The loop's guard is if (entry.kind !== "Value" || entry.valueType !== "object" || !entry.holder || !entry.key) continue;. That is correct for every constructor except Function, because %Function.prototype% is itself a callable (ECMA-262 §20.2.3: "is itself a built-in function object"), so typeof Function.prototype === "function".

The probe (generate-primordials-probe.js, copyOwnProperties) classifies own properties by typeof desc.value: "function"kind: "Method". Scanning Function's own keys therefore records { name: "FunctionPrototype", holder: "FunctionConstructor", key: "prototype", kind: "Method", call: "static" }. The normalization guard sees kind === "Method" and continues, so this entry keeps holder: "FunctionConstructor" and is never promoted to HolderSelf on FunctionPrototype.

Observable in the checked-in artifacts

src/js/primordials.d.ts: declare const $FunctionPrototype: PrimordialMethod<FunctionConstructor, "prototype">; — every other $XPrototype is typed directly as its holder type ($ArrayPrototype: Array<any>, $ObjectPrototype: Object, $MapPrototype: Map<any, any>, $ArrayBufferPrototype: ArrayBuffer, …). The holders.FunctionPrototype.type = "Function" value at line 101 is not consulted for this declaration because entry.holder stayed "FunctionConstructor".

JSCPrimordialsTable.h (generated into WebKit): entries are grouped by entry.holder, so FunctionPrototype lands as V(FunctionPrototype, PROP("prototype"), Method) under JSC_FOREACH_PRIMORDIAL_FunctionConstructor — the only XPrototype entry captured via a property read on its constructor rather than V(FunctionPrototype, SELF, Self) under its own holder table.

src/js/internal/primordials.js: FunctionPrototype: $FunctionPrototype, — emitted via the Method/call === "static" branch, which happens to be byte-identical to what HolderSelf would emit.

Step-by-step

  1. Probe reaches Function in the intrinsic-constructors loop; copyOwnProperties(Function, "FunctionConstructor", "Function", "static").
  2. Reflect.ownKeys(Function) includes "prototype"; desc = { value: Function.prototype, writable: false, enumerable: false, configurable: false }.
  3. typeof desc.value === "function" (spec-mandated), so it takes the type === "function" branch → record({ name: "FunctionPrototype", holder: "FunctionConstructor", key: "prototype", kind: "Method", call: "static", … }).
  4. Normalization loop, line 267: entry.kind !== "Value" is true → continue. Entry unchanged.
  5. .d.ts emitter, case "Method": type = holders["FunctionConstructor"].type = "FunctionConstructor"PrimordialMethod<FunctionConstructor, "prototype">.
  6. Header emitter groups by entry.holder = "FunctionConstructor" → the FunctionPrototype row appears under the FunctionConstructor table with kind Method, not under FunctionPrototype with kind Self.

Why nothing breaks today

  • The module's static-Method branch emits FunctionPrototype: $FunctionPrototype,, identical to what HolderSelf would emit.
  • PrimordialMethod<FunctionConstructor, "prototype"> resolves: FunctionConstructor extends Record<"prototype", Function>Function extends (...args) => infer R is false (the TS Function interface has no bare call signature) → falls through to : Function, exactly what holders.FunctionPrototype.type would have emitted.
  • Function.prototype is { writable: false, configurable: false }, so PROP("prototype") capture on the constructor is tamper-proof anyway.
  • %Function.prototype% is a real JSFunction, so any jsCast<JSFunction*> a Method-kind capture path might do would succeed.

Why it's still worth fixing

REVIEW.md § Correctness: "Fix the whole class in the same PR … parallel switch arms". This is a sibling-site inconsistency: every other XPrototype in the manifest is a HolderSelf on its own holder; FunctionPrototype alone is a Method on FunctionConstructor. If JSCPrimordials.cpp ever gains Method-specific handling that differs from Self (e.g. an unavailable-placeholder path, or a jsCast<JSFunction*> assertion), $FunctionPrototype would silently diverge from every peer with no test flagging it.

Fix

Broaden the normalization guard so the prototype-of-a-Constructor rewrite fires regardless of the probe's typeof classification, e.g.:

for (const entry of entries) {
  if (!entry.holder || !entry.key) continue;
  const isCtorPrototype = entry.key === "prototype" && entry.holder.endsWith("Constructor");
  if (!isCtorPrototype && (entry.kind !== "Value" || entry.valueType !== "object")) continue;
  const target = isCtorPrototype
    ? entry.holder.replace(/Constructor$/, "Prototype")
    : entry.name in holders ? entry.name : null;
  
}

then regenerate all three artifacts.

Comment thread src/codegen/generate-primordials-probe.js
Comment thread src/codegen/generate-primordials-probe.js
robobun added 2 commits August 3, 2026 02:16
…ials-link-time-constants

Conflicts resolved:
- scripts/build/deps/webkit.ts: kept the autobuild-preview-pr-341 pin (this PR depends on oven-sh/WebKit#341).
- src/js/internal/primordials.js: kept the generated file; main's #36318 removed SafePromiseAll from the old hand-written module, which the generated module supersedes entirely.
The merge resolution took ours wholesale for webkit.ts, which dropped
main's new webkitTestFFIPath export and broke the build script. Take
main's file and keep only the WEBKIT_VERSION override.

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 scripts/build/deps/webkit.ts:106-111 — Deleting webkitTestFFIPath here strands its consumer: scripts/build/ci.ts still has import { webkitTestFFIPath } from "./deps/webkit.ts" at line 29 and calls it at lines 288 and 426. A static named import of a non-existent export fails at module-link time, so ci.ts will throw before any code runs and the CI build/upload pipeline breaks. Either restore the export or delete the import and both if (existsSync(testFFI)) blocks in ci.ts in this PR (REVIEW.md § Correctness: "Signature changes and renames → grep the whole repo").

    Extended reasoning...

    What the bug is

    This PR's only change to scripts/build/deps/webkit.ts (besides the WEBKIT_VERSION bump) is the deletion of:

    export function webkitTestFFIPath(cfg: Config): string {
      const root = cfg.webkit === "prebuilt" ? prebuiltDestDir(cfg) : depBuildDir(cfg, "WebKit");
      return resolve(root, "bin", cfg.windows ? "testFFI.exe" : "testFFI");
    }

    But scripts/build/ci.ts — not touched by this PR — still statically imports and uses that function:

    • ci.ts:29import { webkitTestFFIPath } from "./deps/webkit.ts";
    • ci.ts:288const testFFI = webkitTestFFIPath(cfg); followed by if (existsSync(testFFI)) { … upload … }
    • ci.ts:426const testFFI = webkitTestFFIPath(cfg); followed by if (existsSync(testFFI)) { chmodSync(testFFI, 0o755); files.push(testFFI); }

    A repo-wide grep confirms webkitTestFFIPath now appears only in ci.ts — there is no definition anywhere.

    Why nothing prevents it

    This is an ES static named import, not a namespace import or dynamic require. When Bun evaluates ci.ts, module linking resolves each named import against the exporter's module record; ./deps/webkit.ts no longer has an export binding named webkitTestFFIPath, so linking fails with SyntaxError: export 'webkitTestFFIPath' not found in './deps/webkit.ts' (or the equivalent Bun message) before line 1 of ci.ts executes. There is no fallback — the two call sites are behind existsSync guards, but the guard is on the return value, not on the binding, so it never gets that far.

    The build system doesn't type-check scripts/ at compile time as a merge gate the way it does src/, so this doesn't surface until CI actually runs scripts/build/ci.ts — at which point the whole build/upload/artifact-packaging step aborts.

    Step-by-step proof

    1. PR diff, scripts/build/deps/webkit.ts hunk: lines 106–110 are removed (-export function webkitTestFFIPath(cfg: Config): string { … }). grep -n 'webkitTestFFIPath\|testFFI' scripts/build/deps/webkit.ts → no matches.
    2. scripts/build/ci.ts:29import { webkitTestFFIPath } from "./deps/webkit.ts"; (unchanged on this branch).
    3. Bun (and any spec-compliant ESM loader) resolves named imports at instantiation: since ./deps/webkit.ts's module record has no webkitTestFFIPath binding, ResolveExport returns null and instantiation throws a SyntaxError.
    4. Therefore invoking any entry point that evaluates scripts/build/ci.ts (the BuildKite build step, artifact upload, packaging) throws immediately — neither ci.ts:288 nor ci.ts:426 is ever reached.

    Impact

    Concrete CI breakage introduced by this PR. The PR description already says "CI will fail on the prebuilt download until [the WebKit] autobuild completes", but this is a separate, permanent failure mode that will not resolve when the autobuild finishes — ci.ts won't even reach the download step. Per REVIEW.md § Correctness: "Signature changes and renames → grep the whole repo including cfg-gated code and generated-binding inputs; stale call sites compile fine and silently miss the new behavior" — here the stale call site doesn't compile at all. And § Code style: "Delete dead code in the same PR that makes it dead" applies in reverse: deleting an export requires deleting (or updating) its callers atomically.

    Fix

    Two valid options — do one, in this PR:

    • Restore webkitTestFFIPath in scripts/build/deps/webkit.ts if the testFFI upload is still wanted (it's guarded by existsSync, so it's harmless when the binary is absent).
    • Remove the import at ci.ts:29 and the two const testFFI = webkitTestFFIPath(cfg); if (existsSync(testFFI)) { … } blocks at ci.ts:288-292 and ci.ts:426-430, if the WebKit bump intentionally drops the testFFI artifact.

Comment on lines +407 to +411
`#define JSC_PRIMORDIAL_LAZY_TYPE_PROTOTYPE_HOLDER_${macroName} PrimordialHolder::${holder}Prototype\n#define JSC_PRIMORDIAL_LAZY_TYPE_CONSTRUCTOR_HOLDER_${macroName} PrimordialHolder::${holder}Constructor`,
)
.join("\n")}

} // namespace JSC

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.

🟡 The entry.call === "bound" branch emits ${entry.name}: directly, while every other arm of this emitter (Literal, HolderSelf/Value, Getter/Setter, uncurried Method, static Method, ApplyVariant) wraps the key via jsKey(entry.name). Only Promise constructor statics reach this branch today and all are valid identifiers, so the generated output is byte-identical either way — but if a future bound-receiver constructor gains a static whose primordial name needs quoting (as the RegExp legacy statics already do for the sibling Getter/Setter branches), this line would emit invalid JS. One-token fix: ${jsKey(entry.name)}:.

Extended reasoning...

What the issue is

In generate-primordials.ts's propertyLines emitter, the case "Method": block has three sub-branches:

if (entry.call === "uncurried") propertyLines.push(`  ${jsKey(entry.name)}: uncurryThis(${constant(entry)}),`);
else if (entry.call === "bound")
  propertyLines.push(
    `  ${entry.name}: $FunctionPrototypeBind.$call(${constant(entry)}, $${entry.holder!.replace(/Constructor$/, "")}),`,
  );
else propertyLines.push(`  ${jsKey(entry.name)}: ${constant(entry)},`);

The "uncurried" and else (static) arms wrap the property key via jsKey(entry.name); the "bound" arm alone uses raw ${entry.name}. Every other entry.kind case in the surrounding switch — Literal, HolderSelf/Value, Getter/Setter, ApplyVariant — also wraps via jsKey(). jsKey is defined a few lines above as (name) => (/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name)): it quotes names that are not valid JS identifier property keys.

Why nothing breaks today

Only one probe loop feeds call: "bound"for (const name of ["Promise"]) { ...; copyOwnProperties(G[name], ${name}Constructor, name, "bound"); } in generate-primordials-probe.js. The resulting entry.name values are PromiseResolve, PromiseReject, PromiseRace, PromiseAll, PromiseAllSettled, PromiseAny, PromiseWithResolvers, PromiseTry, PromiseLength, PromiseName, PromisePrototype — every one matches /^[A-Za-z_$][\w$]*$/, so jsKey(entry.name) === entry.name for all of them. The generated src/js/internal/primordials.js is byte-identical either way, and the checked-in file confirms this (e.g. PromiseResolve: $FunctionPrototypeBind.$call($PromiseResolve, $Promise),).

Why it's worth flagging anyway

This is precisely the pattern REVIEW.md § Correctness calls out: "Grep for every sibling site sharing the pattern: parallel switch arms". The jsKey() wrapper exists specifically because some Node primordial names are not valid unquoted property keys — the RegExp legacy static getters/setters ("RegExpGet$&", "RegExpGet$*", "RegExpGet$`", "RegExpGet$'", "RegExpGet$+") hit the sibling Getter/Setter branches and require quoting to emit parseable JS. If a future constructor is added to the bound-receiver loop (or Promise gains a static whose derived primordial name contains punctuation), this branch would emit RegExpGet$&: $FunctionPrototypeBind.$call(...) — a syntax error in the generated module — while every sibling branch would handle it correctly.

Step-by-step proof

  1. jsKey at generate-primordials.ts:~322: returns JSON.stringify(name) iff name fails /^[A-Za-z_$][\w$]*$/.
  2. case "Method": / entry.call === "bound" at ~407-410: emits ` ${entry.name}: ...` — no jsKey().
  3. Every peer arm ("Literal" ~397, "HolderSelf"/"Value" ~400, "Getter"/"Setter" ~403, "Method"/"uncurried" ~406, "Method"/else ~411, "ApplyVariant" ~425/426) emits ` ${jsKey(entry.name)}: ...`.
  4. Probe: only ["Promise"] uses copyOwnProperties(..., "bound"); Reflect.ownKeys(Promise) yields length, name, resolve, reject, race, all, allSettled, any, withResolvers, prototype, try, [Symbol.species]getNewKey produces identifier-safe names for all → jsKey would be a no-op → generated output identical.
  5. Hypothetical: an entry { name: "FooGet$&", kind: "Method", call: "bound", holder: "FooConstructor" } would emit FooGet$&: $FunctionPrototypeBind.$call(...), which is a JS SyntaxError; the same entry with call: "static" would emit "FooGet$&": ... and parse.

Impact and fix

Nit — generator-only consistency, zero observable effect on any artifact this PR produces. One-token fix: change ${entry.name}: to ${jsKey(entry.name)}: in the "bound" branch, then regenerate (which will produce no diff).

robobun and others added 4 commits August 3, 2026 03:18
oven-sh/WebKit#341 synced with WebKit main; autobuild-preview-pr-341-01de5fd4
is published with all 38 assets.
…ials-link-time-constants

Brings in #36794 (Upgrade WebKit to 3722912ff800) with the
AbstractModuleRecord/ModuleAnalyzer C++/Rust adaptations the synced
WebKit preview now requires. Kept the autobuild-preview-pr-341-01de5fd4
pin for WEBKIT_VERSION.
- Restore Float16Array to the manifest: it is part of this engine's
  surface and internal/util/inspect indexes primordials by a typed
  array's Symbol.toStringTag, so its constructor must be a member.
  (The reference-realm oracle test now derives its constructor list
  from the probe, so the two can no longer drift.)
- module epilogue: read `unsafe.prototype` and `items.length` into
  locals so each is accessed once (bun/no-duplicate-conditional-property-access).
- Regenerated against the upgraded WebKit base, which also picks up the
  newly-added Iterator.prototype.includes builtin.
@dylan-conway dylan-conway changed the title jsc: expose Node.js-style primordials as link-time constants internal/primordials: Node.js parity, generated and engine-backed Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants