Skip to content

Merge train 216: statement-position inlines, implicit-this rooting, node:net/http value dispatch, i128 split-module literals, Node-API 9/10 hosting, dynamic-key reads (v0.5.1594) - #10610

Merged
proggeramlug merged 16 commits into
mainfrom
train216r
Sep 18, 2026

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

This train lands #10530, #10564, #10565, #10566, #10569 and #10570 as v0.5.1594. Each source commit is verified to preserve its patch-id and authorship.

Two changes this train adds on its own

run_parity_tests.sh: repeated --filter now intersects instead of last-wins (#10585). scripts/run_gap_tests.sh wraps run_parity_tests.sh --filter test_gap_ "$@", so run_gap_tests.sh --filter buffer arrived as two --filters and the second discarded the wrapper's own. The gap suite then selected every one of the 1672 fixtures matching buffer rather than the 823 test_gap_* fixtures pr-gate gates on — while still printing that it was running the gap suite. Measured on one merge-queue sweep: 94 of 192 fixtures (21m08s of 41m37s) were outside the gate's scope, and six of that sweep's seven reds were in that half, where a red cannot block a merge but still costs a full A/B to attribute. A single --filter and the no-filter full sweep are unchanged.

object/native_module.rs split for the 2000-line cap. The file sat at exactly 2000 lines, so #10565's five-line addition tripped check_file_size.sh. CJS default-export resolution moves to object/native_module/cjs_default.rs, leaving 1942. The bodies are byte-identical and the parent re-exports the three names reached from elsewhere.

That region was chosen for a reason worth recording: it carries no raw-handle debt sites. raw_handle_debt.py --no-raise-vs is strictly per-path monotone with no notion of a relocation, so moving debt-carrying code out reads as ceiling raised to N (was not listed at the merge base) even when the total is unchanged — the bare run then demands the emptied source line be deleted, and the two required invocations of the same gate contradict each other. Filed as #10583. All four of this file's recorded sites sit in the vtable-access block, so splitting elsewhere kept the ledger valid; that was luck, not a strategy, and the file now has ~58 lines of headroom.

Validation

Validated head 17e9ad344f. Five-package release build pinned and hash-verified, and re-verified after the gap run (artifacts_match_pin_after_gap=True).

  • Crate suites: codegen 1598 · runtime 4016 (+2 known) · stdlib 139 · hir 447 · transform 152 · cli 1140.
  • Integration (this lane previously ran NO integration suites, so a train could validate everything except the thing a PR is for):
  • All nine preflight gates pass on the merged tree; lint is 82/83, the one red being the public-benchmark freshness step known-red on main.
  • Gap (gate-scoped, now that repeated --filter intersects — each line records what the run actually selected against what test_gap_* matching predicts, so a filter that silently fails to narrow is a red):
gap_dynamic_key_selected ran=1 gate_scope=1 all_matching=1 07:59:55Z
gap_implicit_this_selected ran=2 gate_scope=2 all_matching=2 08:00:15Z
gap_module_value_selected ran=1 gate_scope=1 all_matching=1 08:03:27Z
gap_bigint_selected ran=11 gate_scope=11 all_matching=11 08:04:16Z
gap_inliner_selected ran=1 gate_scope=1 all_matching=1 08:04:27Z
gap_cron_selected ran=1 gate_scope=1 all_matching=2 08:04:44Z
gap_net_selected ran=4 gate_scope=4 all_matching=12 08:10:24Z
gap_proto_selected ran=34 gate_scope=33 all_matching=57 08:16:13Z
gap_split_selected ran=3 gate_scope=3 all_matching=5 narrowed=2 10:02:43Z
  • Two runtime failures, both release-profile artifacts rather than regressions: copy_slot_decode::sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check (observable behind #[cfg(debug_assertions)] at copying.rs:1033) and heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds (main's long-known one).

Reds attributed

The gap sweep produced no unexplained regressions.

#10564 lands partially, and stays open

This train carries #10564 at d2ed8ef9e0 — the rooting fix. After assembly the PR gained three
more commits
(8be1120beb, +218 lines) making the displaced implicit-this/new.target
exception-safe: an inner js_throw crossing a bare save/call/restore site leaves IMPLICIT_THIS
stuck at the inner value instead of the enclosing try's baseline. That hole is pre-existing on
main
and the rooting fix neither introduces nor fixes it, so landing the rooting half alone is a
strict improvement rather than a partial regression.

It is held back for two reasons: the follow-up measures +7.83% instructions on a 5M-iteration
try/catch loop
(895.28M → 965.34M, ~14 per try-push), which is a compute trade needing the
owner's decision rather than a train's; and the author records that none of the four named bare
sites is proven reachable. So the close keyword for #10490 is deliberately NOT carried here, #10564 is left
open, and its remaining three commits ride a later train.

Issues closed by this train

A merge train closes its source PRs rather than merging them, so the Fixes #N keywords in those PR bodies never evaluate. They are carried here, on the PR that actually merges, so they fire:

Fixes #10416
Fixes #10428
Fixes #10429
Fixes #10545
Fixes #10456
Fixes #10461
Fixes #10585

Summary by CodeRabbit

  • Bug Fixes

    • Fixed node:net, http, https, and http2 exports when accessed through aliases, destructuring, or CommonJS module values.
    • Preserved wide BigInt literal values in split-module builds.
    • Prevented incorrect returns during function inlining and improved statement-call performance.
    • Fixed receiver stability during garbage collection in callbacks and array methods.
    • Improved dynamic property-read performance and correctness.
  • New Features

    • Added Node-API version 10 support, including expanded addon compatibility and APIs.
  • Documentation

    • Updated version information and release documentation.

Ralph Küpper and others added 16 commits September 18, 2026 07:51
… the caller

A call used as a statement (`f(x);`) whose inlined callee ended in an
`if` containing `return` had that return spliced into the caller: the
statement arm of inline_calls_in_stmts only looked for nested returns in
take(len - 1) and only rewrote a bare trailing Return. The caller then
returned the callee's value (decimal.js pow() returned true), and at module
top level the stray ret produced invalid LLVM IR.

The statement arm now removes every return structurally
(discard_inlined_returns): `return e` becomes `e;`, and statements after
an `if` that returns move into the branch that falls through to them. No
loop wrapper is added. It declines (keeps the call) instead of duplicating
or dropping statements, or when a return sits under a loop, switch, try or
label.

Two siblings of the same blind spot are fixed as well: the declined-inline
fallback replaced the call statement with the setup hoisted out of its
arguments (deleting the call), and the void-method expression inliner
spliced statements that follow `return;`.
…restores it

`js_native_call_method`'s prototype-override early path (#9247) bound the
callee's `this` through a private `ImplicitThisScope` guard that kept the
displaced value -- the caller's receiver -- in a plain struct field and wrote
it back in `Drop`. The callee is user code; a copying minor inside it moves the
caller's receiver, and the restore reinstalled the retired from-space address,
so the caller's next `this.x` read `undefined` (#10490; cheerio 1.2.0's
`_findBySelector`). The #9445 sweep rooted every `let prev =
js_implicit_this_set(..)` but not the same save hidden in a guard's `Drop`,
nor the two identical guards behind the Array.prototype callback engines
(`DenseThisGuard`, 11 dense methods; `ThisGuard`, 9 `js_arraylike_*`).

Replace the three private guards (and the already-rooted regex copy) with one
shared `object::ImplicitThisScope<'scope>` that roots the displaced value in a
borrowed `RuntimeHandleScope` and re-reads it in `Drop`. Also root the other
displaced values the audit found held across user code: the accessor receiver
override in the handle-method prototype walk, `new.target` in the Intl and
Temporal subclass `super()` bridges, and ten stdlib save/restores (domain,
events, process warnings, net, web streams, tls ALPNCallback, worker_threads).
…their providers

The value forms of net/http/https/http2 exports (a CommonJS require('net'),
an aliased module object, a destructured or pulled-out export, new on a bound
class value) reached a dispatcher that only an auto-optimized stdlib with
external-http-server-pump registered, so they returned undefined under
PERRY_NO_AUTO_OPTIMIZE=1 and in every net-only program. perry-ext-net and
perry-ext-http now own and register their export dispatchers from their
namespace installs and from the entry prologue, net classes construct through
the ctor registry, the net IP helpers are callable values, the provider
namespaces are cached so require('node:http') === require('http'), and
perry-ext-http registers its client handle-dispatch extension so erased
ClientRequest/IncomingMessage/Agent receivers work without the stdlib pump.
…e IR reader

The in-process dialect reader, which builds every function of a split
(multi-codegen-unit) module, materialized integer operands with
`IntType::const_int(v as u64, v < 0)`. That API takes a single 64-bit
word, so an `i128` operand kept only its low word. BigInt literals that fit
in i128 lower to exactly such operands (`NativeRep::SmallBigInt`), so in a
split module `1180591620717411303424n` (2^70) read back as `0n` and a
97-bit negative literal became a different 64-bit value, while single-unit
builds (LLVM's own text parser) were correct.

Widths above 64 bits now build both two's-complement words with
`const_int_arbitrary_precision`, matching the assembler's semantics; the
unsigned full-width spelling LLVM accepts is parsed too.

Tests: a dialect unit test that builds every constant operand form codegen
emits through the typed and the line paths and compares each against LLVM's
own parse of the same text (only the i128 forms diverged); a unit test that
lowers wide BigInt literals through the real emitter and checks the words
passed to js_bigint_from_i128_parts; an integration test compiling the issue
repro with PERRY_CODEGEN_UNITS=2 against Node's output; a gap test.
… as functions in napi_typeof

The Node-API host rejected every addon declaring Node-API 9 or 10 (argon2 0.45, better-sqlite3 13, sharp 0.35) and exported none of the version 9/10 entry points. It now implements the stable surface through version 10: node_api_symbol_for, node_api_create_syntax_error/node_api_throw_syntax_error, node_api_get_module_file_name, node_api_create_property_key_*, node_api_create_external_string_* and node_api_create_buffer_from_arraybuffer. Node's per-module facts (declared version, module file URL) are attributed to the addon whose code runs, and exceptions left pending by async-work completions, TSFN callbacks and finalizers follow Node's uncaught-exception policy instead of leaking into the next call.

napi_typeof kept its own tag ladder, which read an INT32-tagged class ref as a number and a class object as an object. It now classifies through the typeof operator's classifier, napi_create_int32/uint32 produce plain doubles so a small integer can no longer read as a class, the number getters reject class refs, and ToObject returns a class ref unchanged.
Every successful Node-API call rebuilt its napi_ok error-info message as a fresh CString, one allocation per call. The status bookkeeping now reuses the stored message while the status and message are unchanged. The native-callback trampoline switches the module attribution inside the environment borrows it already takes instead of borrowing twice more.
`o[k]` costs 674 instructions per access — in a loop, with a constant key, on a
two-property object — against node's ~4.5. It does not amortize: every
iteration pays the full lookup. `prop_read` is the second most common construct
in real TypeScript at 258 per 1k lines across a 1.6M-line corpus, and
dynamic-key access is how every map-like object and dispatch table reads.

Profiled with symbols, three of the costs are the same shape as the array-push
work: a helper re-deriving per access something already established.

1. `keys_find_slot_by_bytes` ran `clean_arr_ptr` — allocator-ownership plus
   forwarding classification — on `descriptor.keys`, 16.2% of the loop. That
   field is maintained BY THE COLLECTOR: when the keys array moves,
   `shapes::scan_shape_table_rekey_mut` writes the forwarded address back into
   every descriptor record in the family (`(*record).keys = addr as u64`), and
   a descriptor whose keys array died is pruned in the same pass. So the
   pointer read out of a live descriptor already IS the resolved live head.
   The resolved entry deliberately delegates back to the original for
   key_count >= KEYS_INDEX_THRESHOLD, where the indexed path owns its own
   receiver handling.

2. `try_data_get_bytes` asked `is_process_env_ptr` on every access, which reads
   a thread-local to answer "is this process.env?" — `_tlv_get_addr` was 14.1%
   of the loop with half of it from here. A sticky process-global latch means
   the thread-local is touched only once such an object exists.

3. `is_anon_shape_class_id` took an RwLock READ GUARD on every access — 16.1%,
   the largest single frame left after (1). A guard is an atomic
   read-modify-write on a lock word shared by every thread in the image, paid
   to consult a set written only from module init. It is now answered from a
   lock-free open-addressed mirror.

674.1 -> 546.0 instructions per access, -19.0%, both arms built from this
commit's parent. Measured by differencing two probes that differ only in
access count, inside each binary, so driver dispatch and code layout cancel
before the arms are compared; the bare-loop control reads 0.06 and -0.11, and
callback / `new` / object-literal controls are flat to two decimals.

The mirror is sound because the set is INSERT-ONLY — the registrar only ever
calls `insert`, nothing removes — so an entry once published stays valid for
the life of the image and an open-addressed table needs no reclamation scheme.
Slot 0 is the empty sentinel and the registrar rejects class id 0. An insert
that exhausts its probe run sets an overflow flag, after which a miss no longer
proves absence and falls back to the locked set.

It lives in the class IMAGE, beside `parent_dense`, NOT in a process-global:
`ANON_SHAPE_CLASS_IDS` is an `ImageTable` and every access resolves through
`current()`, so a process-global mirror would answer one image's question out
of another image's registrations. The process-global version measured -24.3%
and was wrong; this one is -19.0% and is not.

Two things that did NOT work, recorded so they are not retried:

* A sticky "is the anon-shape set empty?" latch measured ZERO. Object literals
  ARE anon shapes, so the flag is true in essentially every real program and
  the lock was taken anyway. That is why this is a mirror and not a latch.
* Caching the resolved slot against the key was considered and dropped: a
  cached key pointer is a heap pointer whose address can be recycled, which
  would produce a WRONG slot rather than a miss, and it would need its own GC
  root scanner. Removing the re-derivations gets most of it with none of that.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 009c6def-5585-48ae-a5e0-270730c7e05d

📥 Commits

Reviewing files that changed from the base of the PR and between 9df5075 and 17e9ad3.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (98)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10530-inliner-stmt-return.md
  • changelog.d/10564-implicit-this-scope-rooting.md
  • changelog.d/10565-native-module-cjs-default-split.md
  • changelog.d/10565-node-module-value-dispatch.md
  • changelog.d/10566-split-unit-wide-bigint-literals.md
  • changelog.d/10569-napi-v10-typeof.md
  • changelog.d/10570-dynamic-key-receiver-requeries.md
  • changelog.d/10585-parity-filter-intersect.md
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/entry/tests.rs
  • crates/perry-codegen/src/codegen/opts.rs
  • crates/perry-codegen/src/dialect/tests.rs
  • crates/perry-codegen/src/dialect/types.rs
  • crates/perry-codegen/src/ext_registry.rs
  • crates/perry-codegen/src/lib.rs
  • crates/perry-codegen/src/nm_install.rs
  • crates/perry-codegen/src/runtime_decls/objects.rs
  • crates/perry-ext-http/src/client_dispatch_ext.rs
  • crates/perry-ext-http/src/lib.rs
  • crates/perry-ext-http/src/server/mod.rs
  • crates/perry-ext-http/src/server/native_dispatch.rs
  • crates/perry-ext-http/src/test_async_shims.rs
  • crates/perry-ext-net/src/lib.rs
  • crates/perry-ext-net/src/native_dispatch.rs
  • crates/perry-runtime/src/array/generic.rs
  • crates/perry-runtime/src/array/iter_methods.rs
  • crates/perry-runtime/src/builtins/arithmetic.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/implicit_this_scope.rs
  • crates/perry-runtime/src/intl/subclass.rs
  • crates/perry-runtime/src/lib.rs
  • crates/perry-runtime/src/node_api_host/async_work.rs
  • crates/perry-runtime/src/node_api_host/buffers.rs
  • crates/perry-runtime/src/node_api_host/functions.rs
  • crates/perry-runtime/src/node_api_host/loader.rs
  • crates/perry-runtime/src/node_api_host/metadata.rs
  • crates/perry-runtime/src/node_api_host/mod.rs
  • crates/perry-runtime/src/node_api_host/modules.rs
  • crates/perry-runtime/src/node_api_host/symbols.txt
  • crates/perry-runtime/src/node_api_host/tests.rs
  • crates/perry-runtime/src/node_api_host/tsfn.rs
  • crates/perry-runtime/src/node_api_host/v10_tests.rs
  • crates/perry-runtime/src/node_api_host/values.rs
  • crates/perry-runtime/src/object/alloc.rs
  • crates/perry-runtime/src/object/class_image.rs
  • crates/perry-runtime/src/object/class_registry/class_meta.rs
  • crates/perry-runtime/src/object/global_this/fetch_globals.rs
  • crates/perry-runtime/src/object/keys_lookup.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/object/native_call_method.rs
  • crates/perry-runtime/src/object/native_call_method/handle_methods.rs
  • crates/perry-runtime/src/object/native_call_method/primitive_methods.rs
  • crates/perry-runtime/src/object/native_get.rs
  • crates/perry-runtime/src/object/native_module.rs
  • crates/perry-runtime/src/object/native_module/callable_export_check.rs
  • crates/perry-runtime/src/object/native_module/callable_export_table.rs
  • crates/perry-runtime/src/object/native_module/cjs_default.rs
  • crates/perry-runtime/src/object/native_module_dispatch.rs
  • crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs
  • crates/perry-runtime/src/object/native_module_registry.rs
  • crates/perry-runtime/src/object/this_binding.rs
  • crates/perry-runtime/src/process/env_misc.rs
  • crates/perry-runtime/src/regex/site_test.rs
  • crates/perry-runtime/src/value/handle.rs
  • crates/perry-runtime/src/value/mod.rs
  • crates/perry-runtime/src/value/tags.rs
  • crates/perry-stdlib/src/common/dispatch/init.rs
  • crates/perry-stdlib/src/domain.rs
  • crates/perry-stdlib/src/events.rs
  • crates/perry-stdlib/src/events/warnings.rs
  • crates/perry-stdlib/src/net/mod.rs
  • crates/perry-stdlib/src/streams.rs
  • crates/perry-stdlib/src/tls.rs
  • crates/perry-stdlib/src/worker_threads.rs
  • crates/perry-stdlib/src/worker_threads/worker_surface.rs
  • crates/perry-transform/src/inline/call_inliner.rs
  • crates/perry-transform/src/inline/discarded_result.rs
  • crates/perry-transform/src/inline/mod.rs
  • crates/perry/src/commands/compile/link/mod.rs
  • crates/perry/src/commands/compile/native_addon_sidecar.rs
  • crates/perry/src/commands/compile/object_cache.rs
  • crates/perry/src/commands/compile/object_cache/object_cache_tests.rs
  • crates/perry/src/commands/compile/run_pipeline.rs
  • crates/perry/tests/fixtures/node_api_host/addon_v10.c
  • crates/perry/tests/fixtures/node_api_host/addon_v10_expected.txt
  • crates/perry/tests/fixtures/node_api_host/addon_v10_main.js
  • crates/perry/tests/issue_10545_split_unit_wide_bigint_literals.rs
  • crates/perry/tests/node_api_host_e2e.rs
  • docs/src/internals/node-api-host.md
  • run_parity_tests.sh
  • test-files/gap_10428_10429_module_values_helper.cjs
  • test-files/test_gap_10416_inliner_stmt_return.ts
  • test-files/test_gap_10428_10429_node_module_value_dispatch.ts
  • test-files/test_gap_10490_implicit_this_scope_rooting.ts
  • test-files/test_gap_10545_split_unit_wide_bigint_literals.ts
  • test-files/test_gap_dynamic_key_read_paths.ts
 _________________________________________________
< Code Wars Episode V: The Compiler Strikes Back. >
 -------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment