Skip to content

feat: Node-style primordials for runtime builtins - #415

Merged
NathanWalker merged 1 commit into
mainfrom
feat/primordials
Jul 31, 2026
Merged

feat: Node-style primordials for runtime builtins#415
NathanWalker merged 1 commit into
mainfrom
feat/primordials

Conversation

@edusperoni

@edusperoni edusperoni commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #411 — review that one first; this PR targets feat/js-builtins.

What this adds

The runtime's builtin JavaScript installs globals and leaves closures behind that run for the lifetime of the app: event dispatch, the Promise proxy traps, console's smart-stringify, __extends, the URL.searchParams accessor. Until now those closures reached for intrinsics (Array.prototype.slice, JSON.stringify, Object.create, …) through the live globals, so app code replacing one could break runtime internals or observe them.

NativeScript/runtime/js/primordials.js is a new builtin that captures exactly the intrinsics the other builtins need into a frozen, null-prototype namespace, Node-style.

The (binding, primordials) contract

Builtins are now compiled as function bodies with two fixed parameters:

const { someNative } = binding;
const { ArrayPrototypeSlice, ObjectCreate } = primordials;

primordials is built lazily on the first RunBuiltin of an isolate — which happens during runtime init, before any user code — and cached in Caches::Primordials. Consequences:

  • Worker isolates snapshot their own realm's intrinsics automatically.
  • Builtins that compile late still get the pristine snapshot. smart-stringify is only compiled on the first object logged, which can be well after user code has run.
  • binding is unchanged; most builtins still receive undefined for it.
  • The bytecode cache stays sound: it is process-local and in-memory only, and every compile in a process now uses the same 2-parameter signature.

Why uncurrying, uniformly

Instance methods are exposed uncurried, exactly as Node does it:

const uncurryThis = Function.prototype.bind.bind(Function.prototype.call);
// ArrayPrototypeSlice(list, 1)          not  list.slice(1)
// FunctionPrototypeCall(cb, this, ev)   not  cb.call(this, ev)

The runtime always runs V8 jitless on device, so the usual "uncurrying is free once optimized" argument does not apply and the cost had to be measured. Benchmarked on Node 24 / V8 13.6, arm64 host, --jitless:

  • Uncurried primordial call: +5-12% per op versus a raw method call.
  • For event dispatch that is ~+10%, about 26 ns on a 4-listener dispatch — irrelevant next to the listener bodies.
  • Under Ignition, uncurried beats the captured.call(recv, …) alternative, which is why uncurrying is used uniformly rather than mixing styles.
  • Captured statics (JSON.stringify etc.) are free.
  • Reflect.apply is measurably worse and is not used; FunctionPrototypeApply covers those sites.

Plain constructor calls made once at init time (new Map() while bootstrapping) may stay direct — the rule targets code in closures that outlive init.

Enforcement

eslint.config.mjs declares primordials and adds two rules over NativeScript/runtime/js, both verified to fire:

  • no-restricted-properties for every captured static (JSON.stringify, Object.defineProperty/assign/freeze/create/keys/setPrototypeOf/getOwnPropertyDescriptor, Array.from, Reflect.construct, Reflect.apply).
  • no-restricted-globals for every captured constructor (Error, Map, Proxy, TypeError). A const { Proxy } = primordials destructure shadows the global, so the rule only fires on an unguarded reference.

Each message names the replacement. primordials.js itself is exempted via a per-file override. Uncurried instance-method use cannot be matched by receiver, so that stays a review rule — documented as such in NativeScript/runtime/js/README.md.

Deliberately left as live global lookups: Reflect.decorate in inline-functions.js (reflect-metadata installs it after runtime init, and the TypeScript __decorate shim must see it), Blob/File in blob-url.js (provided by the app layer, not the runtime), and String(x)/instanceof Array conversions where tamper-immunity is not affected.

ts-helpers builds native super calls with the captured Reflect.construct rather than bind-then-new, which keeps that path off the array iterator protocol as well.

Tests

New TestRunner/app/tests/PrimordialsTests.js (7 specs) tampers with the intrinsics and checks the runtime keeps working. Each spec keeps the tampered window synchronous and assertion-free, restoring the originals in a finally:

  • a guard spec proving the tampering is actually observable, so the suite cannot pass vacuously
  • global dispatchEvent delivers to every listener (function and handleEvent) with Array.prototype.slice/indexOf/push/splice and Function.prototype.call replaced
  • addEventListener/removeEventListener and once semantics under the same tampering
  • reportError still reaches an error listener with a correct ErrorEvent
  • console.log of a circular object stays non-fatal with JSON.stringify and the replacer's Array.prototype.indexOf/push replaced (the smart-stringify path; its output is not reachable from JS and JsonStringifyObject swallows a throwing stringify, so this spec is a crash/throw check rather than an output assertion — noted in the test)
  • a native _super.call(this, x) over an Objective-C base with Array.prototype.slice/concat, Function.prototype.bind and Reflect.construct replaced
  • __extends and __tsEnum with the Object statics replaced

Result: 912 specs, 0 failures (905 baseline + 7), independently reproduced on a second simulator. ESLint clean; every builtin verified to compile as a (binding, primordials) function body.

Known follow-ups (not in this PR)

Pre-existing live-intrinsic reads that primordials does not yet cover: for...of ObjectKeys(...) in __tsEnum and ArrayFrom(arguments) in inline-functions.js still go through the array iterator protocol, and String(x) conversions in events.js/error-events.js read the live global String.

Summary by CodeRabbit

  • New Features

    • Added a frozen snapshot of JavaScript built-ins for runtime components, captured before application code can modify global behavior.
    • Runtime built-ins now consistently use protected intrinsic operations, improving reliability when global methods are overridden.
  • Bug Fixes

    • Improved event handling, promises, inheritance, serialization, and helper behavior under modified JavaScript globals.
  • Tests

    • Added coverage confirming runtime functionality remains stable when built-in methods are replaced.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Builtins now receive a frozen, per-isolate primordials snapshot. Runtime builtins use captured intrinsic operations, lint rules enforce the usage pattern, build inputs include the snapshot module, and tests cover behavior when application code replaces intrinsic methods.

Changes

Primordials runtime integration

Layer / File(s) Summary
Builtin contract and snapshot wiring
NativeScript/runtime/BuiltinLoader.*, NativeScript/runtime/Caches.h, NativeScript/runtime/js/README.md
The builtin wrapper accepts primordials. BuiltinLoader creates and caches the snapshot per isolate before invocation.
Intrinsic snapshot and build rules
NativeScript/runtime/js/primordials.js, eslint.config.mjs, tools/js2c-inputs.xcfilelist
The runtime captures selected intrinsic references in a frozen object. ESLint rules and JavaScript build inputs include the new contract.
Builtin primordial usage
NativeScript/runtime/js/*.js
Builtins use captured operations for events, URLs, inheritance, Objective-C forwarding, promises, stringification, and TypeScript helpers.
Intrinsic tampering validation
TestRunner/app/tests/PrimordialsTests.js, TestRunner/app/tests/index.js
Tests replace intrinsic methods with throwing functions and validate runtime behavior across event, logging, construction, inheritance, and enum paths.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BuiltinLoader
  participant Caches
  participant kPrimordials
  participant CompiledBuiltin
  BuiltinLoader->>Caches: Check cached Primordials
  BuiltinLoader->>kPrimordials: Create frozen intrinsic snapshot
  kPrimordials-->>Caches: Cache Primordials
  BuiltinLoader->>CompiledBuiltin: Invoke with binding and primordials
Loading

Possibly related PRs

Suggested reviewers: nathanwalker

Poem

I froze the tools before the storm,
And cached them safe in every form.
When methods change and prototypes sway,
The builtins still know their way.
— A watchful rabbit 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding Node-style primordials for runtime builtins.
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.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@edusperoni
edusperoni force-pushed the feat/primordials branch 2 times, most recently from e31477b to bce9ece Compare July 30, 2026 14:17
@edusperoni

Copy link
Copy Markdown
Collaborator Author

Orchestrator review pass (folded into the commit):

  • Independently verified the Reflect.construct switch in ts-helpers (bound-function newTarget semantics and the arguments-object concat edge both hold) and every other conversion.
  • Closed the two flagged coverage gaps: String is now a captured primordial (used by the Event/ErrorEvent constructors and dispatch closures), and the two post-init paths that depended on the tamperable array-iterator protocol — __tsEnum's for...of and the ObjC decorators' Array.from(arguments) — now use index loops. Array.from deliberately has no primordial; the ESLint config documents why and restricts String as a global.
  • Suite after changes: 912/912.

Deferred (pre-existing, unchanged by this PR): child[Symbol.hasInstance] = ... in ts-helpers is likely a silent no-op in sloppy mode (inherited non-writable Function.prototype[@@hasInstance]), and instanceof Array in ObjCClass could become ArrayIsArray — both worth their own look since fixing them changes behavior.

The runtime's builtins install globals and leave closures behind that keep
running for the lifetime of the app: event dispatch, the Promise proxy traps,
console's smart-stringify, __extends. Those closures reached for intrinsics
(Array.prototype.slice, JSON.stringify, Object.create, ...) through the live
globals, so app code replacing one could break or observe runtime internals.

primordials.js captures the intrinsics the other builtins need into a frozen,
null-prototype namespace and BuiltinLoader passes it to every builtin as a
second fixed parameter next to `binding`. It is built lazily on the first
RunBuiltin of an isolate — during runtime init, before user code — and cached
in Caches, so workers snapshot their own realm and late-compiling builtins
still see pristine intrinsics.

Instance methods are uncurried Node-style, `uncurryThis(fn)` being
Function.prototype.bind.bind(Function.prototype.call): ArrayPrototypeSlice(list, 1)
rather than list.slice(1). Benchmarked under jitless (which is how the runtime
always runs on device) uncurried calls cost +5-12% per op over a raw method
call but beat the captured-and-.call() alternative, so they are used uniformly.

ESLint gains no-restricted-properties for the captured statics and
no-restricted-globals for the captured constructors, each pointing at the
replacement; uncurried instance-method use stays a review rule since the
receiver cannot be matched.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
NativeScript/runtime/js/ts-helpers.js (1)

27-38: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fix the silent no-op when assigning child[SymbolHasInstance].

This file has no "use strict" pragma, so it runs in sloppy mode. Function.prototype[Symbol.hasInstance] is a non-writable, non-configurable inherited property. A plain assignment to child[SymbolHasInstance] on a function that does not already own that property fails silently in sloppy mode (it would throw in strict mode instead). The custom instanceof override for extended native classes never actually installs.

Use ObjectDefineProperty (already imported) to force an own property instead of a plain assignment. This mirrors the PR's own deferred-issue note about this exact line.

🐛 Proposed fix
-            child[SymbolHasInstance] = function (instance) {
-                return instance instanceof this.__extended;
-            }
+            ObjectDefineProperty(child, SymbolHasInstance, {
+                value: function (instance) {
+                    return instance instanceof this.__extended;
+                },
+                configurable: true,
+            });
🤖 Prompt for 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.

In `@NativeScript/runtime/js/ts-helpers.js` around lines 27 - 38, Update the
child[SymbolHasInstance] assignment inside extend to use the already imported
ObjectDefineProperty, defining an own SymbolHasInstance property on child with
the existing custom instanceof function as its value.
🧹 Nitpick comments (2)
eslint.config.mjs (1)

14-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add Symbol.hasInstance to the captured-statics lint list.

primordials.js captures SymbolHasInstance: Symbol.hasInstance as a static-like binding, but capturedStatics here does not include an entry for it. Every other captured static (JSON.stringify, Object.*, Reflect.construct) is protected by no-restricted-properties; a bare Symbol.hasInstance reference in a future builtin change would not be caught.

Add ['Symbol', 'hasInstance', 'SymbolHasInstance'] to capturedStatics for parity with the rest of the enforcement mechanism.

♻️ Proposed addition
 const capturedStatics = [
   ['JSON', 'stringify', 'JSONStringify'],
   ['Object', 'assign', 'ObjectAssign'],
   ['Object', 'create', 'ObjectCreate'],
   ['Object', 'defineProperty', 'ObjectDefineProperty'],
   ['Object', 'freeze', 'ObjectFreeze'],
   ['Object', 'getOwnPropertyDescriptor', 'ObjectGetOwnPropertyDescriptor'],
   ['Object', 'keys', 'ObjectKeys'],
   ['Object', 'setPrototypeOf', 'ObjectSetPrototypeOf'],
   ['Reflect', 'construct', 'ReflectConstruct'],
   // No ReflectApply primordial: apply goes through the uncurried
   // Function.prototype.apply, which is one property read cheaper.
   ['Reflect', 'apply', 'FunctionPrototypeApply'],
+  ['Symbol', 'hasInstance', 'SymbolHasInstance'],
 ];
🤖 Prompt for 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.

In `@eslint.config.mjs` around lines 14 - 27, Update the capturedStatics list to
include the Symbol.hasInstance mapping as ['Symbol', 'hasInstance',
'SymbolHasInstance'], matching the existing captured-static enforcement entries.
NativeScript/runtime/js/promise-proxy.js (1)

56-63: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Optional: avoid an unnecessary bound-function allocation on the synchronous path.

Line 60 calls FunctionPrototypeBind(orig, target, x)(), creating and discarding a bound function to invoke it once. FunctionPrototypeCall(orig, target, x) achieves the same result without the extra allocation. This does not need Function.prototype.bind at all since the call happens immediately in the same tick. This is not a regression from this PR; the pattern predates the primordials conversion.

♻️ Proposed refactor
-const { FunctionPrototypeBind, Proxy } = primordials;
+const { FunctionPrototypeBind, FunctionPrototypeCall, Proxy } = primordials;
@@
                 return typeof orig === 'function' ? function(x) {
                     if (!originIsRuntimeLoop || runloop === CFRunLoopGetCurrent()) {
-                        FunctionPrototypeBind(orig, target, x)();
+                        FunctionPrototypeCall(orig, target, x);
                         return target;
                     }
                     CFRunLoopPerformBlock(runloop, kCFRunLoopDefaultMode, FunctionPrototypeBind(orig, target, x));
🤖 Prompt for 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.

In `@NativeScript/runtime/js/promise-proxy.js` around lines 56 - 63, In the
synchronous branch of the function returned by the promise proxy, replace the
immediate FunctionPrototypeBind invocation with FunctionPrototypeCall(orig,
target, x) so the original function is invoked with the same receiver and
argument without allocating a bound function. Leave the asynchronous
CFRunLoopPerformBlock path unchanged.
🤖 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.

Outside diff comments:
In `@NativeScript/runtime/js/ts-helpers.js`:
- Around line 27-38: Update the child[SymbolHasInstance] assignment inside
extend to use the already imported ObjectDefineProperty, defining an own
SymbolHasInstance property on child with the existing custom instanceof function
as its value.

---

Nitpick comments:
In `@eslint.config.mjs`:
- Around line 14-27: Update the capturedStatics list to include the
Symbol.hasInstance mapping as ['Symbol', 'hasInstance', 'SymbolHasInstance'],
matching the existing captured-static enforcement entries.

In `@NativeScript/runtime/js/promise-proxy.js`:
- Around line 56-63: In the synchronous branch of the function returned by the
promise proxy, replace the immediate FunctionPrototypeBind invocation with
FunctionPrototypeCall(orig, target, x) so the original function is invoked with
the same receiver and argument without allocating a bound function. Leave the
asynchronous CFRunLoopPerformBlock path unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d5baefd-2e45-46d0-9e09-7bfc5f6f6105

📥 Commits

Reviewing files that changed from the base of the PR and between 7a4d97f and e79fea2.

📒 Files selected for processing (17)
  • NativeScript/runtime/BuiltinLoader.cpp
  • NativeScript/runtime/BuiltinLoader.h
  • NativeScript/runtime/Caches.h
  • NativeScript/runtime/js/README.md
  • NativeScript/runtime/js/blob-url.js
  • NativeScript/runtime/js/class-extends.js
  • NativeScript/runtime/js/error-events.js
  • NativeScript/runtime/js/events.js
  • NativeScript/runtime/js/inline-functions.js
  • NativeScript/runtime/js/primordials.js
  • NativeScript/runtime/js/promise-proxy.js
  • NativeScript/runtime/js/smart-stringify.js
  • NativeScript/runtime/js/ts-helpers.js
  • TestRunner/app/tests/PrimordialsTests.js
  • TestRunner/app/tests/index.js
  • eslint.config.mjs
  • tools/js2c-inputs.xcfilelist

@NathanWalker
NathanWalker merged commit d5c187c into main Jul 31, 2026
9 checks passed
@NathanWalker
NathanWalker deleted the feat/primordials branch July 31, 2026 05:35
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