Skip to content

Merge train 220: node:http client pump, deferred conditional require, dyn_eval class expressions, inherited property reads, GC slot and ASCII perf, jsonwebtoken binding removal (v0.5.1598) - #10716

Merged
proggeramlug merged 19 commits into
mainfrom
train220r
Sep 19, 2026

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

This train lands 7 PRs as v0.5.1598. Each source commit is verified to preserve its patch-id and authorship.

Two PRs were pulled from this train, each for a defect a diff-scoped gate could not see

#10699 broke native_fluent_chain_still_dispatches_through_native_methods in
crates/perry-hir/tests/fluent_chain_lowering.rs — a suite its own diff never touches. The merge
driver and CI's e2e-scoped both derive which integration suites to run from the diff, so
neither would have run it; only the sweep tier's cargo test --workspace would have, hours after
the merge and attributed to a window rather than a PR. Confirmed by running it directly: 2 passed /
1 failed with the PR, 3 passed / 0 failed without it. Its author has since repaired it at source.

#10668 took crates/perry-ext-http/src/response_headers.rs from 1 to 6 unrooted-local
shapes — REGRESSION: 563 findings exceeds baseline 561, against main's 559. The cause was a
single *mut ArrayHeader held across alloc_string calls inside a loop and reused afterwards:
CLAUDE.md's root-store-dominance violation exactly, on a handle-backed surface. Now fixed, and the
fix found a second pre-existing instance of the same shape in the neighbouring set-cookie builder.

A gate detail worth recording from that second one. unrooted_local_shape.py --no-raise-vs "$BASE_SHA" returned green on the same tree, in the same run_lint_gates.sh run, where the
absolute --check reported the regression. The variant whose entire purpose is catching a rise
against the merge base did not catch one. Filed as #10713; the absolute --check is the only
form this lane now trusts.

Validation

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

  • Crate suites: codegen 1639 · runtime 4083 (+2 known) · stdlib 132 · hir 464 · transform 152 · cli 1141.
  • lint 1 of 83 — only the public-benchmark freshness step known-red on main, read from the
    log rather than inferred from the exit code.
  • Gap: 12 gate-scoped areas, 90 fixtures, every area selecting exactly its test_gap_*
    count, none vacuous
    :
gap_http_selected ran=8 gate_scope=8 all_matching=31 09:38:53Z
gap_client_selected ran=1 gate_scope=1 all_matching=3 09:39:06Z
gap_header_selected ran=3 gate_scope=3 all_matching=8 09:39:35Z
gap_require_selected ran=2 gate_scope=2 all_matching=4 09:39:53Z
gap_classexpr_selected ran=1 gate_scope=1 all_matching=1 09:40:05Z
gap_inherited_selected ran=9 gate_scope=9 all_matching=12 09:40:39Z
gap_slot_selected ran=3 gate_scope=3 all_matching=3 09:40:56Z
gap_ascii_selected ran=3 gate_scope=3 all_matching=3 09:41:10Z
gap_instance_selected ran=19 gate_scope=19 all_matching=25 09:44:19Z
gap_prototype_selected ran=23 gate_scope=23 all_matching=37 09:47:35Z
gap_emitter_selected ran=4 gate_scope=4 all_matching=6 09:47:53Z
gap_buffer_selected ran=14 gate_scope=14 all_matching=31 09:51:06Z
  • No unexplained regressions.

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 #10466
Fixes #10437
Fixes #10661
Fixes #10689
Fixes #10683

perry-bot and others added 19 commits September 19, 2026 10:47
)

The copying minor, the full mark and the remembered-set rebuild each
enumerate child slots for every object they trace, including objects
that have none to enumerate. Finding that out costs ~206 instructions
per object: iterator construction (61), the descriptor body (127), the
worklist push, and the drain entry with its cold header read.

`gc_object_yields_no_child_slots` answers the question from the header
word the caller has already loaded, so those objects are never pushed.
Three of the four terms fold into one mask compare on `_reserved`; the
term order is measured rather than chosen, and the comment says so.

Measured with `perf stat -e instructions:u`, min-of-5, same SHA in both
arms:

  gc3 -7.10%, w20000 -5.75%, w5000 -4.70%, leafarr -4.22%, w1000 -2.25%

Call counts, per consumer:

  copying minor            2,800,337 -> 1,520,316  (-45.7%)
  full mark                2,400,630 -> 1,200,319  (-50.0%)
  remembered-set rebuild     980,690 ->   490,345  (-50.0%)

Peak RSS on gc3 -4.4%, from the smaller worklist. Collection counts are
identical on all seven fixtures, so this perturbs no pacing.

Three fixtures regress: oldyoung +0.11%, dist16_ptr +0.12%,
rec16_ptr +0.06%. This is structural, not noise. The predicate costs
O(traced objects) while the win is O(qualifying objects), and
oldyoung's pointer-free population is objects rather than arrays -- they
pass the mask compare, fail the type test, and so pay both terms while
qualifying for neither.

In the full mark the skip is additionally gated on proxy tracing being
inactive. A pointer-free payload is still handed to
gc_observe_traced_value while a proxy is being traced, so skipping it
there would collect a live proxy's target. The minor and the rebuild
are unconditional; that asymmetry is deliberate.
…at memo probe

concat_byte_parts (the s + t fast path for two statically-typed string
operands) scanned both operands for ASCII-ness twice - once via
bytes_all_ascii up front, again on the heap path via
l_slice.is_ascii() && r_slice.is_ascii() - with the first scan's answer
sitting unused in scope. A new sibling of str_bytes_from_jsvalue,
str_bytes_ascii_from_jsvalue, computes the bit once and threads it
through; bytes_all_ascii itself switches from a byte-at-a-time loop to
<[u8]>::is_ascii() (word-at-a-time, total over arbitrary byte strings).

An earlier version of this change also tried to read a heap string's
ASCII-ness straight off its header (utf16_len == byte_len, free) instead
of scanning at all. That is unsound and was caught in review before
landing: Perry heap-string payloads are not guaranteed valid UTF-8
(WTF-8 lone surrogates, Buffer.toString of arbitrary bytes, FFI blobs -
#6085), and a payload ending in a truncated multi-byte lead byte can
coincide on utf16_len == byte_len without being ASCII
(compute_utf16_len_wtf8 charges a truncated lead its full nominal unit
count while the payload holds fewer bytes than that sequence declares -
string/compare.rs's utf16_cmp_bytes doc names the identical hazard).
The header check survives only as a negative filter (utf16_len !=
byte_len soundly proves non-ASCII, unconditionally, not just for
well-formed input); utf16_len == byte_len is ambiguous and always falls
back to a real scan. js_string_concat_value's memo-admission gate had
the identical exposure independently and is fixed the same way.

No TypeScript-reachable path that constructs such a payload was found:
Buffer.toString (all seven encodings), TextDecoder.decode, and every
bun:ffi string-returning path all validate via
str::from_utf8/from_utf8_lossy (or are fed a Rust &str, valid by
construction) before ever calling js_string_from_bytes. The fix stands
regardless, since js_string_from_bytes is a pub extern "C" entry point
whose own contract must hold for any bytes. Regression tests are
therefore Rust-level against hand-built malformed StringHeaders (the
same technique string/compare.rs's own corpus and tests_guard_page.rs
already use) rather than a gap test - all three fail against the
reverted code, confirmed by temporarily reintroducing it.

The short-concat memo's probe also assembled both operands into a stack
buffer just to hand the hash/lookup helpers one contiguous slice. FNV-1a
is a streaming hash and the byte compare can run in two parts, so
concat_memo_hash_parts / concat_memo_slot_and_tag_parts /
concat_memo_lookup_parts replace the buffer with direct two-slice
hashing and lookup; the single-slice js_string_concat_value memo probe
now goes through the same two-slice primitives.

The memo probe's own break-even hit rate (governed by the probe's
hash/lookup/admit cost, not by the ASCII-determination fix) barely
moved: ~54% before this fix, ~50.6% after, both measured by forcing the
governor on/off via a temporary env knob (since removed).
MEMO_MIN_HIT_SHIFT stays 1 (50%, still the closest power-of-two floor to
either number) - it moved from the original 2 (25%, never measured) in
this same change.

Measured (differential instruction-count probe, bare-loop control flat
in both arms, base and arm built in the same session): 640-distinct
short concat 548 -> 403 instructions/concat (-26.5%), a 73-byte
memo-ineligible concat 644 -> 471 (-26.8%), a 100%-memo-hit workload
319 -> 318 (unchanged, within noise).

Covered by test-files/test_gap_string_concat_memo_ascii_header.ts
(byte-for-byte against node) and GC stress on a concat-heavy fixture
(117,923 copying minors, 14,739 retired from-space sets quarantined, no
fault) confirming the memo's GC roots survive evacuation under the new
two-slice storage.
…ng (#10437)

Perry's CJS->ESM wrap turned every literal require('S') in a wrapped
file into a hoisted static import, eager-initializing the target
regardless of whether the surrounding control flow ever reaches the
call. function_local_specs only kept a require() lazy when every call
site sat inside a function body; a top-level if/for/while/switch/try/
&&/?: guard (including pg's own if (forceNative) { require('./native') })
still forced eager init.

Broaden the classification to also cover a control-flow block
(if/for/while/switch/catch/with/else/try/do/finally) and a
braceless/operator equivalent (cond && require(...), cond ?
require(...) : x, for (...) require(...) with no block) -- matching
Node's actual 'loads only when control flow reaches it' semantics. An
ordinary object literal, class body, or bare grouping block does not
count (the common module.exports = { fs: require('fs') } barrel shape
stays eager), and a process.platform === '<literal>' guard (node-pty's
Windows/Unix terminal split) is exempted since the platform is a
compile-time-known build target, not a runtime unknown.

This was the sole remaining blocker compiling pg from source: pg
crashed at init with Cannot find module 'pg-native' even though its
guarding forceNative check was false.

Fixes #10437.
… on a scalar-replaced object (#10689)

`check_escapes_in_expr`'s `Expr::PropertyGet` arm treated every read on a
scalar-replacement candidate as a plain field read, without checking that
the class chain declares the key. Scalar replacement allocates a slot per
declared field only, so `expr/property_get.rs`'s scalar arm found no slot
and folded the read to the constant `undefined`.

The effect was silent and order-dependent:

    const o = { a: 1 };
    typeof o.toString       // undefined, where node gives "function"
    o.toString()            // correct — a fused call never consults the
                            // elided object

It reached `Object.prototype` members read as values (`toString`,
`constructor`, `hasOwnProperty`), a class's own prototype method read as a
value, and user-added `Object.prototype` properties. Anything that made the
receiver escape — passing it to a function, storing it in an array —
repaired it, which is what made the bug look like it depended on unrelated
earlier statements.

This is the READ half of the rule the write arms already apply: #9024 for
`PropertySet`/`PutValueSet` and #9460 for `PropertyUpdate`, plus the
sibling literal analysis in `escape_objects.rs`. Only the read arm was
missing it.

Reads of declared fields still take the no-heap scalar path, which is
spec-correct because an own property shadows the chain and OrdinaryGet
never reaches the prototype. Reads of undeclared keys now take the ordinary
heap path. A fused method call is unaffected: its callee is handled in the
`Expr::Call` arm and is no longer routed through `PropertyGet`, so
`simple_scalar_method_summary` receivers stay scalar-replaced.
Fixes #10683.

The hand-written native jsonwebtoken binding (crates/perry-ext-jsonwebtoken,
plus a second duplicate implementation in crates/perry-stdlib/src/jsonwebtoken.rs
exporting the same js_jwt_* symbols per #10678) has a live security defect:
verify() returns null instead of throwing on every forgery case (tampered
payload, wrong secret, alg:none, garbage token, tampered signature, expired
token), so `try { jwt.verify(...) } catch { reject() }` never rejects a
forgery. sign(..., { expiresIn: "1h" }) also silently drops the expiry
(string coerces to NaN, and the runtime only writes `exp` when > 0.0).

Removes both copies plus the dedicated codegen lowering path
(lower_call/native/jsonwebtoken.rs's lower_jsonwebtoken_sign/_verify and its
native_runtime_branch.rs dispatch), the decode-only NativeModSig row in
native_table/utils_crypto.rs, the js_jwt_* FFI declarations in
runtime_decls/stdlib_ffi/third_party.rs, the well_known_bindings.toml entry,
the NATIVE_MODULES/manifest rows, the bundled-jsonwebtoken stdlib feature
(re-wiring dep:rsa/dep:spki directly onto perry-stdlib's `crypto` feature,
since webcrypto/key_object.rs and keys.rs need them independently of
jsonwebtoken), and the Android stub exports. The real `jsonwebtoken` crates.io
dependency stays — it is unrelated Rust tooling used by perry's own Apple
code-signing (commands/run/resign.rs, commands/setup/common_apple.rs).

Regenerated docs/api/perry.d.ts, docs/src/api/reference.md (--print-api-manifest)
and docs/src/native-libraries/governance.md (binding_governance.py --table).
Updated workspace-architecture.json (workspace_members 83->82, externalize
33->32) and scripts/string_payload_access_baseline.txt (perry-stdlib
inline-offset sites 40->39, from the deleted stdlib file).
@coderabbitai

coderabbitai Bot commented Sep 19, 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: 27dac9e7-44ee-403e-99ae-6f27bdd8657e

📥 Commits

Reviewing files that changed from the base of the PR and between b0af11e and 5e257b4.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (64)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10667-no-auto-http-client-pump.md
  • changelog.d/10669-zero-slot-skip.md
  • changelog.d/10672-string-concat-memo-ascii-header.md
  • changelog.d/10674-cjs-conditional-require-deferred.md
  • changelog.d/10675-dyn-eval-class-expr.md
  • changelog.d/10687-jsonwebtoken-native-binding-removal.md
  • changelog.d/10689-inherited-read-escape.md
  • crates/perry-api-manifest/src/entries.rs
  • crates/perry-api-manifest/src/entries/part_1.rs
  • crates/perry-codegen/src/collectors/escape_check.rs
  • crates/perry-codegen/src/lower_call/native/jsonwebtoken.rs
  • crates/perry-codegen/src/lower_call/native/mod.rs
  • crates/perry-codegen/src/lower_call/native/native_runtime_branch.rs
  • crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs
  • crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs
  • crates/perry-ext-jsonwebtoken/Cargo.toml
  • crates/perry-ext-jsonwebtoken/src/lib.rs
  • crates/perry-runtime/src/dyn_eval/expr.rs
  • crates/perry-runtime/src/dyn_eval/interp.rs
  • crates/perry-runtime/src/dyn_eval/mod.rs
  • crates/perry-runtime/src/dyn_eval/tests.rs
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/layout.rs
  • crates/perry-runtime/src/gc/tests/layout_trace.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/tests/zero_slot_skip.rs
  • crates/perry-runtime/src/gc/trace.rs
  • crates/perry-runtime/src/gc/verify.rs
  • crates/perry-runtime/src/string/concat.rs
  • crates/perry-runtime/src/string/mod.rs
  • crates/perry-runtime/src/string/tests.rs
  • crates/perry-stdlib/Cargo.toml
  • crates/perry-stdlib/src/jsonwebtoken.rs
  • crates/perry-stdlib/src/lib.rs
  • crates/perry-ui-android/src/stdlib_stubs.rs
  • crates/perry/src/commands/compile/cjs_wrap/extract_requires.rs
  • crates/perry/src/commands/compile/collect_modules.rs
  • crates/perry/src/commands/compile/optimized_libs/no_auto.rs
  • crates/perry/src/commands/compile/optimized_libs/tests.rs
  • crates/perry/src/commands/stdlib_features.rs
  • crates/perry/tests/object_prototype_value_read_10689.rs
  • crates/perry/well_known_bindings.toml
  • docs/api/perry.d.ts
  • docs/src/api/reference.md
  • docs/src/native-libraries/governance.md
  • scripts/string_payload_access_baseline.txt
  • scripts/unrooted_local_shape_baseline.json
  • test-files/_helpers/gap10437_cjs_lazy_require.cjs
  • test-files/_helpers/gap10437_counter.cjs
  • test-files/_helpers/gap10437_native_rethrow.cjs
  • test-files/_helpers/gap10437_side_a.cjs
  • test-files/_helpers/gap10437_side_b.cjs
  • test-files/_helpers/gap10437_side_c.cjs
  • test-files/_helpers/gap10437_side_d.cjs
  • test-files/_helpers/gap10437_side_e.cjs
  • test-files/_helpers/gap10437_side_f.cjs
  • test-files/_helpers/gap10437_side_g.cjs
  • test-files/_helpers/gap10437_side_h.cjs
  • test-files/test_gap_10661_dyn_eval_class_expr.ts
  • test-files/test_gap_cjs_conditional_require_deferred.ts
  • test-files/test_gap_string_concat_memo_ascii_header.ts
  • workspace-architecture.json
 _______________________________________________
< Somewhere, a linter just sighed dramatically. >
 -----------------------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ 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