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/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" 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`. 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. 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. 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/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/class_guard_shape.rs b/crates/perry-runtime/src/object/class_guard_shape.rs new file mode 100644 index 0000000000..9cf4369ce8 --- /dev/null +++ b/crates/perry-runtime/src/object/class_guard_shape.rs @@ -0,0 +1,103 @@ +//! 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 { + // 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) }; + } + } +} + +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`. + 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`]. +#[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() { + // 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/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/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/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, 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/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" + ); +} 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"), 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);