perf: this is a parameter of every JS body; the implicit-this cell is gone (perry-ffi 0.6 typed bodies) - #11637
Merged
Merged
Conversation
added 9 commits
September 28, 2026 02:21
…his-as-a-parameter, stage 0)
No behaviour change. Every place that turns a function body's code pointer
into a call now goes through one funnel, so the body ABI can change (stage 1:
the receiver becomes a parameter) in one place, with the compiler finding
every caller.
Runtime: `closure/body_call.rs` owns the body types and the only transmutes to
them: `js_body_fn_ty!` / `js_body_fn!` / `js_body_call!` /
`js_body_call_unwind!` for closure-ABI bodies `(callee, a0..)`,
`js_method_body_fn!` / `js_method_body_call!` for class method, constructor and
accessor bodies `(this, a0..)`, and `js_bare_body_fn!` for static methods,
static accessors and the lifted well-known hooks `(a0..)`. Routed through it:
the 17 direct arms of `js_closure_call0..16`, `dispatch_with_arity` (33 arms),
`dispatch_rest_bundled` (16 arms, now one macro), the wide ladder,
`DirectCall1..4`, the captureless `some` callback, the async-step microtask,
five `thread.rs` workers, 31 class accessor / static / hook calls, and the
`then(this, ..)` thenable call. The 57 typed native-body type positions
(install helpers, erasure casts) use `js_body_fn_ty!`. Production
(`panic = "abort"`) signatures are unchanged; in unwinding test builds rest
arms 1-6 now use the unwind-capable pointer type arms 0 and 7-15 already used.
Codegen: `expr/body_call.rs::emit_js_body_call` is the only builder of body
calls: the method-site hit, the versioned-loop and resolved-arrow indirect
calls, the known-closure and `$generic` direct calls, imported-object method
bodies and the public typed trampoline's generic fallback.
perry-abi: `JS_BODY_CALLEE_PARAM` / `JS_BODY_FIRST_ARG_PARAM`,
`JS_CLOSURE_CALL_ENTRIES` (the codegen `js_closure_call{N}` declarations are
generated from it; a runtime test checks each entry names the function taking
that many JS arguments) and `JS_CALL_ENTRIES`, from which
`gc_root_dominance_check.py` now reads its JS-call poll-capable set. That adds
`js_closure_call1_receiverless`, which the hand list lacked.
Gate: `scripts/check_js_body_call_funnel.py` (+ `--self-test`, wired into the
lint job) refuses a transmute to a body type outside the runtime funnel and an
indirect call built outside the codegen funnel. Native Rust helpers with a
body-shaped type carry `NOT-A-JS-BODY:`.
Also: delete `js_closure_unbind_this`, which was dead (no caller) and wrong (it
cleared capture slot 0; `this` lives in the LAST slot), and correct four
comments that stated false facts: `CAPTURES_THIS_FLAG` (last slot, not slot
0), the bind-result capture layout (5 slots, not 3), the `js_closure_alloc`
flag note, and the `new.rs` claim that a throw leaves `new.target` set (the
catch savepoint replays it at the nearest `try`).
…as a parameter
Every JS body — a compiled closure body, a function's value wrapper, a class
method's value wrapper, the namespace/no-op/unknown-function wrappers and
every native builtin installed as a function object — is now
double body(i64 callee, i64 this, double a0, ...)
(perry_abi::JS_BODY_THIS_PARAM = 1, FIRST_ARG = 2). The receiver is NaN-boxed
bits in an integer register (owner decision D1). Behavior-neutral by
construction: every caller passes exactly the receiver the implicit-`this`
cell holds for the call, and bodies still read the cell.
Runtime: closure::JsThis (repr(transparent) u64). The body-call funnel
macros take the receiver; funnel callers pass JsThis::current(), read at the
call through the per-agent pointer block (PERRY_AGENT_PTRS slot 1), or the
receiver they know (js_closure_call1_receiverless). DirectCallN's fallback is
a body-typed adapter over js_closure_callN (call ENTRIES keep their
(callee, args) signature in this stage). 1,923 native body definitions
declare `_this: JsThis`; 137 direct Rust calls of native bodies pass the
cell's receiver. perry-ffi exports an ABI-identical JsThis.
Codegen: expr::body_call owns js_body_params / js_body_param_types /
emit_js_body_call(this_bits) / current_this_bits. The method-site hit passes
its receiver; arrow-only direct calls pass undefined; the closure-direct call
passes undefined when it bound the cell to undefined, else the cell's bits;
imported-object and Atomics direct calls pass the cell's bits. Stage-0 miss
closed: the inline `some` loop called its captureless callback directly.
Witness: PERRY_THIS_WITNESS=1 (compile time, in the object-cache key) emits
js_this_param_witness(%js_this, name) at the entry of every body that reads
the cell and reports `PERRY_THIS_WITNESS checks=N mismatches=M` at exit.
Gate: check_js_body_call_funnel.py refuses a body-shaped extern fn without the
receiver and a direct closure-allocator install of one (20 self-test cases).
Tests: closure/receiver_param_tests.rs (exact, padded, rest, wide, hoisted
routes; witness counts a disagreement); perry tests/this_param_abi.rs (route
fixture == node with checks >= 3000 and 0 mismatches; every installed body
defined with the ABI in the IR).
Five runtime/stdlib files pushed over the 2,000-line gate by the added
parameter are split (pipe listeners, Error user props, prototype-method
lookups, a test module, termios impls).
…his-as-a-parameter stage 1b)
The JS body type (`JsThis`, `js_body_fn_ty!`, `JsBody0`..`JsBody16`) is
defined once in perry-abi and shared by perry-runtime and perry-ffi 0.6.0.
perry-ffi's `alloc_closure` / `register_closure_arity` / new
`register_closure_rest` take a typed body instead of `*const u8`, so a body
with the wrong signature does not compile (compile_fail doctests).
Every call entry takes the receiver after the function:
js_closure_call{0..16}(closure, this, ..), js_native_call_value(func, this,
args, len), js_closure_call_array(closure, this, args, len) and
js_closure_call_apply_with_spread(box, this, ..). js_closure_call1_receiverless
is gone; V8's receiverless trampoline gets its own adapter,
js_closure_v8_callback(env, args, len). `DirectCallN::call` takes the
receiver. The ext/UI/audio crates pass receivers instead of saving and
restoring the implicit-`this` cell themselves.
Stage-1 semantics are unchanged: bodies still read the implicit-`this` cell.
An entry handed a receiver other than the cell's value binds the cell to it
for the call (an ImplicitThisScope, which roots the displaced value), except
for an arrow callee; a receiver equal to the cell binds nothing. Runtime and
stdlib plain calls pass `plain_call_receiver()`, which in stage 1 is the
cell's current value, and codegen's plain closure calls pass the cell bits,
so plain calls keep running with whatever the cell holds.
check_js_body_call_funnel.py now also refuses an entry declaration without
the receiver, a closure registrar declared outside runtime/stdlib/perry-ffi,
and a perry-ffi registration function taking `*const u8`.
A body that reads its dynamic `this` now reads its receiver parameter instead of the thread-local implicit-`this` cell: function expressions and object-literal methods store `%js_this` into their rooted entry `this` slot (sloppy bodies apply OrdinaryCallBindThis once), top-level functions that read `this` compile to `perry_fn_X$this` behind a receiver-less public forwarder, `__perry_wrap_<method>` forwards its receiver, and the runtime's native bodies read their `this` parameter. The cell is still written by every caller, so this stage can be measured on its own; stage 3 deletes it. Instructions per call against stage 1 (LTO-off build, same host): an object-literal method reading `this` 227 -> 143, an ES5 prototype method 217 -> 134, `forEach` with a `thisArg` 828 -> 744.
A body's `this` parameter is now the only way it learns its receiver, so no
caller saves, sets or restores an ambient `this` around a call.
- Deleted: the per-agent IMPLICIT_THIS cell, js_implicit_this_get /
_get_sloppy / _set, ImplicitThisScope, its exception savepoint, its HotTls
field and agent-pointer slot, the codegen save/restore funnel
(rooting::implicit_this_*), and the stage-1 witness (PERRY_THIS_WITNESS).
- The receiver-taking entries (js_closure_call{N}, js_native_call_value,
js_closure_call_array, js_closure_call_apply_with_spread) pass their
receiver and bind nothing; a plain call passes undefined
(plain_call_receiver()).
- Every runtime, stdlib and ext route that carried a receiver through the
cell passes it: listeners get their emitter, util.promisify / callbackify /
deprecate call the original with the wrapper's receiver, the legacy
Intl.NumberFormat / DateTimeFormat chain sees its receiver, N-API
napi_call_function passes recv. An accessor on an arguments object now
runs with it as `this` (it never had it).
- Async and generator function expressions bind their receiver at entry;
module top-level `this` is undefined (strict) / globalThis (sloppy).
- A method's entry-resolved callback target
(js_closure_resolve_plain_direct_call) admits ordinary functions too.
Instructions per call (LTO-off build, same host), main -> this change:
closure value call 145 -> 46, two-argument closure call 160 -> 59,
object-literal method-site hit 99 -> 79, object-literal method reading
`this` 223 -> 120, ES5 prototype method 214 -> 110.
…o its own module reactor.rs was 2010 lines, over the 2000-line cap enforced by scripts/check_file_size.sh. Move the async exec/execFile section (cp_exec_async, cp_exec_accumulate, cp_exec_fire_close, cp_defer_exec_callback, cp_exec_cb_thunk) into a sibling reactor/exec_async.rs, the same way stdin.rs and streams.rs were already split out of this file. Pure code move, no behavior change.
…rameter wrapper ABI The this-as-a-parameter ABI now passes the JS receiver as a second i64 parameter (i64 %this_closure, i64 %js_this) into every installed body, so the wrapper's forwarding call must include %js_this alongside %this_closure. Update the expected call shape; the test still asserts what it always asserted -- function identity through the $-export alias resolves by ID, not by the exported spelling.
|
Important Review skippedToo many files! This PR contains 777 files, which is 477 over the limit of 300. To get a review, reduce the PR to 300 files or fewer by splitting it into smaller PRs or changing its base branch. Usage-priced reviews support at most 300 files. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: ⛔ Files ignored due to path filters (5)
📒 Files selected for processing (777)
You can disable this status message by setting the 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. Comment |
added 4 commits
September 28, 2026 17:16
…eceiver parameter perry_wasi_sig.c ref.test candidate types (double (closure, f64 x k)) predate this-as-a-parameter: every real JS body now takes (closure, this: u64, f64 x k) on wasm32. None of the candidates ever match any more, so wasi_body_params always returns None and dispatch falls back to the caller arity instead of the body real one, under-supplying a body that declares more parameters than its call site -- an indirect-call type mismatch that traps under wasmtime (repro: scripts/wasi_smoke iterators.ts, gen().map(fn) through the shared IteratorPrototype[Symbol.iterator] thunk). Add the missing unsigned long long receiver parameter to every perry_body_N candidate typedef and update the doc comment.
# Conflicts: # crates/perry-runtime/src/timer.rs
added 3 commits
September 29, 2026 00:08
…t made stale Moving cp_exec_async into reactor/exec_async.rs took the only allocating function that names CP_NEXT_LIVE_ID out of reactor.rs, so rule B no longer makes the counter a candidate in its declaring file and the inventory entry matches no holder. The static is still a scalar AtomicU64 id counter; the ChildProcess values it keys live in CP_LIVE, which cp_reactor_scan_roots_mut visits.
…ot an inlined dispatcher native_call_value_this was #[inline(always)] over the whole value-call dispatcher, and the this-as-a-parameter funnel routes ~130 runtime callers through it, so every one of them carried its own copy (Function.prototype call/apply thunks, forEach/replace callbacks, each arm_trap_and_run closure). It grew every linked program by ~190 KB on linux-x64 (loop fixture 7,563,048 -> 7,755,560 stripped). The dispatcher body now lives only in the two js_native_call_value definitions; native_call_value_this is a thin call to the export. loop is 7,497,512 with this change, below main.
…install 736782e made Function.prototype.call/.apply run the node:stream subclass-init shim for Readable.call(this, opts) by calling js_node_stream_*_subclass_init directly. That path is linked into every program, so it pinned all of node_stream (and the promise, timer and process-stream code behind it) into programs that never import stream: +554 KB stripped on the linux-x64 loop fixture (7,009,384 -> 7,563,048), and the darwin-arm64 small-program budget over by 6-10%. The shim is now registered by js_nm_install_stream, which codegen emits only when a program imports stream; a stream base callee cannot exist otherwise. The store is black_boxed like js_nm_enable_install_all, since the slot has a single writer and whole-program optimization otherwise devirtualizes the load back into a direct call. The dynamic super() path shares the same entry. loop fixture: 7,497,512 -> 6,952,040 stripped. The #10448/#10449/#10454/ #10544/#10798 and stdio-writable gap tests still match node.
proggeramlug
pushed a commit
that referenced
this pull request
Sep 29, 2026
…erdict nm_stream_subclass_init reassigned its load only under cfg(test), so a non-test build saw an unused `mut` and failed the -D warnings job. Shadow the binding in the test-only branch instead. NM_STREAM_SUBCLASS_INIT holds only the node_stream_subclass_init fn pointer (its one store is in js_nm_install_stream): a code address, never an arena address. Record that no_heap_address verdict for the #11471 thread-exit custody gate, like its NM_CTOR/NM_ATTACH sibling registries.
Main moved class static accessors onto the class function object (#11651) and made a class value its function object (#11609). Under the this-as-a-parameter ABI: - static accessor getters/setters (class_value, static_accessor_call) are bare bodies, called through js_bare_body_fn!; - js_class_constructor_called, the [[Call]] body of every class function object, declares its receiver; - the static data-property method call in class_receiver_arm passes the class as the receiver argument instead of setting the deleted implicit-this cell; - the pinned-roots promise test callbacks declare the receiver. Regenerated the linux gc_call_effects table and the wasm32 runtime ABI table; the gc/mod.rs holder pin covers both sides' edits.
…pped The PASS1_MARKED entry's audit trail took main's side in the merge and lost this branch's note for the implicit-this scanner registration (`scan_dispatch_binding_roots_mut`). Restore it after main's notes.
proggeramlug
added a commit
that referenced
this pull request
Sep 29, 2026
…losure-body registry (#11654) * refactor: one funnel per side for every call of a JS function body (this-as-a-parameter, stage 0) No behaviour change. Every place that turns a function body's code pointer into a call now goes through one funnel, so the body ABI can change (stage 1: the receiver becomes a parameter) in one place, with the compiler finding every caller. Runtime: `closure/body_call.rs` owns the body types and the only transmutes to them: `js_body_fn_ty!` / `js_body_fn!` / `js_body_call!` / `js_body_call_unwind!` for closure-ABI bodies `(callee, a0..)`, `js_method_body_fn!` / `js_method_body_call!` for class method, constructor and accessor bodies `(this, a0..)`, and `js_bare_body_fn!` for static methods, static accessors and the lifted well-known hooks `(a0..)`. Routed through it: the 17 direct arms of `js_closure_call0..16`, `dispatch_with_arity` (33 arms), `dispatch_rest_bundled` (16 arms, now one macro), the wide ladder, `DirectCall1..4`, the captureless `some` callback, the async-step microtask, five `thread.rs` workers, 31 class accessor / static / hook calls, and the `then(this, ..)` thenable call. The 57 typed native-body type positions (install helpers, erasure casts) use `js_body_fn_ty!`. Production (`panic = "abort"`) signatures are unchanged; in unwinding test builds rest arms 1-6 now use the unwind-capable pointer type arms 0 and 7-15 already used. Codegen: `expr/body_call.rs::emit_js_body_call` is the only builder of body calls: the method-site hit, the versioned-loop and resolved-arrow indirect calls, the known-closure and `$generic` direct calls, imported-object method bodies and the public typed trampoline's generic fallback. perry-abi: `JS_BODY_CALLEE_PARAM` / `JS_BODY_FIRST_ARG_PARAM`, `JS_CLOSURE_CALL_ENTRIES` (the codegen `js_closure_call{N}` declarations are generated from it; a runtime test checks each entry names the function taking that many JS arguments) and `JS_CALL_ENTRIES`, from which `gc_root_dominance_check.py` now reads its JS-call poll-capable set. That adds `js_closure_call1_receiverless`, which the hand list lacked. Gate: `scripts/check_js_body_call_funnel.py` (+ `--self-test`, wired into the lint job) refuses a transmute to a body type outside the runtime funnel and an indirect call built outside the codegen funnel. Native Rust helpers with a body-shaped type carry `NOT-A-JS-BODY:`. Also: delete `js_closure_unbind_this`, which was dead (no caller) and wrong (it cleared capture slot 0; `this` lives in the LAST slot), and correct four comments that stated false facts: `CAPTURES_THIS_FLAG` (last slot, not slot 0), the bind-result capture layout (5 slots, not 3), the `js_closure_alloc` flag note, and the `new.rs` claim that a throw leaves `new.target` set (the catch savepoint replays it at the nearest `try`). * perf: this-as-a-parameter stage 1 — every JS body takes its receiver as a parameter Every JS body — a compiled closure body, a function's value wrapper, a class method's value wrapper, the namespace/no-op/unknown-function wrappers and every native builtin installed as a function object — is now double body(i64 callee, i64 this, double a0, ...) (perry_abi::JS_BODY_THIS_PARAM = 1, FIRST_ARG = 2). The receiver is NaN-boxed bits in an integer register (owner decision D1). Behavior-neutral by construction: every caller passes exactly the receiver the implicit-`this` cell holds for the call, and bodies still read the cell. Runtime: closure::JsThis (repr(transparent) u64). The body-call funnel macros take the receiver; funnel callers pass JsThis::current(), read at the call through the per-agent pointer block (PERRY_AGENT_PTRS slot 1), or the receiver they know (js_closure_call1_receiverless). DirectCallN's fallback is a body-typed adapter over js_closure_callN (call ENTRIES keep their (callee, args) signature in this stage). 1,923 native body definitions declare `_this: JsThis`; 137 direct Rust calls of native bodies pass the cell's receiver. perry-ffi exports an ABI-identical JsThis. Codegen: expr::body_call owns js_body_params / js_body_param_types / emit_js_body_call(this_bits) / current_this_bits. The method-site hit passes its receiver; arrow-only direct calls pass undefined; the closure-direct call passes undefined when it bound the cell to undefined, else the cell's bits; imported-object and Atomics direct calls pass the cell's bits. Stage-0 miss closed: the inline `some` loop called its captureless callback directly. Witness: PERRY_THIS_WITNESS=1 (compile time, in the object-cache key) emits js_this_param_witness(%js_this, name) at the entry of every body that reads the cell and reports `PERRY_THIS_WITNESS checks=N mismatches=M` at exit. Gate: check_js_body_call_funnel.py refuses a body-shaped extern fn without the receiver and a direct closure-allocator install of one (20 self-test cases). Tests: closure/receiver_param_tests.rs (exact, padded, rest, wide, hoisted routes; witness counts a disagreement); perry tests/this_param_abi.rs (route fixture == node with checks >= 3000 and 0 mismatches; every installed body defined with the ABI in the IR). Five runtime/stdlib files pushed over the 2,000-line gate by the added parameter are split (pipe listeners, Error user props, prototype-method lookups, a test module, termios impls). * perry-ffi 0.6: one typed JS body ABI, receiver-taking call entries (this-as-a-parameter stage 1b) The JS body type (`JsThis`, `js_body_fn_ty!`, `JsBody0`..`JsBody16`) is defined once in perry-abi and shared by perry-runtime and perry-ffi 0.6.0. perry-ffi's `alloc_closure` / `register_closure_arity` / new `register_closure_rest` take a typed body instead of `*const u8`, so a body with the wrong signature does not compile (compile_fail doctests). Every call entry takes the receiver after the function: js_closure_call{0..16}(closure, this, ..), js_native_call_value(func, this, args, len), js_closure_call_array(closure, this, args, len) and js_closure_call_apply_with_spread(box, this, ..). js_closure_call1_receiverless is gone; V8's receiverless trampoline gets its own adapter, js_closure_v8_callback(env, args, len). `DirectCallN::call` takes the receiver. The ext/UI/audio crates pass receivers instead of saving and restoring the implicit-`this` cell themselves. Stage-1 semantics are unchanged: bodies still read the implicit-`this` cell. An entry handed a receiver other than the cell's value binds the cell to it for the call (an ImplicitThisScope, which roots the displaced value), except for an arrow callee; a receiver equal to the cell binds nothing. Runtime and stdlib plain calls pass `plain_call_receiver()`, which in stage 1 is the cell's current value, and codegen's plain closure calls pass the cell bits, so plain calls keep running with whatever the cell holds. check_js_body_call_funnel.py now also refuses an entry declaration without the receiver, a closure registrar declared outside runtime/stdlib/perry-ffi, and a perry-ffi registration function taking `*const u8`. * perf: this-as-a-parameter stage 2 — bodies read their receiver parameter A body that reads its dynamic `this` now reads its receiver parameter instead of the thread-local implicit-`this` cell: function expressions and object-literal methods store `%js_this` into their rooted entry `this` slot (sloppy bodies apply OrdinaryCallBindThis once), top-level functions that read `this` compile to `perry_fn_X$this` behind a receiver-less public forwarder, `__perry_wrap_<method>` forwards its receiver, and the runtime's native bodies read their `this` parameter. The cell is still written by every caller, so this stage can be measured on its own; stage 3 deletes it. Instructions per call against stage 1 (LTO-off build, same host): an object-literal method reading `this` 227 -> 143, an ES5 prototype method 217 -> 134, `forEach` with a `thisArg` 828 -> 744. * perf: this-as-a-parameter stage 3 — delete the implicit-this cell A body's `this` parameter is now the only way it learns its receiver, so no caller saves, sets or restores an ambient `this` around a call. - Deleted: the per-agent IMPLICIT_THIS cell, js_implicit_this_get / _get_sloppy / _set, ImplicitThisScope, its exception savepoint, its HotTls field and agent-pointer slot, the codegen save/restore funnel (rooting::implicit_this_*), and the stage-1 witness (PERRY_THIS_WITNESS). - The receiver-taking entries (js_closure_call{N}, js_native_call_value, js_closure_call_array, js_closure_call_apply_with_spread) pass their receiver and bind nothing; a plain call passes undefined (plain_call_receiver()). - Every runtime, stdlib and ext route that carried a receiver through the cell passes it: listeners get their emitter, util.promisify / callbackify / deprecate call the original with the wrapper's receiver, the legacy Intl.NumberFormat / DateTimeFormat chain sees its receiver, N-API napi_call_function passes recv. An accessor on an arguments object now runs with it as `this` (it never had it). - Async and generator function expressions bind their receiver at entry; module top-level `this` is undefined (strict) / globalThis (sloppy). - A method's entry-resolved callback target (js_closure_resolve_plain_direct_call) admits ordinary functions too. Instructions per call (LTO-off build, same host), main -> this change: closure value call 145 -> 46, two-argument closure call 160 -> 59, object-literal method-site hit 99 -> 79, object-literal method reading `this` 223 -> 120, ES5 prototype method 214 -> 110. * refactor(runtime): split child_process reactor exec/execFile path into its own module reactor.rs was 2010 lines, over the 2000-line cap enforced by scripts/check_file_size.sh. Move the async exec/execFile section (cp_exec_async, cp_exec_accumulate, cp_exec_fire_close, cp_defer_exec_callback, cp_exec_cb_thunk) into a sibling reactor/exec_async.rs, the same way stdin.rs and streams.rs were already split out of this file. Pure code move, no behavior change. * test(codegen): update export alias identity test for the this-as-a-parameter wrapper ABI The this-as-a-parameter ABI now passes the JS receiver as a second i64 parameter (i64 %this_closure, i64 %js_this) into every installed body, so the wrapper's forwarding call must include %js_this alongside %this_closure. Update the expected call shape; the test still asserts what it always asserted -- function identity through the $-export alias resolves by ID, not by the exported spelling. * changelog: name the fragments after PR #11637 * fix(runtime): the WASI closure-arity signature probe is missing the receiver parameter perry_wasi_sig.c ref.test candidate types (double (closure, f64 x k)) predate this-as-a-parameter: every real JS body now takes (closure, this: u64, f64 x k) on wasm32. None of the candidates ever match any more, so wasi_body_params always returns None and dispatch falls back to the caller arity instead of the body real one, under-supplying a body that declares more parameters than its call site -- an indirect-call type mismatch that traps under wasmtime (repro: scripts/wasi_smoke iterators.ts, gen().map(fn) through the shared IteratorPrototype[Symbol.iterator] thunk). Add the missing unsigned long long receiver parameter to every perry_body_N candidate typedef and update the doc comment. * gc_effects: regenerate tables for the this-as-a-parameter body ABI * perf: a function object points to its body's static JsFunctionInfo; delete the closure-body registry Every JS function body has ONE static, immutable `JsFunctionInfo` (perry-abi): its code address, the parameter count a caller pads to, the rest kind and its fixed prefix, `.length` and its declared fallback, the arrow / strict / async / generator / built-in bits, and the compiler-private trusted-direct and versioned-loop clones. The function object's header word at +8 points to it, so every per-body fact is one load from the object and nothing is looked up by code address. - Codegen emits `@<body>$info` as a constant next to each body, and every allocation names the info. Another module's info is declared `external`, never copied, so a body has one info and one singleton function object. - Runtime natives and perry-ffi addons define the info as a `static` from the body's TYPED pointer (`fn_info!` in the runtime and stdlib, `perry_ffi::js_function_info!` in addons): the parameter count comes from the body's type, and any other signature does not compile. - Deleted: CLOSURE_BODY_REGISTRY, TRUSTED_TARGETS, DISPATCH_RECENT, the twelve `js_register_closure_*` entries with their call sites, and every module-init registration call. A class constructor's rest position travels with its class constructor flags. - perry-ffi 0.6: `alloc_closure` takes `&'static JsFunctionInfo`; `register_closure_arity` / `register_closure_rest` are gone. - `ClosureHeader::code()` proves the cell is a live function object before it loads through the info: comparing an arbitrary receiver's code with a known body stays a compare. - WASI: the `ref.test` body-signature probe (ffi/perry_wasi_sig.c and its build step) is deleted. The info's parameter count is the body's real one, so dispatch no longer infers it from the table entry's wasm type. - check_js_body_call_funnel.py refuses any registrar or deleted code-keyed table name in the crates. The GC holder / registry-lifetime inventories drop the entries for the deleted registration flags and tables, and the PASS1_MARKED census pin is re-audited for the removed census row. - test-files/test_gap_fn_info_facts.ts reads each info field through generic calls, against node. Layout: the info pointer replaces the code pointer at +8 (one dependent load on a generic indirect call through the runtime; the method-site hit caches code and is unchanged). * changelog: name the fragment after #11654 * gc_runtime_root_holders: drop the CP_NEXT_LIVE_ID entry the exec split made stale Moving cp_exec_async into reactor/exec_async.rs took the only allocating function that names CP_NEXT_LIVE_ID out of reactor.rs, so rule B no longer makes the counter a candidate in its declaring file and the inventory entry matches no holder. The static is still a scalar AtomicU64 id counter; the ChildProcess values it keys live in CP_LIVE, which cp_reactor_scan_roots_mut visits. * perf(runtime): runtime value calls go through js_native_call_value, not an inlined dispatcher native_call_value_this was #[inline(always)] over the whole value-call dispatcher, and the this-as-a-parameter funnel routes ~130 runtime callers through it, so every one of them carried its own copy (Function.prototype call/apply thunks, forEach/replace callbacks, each arm_trap_and_run closure). It grew every linked program by ~190 KB on linux-x64 (loop fixture 7,563,048 -> 7,755,560 stripped). The dispatcher body now lives only in the two js_native_call_value definitions; native_call_value_this is a thin call to the export. loop is 7,497,512 with this change, below main. * fix(runtime): reach the stream subclass-init shim through the stream install 736782e made Function.prototype.call/.apply run the node:stream subclass-init shim for Readable.call(this, opts) by calling js_node_stream_*_subclass_init directly. That path is linked into every program, so it pinned all of node_stream (and the promise, timer and process-stream code behind it) into programs that never import stream: +554 KB stripped on the linux-x64 loop fixture (7,009,384 -> 7,563,048), and the darwin-arm64 small-program budget over by 6-10%. The shim is now registered by js_nm_install_stream, which codegen emits only when a program imports stream; a stream base callee cannot exist otherwise. The store is black_boxed like js_nm_enable_install_all, since the slot has a single writer and whole-program optimization otherwise devirtualizes the load back into a direct call. The dynamic super() path shares the same entry. loop fixture: 7,497,512 -> 6,952,040 stripped. The #10448/#10449/#10454/ #10544/#10798 and stdio-writable gap tests still match node. * fix(runtime): stream subclass-init slot: no unused mut, thread-exit verdict nm_stream_subclass_init reassigned its load only under cfg(test), so a non-test build saw an unused `mut` and failed the -D warnings job. Shadow the binding in the test-only branch instead. NM_STREAM_SUBCLASS_INIT holds only the node_stream_subclass_init fn pointer (its one store is in js_nm_install_stream): a code address, never an arena address. Record that no_heap_address verdict for the #11471 thread-exit custody gate, like its NM_CTOR/NM_ATTACH sibling registries. * gc_runtime_root_holders: keep the stage-3 re-audit note the merge dropped The PASS1_MARKED entry's audit trail took main's side in the merge and lost this branch's note for the implicit-this scanner registration (`scan_dispatch_binding_roots_mut`). Restore it after main's notes. --------- Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
proggeramlug
pushed a commit
that referenced
this pull request
Sep 29, 2026
Resolve scripts/gc_runtime_root_holders.json by taking main's PASS1_MARKED text, re-appending this branch's #11549 audit note, and pinning gc/policy.rs to the merged file (it differs from main only by this branch's audited hunks). Port own_override_builtin_install_tests to main's #11654 closure ABI: js_closure_alloc now takes a static JsFunctionInfo, and bodies take the #11637 receiver parameter.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
thisbecomes an ordinary parameter of every JS function body:double body(i64 callee, i64 this, double args...). Previously it went through a per-thread "implicit this" cell. Every method call saved the cell, wrote the receiver into it, called, and restored it, with the saved value held as a GC root across the call. That cell, its save/restore traffic and its root are gone.Commits (reviewable one stage at a time):
closure/body_call.rs, codegenexpr/body_call.rs), plus the gatecheck_js_body_call_funnel.py, so no call site bypasses it. Flat on tsc and Zod.JsBody), and the native→JS call entries take a receiver. An addon function with the wrong signature is now a compile error on every platform, instead of silently mis-reading its arguments on Win64. Pre-1.0, so there are no compatibility shims, and every in-tree ext crate is updated.A merge commit brings in main (#11565's generated GC call-effects table, regenerated with
scripts/gc_call_effects/regen.sh), plus two follow-ups: a file-size split ofchild_process/reactor.rs, and one codegen test updated to the new wrapper ABI.Evidence
tsc and Zod, measured on a 48-thread EPYC host, n=5 interleaved,
instructions:u, each arm linking its own runtime, output identical to node on every run:Call fixtures (instructions per call):
Tests:
this_param_abi2/2,method_site9/9.test_gap_6287_timer_batch_order, which also flips on main under load (3 different orders in 5 runs).GC call-effects tables: the Linux table was regenerated locally. The macOS and Windows tables will be refreshed from this PR's
gc-call-effectsCI artifacts.Follow-up (separate PR, in progress)
The function-pointer-keyed closure body registry (arity, rest, arrow, trusted clones) becomes one static immutable
JsFunctionInfoper body, pointed to by the function object. That deletes three thread-local tables, about 50 registration entry points and their call sites.