Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
533f5ce
refactor: one funnel per side for every call of a JS function body (t…
Sep 26, 2026
43db28b
perf: this-as-a-parameter stage 1 — every JS body takes its receiver …
Sep 27, 2026
d443a47
perry-ffi 0.6: one typed JS body ABI, receiver-taking call entries (t…
Sep 28, 2026
1321a44
perf: this-as-a-parameter stage 2 — bodies read their receiver parameter
Sep 28, 2026
0857f6d
perf: this-as-a-parameter stage 3 — delete the implicit-this cell
Sep 28, 2026
6292802
Merge main (63f6b2af3) into this-as-a-parameter
Sep 28, 2026
e3700e0
refactor(runtime): split child_process reactor exec/execFile path int…
Sep 28, 2026
ecf409b
test(codegen): update export alias identity test for the this-as-a-pa…
Sep 28, 2026
e493c5c
changelog: name the fragments after PR #11637
Sep 28, 2026
4f62c3f
fix(runtime): the WASI closure-arity signature probe is missing the r…
Sep 28, 2026
ae91cb6
gc_effects: regenerate tables for the this-as-a-parameter body ABI
Sep 28, 2026
dde3f3a
Merge remote-tracking branch 'origin/main' into perf-this-param-merged
Sep 28, 2026
5d7e372
Merge remote-tracking branch origin/main into perf-this-as-parameter
Sep 28, 2026
44f5544
gc_runtime_root_holders: drop the CP_NEXT_LIVE_ID entry the exec spli…
Sep 29, 2026
b0de0f1
perf(runtime): runtime value calls go through js_native_call_value, n…
Sep 29, 2026
3492ff3
fix(runtime): reach the stream subclass-init shim through the stream …
Sep 29, 2026
93218f9
Merge main into perf-this-as-parameter
Sep 29, 2026
1ca9c79
fix(runtime): stream subclass-init slot: no unused mut, thread-exit v…
Sep 29, 2026
ca88a03
Merge branch 'main' into perf-this-as-parameter
proggeramlug Sep 29, 2026
d6d0dd9
Merge main into perf-this-as-parameter
Sep 29, 2026
ee5b988
gc_runtime_root_holders: keep the stage-3 re-audit note the merge dro…
Sep 29, 2026
a33fcfb
Merge main into perf-this-as-parameter
Sep 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
10 changes: 10 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,16 @@ jobs:
python3 scripts/check_gc_env_knobs.py --self-test
python3 scripts/check_gc_env_knobs.py

# `this`-as-a-parameter lane, stage 0: every call of a JS function body
# goes through one funnel per side (runtime `closure/body_call.rs`,
# codegen `expr/body_call.rs`), so a body-ABI change is made in one place
# and the compiler finds every caller.
- name: JS body-call funnel
if: ${{ !cancelled() }}
run: |
python3 scripts/check_js_body_call_funnel.py --self-test
python3 scripts/check_js_body_call_funnel.py

# #7982. The in-process LLVM reader's unit gate builds three tracked `.ll`
# corpora and asserts they RAN — which proves the tests ran, not that they
# test today's IR. All three froze on 2026-08-03 and carried zero
Expand Down
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,7 @@ perry-hir = { path = "crates/perry-hir" }
perry-transform = { path = "crates/perry-transform" }
perry-codegen = { path = "crates/perry-codegen" }
perry-dispatch = { path = "crates/perry-dispatch" }
perry-abi = { path = "crates/perry-abi" }
perry-abi = { path = "crates/perry-abi", version = "0.5.1654" }
# #1112: `perry-ffi` is published for external native wrappers. Its optional
# `runtime-link` edge supplies runtime symbols to in-tree adapter tests.
# The dependency-free registration core sits below both crates so future
Expand All @@ -545,7 +545,7 @@ perry-abi = { path = "crates/perry-abi" }
# applies — tests and the shipped prebuilt keep every engine. This mirrors why
# `wasm-host` must stay out of `default`, generalized to all heavy features.
perry-runtime = { path = "crates/perry-runtime", version = "0.5.1011", default-features = false }
perry-ffi = { path = "crates/perry-ffi", version = "0.5.1011" }
perry-ffi = { path = "crates/perry-ffi", version = "0.6.0" }
# turnloop P7 (docs/turnloop/p7-report.md): the loop-driven transport the four
# database bindings share. rlib only — it is linked *into* each binding's
# staticlib, never alongside it, so it exports no symbol of its own.
Expand Down
29 changes: 29 additions & 0 deletions changelog.d/11637-perry-ffi-0.6-typed-js-bodies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
**perry-ffi 0.6 (breaking): one JS body ABI, typed.** Every native body a
function object runs is `body(callee, this, a0, ...)`; its Rust type is
defined once, in perry-abi (`js_body_fn_ty!`, `JsBody0`..`JsBody16`,
`JsThis`), and shared by the runtime and perry-ffi.

- `perry_ffi::alloc_closure`, `register_closure_arity` and the new
`register_closure_rest` take a typed body (`F: JsBody<ClosureHeader>`)
instead of a `*const u8`. A body with the wrong signature — no receiver, a
wrong parameter type, a bare pointer — is a compile error on every
platform. Migrate with a cast to the body's type:
`alloc_closure(my_body as perry_ffi::JsBody1, captures)`, where `my_body`
is `extern "C" fn(*const RawClosureHeader, JsThis, f64) -> f64`.
- Calls into JS take the receiver after the function, `JsThis::UNDEFINED`
for a plain call: `JsClosure::call0..4(this, ...)`, the new
`JsClosure::call_slice(this, &args)` and `perry_ffi::call_value(func,
this, &args)`. The runtime's entries match: `js_closure_call{N}(closure,
this, ...)`, `js_native_call_value(func, this, args, len)`,
`js_closure_call_array(closure, this, args, len)`. No native code reads or
writes an ambient `this`.
- perry-ffi now carries its own version (0.6.0) and depends on perry-abi,
which is published before it (`scripts/publish_perry_ffi.sh`).
- `scripts/check_js_body_call_funnel.py` refuses an `extern` declaration of a
call entry without the receiver (a stale declaration compiles and passes
garbage as `this`), a closure registrar declared outside the runtime,
stdlib and perry-ffi, and a perry-ffi registration function taking a
`*const u8`. It found stale entry declarations in perry-stdlib, the UI
crates and the runtime's own geisterhand registry.
- Every in-tree perry-ffi user (the `perry-ext-*` crates, the UI crates,
perry-audio-miniaudio) is updated.
37 changes: 37 additions & 0 deletions changelog.d/11637-this-as-a-parameter-stage-1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
Every JS body now takes its receiver as a parameter: a compiled closure body,
a function's value wrapper, a class method's value wrapper and every native
builtin installed as a function object are `double body(i64 callee, i64 this,
double a0, ...)` (`perry_abi::JS_BODY_*`; the receiver is NaN-boxed bits in an
integer register, so every floating-point argument register stays free for
JS arguments). This is stage 1 of passing `this` as a parameter instead of
through the per-agent implicit-`this` cell, and it changes no behavior:
every caller passes exactly the receiver the cell holds for the call — the
method-call site its receiver, a call that bound the cell to `undefined`
`undefined`, the runtime's dispatch paths the cell's current value — and
bodies still read the cell.

- The runtime's native bodies declare the receiver (`_this: JsThis`,
`closure::JsThis`, `repr(transparent)` over `u64`); perry-ffi exports an
ABI-identical `JsThis` for wrapper crates. The body-call funnel
(`closure/body_call.rs`) passes it on every route: exact arity, padded
arity, rest bundling, the wide ladder, hoisted `DirectCallN` sites,
microtask steps and worker threads.
- Stage 0 (the preceding commit) made one funnel per side for every call of
a body's code pointer: `closure/body_call.rs` in the runtime,
`expr::body_call` in codegen, and deleted the dead, wrong
`js_closure_unbind_this`.
- Codegen spells the ABI once (`expr::body_call::js_body_params` /
`emit_js_body_call`); a stage-0 miss is closed — the inline
`Array.prototype.some` loop called its captureless callback body directly.
- `PERRY_THIS_WITNESS=1` (compile-time, in the object-cache key) makes the
entry of every compiled body that reads the cell compare its `this`
parameter with the cell and report `PERRY_THIS_WITNESS checks=N
mismatches=M` at exit. The gap corpus, tsc and the call-route fixture run
with zero mismatches.
- `scripts/check_js_body_call_funnel.py` now also refuses a body-shaped
`extern "C" fn` that does not declare the receiver, and a function handed
straight to a closure allocator that does not.

Cost (instructions per call, LTO-off fast build, same host): a closure value
call through the runtime +9 (the dispatcher reads the cell to pass it), a
method-site hit +3. Stage 3 removes the cell and these reads with it.
22 changes: 22 additions & 0 deletions changelog.d/11637-this-as-a-parameter-stage-2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
A body that reads its dynamic `this` now reads its receiver PARAMETER
(stage 2 of passing `this` as a parameter). The thread-local implicit-`this`
cell is still written by every caller in this commit, so stage 2 can be
measured on its own; stage 3 deletes it.

- A function expression or object-literal method that reads `this` stores
`%js_this` into its rooted entry `this` slot before the body's first
safepoint. A sloppy body applies OrdinaryCallBindThis once, in place: an
object receiver is tested inline, anything else goes through
`js_this_coerce_sloppy` (nullish -> globalThis, primitive -> wrapper).
- A top-level function that reads `this` is compiled as
`perry_fn_X$this(i64 %js_this, ...)`. The public `perry_fn_X` symbol that
direct calls and other modules name is a forwarder passing `undefined`; the
function's value wrapper passes the receiver it was given. Such functions
are not arena-threaded.
- `__perry_wrap_<method>` forwards its `this` parameter as the method's
receiver, and the runtime's native bodies read their `this` parameter
instead of the cell.

Instructions per call (LTO-off build, same host) against stage 1: an
object-literal method reading `this` 227 -> 143, an ES5 prototype method
217 -> 134, `forEach` with a `thisArg` 828 -> 744.
38 changes: 38 additions & 0 deletions changelog.d/11637-this-as-a-parameter-stage-3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
The implicit-`this` cell is gone (stage 3 of passing `this` as a parameter):
a body's `this` parameter is the only way it learns its receiver, so no
caller saves, sets or restores anything around a call.

- Deleted: the per-agent `IMPLICIT_THIS` cell, `js_implicit_this_get`,
`_get_sloppy` and `_set`, `ImplicitThisScope`, its exception savepoint,
its `HotTls` field and agent-pointer slot, the codegen save/restore
funnel (`rooting::implicit_this_*`), the stage-1 witness
(`PERRY_THIS_WITNESS`), and `js_closure_call1_receiverless` (identical to
`js_closure_call1` once no cell exists).
- A plain call passes `undefined`: `js_closure_call0..16`,
`js_native_call_value` and `js_closure_call_array`. A call with a
receiver uses the new `js_closure_call_this0..16`,
`js_native_call_value_this` and `js_closure_call_array_this`. perry-ffi
gains `JsClosure::call_this0..4` and `JsThis::{UNDEFINED, as_f64,
from_f64}` (additive).
- Runtime routes that used to carry a receiver through the cell now pass it:
`util.promisify`/`callbackify`/`deprecate` wrappers call the original with
their own receiver (Node's `ReflectApply(original, this, args)`), the
legacy `Intl.NumberFormat.call(obj)` / `DateTimeFormat` chain, event
listeners (the emitter), stream and socket methods, N-API
`napi_call_function`. One route never had it and is fixed: an accessor
defined on an `arguments` object now runs with that object as `this`
(`test-files/test_gap_this_param_receiver_routes.ts`).
- Async and generator function expressions bind their receiver at entry like
any other body. They run once per call; the step closures that run across
resumptions capture it lexically.
- `this` in module top-level code is `undefined` in strict code and
globalThis in sloppy code, the value the cell held when nothing set it.
- A method's entry-resolved callback target
(`js_closure_resolve_plain_direct_call`, formerly `_arrow_`) now admits
ordinary functions too: the call passes the plain-call `undefined`
receiver itself, which is all `js_closure_callN` did for them.

Instructions per call (LTO-off build, same host), main -> this change: a
closure value call 145 -> 46, a two-argument closure call 160 -> 59, a
method-site hit on an object literal 99 -> 79, an object-literal method
reading `this` 223 -> 120, an ES5 prototype method 214 -> 111.
196 changes: 189 additions & 7 deletions crates/perry-abi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,17 @@ pub const STATIC_SHAPE_ID_COUNT: u32 = 1 << 20;
pub const ARRAY_HEADER_SIZE: usize = 8;

/// `agent_ptrs::PERRY_AGENT_PTRS`: the number of per-agent pointer slots.
/// Slot 0 is reserved (the megamorphic follow-up's shape-record directory).
/// Slot 0 is reserved (the megamorphic follow-up's shape-record directory);
/// slot 1 held the implicit-`this` cell's address until this-as-a-parameter
/// deleted the cell, and is free; slot 2 is the stack limit.
pub const AGENT_PTR_SLOTS: usize = 4;
/// Slot 1: the address of this agent's implicit-`this` cell
/// (`tls_hot::HotTls::implicit_this`), which a direct method call binds.
pub const AGENT_PTR_IMPLICIT_THIS: usize = 1;
/// Slot 2: this agent's stack limit (#10812) — not a pointer to anything, the
/// lowest frame address a compiled prologue accepts before it throws
/// `RangeError: Maximum call stack size exceeded`. Null means unchecked.
pub const AGENT_PTR_STACK_LIMIT: usize = 2;
/// `tls_hot::HotTls::agent_ptrs` (Apple aarch64 TSD path; LP64): directly
/// after `implicit_this` (128), behind fixed-size fields only.
pub const HOT_TLS_AGENT_PTRS_OFFSET: usize = 136;
/// `tls_hot::HotTls::agent_ptrs` (Apple aarch64 TSD path; LP64): the first
/// inline value, behind fixed-size fields only.
pub const HOT_TLS_AGENT_PTRS_OFFSET: usize = 128;

/// `closure::ClosureHeader` (LP64): the u32 capture count at 0, the ShapeId
/// at 4 (the same word as `ObjectHeader`), the code pointer at 8, the shaped
Expand All @@ -49,6 +48,189 @@ pub const GC_FLAG_FORWARDED: u8 = 0x80;
/// `gc::GC_HEADER_SIZE`.
pub const GC_HEADER_SIZE: usize = 8;

/// The JS BODY calling convention. Every native body a function object runs —
/// a compiled closure body, a value wrapper, a native builtin installed as a
/// function object, a body an addon registers through perry-ffi — is
///
/// ```text
/// double body(i64 callee, i64 this, double a0, double a1, ...)
/// ```
///
/// where `callee` is the function object (its captures follow the header) and
/// `this` is the NaN-boxed receiver bits ([`JsThis`]), passed in an INTEGER
/// register so every floating-point argument register stays free for JS
/// arguments (SysV x86-64 / AAPCS64; Win64 assigns positionally, which is
/// equally correct). Passing more JS arguments than a body declares is safe
/// (the caller owns the stack argument area); fewer is padded with
/// `undefined` by the caller. Its Rust type is [`js_body_fn_ty!`], defined
/// here and nowhere else. The runtime calls bodies only through
/// `closure/body_call.rs`; emitted code only through
/// `expr::body_call::emit_js_body_call`.
///
/// The `this` parameter is the only way a body learns its receiver: a
/// method-style caller passes the receiver, a plain call `undefined`.
pub const JS_BODY_CALLEE_PARAM: usize = 0;
/// Native parameter index of the receiver (`this`) bits.
pub const JS_BODY_THIS_PARAM: usize = 1;
/// Native parameter index of the first JS argument.
pub const JS_BODY_FIRST_ARG_PARAM: usize = 2;
/// Native parameters every JS body declares before its JS arguments.
pub const JS_BODY_FIXED_PARAMS: usize = 2;

/// NaN-boxed `undefined` (`value::TAG_UNDEFINED` in the runtime, which
/// asserts it equals this).
pub const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001;

/// The receiver a JS body takes as its second native parameter
/// ([`JS_BODY_THIS_PARAM`]): the NaN-boxed `this` bits, in an integer
/// register (`repr(transparent)` over `u64`, so its ABI is exactly a `u64`'s).
#[repr(transparent)]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct JsThis(pub u64);

impl JsThis {
/// `undefined`: the receiver of a plain (non-method) call.
pub const UNDEFINED: JsThis = JsThis(TAG_UNDEFINED);

/// The receiver bits.
#[inline(always)]
pub const fn bits(self) -> u64 {
self.0
}

/// The receiver as a NaN-boxed value.
#[inline(always)]
pub fn as_f64(self) -> f64 {
f64::from_bits(self.0)
}

/// A NaN-boxed value as a receiver.
#[inline(always)]
pub fn from_f64(value: f64) -> Self {
JsThis(value.to_bits())
}
}

/// THE Rust type of a JS body: `js_body_fn_ty!(Callee; a, b)` is
/// `unsafe extern "C" fn(*const Callee, JsThis, f64, f64) -> f64` — the
/// callee header type, then one `f64` per token. A safe `extern "C" fn` body
/// coerces to it.
#[macro_export]
macro_rules! js_body_fn_ty {
(@f64 $x:tt) => { f64 };
($callee:ty; $($x:tt),* $(,)?) => {
unsafe extern "C" fn(
*const $callee,
$crate::JsThis
$(, $crate::js_body_fn_ty!(@f64 $x))*
) -> f64
};
}

/// A JS body with a statically known JS arity: implemented for exactly the
/// [`js_body_fn_ty!`] pointer types (`JsBody0<C>` .. `JsBody16<C>`), so an API
/// taking `impl JsBody<C>` refuses any other signature — a bare `*const u8`,
/// a body without the receiver, a wrong argument type — at compile time.
///
/// # Safety
/// Implemented only here, for the body pointer types; `code` is the body's
/// entry address.
pub unsafe trait JsBody<C>: Copy {
/// The JS parameters the body declares.
const ARITY: u32;
/// The body's code address, for the runtime's registries.
fn code(self) -> *const u8;
}

macro_rules! js_body_types {
($($alias:ident = $n:literal [$($x:tt),*];)*) => {$(
#[doc = concat!("A JS body declaring ", stringify!($n), " JS parameters.")]
pub type $alias<C> = js_body_fn_ty!(C; $($x),*);
// SAFETY: the pointer type is a JS body type by construction.
unsafe impl<C> JsBody<C> for $alias<C> {
const ARITY: u32 = $n;
#[inline(always)]
fn code(self) -> *const u8 {
self as *const u8
}
}
)*};
}

js_body_types! {
JsBody0 = 0 [];
JsBody1 = 1 [a];
JsBody2 = 2 [a, a];
JsBody3 = 3 [a, a, a];
JsBody4 = 4 [a, a, a, a];
JsBody5 = 5 [a, a, a, a, a];
JsBody6 = 6 [a, a, a, a, a, a];
JsBody7 = 7 [a, a, a, a, a, a, a];
JsBody8 = 8 [a, a, a, a, a, a, a, a];
JsBody9 = 9 [a, a, a, a, a, a, a, a, a];
JsBody10 = 10 [a, a, a, a, a, a, a, a, a, a];
JsBody11 = 11 [a, a, a, a, a, a, a, a, a, a, a];
JsBody12 = 12 [a, a, a, a, a, a, a, a, a, a, a, a];
JsBody13 = 13 [a, a, a, a, a, a, a, a, a, a, a, a, a];
JsBody14 = 14 [a, a, a, a, a, a, a, a, a, a, a, a, a, a];
JsBody15 = 15 [a, a, a, a, a, a, a, a, a, a, a, a, a, a, a];
JsBody16 = 16 [a, a, a, a, a, a, a, a, a, a, a, a, a, a, a, a];
}

/// `js_closure_call{N}(callee, this, a0..aN-1)` calls a function object with
/// receiver `this` ([`JsThis::UNDEFINED`] for a plain call); it exists for
/// `N <= JS_CLOSURE_CALL_MAX_ARGS`, and wider calls use
/// `js_closure_call_array(callee, this, args, len)`.
pub const JS_CLOSURE_CALL_MAX_ARGS: usize = 16;
/// The fixed-arity entries, indexed by JS argument count.
pub const JS_CLOSURE_CALL_ENTRIES: [&str; JS_CLOSURE_CALL_MAX_ARGS + 1] = [
"js_closure_call0",
"js_closure_call1",
"js_closure_call2",
"js_closure_call3",
"js_closure_call4",
"js_closure_call5",
"js_closure_call6",
"js_closure_call7",
"js_closure_call8",
"js_closure_call9",
"js_closure_call10",
"js_closure_call11",
"js_closure_call12",
"js_closure_call13",
"js_closure_call14",
"js_closure_call15",
"js_closure_call16",
];
/// Every runtime entry point native code (emitted or Rust) calls to run a JS
/// function. Each takes the receiver after the function, can run arbitrary JS
/// and therefore collect: `scripts/gc_root_dominance_check.py` reads its
/// poll-capable set from THIS list.
pub const JS_CALL_ENTRIES: [&str; JS_CLOSURE_CALL_MAX_ARGS + 1 + 4] = [
"js_closure_call0",
"js_closure_call1",
"js_closure_call2",
"js_closure_call3",
"js_closure_call4",
"js_closure_call5",
"js_closure_call6",
"js_closure_call7",
"js_closure_call8",
"js_closure_call9",
"js_closure_call10",
"js_closure_call11",
"js_closure_call12",
"js_closure_call13",
"js_closure_call14",
"js_closure_call15",
"js_closure_call16",
"js_closure_call_array",
"js_closure_call_apply_with_spread",
"js_native_call_value",
// V8's callback trampoline contract (`func(env, args, len)`, no
// receiver): a plain call.
"js_closure_v8_callback",
];
/// `object::method_site::MethodEntry` — the words the emitted method-call site
/// reads (`perry-codegen/src/expr/method_site.rs`).
pub const METHOD_SITE_WORD_OFFSET: usize = 0;
Expand Down
Loading
Loading