Skip to content

chore: merge train 223 (v0.5.1602) - #10737

Merged
proggeramlug merged 9 commits into
mainfrom
train223r
Sep 19, 2026
Merged

proggeramlug merged 9 commits into
mainfrom
train223r

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Merge train 223 — three PRs validated together as one tree, released as v0.5.1602.

Trains land as their own PR, so the source PRs are closed, not merged, and their close-keywords never fire. Issues resolved are listed at the bottom.

Contents

PR Change
#10634 fix(runtime): route AsyncLocalStorage super() through any bound-export heritage shape
#10646 perf(codegen,runtime): retire the per-access class-field latch, and gate the compiler's copy of the GC header layout
#10673 fix(compile): fall back to compiled JS emit for TS namespace/export= declaration merges

Notes on assembly

#10634 and #10649 are a stack, not independent PRs. Both append a heritage-recognition arm to the same region of crates/perry-runtime/src/object/global_this/fetch_globals.rs, so only one can ride a given train — #10634 cherry-picked clean, then #10649 conflicted on top of it. #10649 follows in a later train, rebased onto this one's result rather than onto #10634's branch head, since this lands as a squash and rebasing onto the pre-squash lineage would guarantee a second rebase.

That file has now produced the same conflict three times, and it carries a specific hazard worth recording: when both sides append a block ending in a coincidentally identical }, git folds that brace into the common region. Accepting both sides then leaves the earlier arm unclosed and a stray } below — with no conflict markers remaining and a plausible-looking diff. Counting braces is a weak check; cargo fmt parses, so it is the real gate. It is green here.

#10646 carries 6 lines that main has since deleted. The assembly proof reports them as absent from the train, which is correct: 50951a7188 removed that code after the PR branched, so the train holds main's newer state and the PR is the stale side. Classified as superseded rather than dropped; 0 lines were actually dropped from any of the three PRs.

Validation

Assembled on 7c5d04d0ea; source heads asserted unchanged since assembly; every PR proven fully represented by patch-id and by subject+author rather than by a pathspec diff.

Green: fmt, file_size, raw_handle (self-test, ceilings, vs-main), gc_runtime_root_holders, check_test_registration, addr_class_inventory, cargo check --workspace --all-targets under -D warnings, the release build of all five pinned artifacts, every unit suite, the derived integration suites, and a 13-area gap sweep with each area asserted to have run a non-zero number of tests.

This train's gap sweep runs with PERRY_RUN_TIMEOUT=30 rather than the harness default of 10s. The default is close enough to a real compile-and-run that a loaded machine turns a pass into a timeout, and the harness classifies a timeout identically to a failure — so the default makes slowness and breakage indistinguishable. It does not substitute for running sweeps sequentially, which is also done.

Issues resolved

Closes #10625
Closes #10662

Ralph Küpper and others added 9 commits September 19, 2026 15:43
…t heritage shape

class X extends AsyncLocalStorage threw "Class constructor
AsyncLocalStorage cannot be invoked without 'new'" at super() for every
heritage shape except a bare import { AsyncLocalStorage } from
"node:async_hooks" binding -- the same defect #10621 fixed for
AsyncResource (#10453). A local alias, a namespace member, a default
import, and a CJS destructured require() all reach the identical bound
native export value the bare import does, but only that shape is
recognized statically at HIR-lowering time
(crates/perry-hir/src/lower_decl/class_decl.rs), which routes to
perry-stdlib's js_async_local_storage_subclass_init via a
codegen-declared extern symbol. Every other shape fell through
js_fetch_or_value_super to a plain CALL of the bound export.

Unlike AsyncResource, whose implementation lives entirely in
perry-runtime, AsyncLocalStorage's subclass-init helper lives in
perry-stdlib (it needs the stdlib Handle registry), and perry-runtime
cannot depend on perry-stdlib. Route through a registration hook
perry-stdlib installs at startup (JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT),
matching the existing JS_NATIVE_ASYNC_HOOKS_CONSTRUCT /
JS_NATIVE_EVENTS_CONSTRUCT pattern already used for this exact kind of
cross-crate reach.

Adds test_gap_10625_asynclocalstorage_heritage.ts covering the canonical
import (control), local alias, namespace member, default import, and
two CJS require() shapes, asserting a real run()/getStore() round-trip
through the subclass -- not just that construction doesn't throw.
Every static-key class-field read gated its fast path on
`@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED`. That is an `external global`, so
on arm64 reading it costs `adrp` + a GOT `ldr` + a dependent `ldrb` through it
+ a compare — four instructions and TWO dependent loads, in the gate block,
before the guard has looked at the receiver at all. It cannot be hoisted: the
runtime flips it mid-execution when a descriptor or accessor lands on a class
prototype, so the load is `volatile` by necessity.

The authority moves onto a value the guard already had to load. Each class
gains `@perry_class_guard_shape_*`, seeded at module init with the same ShapeId
as `@perry_class_shape_id_*` and registered with the runtime;
`disable_class_field_inline_guard` now poisons every registered slot with
`u32::MAX`. ShapeIds are allocated from `[0x8000_0000, 0xC000_0000)` and never
reused, so a poisoned expectation can never match a live object — every guard
misses and routes to the IC, which is exactly what the latch bought. The
expectation is read volatile per access for the same freshness reason the latch
was, and is still cheaper: one module-local `adrp`+`ldr`, no GOT hop.

It is a SEPARATE global from the ShapeId on purpose.
`js_object_alloc_class_inline_keys_stamped` stamps every new instance with the
value read out of the ShapeId global (`lower_call/new_alloc.rs`), so poisoning
that one would brand live objects with a bogus ShapeId instead of closing a
fast path. Subclass arms move to the poisonable global too, or a subclass
receiver would keep hitting the fast path after a flip. Imported-class stubs
carry the expectation through `js_register_imported_class_shape_slot`, and a
rewrite that lands after a disable re-poisons rather than resurrects.

Measured, arm64, `-Os` + `llc -O2 -mcpu=apple-m1`:
- one `o.a` on a typed receiver, executed fast path: 28 -> 23 instructions
  (-18%), one fewer dependent load;
- 16 reads on one receiver, EXECUTED instructions per call (`/usr/bin/time -l`,
  best-of-5, differential over iteration count): 595.2 -> 563.0, -5.4%. The
  per-read marginal is -2 rather than -5 because LLVM already hoisted the
  latch's base register across accesses within a function.

Unlike a clone-gated optimisation this fires wherever the guard does: the latch
is gone from `$generic`, `$spec_b` AND the copy the inliner leaves in the
caller, which is the code that actually executes.

`js_class_field_get_ic`'s truthful ShapeId operand is now loaded in the cold
miss arm instead of the function entry, since the fast path no longer reads it.
The two updated ratchet tests pin both halves of the swap — three loads in the
guard AND no latch — because "three loads" alone would also pass a lowering
that kept the latch and added the expectation.
`perry-codegen` does not depend on `perry-runtime` — its deps are perry-hir,
perry-dispatch and perry-api-manifest. Yet the compiler bakes the collector's
header layout into emitted code twice over: the inline `new` path stores a
packed `GcHeader` word as a COMPILE-TIME constant (#8122, pre-composed per
class into `@perry_class_header_image_*`), and every class-field /
element-shape / method-probe guard masks that word against a literal. So both
sides carry their own `GC_TYPE_OBJECT`, `GC_FLAG_FORWARDED`,
`OBJ_FLAG_HAS_DESCRIPTORS`, `GC_OBJ_TYPED_LAYOUT_INTACT` — 36 restatements
across 10 files — held together by a code comment.

Nothing enforced it. What looks like enforcement is

    const GC_FLAG_FORWARDED_I8: &str = "-128";
    debug_assert_eq!(GC_FLAG_FORWARDED_I8, "-128");

which compares codegen's constant to a string literal: a tautology that never
references the runtime, and is compiled out of `release` and `perry-dev`
besides. Every codegen test naming these bits asserts codegen's own constant
reaches the IR, so they pin codegen to itself and would all stay green.

A flag renumbered in perry-runtime therefore compiled clean, passed every
suite, and shipped a compiler whose inline allocator baked one bit layout while
the collector read another — objects born with flags the GC misreads, which
CLAUDE.md describes as surfacing cycles later as `TypeError: value is not a
function`.

`scripts/check_gc_header_constants.py` re-derives every restatement from the
runtime constant it quotes, including the composites (`READ_FAST_PATH_BLOCKED`
= ARRAY_DESCRIPTORS|HAS_DESCRIPTORS, the fused 32-bit masks
`ELEM_HEADER_MASK` / `GC_OBJECT_METHOD_GUARD_MASK_I32`). A registered constant
that stops existing FAILS, so a fix must delete its own entry; a new
header-shaped `const` in a watched file must be registered or exempted with a
reason, so the next one cannot arrive silently. `--self-test` proves it can
fail; `--list` prints the whole duplicated surface, including the 6 constants
deliberately out of scope. Build-free, so it joins `lint`, a required context.

Writing the registry found 5 restatements a manual grep missed (they are
declared inside function bodies, not at module scope): the array-literal
allocator's three, and two copies of the method-probe fused mask.

Two existing gates moved with it, both of which correctly caught this branch:
`gc_store_site_inventory` wanted GC_STORE_AUDIT markers on the new raw slot
writes (POINTER_FREE — a `u32` in the program's data segment, never a heap
edge), and `shape_descriptor_census` pinned the precheck's old
`expected_class_identity(..., expected_shape_id)` spelling. The census is
updated to the new spelling AND strengthened: it now also requires the
expectation to be read VOLATILE from the poisonable global, because a lowering
that hoisted that load would reopen a fast path the runtime has closed and
would still satisfy a shape-only assertion. Verified to fail when the
`volatile` is dropped.

This does not make the GC header bits movable — it makes moving them a red
build instead of a silent miscompile.
`restore_class_guard_shapes_for_test` is reached only from
`test_reset_class_field_inline_guard`, which is `#[cfg(test)]`, so in an
ordinary build it is dead code and `-D warnings` rejects it. Gate it the same
way its only caller is gated.

Caught by the `warnings` job, which the local run that cleared this branch had
skipped: it was invoked with SKIP_COMPILE_GATES=1, and that tier IS the
`warnings`/`check` jobs.
…declaration merges

`axios` throws `TypeError: Class extends value is not a constructor` at
module-init time via its `https-proxy-agent` -> `agent-base` dependency
chain. `agent-base`'s `src/index.ts` merges `namespace createAgent { export
class Agent extends EventEmitter { ... } }` onto a same-named `function
createAgent()` and exports the result with TS's `export =` form.
`perry.compilePackages` prefers compiling a package's raw TypeScript source
over its published JS emit, and picks that file. Perry's HIR lowers the
namespace's exported `Agent` class as a static-field-set against a synthetic
class entity that is not the same runtime value `export =` ends up
exporting, so `require("agent-base").Agent` reads back `undefined` and the
downstream `class HttpsProxyAgent extends agent_base_1.Agent` throws.

`is_hybrid_cjs_emit_input` (resolve.rs) already falls back to a package's
compiled JS emit for one other TS-source shape Perry can't correctly lower
(#6586's ESM+CJS-epilogue hybrid). Extend it with a second, narrowly-scoped
trigger: a top-level `namespace`/`module` block (excluding ambient `declare
namespace`, which is type-only) combined with a top-level `export =`
statement. Node can't run this non-erasable TS syntax directly either
(`--experimental-strip-types` rejects `namespace`/`export =`), so a package
built this way is never executed from its raw `.ts` source in practice —
falling back to the compiled emit matches what Node actually runs, instead
of attempting to implement namespace/function declaration-merging semantics
in HIR.

Fixes #10662
@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: 1267696f-7b1a-4da6-a506-13fbc542cb32

📥 Commits

Reviewing files that changed from the base of the PR and between 7c5d04d and 8ea2670.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (37)
  • .github/workflows/test.yml
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10634-asynclocalstorage-heritage.md
  • changelog.d/10646-retire-class-field-latch.md
  • changelog.d/10673-namespace-export-equals-fallback.md
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/codegen/string_pool.rs
  • crates/perry-codegen/src/expr/class_field_inline_guard.rs
  • crates/perry-codegen/src/expr/hit_path_access_tests.rs
  • crates/perry-codegen/src/expr/property_get.rs
  • crates/perry-codegen/src/expr/property_get/helpers.rs
  • crates/perry-codegen/src/expr/property_set.rs
  • crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs
  • crates/perry-codegen/src/lower_call/method_override.rs
  • crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs
  • crates/perry-codegen/src/runtime_decls/strings.rs
  • crates/perry-codegen/src/typed_shape.rs
  • crates/perry-runtime/src/gc/layout/typed_shape.rs
  • crates/perry-runtime/src/lib.rs
  • crates/perry-runtime/src/object/class_guard_shape.rs
  • crates/perry-runtime/src/object/descriptor_state.rs
  • crates/perry-runtime/src/object/global_this/fetch_globals.rs
  • crates/perry-runtime/src/object/mod.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/src/commands/compile/cjs_wrap/detect.rs
  • crates/perry/src/commands/compile/cjs_wrap/issue_10662_tests.rs
  • crates/perry/src/commands/compile/cjs_wrap/mod.rs
  • crates/perry/src/commands/compile/resolve.rs
  • crates/perry/tests/issue_10662_namespace_export_equals_fallback.rs
  • scripts/check_gc_header_constants.py
  • scripts/shape_descriptor_census.py
  • test-files/gap_10625_asynclocalstorage_heritage_helper.cjs
  • test-files/test_gap_10625_asynclocalstorage_heritage.ts
 ________________________________________________________________________________________________________________________________________
< Use assertions to prevent the impossible. Assertions validate your assumptions. Use them to protect your code from an uncertain world. >
 ----------------------------------------------------------------------------------------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( 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