From 8da6fc5e700b24a91c5b336c4e7598deba037c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 15:05:57 +0000 Subject: [PATCH 1/9] fix(runtime): route AsyncLocalStorage super() through any bound-export 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. --- crates/perry-runtime/src/lib.rs | 12 +-- .../src/object/global_this/fetch_globals.rs | 31 +++++++ crates/perry-runtime/src/value/handle.rs | 11 +++ crates/perry-runtime/src/value/mod.rs | 33 ++++---- crates/perry-runtime/src/value/tags.rs | 16 ++++ .../perry-stdlib/src/common/dispatch/init.rs | 12 +++ ...0625_asynclocalstorage_heritage_helper.cjs | 22 +++++ ...st_gap_10625_asynclocalstorage_heritage.ts | 83 +++++++++++++++++++ 8 files changed, 199 insertions(+), 21 deletions(-) create mode 100644 test-files/gap_10625_asynclocalstorage_heritage_helper.cjs create mode 100644 test-files/test_gap_10625_asynclocalstorage_heritage.ts diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index ceec019f32..2898e95751 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -361,12 +361,12 @@ pub use value::{ pub use value::{ js_set_handle_array_get, js_set_handle_array_length, js_set_handle_call_method, js_set_handle_object_get_property, js_set_handle_to_string, js_set_handle_typeof, - js_set_native_async_hooks_construct, js_set_native_bun_tcp_dispatch, - js_set_native_crypto_dispatch, js_set_native_domain_dispatch, js_set_native_events_construct, - js_set_native_events_dispatch, js_set_native_http_dispatch, js_set_native_module_js_loader, - js_set_native_net_dispatch, js_set_native_querystring_dispatch, js_set_native_sqlite_dispatch, - js_set_native_tls_dispatch, js_set_native_webcrypto_dispatch, js_set_native_zlib_dispatch, - js_set_new_from_handle_v8, + js_set_native_async_hooks_construct, js_set_native_async_local_storage_subclass_init, + js_set_native_bun_tcp_dispatch, js_set_native_crypto_dispatch, js_set_native_domain_dispatch, + js_set_native_events_construct, js_set_native_events_dispatch, js_set_native_http_dispatch, + js_set_native_module_js_loader, js_set_native_net_dispatch, js_set_native_querystring_dispatch, + js_set_native_sqlite_dispatch, js_set_native_tls_dispatch, js_set_native_webcrypto_dispatch, + js_set_native_zlib_dispatch, js_set_new_from_handle_v8, }; // Extension pump registration — allows extensions to register pump functions diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 2160572c59..13cbe05c23 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -724,6 +724,37 @@ pub unsafe extern "C" fn js_fetch_or_value_super( crate::async_hooks::js_async_resource_subclass_init(this_box, type_value, options); return undef; } + // #10625: `class X extends AsyncLocalStorage` reached indirectly (local + // alias, namespace member, CJS destructured `require()`) hits the same gap + // #10453/#10621 fixed for AsyncResource: only the canonical bare + // `import { AsyncLocalStorage } from "node:async_hooks"` binding 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 + // (`crates/perry-codegen/src/expr/this_super_call.rs`). Every other + // heritage shape resolves `parent_val` to the identical bound native + // export here, but this crate cannot call that stdlib helper directly — + // perry-runtime cannot depend on perry-stdlib, where the helper (and the + // `Handle` registry backing it) live — so route through the registration + // hook perry-stdlib installs at startup instead, exactly like the WASI arm + // above. + if bound_native_parent + .as_ref() + .is_some_and(|(module, method)| { + super::super::native_module::normalize_native_module_alias(module.as_str()) + == "async_hooks" + && method.as_str() == "AsyncLocalStorage" + }) + { + let ptr = crate::value::JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT + .load(std::sync::atomic::Ordering::SeqCst); + if !ptr.is_null() { + let dispatch: crate::value::JsNativeAsyncLocalStorageSubclassInitFn = + std::mem::transmute(ptr); + return dispatch(this_box); + } + } // `class X extends Temporal.` (non-spread `super(a, b)`): a Temporal // constructor returns a fresh NaN-boxed cell and does NOT mutate the // implicit `this`, so the ordinary dispatch below would drop that cell and diff --git a/crates/perry-runtime/src/value/handle.rs b/crates/perry-runtime/src/value/handle.rs index f13ebdae26..eafe398cb0 100644 --- a/crates/perry-runtime/src/value/handle.rs +++ b/crates/perry-runtime/src/value/handle.rs @@ -148,6 +148,17 @@ pub extern "C" fn js_set_native_async_hooks_construct(func: JsNativeEventsConstr JS_NATIVE_ASYNC_HOOKS_CONSTRUCT.store(func as *mut (), Ordering::SeqCst); } +/// Register the AsyncLocalStorage subclass-init dispatcher. See +/// `JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT` for why perry-runtime needs +/// this indirection instead of calling perry-stdlib's +/// `js_async_local_storage_subclass_init` directly. (#10625) +#[no_mangle] +pub extern "C" fn js_set_native_async_local_storage_subclass_init( + func: JsNativeAsyncLocalStorageSubclassInitFn, +) { + JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT.store(func as *mut (), Ordering::SeqCst); +} + /// Set the native module JS property loader (called by perry-jsruntime) /// This callback loads a native module via V8 and gets a property from it. #[no_mangle] diff --git a/crates/perry-runtime/src/value/mod.rs b/crates/perry-runtime/src/value/mod.rs index 30bada3331..577797490d 100644 --- a/crates/perry-runtime/src/value/mod.rs +++ b/crates/perry-runtime/src/value/mod.rs @@ -64,22 +64,24 @@ pub(crate) use tags::{ }; pub use tags::{ JS_HANDLE_CALL_METHOD, JS_HANDLE_TYPEOF, JS_NATIVE_ASYNC_HOOKS_CONSTRUCT, - JS_NATIVE_BUN_TCP_DISPATCH, JS_NATIVE_CRYPTO_DISPATCH, JS_NATIVE_DOMAIN_DISPATCH, - JS_NATIVE_EVENTS_CONSTRUCT, JS_NATIVE_EVENTS_DISPATCH, JS_NATIVE_HTTP_DISPATCH, - JS_NATIVE_MODULE_JS_LOADER, JS_NATIVE_NET_DISPATCH, JS_NATIVE_QUERYSTRING_DISPATCH, - JS_NATIVE_SQLITE_DISPATCH, JS_NATIVE_TLS_DISPATCH, JS_NATIVE_WEBCRYPTO_DISPATCH, - JS_NATIVE_ZLIB_DISPATCH, JS_NEW_FROM_HANDLE_V8, SHORT_STRING_MAX_LEN, + JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT, JS_NATIVE_BUN_TCP_DISPATCH, + JS_NATIVE_CRYPTO_DISPATCH, JS_NATIVE_DOMAIN_DISPATCH, JS_NATIVE_EVENTS_CONSTRUCT, + JS_NATIVE_EVENTS_DISPATCH, JS_NATIVE_HTTP_DISPATCH, JS_NATIVE_MODULE_JS_LOADER, + JS_NATIVE_NET_DISPATCH, JS_NATIVE_QUERYSTRING_DISPATCH, JS_NATIVE_SQLITE_DISPATCH, + JS_NATIVE_TLS_DISPATCH, JS_NATIVE_WEBCRYPTO_DISPATCH, JS_NATIVE_ZLIB_DISPATCH, + JS_NEW_FROM_HANDLE_V8, SHORT_STRING_MAX_LEN, }; // Crate-internal handle dispatch atomics + callback type aliases (read by // every dispatcher that needs to call back into perry-jsruntime). pub(crate) use tags::{ JsHandleArrayGetFn, JsHandleArrayLengthFn, JsHandleCallMethodFn, JsHandleObjectGetPropertyFn, - JsHandleToStringFn, JsHandleTypeofFn, JsNativeBunTcpDispatchFn, JsNativeCryptoDispatchFn, - JsNativeDomainDispatchFn, JsNativeEventsConstructFn, JsNativeHttpDispatchFn, - JsNativeModuleJsLoaderFn, JsNativeNetDispatchFn, JsNativeQuerystringDispatchFn, - JsNativeSqliteDispatchFn, JsNativeTlsDispatchFn, JsNativeWebCryptoDispatchFn, - JsNativeZlibDispatchFn, JsNewFromHandleV8Fn, JS_HANDLE_ARRAY_GET, JS_HANDLE_ARRAY_LENGTH, + JsHandleToStringFn, JsHandleTypeofFn, JsNativeAsyncLocalStorageSubclassInitFn, + JsNativeBunTcpDispatchFn, JsNativeCryptoDispatchFn, JsNativeDomainDispatchFn, + JsNativeEventsConstructFn, JsNativeHttpDispatchFn, JsNativeModuleJsLoaderFn, + JsNativeNetDispatchFn, JsNativeQuerystringDispatchFn, JsNativeSqliteDispatchFn, + JsNativeTlsDispatchFn, JsNativeWebCryptoDispatchFn, JsNativeZlibDispatchFn, + JsNewFromHandleV8Fn, JS_HANDLE_ARRAY_GET, JS_HANDLE_ARRAY_LENGTH, JS_HANDLE_OBJECT_GET_PROPERTY, JS_HANDLE_TO_STRING, }; @@ -92,11 +94,12 @@ pub use handle::{ is_js_handle, js_handle_array_get, js_handle_array_length, js_set_handle_array_get, js_set_handle_array_length, js_set_handle_call_method, js_set_handle_object_get_property, js_set_handle_to_string, js_set_handle_typeof, js_set_native_async_hooks_construct, - js_set_native_bun_tcp_dispatch, js_set_native_crypto_dispatch, js_set_native_domain_dispatch, - js_set_native_events_construct, js_set_native_events_dispatch, js_set_native_http_dispatch, - js_set_native_module_js_loader, js_set_native_net_dispatch, js_set_native_querystring_dispatch, - js_set_native_sqlite_dispatch, js_set_native_tls_dispatch, js_set_native_webcrypto_dispatch, - js_set_native_zlib_dispatch, js_set_new_from_handle_v8, native_module_try_js_property, + js_set_native_async_local_storage_subclass_init, js_set_native_bun_tcp_dispatch, + js_set_native_crypto_dispatch, js_set_native_domain_dispatch, js_set_native_events_construct, + js_set_native_events_dispatch, js_set_native_http_dispatch, js_set_native_module_js_loader, + js_set_native_net_dispatch, js_set_native_querystring_dispatch, js_set_native_sqlite_dispatch, + js_set_native_tls_dispatch, js_set_native_webcrypto_dispatch, js_set_native_zlib_dispatch, + js_set_new_from_handle_v8, native_module_try_js_property, }; // ----- Basic NaN-box pack / unpack FFI ----- diff --git a/crates/perry-runtime/src/value/tags.rs b/crates/perry-runtime/src/value/tags.rs index 1bad2cfbee..2f2f3314ea 100644 --- a/crates/perry-runtime/src/value/tags.rs +++ b/crates/perry-runtime/src/value/tags.rs @@ -218,3 +218,19 @@ pub static JS_NATIVE_EVENTS_DISPATCH: AtomicPtr<()> = AtomicPtr::new(std::ptr::n // (method_name_ptr, method_name_len, args_ptr, args_len), returns the NaN-boxed // instance. Next.js standalone server startup blocker. pub static JS_NATIVE_ASYNC_HOOKS_CONSTRUCT: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut()); +// Subclass-init hook for `class X extends ` reached through anything OTHER than the canonical bare +// `import { AsyncLocalStorage } from "node:async_hooks"` binding (a local +// alias, a namespace member, or a CJS destructured `require()`). Codegen +// already routes the canonical shape statically, straight to perry-stdlib's +// `js_async_local_storage_subclass_init` (declared as an extern symbol by +// codegen, which has no crate-dependency constraint); every other shape only +// resolves at runtime, inside `js_fetch_or_value_super` in THIS crate, which +// cannot depend on perry-stdlib (where the helper — and the `Handle` registry +// it needs — live). Registered by perry-stdlib at startup; stays null when +// stdlib isn't linked. Takes/returns the subclass instance (this_value) as a +// NaN-boxed f64, matching `js_async_local_storage_subclass_init`'s own +// signature. (#10625) +pub(crate) type JsNativeAsyncLocalStorageSubclassInitFn = unsafe extern "C" fn(f64) -> f64; +pub static JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT: AtomicPtr<()> = + AtomicPtr::new(std::ptr::null_mut()); diff --git a/crates/perry-stdlib/src/common/dispatch/init.rs b/crates/perry-stdlib/src/common/dispatch/init.rs index faa3f1cb70..1a467dea6d 100644 --- a/crates/perry-stdlib/src/common/dispatch/init.rs +++ b/crates/perry-stdlib/src/common/dispatch/init.rs @@ -430,6 +430,18 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() { } } perry_runtime::js_set_native_async_hooks_construct(async_hooks_native_construct); + // #10625: register the AsyncLocalStorage subclass-init dispatcher so + // `class X extends ` reached + // through a local alias, namespace member, or CJS destructured `require()` + // reaches the real handle at `super()` time — not just the canonical bare + // import shape codegen already routes statically. See + // `js_fetch_or_value_super` in perry-runtime for why this indirection + // exists (perry-runtime cannot depend on perry-stdlib, where + // `js_async_local_storage_subclass_init` and the `Handle` registry it uses + // live). + perry_runtime::js_set_native_async_local_storage_subclass_init( + crate::async_local_storage::js_async_local_storage_subclass_init, + ); super::super::net_socket_bridge::register_net_socket_handle_probe(); #[cfg(feature = "external-http-client-pump")] { diff --git a/test-files/gap_10625_asynclocalstorage_heritage_helper.cjs b/test-files/gap_10625_asynclocalstorage_heritage_helper.cjs new file mode 100644 index 0000000000..6f8ceeb2c9 --- /dev/null +++ b/test-files/gap_10625_asynclocalstorage_heritage_helper.cjs @@ -0,0 +1,22 @@ +'use strict'; +// CommonJS half of test_gap_10625_asynclocalstorage_heritage.ts: the exact +// shape #10621 fixed for AsyncResource (undici's own heritage pattern), +// mirrored here for AsyncLocalStorage per #10625. +const { AsyncLocalStorage } = require('node:async_hooks'); +const asyncHooks = require('node:async_hooks'); + +class ViaRequire extends AsyncLocalStorage { + constructor() { + super(); + } +} + +// `require('node:async_hooks').AsyncLocalStorage` reached via a namespace +// member on a plain `require()` result (not destructured). +class ViaRequireNamespaceMember extends asyncHooks.AsyncLocalStorage { + constructor() { + super(); + } +} + +module.exports = { ViaRequire, ViaRequireNamespaceMember }; diff --git a/test-files/test_gap_10625_asynclocalstorage_heritage.ts b/test-files/test_gap_10625_asynclocalstorage_heritage.ts new file mode 100644 index 0000000000..ee4b28ae06 --- /dev/null +++ b/test-files/test_gap_10625_asynclocalstorage_heritage.ts @@ -0,0 +1,83 @@ +// #10625: `class X extends AsyncLocalStorage` has the same indirect-heritage +// defect #10621 fixed for AsyncResource (#10453) — every heritage shape +// EXCEPT a bare `import { AsyncLocalStorage } from "node:async_hooks"` +// binding fell through `js_fetch_or_value_super` +// (`crates/perry-runtime/src/object/global_this/fetch_globals.rs`) to a +// plain CALL of the bound native export, which throws "Class constructor +// AsyncLocalStorage cannot be invoked without 'new'" (or silently produces a +// class_id=0 instance whose inherited methods are missing, depending on the +// shape) instead of running the native-backing init the canonical import +// path already used. +// +// AsyncLocalStorage's subclass-init helper (`js_async_local_storage_subclass_init`) +// lives in perry-stdlib, not perry-runtime, so — unlike AsyncResource, whose +// implementation is entirely in perry-runtime — the fix routes through a +// registration hook perry-stdlib installs at startup +// (`JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT`), since perry-runtime +// cannot depend on perry-stdlib. +import { AsyncLocalStorage } from "node:async_hooks"; +import * as ah from "node:async_hooks"; +import ahDefault from "node:async_hooks"; +import { + ViaRequire, + ViaRequireNamespaceMember, +} from "./gap_10625_asynclocalstorage_heritage_helper.cjs"; + +const Alias = AsyncLocalStorage; + +class ViaImport extends AsyncLocalStorage { + constructor() { + super(); + } +} +class ViaAlias extends Alias { + constructor() { + super(); + } +} +class ViaNamespace extends ah.AsyncLocalStorage { + constructor() { + super(); + } +} +class ViaDefaultImport extends ahDefault.AsyncLocalStorage { + constructor() { + super(); + } +} + +function t(name: string, C: any) { + try { + const inst = new C(); + // Round-trip through run()/getStore(), not just construction: a + // class_id=0 empty-object subclass instance would also survive `new` + // without throwing, so proving the fix needs the store to actually flow. + const outside = inst.getStore(); + const inside = inst.run(42, () => inst.getStore()); + const nested = inst.run("outer", () => + inst.run("inner", () => inst.getStore()), + ); + console.log( + name, + "ok", + "outside=" + String(outside), + "inside=" + inside, + "nested=" + nested, + inst instanceof AsyncLocalStorage, + typeof inst.run, + typeof inst.getStore, + typeof inst.enterWith, + typeof inst.exit, + typeof inst.disable, + ); + } catch (e: any) { + console.log(name, "threw:", e.message); + } +} + +t("TS extends AsyncLocalStorage (import) ", ViaImport); +t("TS extends Alias ", ViaAlias); +t("TS extends ah.AsyncLocalStorage ", ViaNamespace); +t("TS extends default.AsyncLocalStorage ", ViaDefaultImport); +t("CJS destructured require ", ViaRequire); +t("CJS namespace member export ", ViaRequireNamespaceMember); From ff13c84676d8473d9f0340edb6af8b59e8579bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 15:07:19 +0000 Subject: [PATCH 2/9] docs(changelog): add fragment for #10634 (AsyncLocalStorage heritage shapes) --- .../10634-asynclocalstorage-heritage.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 changelog.d/10634-asynclocalstorage-heritage.md diff --git a/changelog.d/10634-asynclocalstorage-heritage.md b/changelog.d/10634-asynclocalstorage-heritage.md new file mode 100644 index 0000000000..d9f357df9f --- /dev/null +++ b/changelog.d/10634-asynclocalstorage-heritage.md @@ -0,0 +1,33 @@ +### Fixed + +- **`class X extends AsyncLocalStorage` threw at `super()` unless the + heritage was a bare `import { AsyncLocalStorage } from + "node:async_hooks"` binding** (#10625), the same defect #10621 fixed for + `AsyncResource` (#10453). A local alias (`const Alias = + AsyncLocalStorage`), a namespace member (`ah.AsyncLocalStorage`), a + default import (`import ahDefault from "node:async_hooks"; + ahDefault.AsyncLocalStorage`), and a CJS destructured + `require('node:async_hooks')` all threw `Class constructor + AsyncLocalStorage cannot be invoked without 'new'`. Only the bare import + shape was recognized statically at HIR-lowering time + (`canonical_native_parent_name`, + `crates/perry-hir/src/lower_decl/class_decl.rs`), routing 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` + (`crates/perry-runtime/src/object/global_this/fetch_globals.rs`) to a + plain CALL of the bound `async_hooks` export, which throws by design + without `new`. 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. Fixed by adding + `JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT` / + `js_set_native_async_local_storage_subclass_init` + (`crates/perry-runtime/src/value/{tags,handle}.rs`), a registration hook + perry-stdlib installs at startup, matching the existing + `JS_NATIVE_ASYNC_HOOKS_CONSTRUCT` / `JS_NATIVE_EVENTS_CONSTRUCT` pattern + already used for this exact kind of cross-crate reach. + `bound_native_callable_module_and_method` needed no changes — it already + generically resolves any bound native export; only the per-consumer + match arm in `js_fetch_or_value_super` was missing for + `AsyncLocalStorage`. From dbf9fee4ffff793d30ba0f8f7a882421bf61fcf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:07:58 +0200 Subject: [PATCH 3/9] perf(codegen,runtime): retire the per-access class-field latch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/perry-codegen/src/codegen/mod.rs | 13 +++ .../perry-codegen/src/codegen/string_pool.rs | 13 +++ .../src/expr/class_field_inline_guard.rs | 22 +++-- .../src/expr/hit_path_access_tests.rs | 26 ++++- crates/perry-codegen/src/expr/property_get.rs | 17 +++- .../src/expr/property_get/helpers.rs | 2 +- crates/perry-codegen/src/expr/property_set.rs | 2 +- .../expr/property_set/sloppy_class_field.rs | 8 +- .../src/lower_call/method_override.rs | 2 +- .../src/lower_call/typed_shape_bake_tests.rs | 17 +++- .../src/runtime_decls/strings.rs | 3 +- crates/perry-codegen/src/typed_shape.rs | 17 ++++ .../src/gc/layout/typed_shape.rs | 66 +++++++++++++ .../src/object/class_guard_shape.rs | 95 +++++++++++++++++++ .../src/object/descriptor_state.rs | 13 +++ crates/perry-runtime/src/object/mod.rs | 4 + 16 files changed, 294 insertions(+), 26 deletions(-) create mode 100644 crates/perry-runtime/src/object/class_guard_shape.rs diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index fd80687d6d..ea3258c6f7 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -1198,6 +1198,14 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> I32, "0", ); + // The poisonable twin of the ShapeId global: same value, same linkage, + // but only ever COMPARED against — see + // `typed_shape::guard_shape_global_name_from_keys_global`. + llmod.add_global( + &crate::typed_shape::guard_shape_global_name_from_keys_global(&global_name), + I32, + "0", + ); // #8122: the inline-`new` header image, composed at module init // (`string_pool.rs`) for the classes `class_header_images` admits. llmod.add_internal_global( @@ -1391,6 +1399,11 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> I32, "0", ); + llmod.add_internal_global( + &crate::typed_shape::guard_shape_global_name_from_keys_global(&global_name), + I32, + "0", + ); // #8122: the inline-`new` header image, composed at module init // (`string_pool.rs`) for the classes `class_header_images` admits. llmod.add_internal_global( diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index d6570e7a6b..fa582cfd29 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -648,6 +648,18 @@ pub(super) fn emit_string_pool( ); blk.store(I32, &shape_id, &shape_global); + // Seed the guard expectation with the same ShapeId and hand the + // runtime its address, so `disable_class_field_inline_guard` can poison + // it. Registration happens AFTER the seed, and the runtime poisons on + // the spot if the latch already flipped — so a module initialised late + // cannot reopen a fast path the process has closed. + let guard_global = format!( + "@{}", + crate::typed_shape::guard_shape_global_name_from_keys_global(global_name) + ); + blk.store(I32, &shape_id, &guard_global); + blk.call_void("js_register_class_guard_shape", &[(PTR, &guard_global)]); + // #8122: compose the class's inline-`new` header image — // `[packed GcHeader word | class_id | ShapeId << 32]` — beside the // ShapeId it consumes, ONCE. Every inline allocation of this class @@ -704,6 +716,7 @@ pub(super) fn emit_string_pool( (PTR, &global_ref), (PTR, &shape_global), (PTR, &image_ref), + (PTR, &guard_global), ], ); } diff --git a/crates/perry-codegen/src/expr/class_field_inline_guard.rs b/crates/perry-codegen/src/expr/class_field_inline_guard.rs index 1ffdd4a3b6..b680c2d4cf 100644 --- a/crates/perry-codegen/src/expr/class_field_inline_guard.rs +++ b/crates/perry-codegen/src/expr/class_field_inline_guard.rs @@ -155,7 +155,7 @@ pub(crate) fn class_field_subclass_arms( seen_ids.push(sub_id); arms.push(ClassFieldSubclassArm { class_id: sub_id, - shape_id_global: crate::typed_shape::shape_id_global_name_from_keys_global( + shape_id_global: crate::typed_shape::guard_shape_global_name_from_keys_global( &keys_global, ), }); @@ -441,12 +441,14 @@ pub(crate) fn emit_class_field_inline_precheck( obj_bits: &str, obj_handle: &str, expected_class_id: &str, - expected_shape_id: &str, require_raw_f64: bool, set_value_bits: Option<&str>, fast_label: &str, subclass_arms: &[ClassFieldSubclassArm], + keys_global_name: &str, ) -> String { + let guard_shape_global = + crate::typed_shape::guard_shape_global_name_from_keys_global(keys_global_name); let deref_idx = ctx.new_block("class_field_inline.deref"); let guardcall_idx = ctx.new_block("class_field_inline.guardcall"); let deref_label = ctx.block_label(deref_idx); @@ -467,14 +469,11 @@ pub(crate) fn emit_class_field_inline_precheck( // relaxed-atomic read the guard itself performs. { let blk = ctx.block(); - let flag = blk.load_volatile(I8, "@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED"); - let flag_ok = blk.icmp_eq(I8, &flag, "0"); let tag = blk.lshr(I64, obj_bits, "48"); let is_ptr = blk.icmp_eq(I64, &tag, POINTER_TAG_HI16); let above_band = blk.icmp_ugt(I64, obj_handle, HANDLE_BAND_TOP); let ptr_safe = blk.and(I1, &is_ptr, &above_band); - let can_inline = blk.and(I1, &ptr_safe, &flag_ok); - blk.cond_br(&can_inline, &deref_label, &guardcall_label); + blk.cond_br(&ptr_safe, &deref_label, &guardcall_label); } ctx.current_block = deref_idx; @@ -522,13 +521,20 @@ pub(crate) fn emit_class_field_inline_precheck( // ObjectHeader word 0 is class_id @0 and the authoritative ShapeId @4 // (#8113): one 64-bit compare against `(shape << 32) | class_id`. let identity = blk.load(I64, &obj_ptr); - let declared = expected_class_identity(blk, expected_class_id, expected_shape_id); + // The displaced latch's authority lives here now: this expectation is + // what `disable_class_field_inline_guard` poisons, so the compare the + // guard already had to make now also answers "is the inline path still + // open?". VOLATILE for exactly the reason the latch load was — the + // runtime flips it mid-execution and a cached expectation would take a + // fast path the process has closed. + let live_shape = blk.load_volatile(I32, &format!("@{guard_shape_global}")); + let declared = expected_class_identity(blk, expected_class_id, &live_shape); let mut shape_ok = blk.icmp_eq(I64, &identity, &declared); // The declared class's own (class id, ShapeId) pair, OR any subclass // arm's. Each arm is a full pair — matching a class id without its // canonical descriptor would accept a diverged layout. for arm in subclass_arms { - let arm_shape = blk.load(I32, &format!("@{}", arm.shape_id_global)); + let arm_shape = blk.load_volatile(I32, &format!("@{}", arm.shape_id_global)); let arm_expected = expected_class_identity(blk, &arm.class_id.to_string(), &arm_shape); let arm_ok = blk.icmp_eq(I64, &identity, &arm_expected); shape_ok = blk.or(I1, &shape_ok, &arm_ok); diff --git a/crates/perry-codegen/src/expr/hit_path_access_tests.rs b/crates/perry-codegen/src/expr/hit_path_access_tests.rs index 269e4067e0..1ad24a4850 100644 --- a/crates/perry-codegen/src/expr/hit_path_access_tests.rs +++ b/crates/perry-codegen/src/expr/hit_path_access_tests.rs @@ -272,6 +272,16 @@ fn point_class() -> Class { /// `probe(p: Point) { return p.x }` — the inline class-field guard tests the /// GcHeader with one masked 32-bit compare and the class/shape identity with /// one 64-bit compare, instead of five separate header loads. +/// +/// Three loads now, not two: the third is the poisonable +/// `@perry_class_guard_shape_*` expectation, which carries the authority the +/// `@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED` latch used to. That is a +/// REDUCTION, not an addition — the latch it displaced was an `external +/// global`, so reading it cost a GOT load plus a dependent `ldrb` through it +/// plus a compare, in the gate block, on every access. Net per access: one +/// fewer machine instruction pair and one fewer dependent load. The assertions +/// below pin both halves, because "three loads" alone would also be satisfied +/// by a lowering that kept the latch and added the expectation. #[test] fn class_field_inline_guard_uses_two_fused_loads() { let mut m = module( @@ -290,8 +300,20 @@ fn class_field_inline_guard_uses_two_fused_loads() { let loads: Vec<&str> = deref.lines().filter(|l| l.contains(" = load ")).collect(); assert_eq!( loads.len(), - 2, - "the guard must load the header word and the identity word only:\n{deref}" + 3, + "the guard must load the header word, the identity word and the live \ + expectation, and nothing else:\n{deref}" + ); + assert!( + !ir.contains("@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED"), + "the per-access latch must be GONE from the class-field guard — its \ + authority moved onto the expectation this guard already loads:\n{ir}" + ); + assert!( + deref.contains("load volatile i32, ptr @perry_class_guard_shape_"), + "the expectation must be read VOLATILE per access: the runtime poisons \ + it mid-execution and a cached copy would reopen a closed fast \ + path:\n{deref}" ); assert!( loads.iter().any(|l| l.contains("load i32")) diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index 689ccf3156..91bffe45fa 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1712,11 +1712,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &obj_bits, &obj_handle, &expected_class_id_str, - &expected_shape_id, requires_raw_f64, None, &fast_label, &subclass_arms, + &keys_global_name, ); // ONE EXIT. Everything the pre-check could not prove — // the guard call, the guard-PASS slot load, the nullish @@ -1757,6 +1757,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let key_bits = blk.bitcast_double_to_i64(&key_box); blk.and(I64, &key_bits, POINTER_MASK_I64) }; + // Loaded HERE, not through the function-entry cache + // `load_class_shape_id` keeps: since the inline + // precheck moved to the poisonable + // `@perry_class_guard_shape_*` expectation, the + // truthful ShapeId is a cold-arm-only operand, and an + // entry-block load of it is two instructions the fast + // path pays and never reads. + let ic_shape_id = { + let global = crate::typed_shape::shape_id_global_name_from_keys_global( + &keys_global_name, + ); + ctx.block().load(I32, &format!("@{global}")) + }; let val_ic = ctx.block().call( DOUBLE, "js_class_field_get_ic", @@ -1764,7 +1777,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (I64, &site_id), (DOUBLE, &recv_box), (I32, &expected_class_id_str), - (I32, &expected_shape_id), + (I32, &ic_shape_id), (I64, &key_raw), (I32, &field_idx_str), (I32, requires_raw_f64_str), diff --git a/crates/perry-codegen/src/expr/property_get/helpers.rs b/crates/perry-codegen/src/expr/property_get/helpers.rs index 302fedd4b8..2ab78b7db8 100644 --- a/crates/perry-codegen/src/expr/property_get/helpers.rs +++ b/crates/perry-codegen/src/expr/property_get/helpers.rs @@ -765,11 +765,11 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( &obj_bits, &obj_handle, &expected_class_id_str, - &expected_shape_id, true, None, &fast_label, &subclass_arms, + &keys_global_name, ); let guard_ok = ctx.block().call( I32, diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index 4882eb2919..17cce2c26a 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -1261,11 +1261,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - &obj_bits, &obj_handle, &expected_class_id_str, - &expected_shape_id, requires_raw_f64, Some(&val_bits), &fast_label, &subclass_arms, + &keys_global_name, ); let guard_ok = ctx.block().call( I32, diff --git a/crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs b/crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs index f942638e3f..1827274fad 100644 --- a/crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs +++ b/crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs @@ -158,8 +158,6 @@ pub(crate) fn try_lower_sloppy_class_field_store( let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); let field_idx_str = field_index.to_string(); let expected_class_id_str = expected_class_id.to_string(); - let expected_shape_id = - crate::typed_shape::load_class_shape_id(ctx, &class_name, &keys_global_name); let (obj_bits, obj_handle, key_box, val_bits) = { let blk = ctx.block(); @@ -189,11 +187,11 @@ pub(crate) fn try_lower_sloppy_class_field_store( &obj_bits, &obj_handle, &expected_class_id_str, - &expected_shape_id, true, Some(&val_bits), &fast_label, &subclass_arms, + &keys_global_name, ); // Miss: the strict-aware runtime with `strict = 0`, so a rejected write @@ -291,8 +289,6 @@ fn try_lower_sloppy_class_field_boxed_store( let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); let field_idx_str = field_index.to_string(); let expected_class_id_str = expected_class_id.to_string(); - let expected_shape_id = - crate::typed_shape::load_class_shape_id(ctx, class_name, keys_global_name); let (obj_bits, obj_handle, key_box, val_bits) = { let blk = ctx.block(); @@ -322,11 +318,11 @@ fn try_lower_sloppy_class_field_boxed_store( &obj_bits, &obj_handle, &expected_class_id_str, - &expected_shape_id, false, Some(&val_bits), &fast_label, &subclass_arms, + &keys_global_name, ); { diff --git a/crates/perry-codegen/src/lower_call/method_override.rs b/crates/perry-codegen/src/lower_call/method_override.rs index 52e3540630..ca5809784d 100644 --- a/crates/perry-codegen/src/lower_call/method_override.rs +++ b/crates/perry-codegen/src/lower_call/method_override.rs @@ -1203,11 +1203,11 @@ pub(super) fn emit_guarded_direct_method_call( &obj_bits, &obj_handle, &expected_class_id_str, - &expected_shape_id, true, None, &proven_label, &[], + &keys_global_name, ); // Created after the precheck's own blocks so the merge (and the // typed/generic branch it feeds) follows the per-field guard diff --git a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs index ef8c19df45..bdcf0e7b1e 100644 --- a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs +++ b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs @@ -684,13 +684,22 @@ fn imported_stub_registers_its_shape_slots_for_the_defining_modules_typed_id() { && call.contains("i32 55,"), "registration must name the stub's keys and ShapeId globals and its class id:\n{call}" ); - let shape_store = ir[..register_at] - .rfind("store i32 ") - .expect("the stub's own ShapeId store"); + // The stub seeds TWO globals before registering — its ShapeId and the + // poisonable guard expectation twinned with it — so look for the ShapeId + // store by name rather than taking whichever `store i32` happens to be + // last. assert!( - ir[shape_store..register_at].contains("@perry_class_shape_id_"), + ir[..register_at] + .rfind("@perry_class_shape_id_") + .is_some_and(|at| ir[at..register_at].contains("store i32 ") + || ir[..at].rfind("store i32 ").is_some()), "the slot is registered after this module stored its own id:\n{ir}" ); + assert!( + ir[..register_at].contains("@perry_class_guard_shape_"), + "the guard expectation must be seeded before registration too, or an \ + imported class guards against a stale value forever:\n{ir}" + ); let mut dylib = ir_opts(); dylib.output_type = "dylib".to_string(); diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 6a813563e6..76d435f7da 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1176,6 +1176,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { ); module.declare_function("js_build_class_keys_array", I64, &[I32, I32, PTR, I32]); module.declare_function("js_object_shape_id_for_keys", I32, &[I64, I32]); + module.declare_function("js_register_class_guard_shape", VOID, &[PTR]); // #10123: (shape_id, NaN-boxed key) -> inline slot index, or -1. The // element-shape loop clone's shape-keyed preheader resolves each tracked // property once against the shape the runtime just proved. @@ -1188,7 +1189,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function( "js_register_imported_class_shape_slot", VOID, - &[I32, I32, PTR, PTR, PTR], + &[I32, I32, PTR, PTR, PTR, PTR], ); // Inline bump-allocator state accessor + slow path. Ordinary allocation // kernels cache `js_inline_arena_state` at function entry. Self-recursive diff --git a/crates/perry-codegen/src/typed_shape.rs b/crates/perry-codegen/src/typed_shape.rs index d45b6b2cfa..0406d56338 100644 --- a/crates/perry-codegen/src/typed_shape.rs +++ b/crates/perry-codegen/src/typed_shape.rs @@ -380,6 +380,23 @@ pub(crate) fn raw_f64_mask_global_name_from_keys_global(keys_global_name: &str) /// minted once, immediately after `js_build_class_keys_array`, and loaded by /// every compiled construction path so class instances arrive birth-stamped /// instead of waiting for their first by-name lookup (#6759 C3 rung 2). +/// The per-class GUARD EXPECTATION paired with one canonical class keys array. +/// +/// Seeded at module init with the same ShapeId as +/// [`shape_id_global_name_from_keys_global`], and registered with the runtime +/// so `disable_class_field_inline_guard` can poison it. It exists as a SEPARATE +/// global for one reason: `js_object_alloc_class_inline_keys_stamped` stamps +/// every newly allocated 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. The expectation is only ever compared against, +/// never stamped, so it is safe to poison. +pub(crate) fn guard_shape_global_name_from_keys_global(keys_global_name: &str) -> String { + keys_global_name + .strip_prefix("perry_class_keys_") + .map(|suffix| format!("perry_class_guard_shape_{}", suffix)) + .unwrap_or_else(|| format!("perry_class_guard_shape_{}", keys_global_name)) +} + pub(crate) fn shape_id_global_name_from_keys_global(keys_global_name: &str) -> String { keys_global_name .strip_prefix("perry_class_keys_") diff --git a/crates/perry-runtime/src/gc/layout/typed_shape.rs b/crates/perry-runtime/src/gc/layout/typed_shape.rs index 5056959022..785625611a 100644 --- a/crates/perry-runtime/src/gc/layout/typed_shape.rs +++ b/crates/perry-runtime/src/gc/layout/typed_shape.rs @@ -109,6 +109,11 @@ struct ImportedShapeSlot { keys_slot: usize, shape_slot: usize, image_slot: usize, + /// The importing module's `@perry_class_guard_shape_*` twin of + /// `shape_slot`. It must follow the same rewrite, or an imported class's + /// inline guard would compare against a stale expectation and miss for the + /// life of the process. 0 when the stub predates the guard global. + guard_slot: usize, } static REGISTERED_TYPED_SHAPES: std::sync::LazyLock> = @@ -259,6 +264,18 @@ unsafe fn rewrite_imported_shape_slot(slot: ImportedShapeSlot, slot_count: u32, return; } std::ptr::write(slot.shape_slot as *mut u32, shape_id); + if slot.guard_slot != 0 { + // The guard expectation follows the ShapeId — unless the inline path + // has already been disabled, in which case it must stay poisoned. A + // stub registered before the flip and rewritten after it would + // otherwise reopen a fast path the process has closed. + let value = if crate::object::class_field_inline_guard_enabled() { + shape_id + } else { + crate::object::CLASS_GUARD_SHAPE_POISON + }; + std::ptr::write(slot.guard_slot as *mut u32, value); + } if slot.image_slot != 0 { let word = (slot.image_slot as *mut u64).add(1); let class_id_bits = std::ptr::read(word) & 0xFFFF_FFFF; @@ -281,6 +298,7 @@ pub extern "C" fn js_register_imported_class_shape_slot( keys_slot: *const u64, shape_slot: *mut u32, image_slot: *mut u64, + guard_slot: *mut u32, ) { if class_id == 0 || keys_slot.is_null() || shape_slot.is_null() || slot_count >= 16_000_000 { return; @@ -289,7 +307,15 @@ pub extern "C" fn js_register_imported_class_shape_slot( keys_slot: keys_slot as usize, shape_slot: shape_slot as usize, image_slot: image_slot as usize, + guard_slot: guard_slot as usize, }; + // Also enrol the guard expectation for process-wide poisoning, so a LATER + // `disable_class_field_inline_guard` reaches an imported class's slot too. + if !guard_slot.is_null() { + // SAFETY: a compiled `@perry_class_guard_shape_*` global, static and + // writable for the life of the image (the caller's contract above). + unsafe { crate::object::js_register_class_guard_shape(guard_slot) }; + } let mut registered = registered_typed_shapes(); match registered .typed_by_class @@ -313,6 +339,7 @@ static KEEP_JS_REGISTER_IMPORTED_CLASS_SHAPE_SLOT: extern "C" fn( *const u64, *mut u32, *mut u64, + *mut u32, ) = js_register_imported_class_shape_slot; #[allow(clippy::too_many_arguments)] @@ -642,6 +669,10 @@ mod imported_shape_slot_tests { keys: Box, shape: Box, image: Box<[u64; 2]>, + /// The poisonable guard expectation twinned with `shape`. It must + /// follow every rewrite `shape` gets, or an imported class's inline + /// field guard compares against a stale value for the whole process. + guard: Box, } fn slots(class_id: u32) -> Slots { @@ -651,6 +682,7 @@ mod imported_shape_slot_tests { keys: Box::new(keys), shape: Box::new(ordinary), image: Box::new([0x1234_5678, ((ordinary as u64) << 32) | class_id as u64]), + guard: Box::new(ordinary), } } @@ -661,6 +693,7 @@ mod imported_shape_slot_tests { &*s.keys as *const u64, &mut *s.shape as *mut u32, s.image.as_mut_ptr(), + &mut *s.guard as *mut u32, ); } @@ -685,6 +718,14 @@ mod imported_shape_slot_tests { "the consumer's packed word is untouched" ); assert_eq!(s.image[1], ((typed as u64) << 32) | class_id as u64); + // Both rewrite paths must carry the guard expectation with them. If + // this drifts, an imported class's inline field guard compares a live + // object against the stub's ORDINARY id forever — it never goes wrong, + // it just silently never hits, which no correctness test would catch. + assert_eq!( + *s.guard, typed, + "the guard expectation follows the ShapeId slot" + ); } /// The consumer registers first (its string pool ran before the defining @@ -711,6 +752,29 @@ mod imported_shape_slot_tests { assert_published(class_id, &s, typed); } + /// Disabling the inline path poisons a registered expectation, and a + /// LATER rewrite must not resurrect it. + #[test] + fn a_disabled_inline_path_keeps_imported_expectations_poisoned() { + let class_id = 0x0B1_1005; + let mut s = slots(class_id); + register(class_id, &mut s); + crate::object::disable_class_field_inline_guard(); + assert_eq!( + *s.guard, + crate::object::CLASS_GUARD_SHAPE_POISON, + "disabling must poison an already-registered expectation" + ); + let typed = mint(class_id, *s.keys); + assert_eq!(*s.shape, typed, "the ShapeId slot still follows the mint"); + assert_eq!( + *s.guard, + crate::object::CLASS_GUARD_SHAPE_POISON, + "a rewrite after the disable must NOT reopen the fast path" + ); + crate::object::test_reset_class_field_inline_guard(); + } + /// A slot whose keys global does not hold the typed id's keys array, or /// whose slot count differs, is never rewritten. #[test] @@ -725,6 +789,7 @@ mod imported_shape_slot_tests { &*foreign.keys as *const u64, &mut *foreign.shape as *mut u32, foreign.image.as_mut_ptr(), + &mut *foreign.guard as *mut u32, ); let mut narrow = slots(class_id); let narrow_ordinary = *narrow.shape; @@ -734,6 +799,7 @@ mod imported_shape_slot_tests { &*narrow.keys as *const u64, &mut *narrow.shape as *mut u32, std::ptr::null_mut(), + &mut *narrow.guard as *mut u32, ); let _typed = mint(class_id, *narrow.keys); assert_eq!( diff --git a/crates/perry-runtime/src/object/class_guard_shape.rs b/crates/perry-runtime/src/object/class_guard_shape.rs new file mode 100644 index 0000000000..6a67f51607 --- /dev/null +++ b/crates/perry-runtime/src/object/class_guard_shape.rs @@ -0,0 +1,95 @@ +//! The per-class guard expectation: the poisonable carrier that replaced the +//! `@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED` latch on the per-access +//! class-field guard. +//! +//! # Why a second global, beside the ShapeId +//! +//! The obvious move is to poison `@perry_class_shape_id_*` itself — the guard +//! already loads it, so disabling the fast path would cost nothing. That is +//! UNSOUND: `js_object_alloc_class_inline_keys_stamped` stamps every newly +//! allocated instance with the value read out of that global +//! (`perry-codegen/src/lower_call/new_alloc.rs`), so poisoning it would brand +//! live objects with a bogus ShapeId rather than close a fast path. +//! +//! `@perry_class_guard_shape_*` is seeded with the same ShapeId at module init +//! and is only ever COMPARED against, never stamped — so it is safe to poison, +//! and the guard gets the latch's authority out of a load it was making +//! anyway. Net on the emitted fast path of a static-key read: 28 -> 23 ARM64 +//! instructions, and one fewer dependent load (the latch was an `external +//! global`, so reading it cost a GOT load plus an `ldrb` through it). + +use super::descriptor_state::PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED; +use std::sync::atomic::Ordering; + +/// The value written into every registered `@perry_class_guard_shape_*` slot +/// when the inline fast path is disabled. +/// +/// ShapeIds are allocated from `[SHAPE_ID_BASE, SHAPE_ID_END)` = +/// `[0x8000_0000, 0xC000_0000)` and are never reused (`object/shapes.rs`), so +/// `u32::MAX` can never be a live ShapeId. A guard comparing an object's +/// `class_id | ShapeId << 32` word against a poisoned expectation therefore +/// misses for EVERY receiver, which is exactly what the latch bought — at no +/// per-access cost, because the guard already had to load its expectation. +pub const CLASS_GUARD_SHAPE_POISON: u32 = u32::MAX; + +/// Addresses of the per-class guard-expectation slots compiled code emits. +/// +/// Held as `(usize, u32)` — address plus the ShapeId it was seeded with — and +/// never as `*mut u32`: these point into the program's own data segment, never +/// into the Perry heap, so this table is deliberately NOT a GC root holder and +/// must not be registered with `gc_register_mutable_root_scanner`. The seeded +/// value is kept so `test_reset_class_field_inline_guard` can restore it; +/// production never unpoisons (the decision is monotonic). +static CLASS_GUARD_SHAPE_SLOTS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +/// Register a compiled module's per-class guard-expectation slot. +/// +/// Called once per class from module init, right after the slot is seeded with +/// the class's freshly minted ShapeId. A module initialised AFTER the latch +/// already flipped is poisoned on the spot, so late `require`/`dlopen` arrivals +/// cannot reopen a fast path the process has already closed. +/// +/// # Safety +/// `slot` must be a valid, writable, 4-byte-aligned `u32` with static lifetime +/// — i.e. a `@perry_class_guard_shape_*` global emitted by perry-codegen. +#[no_mangle] +pub unsafe extern "C" fn js_register_class_guard_shape(slot: *mut u32) { + if slot.is_null() { + return; + } + if let Ok(mut slots) = CLASS_GUARD_SHAPE_SLOTS.lock() { + // SAFETY: caller contract above. + let seeded = unsafe { slot.read() }; + slots.push((slot as usize, seeded)); + if PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED.load(Ordering::Relaxed) != 0 { + // SAFETY: caller contract above. + unsafe { slot.write(CLASS_GUARD_SHAPE_POISON) }; + } + } +} + +pub(super) fn poison_class_guard_shapes() { + if let Ok(slots) = CLASS_GUARD_SHAPE_SLOTS.lock() { + for &(addr, _) in slots.iter() { + // SAFETY: every entry was registered through + // `js_register_class_guard_shape`, whose contract requires a valid + // writable static `u32`. + unsafe { (addr as *mut u32).write(CLASS_GUARD_SHAPE_POISON) }; + } + } +} + +/// Restore every registered expectation to the ShapeId it was seeded with. +/// +/// Production never does this — the disable decision is monotonic — but a test +/// that flips the latch must not leave later tests guarding against +/// [`CLASS_GUARD_SHAPE_POISON`]. +pub(super) fn restore_class_guard_shapes_for_test() { + if let Ok(slots) = CLASS_GUARD_SHAPE_SLOTS.lock() { + for &(addr, seeded) in slots.iter() { + // SAFETY: registered through `js_register_class_guard_shape`. + unsafe { (addr as *mut u32).write(seeded) }; + } + } +} diff --git a/crates/perry-runtime/src/object/descriptor_state.rs b/crates/perry-runtime/src/object/descriptor_state.rs index 7f2e23ed8b..45f2b6b671 100644 --- a/crates/perry-runtime/src/object/descriptor_state.rs +++ b/crates/perry-runtime/src/object/descriptor_state.rs @@ -290,7 +290,15 @@ pub static PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED: AtomicU8 = AtomicU8::new(0); /// Disable the codegen-inlined class-field fast path process-wide (see /// [`PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED`]). Idempotent. +/// +/// Sets the latch AND poisons every registered guard-expectation slot. The two +/// are one decision with two carriers: sites that still read the latch keep +/// working unchanged, while `emit_class_field_inline_precheck` — the per-access +/// guard on every static-key read — gets the same authority for free out of the +/// expectation it already loads. Poison first, so no thread can observe a set +/// latch beside a live expectation. pub(crate) fn disable_class_field_inline_guard() { + super::class_guard_shape::poison_class_guard_shapes(); PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED.store(1, Ordering::Relaxed); } @@ -302,6 +310,11 @@ pub(crate) fn class_field_inline_guard_enabled() -> bool { #[cfg(test)] pub(crate) fn test_reset_class_field_inline_guard() { PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED.store(0, Ordering::Relaxed); + // Unpoison every registered expectation back to the ShapeId it was seeded + // with. Production never does this — the disable decision is monotonic — + // but a test that flips the latch must not leave later tests guarding + // against `CLASS_GUARD_SHAPE_POISON`. + super::class_guard_shape::restore_class_guard_shapes_for_test(); // Also clear the C5a per-key vetting sets (production-monotonic, so // without this a key name reused across tests in one process would // inherit an earlier test's declared-field / installed-key state and diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index d633db2d2c..8d2b99c02d 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -200,6 +200,7 @@ mod websocket_global; mod with_env; // Issue #1103 follow-up: behavior-preserving split of the residual top-level // helpers that lived directly in `object/mod.rs`. +mod class_guard_shape; mod class_meta_registry; pub(crate) mod descriptor_state; mod this_binding; @@ -265,6 +266,7 @@ pub use with_env::*; // Re-exports for the residual-helper split (issue #1103 follow-up). Explicit // named re-exports keep existing `crate::object::X` / bare-name call sites in // the object submodules resolving unchanged. +pub use class_guard_shape::{js_register_class_guard_shape, CLASS_GUARD_SHAPE_POISON}; pub(crate) use class_meta_registry::{ builtin_error_prototype_name, class_generic_origin, extends_builtin_error, fetch_parent_kind, lookup_has_instance_hook, lookup_to_string_tag_hook, register_fetch_parent_kind, @@ -276,6 +278,8 @@ pub use class_meta_registry::{ }; #[cfg(test)] pub(crate) use descriptor_state::test_may_have_descriptor_entry; +#[cfg(test)] +pub(crate) use descriptor_state::test_reset_class_field_inline_guard; pub use descriptor_state::PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED; pub(crate) use descriptor_state::{ accessor_descriptor_keys_for_obj, class_field_inline_guard_enabled, From 04843633a97b4ef18f2f2b26ccca2358215623aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 16:55:55 +0200 Subject: [PATCH 4/9] tooling: gate the compiler's copy of the GC header layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .github/workflows/test.yml | 18 + .../src/object/class_guard_shape.rs | 7 + scripts/check_gc_header_constants.py | 402 ++++++++++++++++++ scripts/shape_descriptor_census.py | 19 +- 4 files changed, 444 insertions(+), 2 deletions(-) create mode 100755 scripts/check_gc_header_constants.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3cd21240da..30bf439b9a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -586,6 +586,24 @@ jobs: python3 scripts/check_node_version_consistency.py --self-test python3 scripts/check_node_version_consistency.py + # perry-codegen does NOT depend on perry-runtime, yet it bakes the + # collector's header layout into emitted code: the inline `new` path + # stores a packed GcHeader word as a compile-time constant (#8122) and + # every class-field / element-shape guard masks that word against a + # literal. Both sides carried their own copy of GC_TYPE_OBJECT, + # GC_FLAG_FORWARDED, GC_OBJ_TYPED_LAYOUT_INTACT and friends, held together + # only by a comment -- the `debug_assert_eq!` that looks like it checks + # them compares codegen's constant to a string literal (a tautology) and + # is compiled out of release and perry-dev anyway. A renumbering in the + # runtime therefore compiled clean, passed every suite, and shipped a + # binary whose objects the collector misreads. Build-free, so it belongs + # in `lint`, which IS a required context. + - name: GC header constant consistency + if: ${{ !cancelled() }} + run: | + python3 scripts/check_gc_header_constants.py --self-test + python3 scripts/check_gc_header_constants.py + # #7341 layer 3. A RuntimeHandleScope gives an object liveness; it does # nothing for a raw pointer already read out of the slot. Every rooting bug # in the quarantine sweep had rooting ALREADY -- what was missing was diff --git a/crates/perry-runtime/src/object/class_guard_shape.rs b/crates/perry-runtime/src/object/class_guard_shape.rs index 6a67f51607..3dcf60e47e 100644 --- a/crates/perry-runtime/src/object/class_guard_shape.rs +++ b/crates/perry-runtime/src/object/class_guard_shape.rs @@ -63,6 +63,9 @@ pub unsafe extern "C" fn js_register_class_guard_shape(slot: *mut u32) { let seeded = unsafe { slot.read() }; slots.push((slot as usize, seeded)); if PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED.load(Ordering::Relaxed) != 0 { + // GC_STORE_AUDIT(POINTER_FREE): a `u32` ShapeId in the program's own + // data segment, never a heap edge — the collector neither scans nor + // rewrites it. // SAFETY: caller contract above. unsafe { slot.write(CLASS_GUARD_SHAPE_POISON) }; } @@ -72,6 +75,8 @@ pub unsafe extern "C" fn js_register_class_guard_shape(slot: *mut u32) { pub(super) fn poison_class_guard_shapes() { if let Ok(slots) = CLASS_GUARD_SHAPE_SLOTS.lock() { for &(addr, _) in slots.iter() { + // GC_STORE_AUDIT(POINTER_FREE): a `u32` ShapeId in the program's own + // data segment, never a heap edge. // SAFETY: every entry was registered through // `js_register_class_guard_shape`, whose contract requires a valid // writable static `u32`. @@ -88,6 +93,8 @@ pub(super) fn poison_class_guard_shapes() { pub(super) fn restore_class_guard_shapes_for_test() { if let Ok(slots) = CLASS_GUARD_SHAPE_SLOTS.lock() { for &(addr, seeded) in slots.iter() { + // GC_STORE_AUDIT(POINTER_FREE): a `u32` ShapeId in the program's own + // data segment, never a heap edge. // SAFETY: registered through `js_register_class_guard_shape`. unsafe { (addr as *mut u32).write(seeded) }; } diff --git a/scripts/check_gc_header_constants.py b/scripts/check_gc_header_constants.py new file mode 100755 index 0000000000..5fc08d736c --- /dev/null +++ b/scripts/check_gc_header_constants.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +"""Hold every compiler-side restatement of a GC header bit to the runtime's own. + +WHY THIS EXISTS +--------------- +`perry-codegen` does NOT depend on `perry-runtime` — its dependencies are +perry-hir, perry-dispatch and perry-api-manifest. Yet the compiler bakes the +collector's header layout into emitted code in two load-bearing ways: + +* the inline `new` path stores a packed `GcHeader` word as a COMPILE-TIME + constant (`target_layout::inline_alloc_gc_packed`, #8122), pre-composed per + class into `@perry_class_header_image_*`; and +* every class-field / element-shape guard masks that word against a literal + and compares it to a literal (`expr/class_field_inline_guard.rs`, + `expr/element_shape_guard.rs`). + +Both sides therefore carry their own copy of `GC_TYPE_OBJECT`, +`GC_FLAG_FORWARDED`, `OBJ_FLAG_HAS_DESCRIPTORS`, `GC_OBJ_TYPED_LAYOUT_INTACT` +and friends, and until this checker the agreement was held by a code comment +("Runtime-side name: `gc::layout::GC_OBJ_TYPED_LAYOUT_INTACT`"). + +The things that LOOK like they enforce it do not: + + // expr/class_field_inline_guard.rs + const GC_FLAG_FORWARDED_I8: &str = "-128"; + ... + debug_assert_eq!(GC_FLAG_FORWARDED_I8, "-128"); + +That compares codegen's constant to a string literal — a tautology. It is a +useful "you edited the const, now fix the mask arithmetic below" pin, but it +never references the runtime, so it cannot detect a renumbering there; and per +CLAUDE.md's profile note it is compiled out of `release` AND `perry-dev` +anyway. Every codegen test naming these bits asserts codegen's own constant +appears in the emitted IR, so they pin codegen to itself and would all stay +green. + +So, before this checker, renumbering a flag in `perry-runtime` 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. CLAUDE.md describes that class of bug as surfacing cycles later as +`TypeError: value is not a function`, nowhere near the cause. + +WHAT THIS DOES NOT CATCH (stated plainly, per CLAUDE.md's gate rules) +-------------------------------------------------------------------- +* Whether a bit ASSIGNMENT is the right one. This only proves the two sides + agree, never that the value is well chosen. +* Restatements that are not a `const` declaration — a bare literal inline in + an expression is invisible here. The registry below is the defence against + that: a checked constant must be DECLARED, so review has one place to look. +* Layout contracts other than the 32-bit GC header word. String, Map and array + header offsets/sizes are duplicated the same way and are enumerated in + `OUT_OF_SCOPE` rather than silently ignored — they belong to different + subsystems and want their own derivation, not a second-guessing one here. +* Field OFFSETS within `GcHeader` (obj_type @-8, gc_flags @-7, _reserved @-6). + Those are asserted structurally by the runtime's own layout tests. + +Usage: + python3 scripts/check_gc_header_constants.py # check + python3 scripts/check_gc_header_constants.py --list # describe + python3 scripts/check_gc_header_constants.py --self-test # prove it can fail +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +# --------------------------------------------------------------------------- +# The authoritative side: where each runtime constant is DEFINED. +# --------------------------------------------------------------------------- +RUNTIME_SOURCES = [ + "crates/perry-runtime/src/gc/types.rs", + "crates/perry-runtime/src/gc/layout.rs", +] + +# Runtime constants this checker resolves. Anything a codegen restatement +# derives from must be listed here, so a rename on the runtime side fails loudly +# instead of leaving a restatement unanchored. +RUNTIME_WANTED = { + "GC_TYPE_ARRAY", + "GC_TYPE_OBJECT", + "GC_TYPE_MAP", + "GC_FLAG_ARENA", + "GC_FLAG_FORWARDED", + "OBJ_FLAG_FROZEN", + "OBJ_FLAG_PACKED_NUMERIC_PROOF", + "OBJ_FLAG_PLAIN_ORDINARY", + "OBJ_FLAG_ARRAY_DESCRIPTORS", + "OBJ_FLAG_STABLE_TOMBSTONES", + "OBJ_FLAG_HAS_DESCRIPTORS", + "GC_LAYOUT_POINTER_FREE", + "GC_LAYOUT_SIDE_MASK", + "GC_OBJ_TYPED_LAYOUT_INTACT", +} + +# --------------------------------------------------------------------------- +# The registry: every codegen-side restatement, and how to re-derive it. +# +# `expr` is evaluated with the runtime constants in scope. A restatement whose +# declaration has vanished FAILS — a fix must delete its entry, so this list +# cannot rot into a description of a tree that no longer exists. +# +# Byte positions inside the 32-bit header word (little-endian): +# bits 0..7 obj_type | bits 8..15 gc_flags | bits 16..31 _reserved +# --------------------------------------------------------------------------- +Restatement = tuple[str, str, str, str] # (file, const, expr, why) + +REGISTRY: list[Restatement] = [ + # --- the packed GcHeader word the inline `new` path bakes (#8122) -------- + ("crates/perry-codegen/src/target_layout.rs", "GC_TYPE_OBJECT", + "GC_TYPE_OBJECT", "byte 0 of the baked header word"), + ("crates/perry-codegen/src/target_layout.rs", "GC_FLAG_ARENA", + "GC_FLAG_ARENA", "byte 1 of the baked header word"), + ("crates/perry-codegen/src/target_layout.rs", "GC_LAYOUT_POINTER_FREE", + "GC_LAYOUT_POINTER_FREE", "_reserved half of the baked header word"), + ("crates/perry-codegen/src/target_layout.rs", "GC_LAYOUT_SIDE_MASK", + "GC_LAYOUT_SIDE_MASK", "_reserved half of the baked header word"), + ("crates/perry-codegen/src/target_layout.rs", "GC_OBJ_TYPED_LAYOUT_INTACT", + "GC_OBJ_TYPED_LAYOUT_INTACT", "_reserved half of the baked header word"), + # `new_alloc.rs` re-derives the same word at the allocation site and + # cross-checks it against the per-class table; both copies are pinned. + ("crates/perry-codegen/src/lower_call/new_alloc.rs", "GC_TYPE_OBJECT", + "GC_TYPE_OBJECT", "allocation-site copy of the baked header word"), + ("crates/perry-codegen/src/lower_call/new_alloc.rs", "GC_FLAG_ARENA", + "GC_FLAG_ARENA", "allocation-site copy of the baked header word"), + ("crates/perry-codegen/src/lower_call/new_alloc.rs", "GC_LAYOUT_POINTER_FREE", + "GC_LAYOUT_POINTER_FREE", "allocation-site copy of the baked header word"), + ("crates/perry-codegen/src/lower_call/new_alloc.rs", "GC_OBJ_TYPED_LAYOUT_INTACT", + "GC_OBJ_TYPED_LAYOUT_INTACT", "allocation-site copy of the baked header word"), + + # --- the class-field inline guard --------------------------------------- + ("crates/perry-codegen/src/expr/class_field_inline_guard.rs", "GC_TYPE_OBJECT", + "GC_TYPE_OBJECT", "guard: obj_type byte"), + ("crates/perry-codegen/src/expr/class_field_inline_guard.rs", "GC_FLAG_FORWARDED_I8", + "GC_FLAG_FORWARDED - 256", "guard: gc_flags 0x80 spelled as a signed i8"), + ("crates/perry-codegen/src/expr/class_field_inline_guard.rs", "TYPED_LAYOUT_INTACT_BIT", + "GC_OBJ_TYPED_LAYOUT_INTACT", "guard: raw-f64 slots need the intact bit"), + ("crates/perry-codegen/src/expr/class_field_inline_guard.rs", "OBJ_FLAG_FROZEN_BIT", + "OBJ_FLAG_FROZEN", "guard: a frozen receiver must route through the setter"), + ("crates/perry-codegen/src/expr/class_field_inline_guard.rs", + "OBJ_FLAG_PACKED_NUMERIC_PROOF_BIT", "OBJ_FLAG_PACKED_NUMERIC_PROOF", + "guard: #8690 Array-subclass numeric-prefix proof"), + ("crates/perry-codegen/src/expr/class_field_inline_guard.rs", + "OBJ_FLAG_READ_FAST_PATH_BLOCKED", + "OBJ_FLAG_ARRAY_DESCRIPTORS | OBJ_FLAG_HAS_DESCRIPTORS", + "guard: #5654 per-receiver descriptor veto (a COMPOSITE of two flags)"), + ("crates/perry-codegen/src/expr/class_field_inline_guard.rs", + "OBJ_FLAG_WRITE_FAST_PATH_BLOCKED", + "OBJ_FLAG_ARRAY_DESCRIPTORS | OBJ_FLAG_HAS_DESCRIPTORS" + " | OBJ_FLAG_PACKED_NUMERIC_PROOF | OBJ_FLAG_FROZEN", + "guard: the write side adds frozen + the packed-numeric proof"), + + # --- the element-shape guard's fused masks ------------------------------- + ("crates/perry-codegen/src/expr/element_shape_guard.rs", "GC_TYPE_ARRAY", + "GC_TYPE_ARRAY", "element guard: obj_type byte"), + ("crates/perry-codegen/src/expr/element_shape_guard.rs", "ELEM_HEADER_MASK", + "0xFF | (GC_FLAG_FORWARDED << 8)" + " | ((GC_OBJ_TYPED_LAYOUT_INTACT | OBJ_FLAG_HAS_DESCRIPTORS) << 16)", + "element guard: one fused 32-bit mask over all three header bytes"), + ("crates/perry-codegen/src/expr/element_shape_guard.rs", "ELEM_HEADER_EXPECT", + "GC_TYPE_OBJECT | (GC_OBJ_TYPED_LAYOUT_INTACT << 16)", + "element guard: the value ELEM_HEADER_MASK must produce"), + ("crates/perry-codegen/src/expr/element_shape_guard.rs", "ELEM_HEADER_SHAPE_MASK", + "0xFF | (GC_FLAG_FORWARDED << 8) | (OBJ_FLAG_HAS_DESCRIPTORS << 16)", + "element guard: shape-keyed arm drops the intact conjunct"), + ("crates/perry-codegen/src/expr/element_shape_guard.rs", "ELEM_HEADER_SHAPE_EXPECT", + "GC_TYPE_OBJECT", "element guard: shape-keyed arm's expected value"), + + # --- the array-literal inline allocator's baked header word ------------- + ("crates/perry-codegen/src/expr/array_literal.rs", "GC_TYPE_ARRAY", + "GC_TYPE_ARRAY", "array literal: obj_type byte of the baked header word"), + ("crates/perry-codegen/src/expr/array_literal.rs", "GC_FLAG_ARENA", + "GC_FLAG_ARENA", "array literal: gc_flags byte of the baked header word"), + ("crates/perry-codegen/src/expr/array_literal.rs", "GC_LAYOUT_POINTER_FREE", + "GC_LAYOUT_POINTER_FREE", "array literal: _reserved half of the baked header word"), + + # --- the typed-f64 receiver method probe's fused mask ------------------- + ("crates/perry-codegen/src/lower_call/method_override.rs", + "GC_OBJECT_METHOD_GUARD_MASK_I32", + "0xFF | (GC_FLAG_FORWARDED << 8)" + " | ((OBJ_FLAG_HAS_DESCRIPTORS | OBJ_FLAG_PACKED_NUMERIC_PROOF) << 16)", + "method probe: one fused 32-bit mask over all three header bytes"), + ("crates/perry-codegen/src/lower_call/property_get/imported_object.rs", + "GC_OBJECT_METHOD_GUARD_MASK_I32", + "0xFF | (GC_FLAG_FORWARDED << 8)" + " | ((OBJ_FLAG_HAS_DESCRIPTORS | OBJ_FLAG_PACKED_NUMERIC_PROOF) << 16)", + "imported-object probe: second copy of the same fused mask"), + + # --- other single-bit restatements -------------------------------------- + ("crates/perry-codegen/src/expr/in_presence_ic.rs", "GC_TYPE_OBJECT", + "GC_TYPE_OBJECT", "`in` presence IC: obj_type byte"), + ("crates/perry-codegen/src/expr/in_presence_ic.rs", "GC_FLAG_FORWARDED", + "GC_FLAG_FORWARDED", "`in` presence IC: not-forwarded"), + ("crates/perry-codegen/src/expr/arrays_finds.rs", "GC_TYPE_MAP", + "GC_TYPE_MAP", "Map find fast path: obj_type byte"), + ("crates/perry-codegen/src/expr/arrays_finds.rs", "GC_FLAG_FORWARDED", + "GC_FLAG_FORWARDED", "Map find fast path: not-forwarded"), + ("crates/perry-codegen/src/lower_call/method_override.rs", "GC_TYPE_OBJECT", + "GC_TYPE_OBJECT", "typed-f64 receiver method probe: obj_type byte"), + ("crates/perry-codegen/src/lower_call/property_get/imported_object.rs", "GC_TYPE_OBJECT", + "GC_TYPE_OBJECT", "imported-object property get: obj_type byte"), + ("crates/perry-codegen/src/expr/proxy_reflect.rs", "PLAIN_ORDINARY_OBJ_FLAG", + "OBJ_FLAG_PLAIN_ORDINARY", "proxy/reflect: plain-ordinary veto"), + ("crates/perry-codegen/src/expr/proxy_reflect_write_ic.rs", "STABLE_TOMBSTONES_OBJ_FLAG", + "OBJ_FLAG_STABLE_TOMBSTONES", "proxy write IC: stable-tombstones veto"), + ("crates/perry-codegen/src/codegen/string_pool.rs", "GC_LAYOUT_AND_INTACT_MASK", + "GC_LAYOUT_POINTER_FREE | GC_LAYOUT_SIDE_MASK | GC_OBJ_TYPED_LAYOUT_INTACT", + "module init: the header-image layout bits it may rewrite"), + ("crates/perry-codegen/src/codegen/string_pool.rs", "GC_SIDE_MASK_AND_INTACT", + "GC_LAYOUT_SIDE_MASK | GC_OBJ_TYPED_LAYOUT_INTACT", + "module init: the side-mask + intact pair it writes"), +] + +# Declared constants this checker deliberately does not anchor, each with the +# reason. Enumerated rather than ignored: a reader should be able to see the +# whole duplicated surface in one place, including the parts out of scope. +OUT_OF_SCOPE = { + ("crates/perry-codegen/src/target_layout.rs", "GC_HEADER_SIZE_BYTES"): + "a STRUCT SIZE, not a bit assignment; the runtime asserts it structurally", + ("crates/perry-codegen/src/lower_call/new_alloc.rs", "GC_HEADER_SIZE"): + "same struct size, re-derived at the allocation site", + ("crates/perry-codegen/src/expr/array_literal.rs", "GC_HEADER_SIZE"): + "same struct size, re-derived at the array-literal site", + ("crates/perry-codegen/src/gc_map.rs", "GC_MAP_MAGIC"): + "`.perry_gcmap` section format, not the object header", + ("crates/perry-codegen/src/gc_map.rs", "GC_MAP_VERSION"): + "`.perry_gcmap` section format, not the object header", + ("crates/perry-codegen/src/gc_map.rs", "GC_MAP_LABEL"): + "`.perry_gcmap` section format, not the object header", +} + +# Prefixes that make a codegen `const` look like a header restatement. A new +# declaration matching one of these must be registered above or exempted in +# OUT_OF_SCOPE, so the next one cannot arrive silently — the rule +# `check_node_version_consistency.py` uses for `node-version:` literals. +WATCHED = re.compile(r"^(GC_|OBJ_|TYPED_LAYOUT|PLAIN_ORDINARY_OBJ|STABLE_TOMBSTONES_OBJ|ELEM_HEADER)") + +CONST_RE = re.compile( + r"^\s*(?:pub(?:\([^)]*\))?\s+)?const\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*[^=]+=\s*([^;]+);" +) + + +def parse_consts(path: Path) -> dict[str, str]: + out: dict[str, str] = {} + for line in path.read_text().splitlines(): + m = CONST_RE.match(line) + if m: + out.setdefault(m.group(1), m.group(2).strip()) + return out + + +def literal_value(raw: str) -> int | None: + """The integer a declaration's right-hand side denotes, or None.""" + text = raw.strip().strip('"').strip() + text = re.sub(r"_", "", text) + try: + return int(text, 0) + except ValueError: + return None + + +def runtime_values(root: Path) -> tuple[dict[str, int], list[str]]: + values: dict[str, int] = {} + problems: list[str] = [] + for rel in RUNTIME_SOURCES: + path = root / rel + if not path.exists(): + problems.append(f"runtime source missing: {rel}") + continue + for name, raw in parse_consts(path).items(): + if name in RUNTIME_WANTED and name not in values: + v = literal_value(raw) + if v is not None: + values[name] = v + for name in sorted(RUNTIME_WANTED - values.keys()): + problems.append( + f"runtime constant {name} not found in {' or '.join(RUNTIME_SOURCES)} — " + "it was renamed or moved; update RUNTIME_SOURCES/RUNTIME_WANTED so the " + "restatements that derive from it stay anchored" + ) + return values, problems + + +def check(root: Path) -> list[str]: + values, problems = runtime_values(root) + if problems: + return problems + + declared: dict[tuple[str, str], str] = {} + for rel in sorted({entry[0] for entry in REGISTRY} | {k[0] for k in OUT_OF_SCOPE}): + path = root / rel + if not path.exists(): + problems.append(f"registered file missing: {rel}") + continue + for name, raw in parse_consts(path).items(): + declared[(rel, name)] = raw + + for rel, const, expr, why in REGISTRY: + raw = declared.get((rel, const)) + if raw is None: + problems.append( + f"{rel}: registered constant {const} no longer declared " + f"({why}) — delete its REGISTRY entry in the same commit" + ) + continue + got = literal_value(raw) + if got is None: + problems.append(f"{rel}:{const} = {raw!r} is not an integer literal") + continue + want = eval(expr, {"__builtins__": {}}, dict(values)) # noqa: S307 - fixed table + if got != want: + problems.append( + f"{rel}:{const} = {got} (0x{got:X}) but the runtime says " + f"{want} (0x{want:X})\n" + f" derivation: {expr}\n" + f" role: {why}\n" + f" The compiler bakes this into emitted code and does NOT link " + f"perry-runtime, so a mismatch ships a binary whose objects the " + f"collector misreads. Fix the compiler side, or update the " + f"derivation if the runtime deliberately moved the bit." + ) + + # Nothing header-shaped may arrive unregistered. + watched_files = {entry[0] for entry in REGISTRY} | {k[0] for k in OUT_OF_SCOPE} + known = {(rel, const) for rel, const, _, _ in REGISTRY} | set(OUT_OF_SCOPE) + for (rel, const) in sorted(declared): + if rel in watched_files and WATCHED.match(const) and (rel, const) not in known: + problems.append( + f"{rel}: {const} looks like a GC header restatement but is not " + "registered. Add it to REGISTRY with the expression that " + "re-derives it from perry-runtime, or to OUT_OF_SCOPE with a reason." + ) + return problems + + +def self_test(root: Path) -> int: + """Prove the checker can fail: perturb one runtime value and expect a report.""" + values, _ = runtime_values(root) + saved = values["GC_OBJ_TYPED_LAYOUT_INTACT"] + rel, const, expr, why = next( + e for e in REGISTRY if e[1] == "TYPED_LAYOUT_INTACT_BIT" + ) + raw = parse_consts(root / rel).get(const) + got = literal_value(raw) + perturbed = dict(values) + perturbed["GC_OBJ_TYPED_LAYOUT_INTACT"] = saved << 1 + want = eval(expr, {"__builtins__": {}}, perturbed) # noqa: S307 + if got == want: + print("self-test FAILED: a moved intact bit was not detected", file=sys.stderr) + return 1 + if check(root): + print("self-test FAILED: the tree is already red", file=sys.stderr) + return 1 + print( + "check_gc_header_constants self-test: OK — a one-bit move of " + "GC_OBJ_TYPED_LAYOUT_INTACT is detected, and the tree is currently clean" + ) + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--list", action="store_true", help="describe what is pinned") + ap.add_argument("--self-test", action="store_true", help="prove the checker can fail") + args = ap.parse_args() + + if args.self_test: + return self_test(REPO) + + if args.list: + values, _ = runtime_values(REPO) + print("Authoritative runtime values:") + for name in sorted(values): + print(f" {name:32s} = {values[name]} (0x{values[name]:X})") + print(f"\nCompiler-side restatements pinned ({len(REGISTRY)}):") + for rel, const, expr, why in REGISTRY: + print(f" {rel}\n {const} = {expr}\n {why}") + print(f"\nDeclared but out of scope ({len(OUT_OF_SCOPE)}):") + for (rel, const), reason in sorted(OUT_OF_SCOPE.items()): + print(f" {rel}:{const} — {reason}") + return 0 + + problems = check(REPO) + if problems: + print("check_gc_header_constants: FAILED\n", file=sys.stderr) + for p in problems: + print(f" - {p}\n", file=sys.stderr) + return 1 + print( + f"check_gc_header_constants: OK — {len(REGISTRY)} compiler-side header " + f"restatements agree with perry-runtime " + f"({len(OUT_OF_SCOPE)} declared constants out of scope, listed with reasons)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/shape_descriptor_census.py b/scripts/shape_descriptor_census.py index 910f1ed5a1..1361cf1497 100644 --- a/scripts/shape_descriptor_census.py +++ b/scripts/shape_descriptor_census.py @@ -676,6 +676,16 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: # and compares it with `expected_class_identity`, which puts # the ShapeId in the high 32 bits. All three halves are # required, so dropping the ShapeId from the compare fails. + # + # The expectation is the POISONABLE `@perry_class_guard_shape_*` + # twin, not `@perry_class_shape_id_*`, and it is read VOLATILE + # per access. That is load-bearing, not incidental: this compare + # now carries the authority the + # `@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED` latch used to, so a + # lowering that hoisted the load, or read the ShapeId global + # instead, would take a fast path the runtime has already closed + # — and would still pass a shape-only assertion. Both halves are + # required here for that reason. require_code( body, r"load\s*\(\s*I64\s*,\s*&obj_ptr\s*\)", @@ -683,8 +693,13 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: ) require_code( body, - r"expected_class_identity\s*\(\s*blk\s*,\s*expected_class_id\s*,\s*expected_shape_id\s*\)", - f"{name} compares the identity word with the expected ShapeId", + r"expected_class_identity\s*\(\s*blk\s*,\s*expected_class_id\s*,\s*&live_shape\s*\)", + f"{name} compares the identity word with the live expectation", + ) + require_code( + body, + r"load_volatile\s*\(\s*I32\s*,\s*&format!\(\s*\"@\{guard_shape_global\}\"", + f"{name} reads the poisonable expectation VOLATILE, per access", ) require_code( function_body(raw_class_guard, "expected_class_identity"), From 3e4afd0b68518dfdc79ed9e1ec3b188e53dbd0d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 19:07:10 +0200 Subject: [PATCH 5/9] docs(changelog): fragment for #10646 --- changelog.d/10646-retire-class-field-latch.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 changelog.d/10646-retire-class-field-latch.md diff --git a/changelog.d/10646-retire-class-field-latch.md b/changelog.d/10646-retire-class-field-latch.md new file mode 100644 index 0000000000..f93d0625dc --- /dev/null +++ b/changelog.d/10646-retire-class-field-latch.md @@ -0,0 +1,59 @@ +### Static-key class-field reads drop the per-access latch (−18% on the guard's fast path) + +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 cost `adrp` + a GOT `ldr` + a dependent `ldrb` through it + a +compare — four instructions and two dependent loads, before the guard had +looked at the receiver at all. It could not be hoisted: the runtime flips it +mid-execution when a descriptor or accessor lands on a class prototype, so the +load was `volatile` by necessity. + +That authority now rides on a value the guard already had to load. Each class +gains a `@perry_class_guard_shape_*` expectation, seeded at module init with +the class ShapeId and registered with the runtime; +`disable_class_field_inline_guard` 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, exactly what the latch bought. + +It is deliberately a SEPARATE global from `@perry_class_shape_id_*`: that one is +stamped into every new instance by `js_object_alloc_class_inline_keys_stamped`, +so poisoning it would brand live objects with a bogus ShapeId rather than close +a fast path. Subclass arms and imported-class stubs carry the poisonable +expectation too, and an imported-stub rewrite that lands after a disable +re-poisons rather than resurrects. + +Measured on arm64 (`-Os` + `llc -O2 -mcpu=apple-m1`): one `o.a` on a typed +receiver goes from 28 to 23 executed fast-path instructions (−18%) with one +fewer dependent load; a probe making 16 reads on one receiver goes from 595.2 +to 563.0 executed instructions per call (−5.4%, `/usr/bin/time -l` instructions +retired, differenced over iteration count). The per-read marginal is −2 rather +than −5 because LLVM already hoisted the latch's GOT base register across +accesses within a function. The latch is gone from `$generic`, `$spec_b` and +the copy the inliner leaves in the caller — the last of which is the code that +actually executes. + +### The compiler's copy of the GC header layout is now gated + +`perry-codegen` does not depend on `perry-runtime`, yet it bakes the collector's +header layout into emitted code: the inline `new` path stores a packed +`GcHeader` word as a compile-time constant, and every class-field / +element-shape / method-probe guard masks that word against a literal. Thirty-six +restatements across ten files, with the agreement held by a code comment — the +`debug_assert_eq!` that looked like enforcement compared codegen's constant to a +string literal, a tautology that never referenced the runtime and is compiled +out of `release` and `perry-dev` anyway. A flag renumbered in the runtime +compiled clean, passed every suite, and shipped a compiler whose allocator baked +one bit layout while the collector read another. + +`scripts/check_gc_header_constants.py` (in `lint`) re-derives every restatement +from the runtime constant it quotes, including composites and the fused 32-bit +masks. A registered constant that stops existing fails, so a fix deletes its own +entry, and a new header-shaped `const` in a watched file must be registered or +exempted with a reason. Writing the registry found five restatements a +module-scope grep misses, because they are declared inside function bodies. + +`shape_descriptor_census` gains a matching requirement: the class-field +precheck must read its expectation VOLATILE from the poisonable global, since a +lowering that hoisted that load would reopen a fast path the runtime has closed +and would still satisfy a shape-only assertion. From 08b2d1f414406e5b2a9a7fffa57f93699ef9c4ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 20:00:12 +0200 Subject: [PATCH 6/9] fix(runtime): gate the guard-shape test restore on cfg(test) `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. --- crates/perry-runtime/src/object/class_guard_shape.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/perry-runtime/src/object/class_guard_shape.rs b/crates/perry-runtime/src/object/class_guard_shape.rs index 3dcf60e47e..9cf4369ce8 100644 --- a/crates/perry-runtime/src/object/class_guard_shape.rs +++ b/crates/perry-runtime/src/object/class_guard_shape.rs @@ -90,6 +90,7 @@ pub(super) fn poison_class_guard_shapes() { /// Production never does this — the disable decision is monotonic — but a test /// that flips the latch must not leave later tests guarding against /// [`CLASS_GUARD_SHAPE_POISON`]. +#[cfg(test)] pub(super) fn restore_class_guard_shapes_for_test() { if let Ok(slots) = CLASS_GUARD_SHAPE_SLOTS.lock() { for &(addr, seeded) in slots.iter() { From 5e917b1b60584b880857127f9091695198b92b69 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Fri, 18 Sep 2026 22:43:30 +0000 Subject: [PATCH 7/9] fix(compile): fall back to compiled JS emit for TS namespace/export= declaration merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../src/commands/compile/cjs_wrap/detect.rs | 52 +++++ .../compile/cjs_wrap/issue_10662_tests.rs | 101 ++++++++++ .../src/commands/compile/cjs_wrap/mod.rs | 2 + crates/perry/src/commands/compile/resolve.rs | 36 +++- ..._10662_namespace_export_equals_fallback.rs | 187 ++++++++++++++++++ 5 files changed, 369 insertions(+), 9 deletions(-) create mode 100644 crates/perry/src/commands/compile/cjs_wrap/issue_10662_tests.rs create mode 100644 crates/perry/tests/issue_10662_namespace_export_equals_fallback.rs diff --git a/crates/perry/src/commands/compile/cjs_wrap/detect.rs b/crates/perry/src/commands/compile/cjs_wrap/detect.rs index 76b979e912..6faa700197 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/detect.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/detect.rs @@ -458,6 +458,58 @@ pub(in crate::commands::compile) fn has_top_level_module_exports_assignment(sour false } +/// Returns true if `source` (expected to already be comment/string-stripped +/// via [`strip_comments_and_strings`]) contains a top-level TypeScript +/// `namespace X { … }` / legacy `module X { … }` declaration with a REAL +/// (non-ambient) body — i.e. NOT `declare namespace X { … }`, which is +/// type-only and never emits runtime code, so it can't be the cause of a +/// namespace/function declaration-merge going missing at runtime. +/// +/// Line-anchored rather than depth-tracked, unlike [`has_top_level_esm`]: a +/// `namespace`/`module` block is hand-authored (or `tsc`-emitted) TypeScript +/// source, never a minified bundle, so it is always written starting its own +/// line. Requiring the `{` to follow the (possibly dotted) namespace name on +/// the SAME statement, with only whitespace/dots in between, keeps this from +/// matching ordinary CommonJS `module.exports = { … }` — there `module` is +/// followed immediately by `.`, never by whitespace then an identifier. +/// +/// Used together with [`has_top_level_export_equals`] (#10662): a package +/// like `agent-base` merges `namespace createAgent { export class Agent +/// extends EventEmitter { … } }` onto a same-named `function createAgent()` +/// and exports the merged value via `export = createAgent`. Perry's HIR +/// lowers the namespace's exported members as static-field-set init +/// statements against a synthetic class entity that does not end up being +/// the SAME runtime object `export =` exports — so a downstream `class X +/// extends pkg.Agent` sees `pkg.Agent` as `undefined` and throws "Class +/// extends value is not a constructor" (axios's `https-proxy-agent` → +/// `agent-base` dependency chain). 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 — only via its compiled emit. Detecting the +/// shape and falling back to that emit (see `is_hybrid_cjs_emit_input` in +/// `resolve.rs`) matches what Node actually runs, instead of attempting to +/// correctly implement namespace/function declaration merging. +pub(in crate::commands::compile) fn has_top_level_namespace_or_module_block(source: &str) -> bool { + let re = perry_perex::tooling::Regex::new( + r"(?m)^[ \t]*(declare\s+)?(?:export\s+)?(?:namespace|module)\s+[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*\s*\{", + ) + .expect("valid namespace/module regex"); + re.captures_iter(source).any(|cap| cap.get(1).is_none()) +} + +/// Returns true if `source` (comment/string-stripped) contains a top-level +/// TypeScript `export = ;` statement — the CJS-interop export form a +/// namespace-merged package like `agent-base` uses instead of `module.exports +/// = …` (see [`has_top_level_namespace_or_module_block`], #10662). The +/// trailing character class excludes `export ==`/`export =>`; neither is +/// valid syntax here, but the exclusion costs nothing and avoids relying on +/// lookahead, which the `regex` crate doesn't support. +pub(in crate::commands::compile) fn has_top_level_export_equals(source: &str) -> bool { + let re = perry_perex::tooling::Regex::new(r"(?m)^[ \t]*export\s*=[\s\w$(\[{]") + .expect("valid export= regex"); + re.is_match(source) +} + /// Returns true if `line` starts with `keyword` followed by a character /// that can legally begin an `import`/`export` statement's continuation: /// space, `{`, `*` (export only), `"`, `'`, or `(` (dynamic import). We diff --git a/crates/perry/src/commands/compile/cjs_wrap/issue_10662_tests.rs b/crates/perry/src/commands/compile/cjs_wrap/issue_10662_tests.rs new file mode 100644 index 0000000000..aed104f1b0 --- /dev/null +++ b/crates/perry/src/commands/compile/cjs_wrap/issue_10662_tests.rs @@ -0,0 +1,101 @@ +//! Regression tests for #10662: `agent-base`'s TypeScript `namespace +//! createAgent { export class Agent extends EventEmitter { … } } export = +//! createAgent;` — a `function createAgent()` merged with a namespace of the +//! same name, exported via TS's `export =` form. Perry's HIR lowers the +//! namespace's exported `Agent` class as a static-field-set against a +//! synthetic class entity distinct from the runtime function value +//! `export =` actually exports, so a downstream `https-proxy-agent extends +//! agent_base_1.Agent` (an `axios` transitive dependency) sees `.Agent` as +//! `undefined` and throws "Class extends value is not a constructor". +//! +//! `has_top_level_namespace_or_module_block` / +//! `has_top_level_export_equals` detect this shape so +//! `is_hybrid_cjs_emit_input` (`resolve.rs`) can fall back to the package's +//! compiled JS emit — the same emit Node itself runs, since +//! `--experimental-strip-types` can't execute raw `namespace`/`export =` +//! syntax either. + +use super::detect::{ + has_top_level_export_equals, has_top_level_namespace_or_module_block, + strip_comments_and_strings, +}; + +#[test] +fn namespace_block_detects_the_agent_base_shape() { + let src = strip_comments_and_strings( + "function createAgent(opts) {\n return new createAgent.Agent(opts);\n}\n\nnamespace createAgent {\n export class Agent extends EventEmitter {}\n}\n\nexport = createAgent;\n", + ); + assert!(has_top_level_namespace_or_module_block(&src)); + assert!(has_top_level_export_equals(&src)); +} + +#[test] +fn namespace_block_accepts_legacy_module_keyword_and_dotted_names() { + let src = strip_comments_and_strings("module Foo.Bar {\n export const x = 1;\n}\n"); + assert!(has_top_level_namespace_or_module_block(&src)); +} + +#[test] +fn ambient_declare_namespace_is_not_flagged() { + // `declare namespace X { … }` is type-only — it never emits runtime + // code, so it cannot be the cause of a namespace/function merge going + // missing at runtime, and must not trigger the JS-emit fallback. + let src = strip_comments_and_strings( + "declare namespace createAgent {\n export class Agent {}\n}\nexport = createAgent;\n", + ); + assert!(!has_top_level_namespace_or_module_block(&src)); +} + +#[test] +fn ordinary_cjs_module_exports_object_literal_is_not_flagged() { + // `module.exports = { … }` is the single most common CommonJS shape — + // `module` is followed by `.`, never by whitespace then an identifier, + // so it must never be mistaken for a `namespace`/`module X {` block. + let src = strip_comments_and_strings( + "function build() { return 1; }\nmodule.exports = { build: build, value: 42 };\n", + ); + assert!(!has_top_level_namespace_or_module_block(&src)); +} + +#[test] +fn export_equals_matches_the_export_equals_form_only() { + assert!(has_top_level_export_equals(&strip_comments_and_strings( + "export = createAgent;\n" + ))); + assert!(has_top_level_export_equals(&strip_comments_and_strings( + "export=createAgent;\n" + ))); + + // Ordinary ESM export forms must not match — none of these are the + // CJS-interop `export =` shape. + assert!(!has_top_level_export_equals(&strip_comments_and_strings( + "export const x = 1;\n" + ))); + assert!(!has_top_level_export_equals(&strip_comments_and_strings( + "export class Foo {}\n" + ))); + assert!(!has_top_level_export_equals(&strip_comments_and_strings( + "export default Foo;\n" + ))); + assert!(!has_top_level_export_equals(&strip_comments_and_strings( + "export { Foo };\n" + ))); +} + +#[test] +fn plain_esm_or_cjs_source_without_the_merge_shape_is_unaffected() { + // A normal ESM file with a class extending an imported native builtin — + // the overwhelmingly common case — must not be flagged: no namespace + // block, no `export =`. + let esm = strip_comments_and_strings( + "import { EventEmitter } from 'events';\nexport class Agent extends EventEmitter {}\n", + ); + assert!(!has_top_level_namespace_or_module_block(&esm)); + assert!(!has_top_level_export_equals(&esm)); + + // A normal CJS file. + let cjs = + strip_comments_and_strings("'use strict';\nclass Agent {}\nmodule.exports = { Agent };\n"); + assert!(!has_top_level_namespace_or_module_block(&cjs)); + assert!(!has_top_level_export_equals(&cjs)); +} diff --git a/crates/perry/src/commands/compile/cjs_wrap/mod.rs b/crates/perry/src/commands/compile/cjs_wrap/mod.rs index 815617bb51..83079a1345 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/mod.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/mod.rs @@ -43,6 +43,8 @@ mod extract_requires; mod hoist_classes; mod wrap; +#[cfg(test)] +mod issue_10662_tests; #[cfg(test)] mod issue_6585_tests; #[cfg(test)] diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index b77abcb267..54d2d205c0 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -767,13 +767,29 @@ fn original_source_via_map(entry: &Path) -> Option { original_source_from_map_file(&append_map_extension(entry)) } -/// A published CommonJS package can ship the TypeScript input to its CJS emit. -/// Some such inputs are intentionally hybrid: normal ESM declarations for -/// TypeScript plus a top-level `module.exports = ...` interop epilogue. The -/// source is not a directly executable module in Perry: ESM classification -/// leaves `module` unbound, while CJS wrapping would move its `export` -/// declarations inside an IIFE. Node loads the emitted JS entry, so keep that -/// entry instead of following its source map for this narrow shape (#6586). +/// A published CommonJS package can ship a TypeScript input that is not +/// directly executable as a Perry module, in which case Perry should keep +/// the package on its compiled JS emit instead of the raw source (matching +/// what Node actually runs) rather than following a source map / `src/` +/// convention to that source. Two known shapes trigger this, both narrow and +/// evidence-driven rather than a general "prefer JS" default: +/// +/// - **ESM-plus-CJS-epilogue hybrid** (#6586): normal ESM declarations for +/// TypeScript plus a top-level `module.exports = ...` interop epilogue. +/// ESM classification leaves `module` unbound, while CJS wrapping would +/// move its `export` declarations inside an IIFE — neither executes. +/// - **Namespace/function declaration merging via `export =`** (#10662): +/// `namespace X { export class Y extends Z {} }` merged onto a same-named +/// `function X() {}` and exported with `export = X` — the shape +/// `agent-base` (an `axios` → `https-proxy-agent` transitive dependency) +/// uses. Perry's HIR lowers the namespace's exported members as static +/// fields against a synthetic class entity that is not the SAME runtime +/// value `export =` ends up exporting, so e.g. `pkg.Agent` reads back as +/// `undefined` and a downstream `class X extends pkg.Agent` throws "Class +/// extends value is not a constructor". Node can't run this non-erasable +/// TS syntax directly either (`--experimental-strip-types` rejects +/// `namespace`/`export =`), so such a package is never executed from its +/// raw `.ts` source in practice — only via its compiled emit. fn is_hybrid_cjs_emit_input(path: &Path) -> bool { static CACHE: OnceLock>> = OnceLock::new(); @@ -789,8 +805,10 @@ fn is_hybrid_cjs_emit_input(path: &Path) -> bool { return false; }; let stripped = super::cjs_wrap::detect::strip_comments_and_strings(&source); - let hybrid = super::cjs_wrap::detect::has_top_level_esm(&stripped) - && super::cjs_wrap::detect::has_top_level_module_exports_assignment(&stripped); + let hybrid = (super::cjs_wrap::detect::has_top_level_esm(&stripped) + && super::cjs_wrap::detect::has_top_level_module_exports_assignment(&stripped)) + || (super::cjs_wrap::detect::has_top_level_namespace_or_module_block(&stripped) + && super::cjs_wrap::detect::has_top_level_export_equals(&stripped)); cache .lock() .expect("hybrid source cache") diff --git a/crates/perry/tests/issue_10662_namespace_export_equals_fallback.rs b/crates/perry/tests/issue_10662_namespace_export_equals_fallback.rs new file mode 100644 index 0000000000..72336c2540 --- /dev/null +++ b/crates/perry/tests/issue_10662_namespace_export_equals_fallback.rs @@ -0,0 +1,187 @@ +//! Regression test for #10662: `axios` throws `TypeError: Class extends +//! value is not a constructor` at module-init time because its transitive +//! dependency chain `https-proxy-agent` -> `agent-base` hits a TypeScript +//! declaration-merging shape Perry's HIR does not lower correctly. +//! +//! `agent-base`'s real source (`src/index.ts`) is: +//! +//! ```ts +//! function createAgent(opts) { return new createAgent.Agent(opts); } +//! namespace createAgent { +//! export class Agent extends EventEmitter { ... } +//! } +//! export = createAgent; +//! ``` +//! +//! `perry.compilePackages` prefers compiling a package's raw TypeScript +//! source over its published JS emit (`resolve_package_source_entry`), and +//! picks `src/index.ts` here since `agent-base` ships both. Perry's HIR +//! lowers the namespace's exported `Agent` class as a `StaticFieldSet` +//! against a synthetic class entity that is NOT the same runtime object +//! `export =` ends up exporting: `require("agent-base").Agent` reads back +//! as `undefined`, and `https-proxy-agent`'s `class HttpsProxyAgent extends +//! agent_base_1.Agent` throws. +//! +//! The fix (`is_hybrid_cjs_emit_input` in `resolve.rs`, alongside its +//! existing #6586 ESM+CJS-epilogue trigger) detects the namespace-block + +//! `export =` shape and falls back to the package's compiled JS emit +//! instead — the same file Node itself runs (raw `namespace`/`export =` +//! isn't valid under `--experimental-strip-types` either, so a package +//! built this way is never executed from its `.ts` source in practice). +//! +//! This fixture mirrors the real shape exactly enough to reproduce the bug +//! (namespace-merged-with-function class extending a native `EventEmitter`, +//! consumed by a downstream CJS `class X extends pkg.Agent`) without +//! depending on the actual npm packages. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn namespace_merged_function_export_equals_falls_back_to_js_emit() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + + std::fs::write( + root.join("package.json"), + r#"{ + "name": "issue-10662-consumer", + "private": true, + "perry": { + "compilePackages": ["agent-base-like"], + "allow": { "compilePackages": ["agent-base-like"] } + } +}"#, + ) + .expect("write consumer package.json"); + + // `agent-base`'s exact shape: a package.json "main" pointing at the + // compiled JS, PLUS a `src/index.ts` Perry would otherwise prefer. + let pkg = root.join("node_modules").join("agent-base-like"); + std::fs::create_dir_all(pkg.join("src")).expect("mkdir src"); + std::fs::create_dir_all(pkg.join("dist").join("src")).expect("mkdir dist/src"); + std::fs::write( + pkg.join("package.json"), + r#"{ "name": "agent-base-like", "version": "1.0.0", "main": "dist/src/index", "typings": "dist/src/index" }"#, + ) + .expect("write agent-base-like package.json"); + + // The raw TS source: `namespace createAgent { export class Agent + // extends EventEmitter { ... } }` merged onto `function createAgent()`, + // exported via `export =`. Perry cannot correctly lower this shape + // today (#10662) — the JS-emit fallback is what makes it work. + std::fs::write( + pkg.join("src").join("index.ts"), + r#"import { EventEmitter } from 'events'; + +function createAgent(opts?: any) { + return new createAgent.Agent(opts); +} + +namespace createAgent { + export class Agent extends EventEmitter { + public tag: string; + constructor(opts?: any) { + super(); + this.tag = "agent-tag"; + } + } +} + +export = createAgent; +"#, + ) + .expect("write agent-base-like src/index.ts"); + + // The compiled emit `tsc` would actually publish — plain CJS, no + // namespace-merge complexity, `require()`d by Node in practice. + std::fs::write( + pkg.join("dist").join("src").join("index.js"), + r#""use strict"; +const events_1 = require("events"); +function createAgent(opts) { + return new createAgent.Agent(opts); +} +(function (createAgent) { + class Agent extends events_1.EventEmitter { + constructor(opts) { + super(); + this.tag = "agent-tag"; + } + } + createAgent.Agent = Agent; +})(createAgent || (createAgent = {})); +module.exports = createAgent; +"#, + ) + .expect("write agent-base-like dist/src/index.js"); + + // The `https-proxy-agent` half: a downstream CJS file (already + // "compiled" — no namespace complexity of its own) whose class extends + // the namespace-merged package's exported member. + let entry = root.join("main.ts"); + std::fs::write( + &entry, + r#"import "./downstream.cjs"; +"#, + ) + .expect("write entry"); + std::fs::write( + root.join("downstream.cjs"), + r#"'use strict'; +const pkg = require("agent-base-like"); +if (typeof pkg !== "function") { + throw new Error("expected agent-base-like's export = value to be callable, got " + typeof pkg); +} +if (typeof pkg.Agent !== "function") { + throw new Error("expected pkg.Agent to be a constructor, got " + typeof pkg.Agent); +} +class Downstream extends pkg.Agent { + constructor() { + super(); + this.extra = "downstream"; + } +} +const d = new Downstream(); +let seen = 0; +d.on("x", () => { seen++; }); +d.emit("x"); +console.log("tag:", d.tag, "extra:", d.extra, "events:", seen); +"#, + ) + .expect("write downstream.cjs"); + + let output = root.join("main_bin"); + let compile = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed (namespace-merge JS-emit fallback regressed?)\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output).output().expect("run compiled binary"); + let stdout = String::from_utf8_lossy(&run.stdout); + assert!( + run.status.success(), + "compiled binary failed (agent-base-like's namespace-merged Agent should have resolved via the dist/ fallback)\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + stdout, + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + stdout, "tag: agent-tag extra: downstream events: 1\n", + "downstream class extending a namespace-merged native-base subclass must construct and behave correctly" + ); +} From f097d183cb104736141e762cbb54e86c446c79e1 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Fri, 18 Sep 2026 22:44:21 +0000 Subject: [PATCH 8/9] docs: changelog fragment for #10673 --- .../10673-namespace-export-equals-fallback.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 changelog.d/10673-namespace-export-equals-fallback.md diff --git a/changelog.d/10673-namespace-export-equals-fallback.md b/changelog.d/10673-namespace-export-equals-fallback.md new file mode 100644 index 0000000000..ae9f869667 --- /dev/null +++ b/changelog.d/10673-namespace-export-equals-fallback.md @@ -0,0 +1,17 @@ +Fixed `axios` (and any `perry.compilePackages` target with a similar shape) +throwing `TypeError: Class extends value is not a constructor` at +module-init time. The blocker was in the `https-proxy-agent` -> `agent-base` +dependency chain: `agent-base`'s TypeScript source merges `namespace +createAgent { export class Agent extends EventEmitter { ... } }` onto a +same-named `function createAgent()` and exports it with `export =` — +Perry's HIR doesn't correctly attach the namespace's exported members to +the same runtime value `export =` ends up exporting, so +`require("agent-base").Agent` read back as `undefined` and the downstream +`class HttpsProxyAgent extends agent_base_1.Agent` threw. Perry's +`compilePackages` module resolution now detects this TS +namespace/function-merge + `export =` shape and falls back to the +package's compiled JS emit instead of its raw `.ts` source — the same file +Node itself runs, since `--experimental-strip-types` can't execute raw +`namespace`/`export =` syntax either. Extends the existing #6586 +ESM+CJS-epilogue fallback in `is_hybrid_cjs_emit_input` with a second, +narrowly-scoped trigger; not keyed on the `agent-base` package name. From 8ea26703be6bca0bf71d840934f8c3e6aac45496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 15:43:30 +0200 Subject: [PATCH 9/9] chore: release merge train 223 as v0.5.1602 --- CLAUDE.md | 2 +- Cargo.lock | 156 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 80 insertions(+), 80 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 23493644aa..d7348143dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1601 +**Current Version:** 0.5.1602 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index c475745472..9ddd47a904 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5623,7 +5623,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "anyhow", "base64 0.22.1", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-dispatch", "serde", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "cc", "libc", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "aho-corasick", "anyhow", @@ -5721,7 +5721,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "anyhow", "perry-hir", @@ -5729,7 +5729,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "anyhow", "perry-hir", @@ -5737,7 +5737,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "anyhow", "perry-dispatch", @@ -5746,7 +5746,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "anyhow", "perry-hir", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "anyhow", "base64 0.22.1", @@ -5766,7 +5766,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "anyhow", "perry-hir", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "async-trait", "clap", @@ -5798,14 +5798,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "serde", "serde_json", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1601" +version = "0.5.1602" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5824,7 +5824,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "anyhow", "clap", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "block2", "objc2", @@ -5849,7 +5849,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "argon2", "perry-ffi", @@ -5858,7 +5858,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-ffi", "reqwest", @@ -5867,7 +5867,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "bcrypt", "perry-ffi", @@ -5875,7 +5875,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-ffi", "rusqlite", @@ -5883,7 +5883,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-ffi", "scraper", @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-ffi", "perry-runtime", @@ -5899,7 +5899,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "chrono", "cron", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "chrono", "perry-ffi", @@ -5917,7 +5917,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-ffi", "rust_decimal", @@ -5925,7 +5925,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-ffi", "serde_json", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5941,7 +5941,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-ffi", "perry-runtime", @@ -5949,14 +5949,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "bytes", "http-body-util", @@ -5973,7 +5973,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "bytes", "lazy_static", @@ -5986,7 +5986,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "base64 0.22.1", "bytes", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "lazy_static", "perry-ffi", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "lru", "perry-ffi", @@ -6037,7 +6037,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "chrono", "perry-ffi", @@ -6045,7 +6045,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "bson", "futures-util", @@ -6057,7 +6057,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "chrono", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "nanoid", "perry-ffi", @@ -6078,7 +6078,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "bytes", "perry-ffi", @@ -6093,7 +6093,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "lettre", "perry-ffi", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "notify", "perry-ffi", @@ -6134,7 +6134,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-ffi", "printpdf", @@ -6142,7 +6142,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-ffi", "sqlx", @@ -6151,7 +6151,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-ffi", "perry-runtime", @@ -6160,7 +6160,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "governor", "perry-ffi", @@ -6168,7 +6168,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "fast_image_resize", "image", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "lazy_static", "perry-ffi", @@ -6188,7 +6188,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "anyhow", "perry-ffi", @@ -6208,7 +6208,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-ffi", "perry-runtime", @@ -6217,7 +6217,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-ffi", "uuid", @@ -6225,7 +6225,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "futures-util", "lazy_static", @@ -6238,7 +6238,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "brotli", "flate2", @@ -6248,7 +6248,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6258,7 +6258,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "anyhow", "perry-api-manifest", @@ -6278,11 +6278,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1601" +version = "0.5.1602" [[package]] name = "perry-parser" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "anyhow", "perry-diagnostics", @@ -6295,7 +6295,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perex", "regex", @@ -6303,7 +6303,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "ahash", "base64 0.22.1", @@ -6361,14 +6361,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6455,21 +6455,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "dirs", "perry-ffi", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "base64 0.22.1", "jni", @@ -6494,7 +6494,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "rand 0.10.2", "serde", @@ -6504,7 +6504,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6527,7 +6527,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "base64 0.22.1", "block2", @@ -6544,7 +6544,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "base64 0.22.1", "block2", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1601" +version = "0.5.1602" [[package]] name = "perry-ui-test" @@ -6572,11 +6572,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1601" +version = "0.5.1602" [[package]] name = "perry-ui-tvos" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "base64 0.22.1", "block2", @@ -6593,7 +6593,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "base64 0.22.1", "block2", @@ -6610,7 +6610,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "block2", "libc", @@ -6624,7 +6624,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "base64 0.22.1", "libc", @@ -6643,7 +6643,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "base64 0.22.1", "libc", @@ -6656,7 +6656,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "anyhow", "base64 0.22.1", @@ -6671,7 +6671,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1601" +version = "0.5.1602" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index cf7d476b65..5bc2032d98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -335,7 +335,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1601" +version = "0.5.1602" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"