diff --git a/CLAUDE.md b/CLAUDE.md index d5f62a6bd6..f3e800e7c7 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.1579 +**Current Version:** 0.5.1580 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 57c1d2bb44..1bf2332cbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5583,7 +5583,7 @@ checksum = "1542e48011813fbdf3c075da4a4ed53ee93c816eef62e36eb5064a6fd2be10a5" [[package]] name = "perry" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "anyhow", "base64 0.22.1", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-dispatch", "serde", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "cc", "libc", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "aho-corasick", "anyhow", @@ -5681,7 +5681,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "anyhow", "perry-hir", @@ -5689,7 +5689,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "anyhow", "perry-hir", @@ -5697,7 +5697,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "anyhow", "perry-dispatch", @@ -5706,7 +5706,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "anyhow", "perry-hir", @@ -5714,7 +5714,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "anyhow", "base64 0.22.1", @@ -5726,7 +5726,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "anyhow", "perry-hir", @@ -5734,7 +5734,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "async-trait", "clap", @@ -5758,14 +5758,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "serde", "serde_json", @@ -5773,7 +5773,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1579" +version = "0.5.1580" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5784,7 +5784,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "anyhow", "clap", @@ -5799,7 +5799,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "block2", "objc2", @@ -5809,7 +5809,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "argon2", "perry-ffi", @@ -5818,7 +5818,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-ffi", "reqwest", @@ -5827,7 +5827,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "bcrypt", "perry-ffi", @@ -5835,7 +5835,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-ffi", "rusqlite", @@ -5843,7 +5843,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-ffi", "scraper", @@ -5851,7 +5851,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-ffi", "perry-runtime", @@ -5859,7 +5859,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "chrono", "cron", @@ -5869,7 +5869,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "chrono", "perry-ffi", @@ -5877,7 +5877,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-ffi", "rust_decimal", @@ -5885,7 +5885,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-ffi", "serde_json", @@ -5893,7 +5893,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5901,7 +5901,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-ffi", "perry-runtime", @@ -5909,14 +5909,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "bytes", "http-body-util", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "bytes", "lazy_static", @@ -5946,7 +5946,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "base64 0.22.1", "bytes", @@ -5978,7 +5978,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "lazy_static", "perry-ffi", @@ -5988,7 +5988,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -5999,7 +5999,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "lru", "perry-ffi", @@ -6008,7 +6008,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "chrono", "perry-ffi", @@ -6016,7 +6016,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "bson", "futures-util", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "chrono", "perry-ffi", @@ -6040,7 +6040,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "nanoid", "perry-ffi", @@ -6049,7 +6049,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "bytes", "perry-ffi", @@ -6064,7 +6064,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "const-oid 0.10.2", "der 0.8.1", @@ -6083,7 +6083,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "lettre", "perry-ffi", @@ -6093,7 +6093,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "notify", "perry-ffi", @@ -6105,7 +6105,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-ffi", "printpdf", @@ -6113,7 +6113,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-ffi", "sqlx", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-ffi", "perry-runtime", @@ -6131,7 +6131,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "governor", "perry-ffi", @@ -6139,7 +6139,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "fast_image_resize", "image", @@ -6150,7 +6150,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "lazy_static", "perry-ffi", @@ -6159,7 +6159,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "anyhow", "perry-ffi", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-ffi", "perry-runtime", @@ -6188,7 +6188,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-ffi", "uuid", @@ -6196,7 +6196,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-ffi", "perry-validation", @@ -6205,7 +6205,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "futures-util", "lazy_static", @@ -6218,7 +6218,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "brotli", "flate2", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6238,7 +6238,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "anyhow", "perry-api-manifest", @@ -6258,11 +6258,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1579" +version = "0.5.1580" [[package]] name = "perry-parser" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "anyhow", "perry-diagnostics", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perex", "regex", @@ -6283,7 +6283,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "ahash", "base64 0.22.1", @@ -6341,14 +6341,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6437,21 +6437,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "dirs", "perry-ffi", @@ -6461,7 +6461,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "base64 0.22.1", "jni", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "rand 0.10.2", "serde", @@ -6486,7 +6486,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6509,7 +6509,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "base64 0.22.1", "block2", @@ -6526,7 +6526,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "base64 0.22.1", "block2", @@ -6543,7 +6543,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1579" +version = "0.5.1580" [[package]] name = "perry-ui-test" @@ -6554,11 +6554,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1579" +version = "0.5.1580" [[package]] name = "perry-ui-tvos" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "base64 0.22.1", "block2", @@ -6575,7 +6575,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "base64 0.22.1", "block2", @@ -6592,7 +6592,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "block2", "libc", @@ -6606,7 +6606,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "base64 0.22.1", "libc", @@ -6625,7 +6625,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "base64 0.22.1", "libc", @@ -6638,7 +6638,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "anyhow", "base64 0.22.1", @@ -6653,7 +6653,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "idna", "regex", @@ -6663,7 +6663,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1579" +version = "0.5.1580" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 72bdd474c6..77d59df778 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1579" +version = "0.5.1580" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/10352-null-typed-record-field-gc-mask.md b/changelog.d/10352-null-typed-record-field-gc-mask.md new file mode 100644 index 0000000000..68de6ce0f2 --- /dev/null +++ b/changelog.d/10352-null-typed-record-field-gc-mask.md @@ -0,0 +1,9 @@ +### Fixed + +- **A `null`-typed record field was read as a proof the collector may skip the slot, silently truncating object graphs (#10348).** A closed-shape object literal lowers to `new __AnonShape_*(…)`, and the synthesized class's field types become the class's compile-time GC masks: `js_gc_typed_shape_id_for_keys` registers them against a dedicated ShapeId and every allocation stamps `SIDE_MASK | TYPED_LAYOUT_INTACT` from the baked header image (#8405), with no per-object validation and no downgrade. `Type::Null` / `Type::Void` are the two declared types `type_is_pointer_bearing` answers `false` for — and the two a *variable* is most trivially wrong about. `var head = null` infers `Type::Null`, and the post-lowering widening pass that exists to repair exactly that both runs after the class is minted and did not cover those two types at all. So `{ id, payload, tag, next: head }` registered `ptr_mask = 0b0011` for a record whose live pointer slots are `{payload, next}`: `next` was never scanned, never marked and never rewritten, 3.7% of the graph came out truncated and cross-linked (at 100k constructions the walk reported 329,754 nodes for 320,000 ever allocated), and reading a recycled node threw `Cannot read properties of undefined` from a plain field access. Exit code 0, no warning. `PERRY_GC_VERIFY_EVACUATION=1` aborted on it with `parent_space=old_page remembered=no visitor=ObjectFields`, and `PERRY_GC_FROMSPACE_SCAN=1` named the real defect: 14,727 live marked objects each holding an unevacuated `next`. + + A record field now takes a `Null` / `Void` type only from an expression that *is* that value (a literal `null` / `undefined`); anything else contributes `Any`. `lower::type_widening` gained the matching `Null` / `Void` arm, gated on a new `non_nullish` set so a local that only ever holds `null`/`undefined` is not widened for nothing. + + No performance tradeoff, shown structurally rather than by timing: a base and a fixed compiler emit **byte-identical object files** for honest code — a linked list built from `{ value, next: null }`, numeric `{ v: i, w: i + 1 }` churn, and a mixed pointer/primitive record with arrays and strings. `{ next: null }` keeps its exact field type, its mask and its `POINTER_FREE` eligibility. + + Worth recording for the next hunt: every "clean" row in the issue's narrowing table was a **false green**. With the same wrong mask and the post-allocation store removed, the reproducer prints the correct node count while the from-space scan still reports 15,123 dangling references — the dropped children had not been recycled into anything visible yet. The regression test therefore asserts the collector's own whole-heap invariant (`PERRY_GC_FROMSPACE_SCAN_ABORT=1`), not the printed answer. diff --git a/changelog.d/10358-unimported-export-shadows-global.md b/changelog.d/10358-unimported-export-shadows-global.md new file mode 100644 index 0000000000..b12e394c67 --- /dev/null +++ b/changelog.d/10358-unimported-export-shadows-global.md @@ -0,0 +1,10 @@ +An un-imported export no longer shadows a global intrinsic. + +A module that exports a name matching a global intrinsic made that name resolve +to the export at every call site in the program — even in modules that never +imported it. The global was shadowed by a binding the consuming module could not +see, so calls that should have reached the intrinsic reached the module's export +instead. + +Resolution now requires the importing module to actually name the binding before +an export can win over the intrinsic. diff --git a/crates/perry-hir/src/analysis.rs b/crates/perry-hir/src/analysis.rs index 49a6ffa300..b9fba65cee 100644 --- a/crates/perry-hir/src/analysis.rs +++ b/crates/perry-hir/src/analysis.rs @@ -9,11 +9,22 @@ use crate::ir::*; use crate::walker::{walk_expr_children, walk_expr_children_mut}; mod builtins; +pub(crate) use builtins::is_builtin_global_value_name; pub(crate) use builtins::{ builtin_constructor_length, builtin_global_function_length, builtin_static_function_length, - is_builtin_function, is_builtin_global_value_name, is_builtin_static_function_member, + is_builtin_function, is_builtin_static_function_member, }; +/// Whether `name` is one of the global constructors / namespaces the runtime +/// installs on `globalThis` (`Request`, `Response`, `Headers`, `URL`, `Map`, +/// …). Public so the compile pipeline can tell a global apart from a +/// user-defined class of the same name — see #10356: implicitly registering +/// an *un-imported* exported class under a global's name shadows the global +/// for every `new` in the importing module. +pub fn is_global_intrinsic_value_name(name: &str) -> bool { + is_builtin_global_value_name(name) +} + mod uses_this; pub(crate) use uses_this::{closure_uses_new_target, closure_uses_this, uses_this_stmt}; diff --git a/crates/perry-hir/src/lower/expr_object.rs b/crates/perry-hir/src/lower/expr_object.rs index e57b9b89a5..e4349fb468 100644 --- a/crates/perry-hir/src/lower/expr_object.rs +++ b/crates/perry-hir/src/lower/expr_object.rs @@ -644,6 +644,38 @@ fn accessor_key_expr(key: MethodKeyKind) -> Expr { } } +/// The type a closed-shape literal's property contributes to the synthesized +/// `__AnonShape_*` record — which is NOT merely a hint (#10348). +/// +/// Codegen turns these field types into the class's compile-time GC masks +/// (`typed_shape::typed_layout_from_fields`), `js_gc_typed_shape_id_for_keys` +/// registers them against a dedicated ShapeId, and every allocation of the +/// record then stamps `SIDE_MASK | TYPED_LAYOUT_INTACT` from the baked header +/// image (#8405) — with no per-object validation and no downgrade. A field the +/// pointer mask omits is a field the collector never scans: its child is +/// neither marked nor rewritten. +/// +/// `Type::Null` / `Type::Void` are the two types that say "this slot can never +/// hold a heap pointer" while being trivially wrong about a *variable*. Perry +/// infers `var head = null` as `Type::Null` and the later `head = { … }` +/// repairs it only in the post-lowering widening pass +/// (`lower::type_widening`), which runs long after this class is minted. So +/// `{ next: head }` recorded `next: Null`, the mask dropped that slot, and the +/// chain it pointed at was collected underneath a live object — #10348's +/// silently truncated and cross-linked graphs. +/// +/// The claim is therefore taken only from an expression that *is* the value: a +/// literal `null` / `undefined`. Anything else contributes `Any`, which is what +/// the property's provable type is. A literal keeps its exact type, so the +/// ubiquitous `{ next: null }` record is unchanged — both its mask and its +/// `POINTER_FREE` eligibility — and nothing that was already true gets slower. +fn record_field_type(ty: &Type, value: &Expr) -> Type { + if matches!(ty, Type::Null | Type::Void) && !matches!(value, Expr::Null | Expr::Undefined) { + return Type::Any; + } + ty.clone() +} + pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> Result { // A directly exported object is the producer boundary for #8775. Consume // the marker here so nested literals continue through their ordinary @@ -913,7 +945,7 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R if !bail { let field_shapes: Vec<(String, Type)> = fields .iter() - .map(|(name, ty, _)| (name.clone(), ty.clone())) + .map(|(name, ty, value)| (name.clone(), record_field_type(ty, value))) .collect(); let class_name = ctx.synthesize_anon_shape_class(&field_shapes); let args: Vec = fields.into_iter().map(|(_, _, value)| value).collect(); diff --git a/crates/perry-hir/src/lower/type_widening.rs b/crates/perry-hir/src/lower/type_widening.rs index 2bfa986419..e423b3d96c 100644 --- a/crates/perry-hir/src/lower/type_widening.rs +++ b/crates/perry-hir/src/lower/type_widening.rs @@ -14,6 +14,13 @@ //! Deliberately conservative: only RHS shapes that are statically known to //! be non-numeric trigger widening, so number-typed fast paths for actual //! numeric code are untouched (zero-regression requirement). +//! +//! #10348 added the `Type::Null` / `Type::Void` arm, where a stale declared +//! type is not merely slow but unsound: those two are the declared types that +//! `typed_shape::type_is_pointer_bearing` answers `false` for, and that answer +//! becomes a class's compile-time GC pointer mask. A slot the mask omits is a +//! slot the collector never scans, so `var head = null; head = { … }` left a +//! live object's child unmarked and unrewritten. use crate::analysis::{infer_expr_type, HirTypeEnv}; use crate::ir::*; @@ -60,6 +67,37 @@ struct WidenSets { non_number_primitive: HashSet, /// Assigned a certainly non-array object -> revoke Array/Tuple intrinsics. non_array_object: HashSet, + /// Assigned a value that is certainly NOT `null` / `undefined` → widen a + /// `null`/`undefined`-declared type. Kept separate from `object_like` + /// because that set is deliberately entered by `null` and `undefined` too + /// ([`type_is_certainly_object_like`] counts them), which is exactly the + /// assignment that must NOT widen here. + non_nullish: HashSet, +} + +/// Is this RHS certainly `null` / `undefined`? +/// +/// The narrow side of the test on purpose: everything it cannot prove nullish +/// widens a `null`/`undefined`-declared local, because that declared type is +/// read as a GC fact and not just a codegen hint — see the `Type::Null` arm of +/// [`widen_lets_stmt`]. +/// +/// The structural arms are [`rhs_certainly_object_like`]'s own list, answered +/// the other way round, so this costs no extra `infer_expr_type` on any RHS +/// that function already decided by shape. +fn rhs_certainly_nullish(expr: &Expr, env: &HirTypeEnv) -> bool { + match expr { + Expr::Null | Expr::Undefined => true, + Expr::This + | Expr::Object(_) + | Expr::ObjectSpread { .. } + | Expr::ObjectAssign { .. } + | Expr::Array(_) + | Expr::ArraySpread(_) + | Expr::Closure { .. } + | Expr::New { .. } => false, + _ => matches!(infer_expr_type(expr, env), Type::Null | Type::Void), + } } fn rhs_certainly_non_array_object(expr: &Expr) -> bool { @@ -82,8 +120,12 @@ fn visit_expr(expr: &Expr, out: &mut WidenSets, env: &HirTypeEnv) { } if rhs_certainly_object_like(rhs, env) { out.object_like.insert(*id); + if !rhs_certainly_nullish(rhs, env) { + out.non_nullish.insert(*id); + } } else if rhs_certainly_non_number_primitive(rhs, env) { out.non_number_primitive.insert(*id); + out.non_nullish.insert(*id); } } if let Expr::Closure { params, body, .. } = expr { @@ -193,6 +235,17 @@ fn widen_lets_stmt(stmt: &mut Stmt, sets: &WidenSets) { } Type::String | Type::Boolean => sets.object_like.contains(id), Type::Array(_) | Type::Tuple(_) => sets.non_array_object.contains(id), + // #10348: `var head = null` infers `Type::Null`, and this arm + // used to be `_ => false` — so no later assignment could ever + // repair it. Of every declared type this pass covers these two + // are the ones whose lie is not merely slow: `Null` and `Void` + // are not pointer-bearing + // (`typed_shape::type_is_pointer_bearing`), so a slot typed + // from such a local is left OUT of a class's compile-time GC + // pointer mask and the collector never scans it. The `head` + // holding a `{ … }` was exactly that, and its `next` chain was + // collected out from under a live object. + Type::Null | Type::Void => sets.non_nullish.contains(id), _ => false, }; if widen { @@ -298,6 +351,7 @@ impl TypeWidening { if self.sets.object_like.is_empty() && self.sets.non_number_primitive.is_empty() && self.sets.non_array_object.is_empty() + && self.sets.non_nullish.is_empty() { return; } @@ -540,6 +594,90 @@ mod tests { assert_eq!(let_ty(&module.init, 1), &Type::Any); } + /// #10348: `var head = null; for (…) head = new C();` — the declared + /// `Type::Null` is what `typed_shape::type_is_pointer_bearing` reads as + /// "this slot can never hold a heap pointer", so leaving it stale takes a + /// live child out of the collector's reach. + #[test] + fn widens_null_declared_local_assigned_object() { + let mut module = Module::new("type-widening-test"); + module.init = vec![ + Stmt::Let { + id: 1, + name: "head".to_string(), + ty: Type::Null, + mutable: true, + init: Some(Expr::Null), + }, + Stmt::Expr(Expr::LocalSet( + 1, + Box::new(Expr::New { + class_name: "__AnonShape_test".to_string(), + args: vec![], + type_args: vec![], + byte_offset: 0, + cap_args_appended: 0, + }), + )), + ]; + + let mut widening = TypeWidening::from_module(&module); + widening.collect(&module.init); + widening.apply(&mut module.init); + + assert_eq!(let_ty(&module.init, 1), &Type::Any); + } + + /// A string is object-unlike but still a heap pointer, so it has to widen a + /// `null`-declared local for the same reason an object does. + #[test] + fn widens_null_declared_local_assigned_string() { + let mut module = Module::new("type-widening-test"); + module.init = vec![ + Stmt::Let { + id: 1, + name: "tag".to_string(), + ty: Type::Null, + mutable: true, + init: Some(Expr::Null), + }, + Stmt::Expr(Expr::LocalSet(1, Box::new(Expr::String("x".to_string())))), + ]; + + let mut widening = TypeWidening::from_module(&module); + widening.collect(&module.init); + widening.apply(&mut module.init); + + assert_eq!(let_ty(&module.init, 1), &Type::Any); + } + + /// The other half of the contract: a local that is only ever re-assigned + /// `null` / `undefined` keeps its exact declared type. `object_like` is + /// entered by those assignments (`type_is_certainly_object_like` counts + /// `Null`/`Void`), so widening off that set alone would demote every + /// nullable local in the program to `Any`. + #[test] + fn preserves_null_declared_local_assigned_only_nullish() { + let mut module = Module::new("type-widening-test"); + module.init = vec![ + Stmt::Let { + id: 1, + name: "head".to_string(), + ty: Type::Null, + mutable: true, + init: Some(Expr::Null), + }, + Stmt::Expr(Expr::LocalSet(1, Box::new(Expr::Null))), + Stmt::Expr(Expr::LocalSet(1, Box::new(Expr::Undefined))), + ]; + + let mut widening = TypeWidening::from_module(&module); + widening.collect(&module.init); + widening.apply(&mut module.init); + + assert_eq!(let_ty(&module.init, 1), &Type::Null); + } + #[test] fn preserves_numeric_local_assigned_never_expression() { let mut module = Module::new("type-widening-test"); diff --git a/crates/perry-hir/tests/anon_shape_field_types.rs b/crates/perry-hir/tests/anon_shape_field_types.rs index e25a3c30bf..170d652d2b 100644 --- a/crates/perry-hir/tests/anon_shape_field_types.rs +++ b/crates/perry-hir/tests/anon_shape_field_types.rs @@ -222,3 +222,67 @@ fn inner_shadow_does_not_inherit_the_counter_type() { ); assert_eq!(tys(&m, "r"), vec![Type::Any]); } + +/// #10348 — the case where a minted field type is not merely imprecise but +/// **unsound**, and silently corrupts the heap. +/// +/// `var head = null` infers `Type::Null`, and `Type::Null` is one of the two +/// types `typed_shape::type_is_pointer_bearing` answers `false` for. Codegen +/// turns the minted field types into the class's compile-time GC pointer mask, +/// `js_gc_typed_shape_id_for_keys` registers it against a dedicated ShapeId, +/// and every allocation stamps `SIDE_MASK | TYPED_LAYOUT_INTACT` from the baked +/// header image (#8405) — no per-object validation, no downgrade. So +/// `{ next: head }` declared `next` unscannable while the loop stored an object +/// there: the chain was neither marked nor rewritten, and the graph came out +/// truncated and cross-linked with exit code 0. +/// +/// Unlike the `Number` fields above there is no self-healing store guard behind +/// this one, so the mint has to be right the first time. +#[test] +fn null_typed_local_value_mints_any_field() { + let m = lower_src( + r#" + function makeChain(seed) { + var head = null; + for (var i = 0; i < 8; i++) { + head = { id: seed + i, tag: null, next: head }; + } + return head; + } + console.log(makeChain(1).id); + "#, + ); + assert_eq!( + tys(&m, "next"), + vec![Type::Any, Type::Null, Type::Any], + "`next: head` is a LOCAL typed `Null` by its initializer and reassigned \ + an object one line later, so the property must mint `Any` — a `Null` \ + field leaves the slot out of the class's GC pointer mask and the \ + collector never scans it (#10348). `tag: null` is the literal itself \ + and keeps its exact type." + ); +} + +/// The other half, and the reason this is keyed on the expression rather than +/// on the type alone: a literal `null` IS its value, so the ubiquitous +/// `{ next: null }` record keeps its exact field type — same mask, same +/// `POINTER_FREE` eligibility, no new scanning work. (A literal `undefined` +/// already minted `Any` before #10348 and still does; it is pinned here so the +/// two spellings can never silently swap places.) +#[test] +fn literal_nullish_property_keeps_its_exact_field_type() { + let m = lower_src( + r#" + for (let i = 0; i < 10; i++) { + const o = { head: null, tail: undefined, n: i }; + console.log(o.head, o.tail, o.n); + } + "#, + ); + assert_eq!( + tys(&m, "head"), + vec![Type::Null, Type::Any, Type::Number], + "a literal `null` proves its own slot holds no pointer; widening it \ + would put the slot into the pointer mask for nothing" + ); +} diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index ebc7fd963d..16e2fb4457 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -4806,6 +4806,21 @@ pub fn run_with_parse_cache( if !class.is_exported { continue; } + // #10356: this loop registers classes the importing module + // never named in its specifier list. The comment above + // argues that is safe because a same-named LOCAL class + // wins in `compile_module` — true, but a global intrinsic + // is not a local class, so nothing outranks the implicit + // entry and `new Request(...)` in the importer builds the + // exporter's `class Request` instead of the global one. + // (A generated SDK exporting `Request`/`Response`/`Headers` + // is common: hey-api, openapi-typescript, oazapfts.) + // An EXPLICIT `import { Request } from "./mod.js"` is + // pushed by the specifier-driven sites above and already + // wins the name dedup below, so it is unaffected. + if perry_hir::analysis::is_global_intrinsic_value_name(&class.name) { + continue; + } // Dedup across multiple import statements: the same class // may be transitively reachable from several imports, and // the same-class-twice case would produce duplicate diff --git a/crates/perry/tests/gc_record_null_typed_field_10348.rs b/crates/perry/tests/gc_record_null_typed_field_10348.rs new file mode 100644 index 0000000000..8be6ebbf9c --- /dev/null +++ b/crates/perry/tests/gc_record_null_typed_field_10348.rs @@ -0,0 +1,115 @@ +//! #10348 — a closed-shape record field minted from a `null`-typed local is +//! left OUT of the class's compile-time GC pointer mask, so the collector +//! never scans that slot. +//! +//! `var head = null` infers `Type::Null`; the loop below then stores an object +//! into it. `typed_shape::type_is_pointer_bearing` answers `false` for +//! `Type::Null`, so the `next` slot of the synthesized `__AnonShape_*` record +//! was excluded from the mask that `js_gc_typed_shape_id_for_keys` registers +//! (#8405). Every allocation stamps `SIDE_MASK | TYPED_LAYOUT_INTACT` from the +//! baked header image, which means no per-object validation and no downgrade: +//! the chain hanging off `next` was neither marked nor rewritten, and the +//! program printed a truncated, cross-linked graph with exit code 0. +//! +//! **The check is `PERRY_GC_FROMSPACE_SCAN_ABORT`, not output parity.** That +//! scan walks every payload word of the whole heap and ignores layout state +//! entirely, so it sees the defect at the moment the collector drops the edge. +//! Output parity does not: with the same wrong mask and the `c.tag = …` store +//! removed, the 40 000-chain reproducer from the issue prints the *correct* +//! node count while the scan still reports 15 123 dangling references — the +//! dropped children simply had not been recycled into anything visible yet. A +//! test written against the printed answer would therefore have passed on a +//! heap that was already corrupt, so the assertion has to be the collector's +//! own whole-heap invariant. Parity with node is asserted too, as the symptom +//! the issue was filed for. +//! +//! Scale: 2 000 retained chains / 20 000 constructions is ~25 ms and aborts on +//! the first offender before the fix. The issue's 40 000-chain shape is what it +//! takes to reach OLD-PAGE evacuation and make `PERRY_GC_VERIFY_EVACUATION` +//! fire; that knob is silent at this size, which is exactly why the from-space +//! scan is the one armed here. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +/// A ring of retained 8-node chains, each node carrying a heap-pointer field +/// allocated with it (`payload`) and a later heap-pointer store (`tag`). +const RETAINED_CHAIN_RING: &str = r#" +var CHAINS = 2000, CHAIN_LEN = 8, TOTAL = 20000; + +function makeChain(seed) { + var head = null; + for (var i = 0; i < CHAIN_LEN; i++) { + head = { id: seed + i, payload: [seed, i], tag: null, next: head }; + } + return head; +} + +var ring = new Array(CHAINS); +for (var i = 0; i < CHAINS; i++) ring[i] = null; + +for (var n = 0; n < TOTAL; n++) { + var c = makeChain(n); + c.tag = "n" + (n % 64); + ring[n % CHAINS] = c; +} + +var nodes = 0, truncated = 0; +for (var i = 0; i < CHAINS; i++) { + var cur = ring[i], d = 0; + while (cur !== null) { nodes++; cur = cur.next; d++; if (d > 50) break; } + if (d !== CHAIN_LEN) truncated++; +} +console.log("nodes=" + nodes + " expected=" + (CHAINS * CHAIN_LEN) + " truncated=" + truncated); +"#; + +#[test] +fn retained_chain_ring_keeps_every_next_edge_scannable() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, RETAINED_CHAIN_RING).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + // The layout-blind whole-heap scan: aborts on the FIRST surviving word that + // points into from-space, whatever the object's declared layout claims. + let run = Command::new(&output) + .current_dir(dir.path()) + .env("PERRY_GC_FROMSPACE_SCAN_ABORT", "1") + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "the from-space scan found a dropped edge (exit {:?}) — a record field \ + typed from a `null`-initialized local was left out of the class's GC \ + pointer mask (#10348)\nstdout:\n{}\nstderr:\n{}", + run.status.code(), + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + + let stdout = String::from_utf8_lossy(&run.stdout); + assert!( + stdout.contains("nodes=16000 expected=16000 truncated=0"), + "every chain must still be intact and whole; got: {stdout}" + ); +} diff --git a/crates/perry/tests/issue_10356_unimported_export_shadows_global.rs b/crates/perry/tests/issue_10356_unimported_export_shadows_global.rs new file mode 100644 index 0000000000..6764572926 --- /dev/null +++ b/crates/perry/tests/issue_10356_unimported_export_shadows_global.rs @@ -0,0 +1,173 @@ +//! Regression test for #10356: importing ONE name from a module also bound +//! that module's OTHER exported classes in the importer, so a user-defined +//! `class Request` shadowed the global fetch `Request`. +//! +//! `run_pipeline.rs` registers every exported class of every module an +//! importer touches, deliberately, "even when the class name wasn't in the +//! specifier list" — the stated safety argument being that a same-named LOCAL +//! class wins in `compile_module`. That argument holds for local classes, but +//! a global intrinsic is not a local class, so nothing outranked the implicit +//! entry: `new Request(url, init)` in the importer built the *exporter's* +//! class and `.headers` came back `undefined`. +//! +//! This was OpenCode's TUI bootstrap wall. `packages/sdk/js/src/v2/client.ts` +//! imports only `OpencodeClient` from `gen/sdk.gen.ts`, which also happens to +//! export `class Request extends HeyApiClient`; `rewrite()`'s +//! `new Request(url, request).headers.delete(...)` then threw +//! "Cannot read properties of undefined (reading 'delete')". Generated SDKs +//! exporting `Request`/`Response`/`Headers` are common (hey-api, +//! openapi-typescript, oazapfts). +//! +//! Per ESM a named import binds exactly the names it lists, so `Request` in +//! the importer is the global. bun, node and tsc all agree. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn runtime_dir() -> PathBuf { + std::env::var_os("PERRY_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| { + perry_bin() + .parent() + .expect("compiler directory") + .to_path_buf() + }) +} + +/// Mirrors `packages/sdk/js/src/v2/gen/sdk.gen.ts`: a module that exports a +/// class named `Request` alongside the one class the importer actually wants. +const MOD_SOURCE: &str = r#" +export class HeyApiClient { + client: any + constructor(config: any) { this.client = config?.client ?? null } +} +export class Request extends HeyApiClient { + readonly kind = "user-sdk-request" +} +export class Response { + readonly kind = "user-sdk-response" +} +export class OpencodeClient { + readonly name = "OpencodeClient" +} +"#; + +/// A second module whose `Request` IS explicitly imported — the control. The +/// fix must not disturb a real named import of a global-shadowing class. +const EXPLICIT_SOURCE: &str = r#" +export class Request { + readonly kind = "explicitly-imported" + constructor(_a?: any, _b?: any) {} +} +"#; + +/// `main.ts` imports only `OpencodeClient`, so every `Request` below is the +/// global one. `shadow.ts` is where an explicit import is exercised. +const MAIN_SOURCE: &str = r#" +import { OpencodeClient } from "./mod.js" +import { explicitKind } from "./shadow.js" + +const c = new OpencodeClient() +console.log("1 import-works:", c.name) + +const url = new URL("http://example.com/x?a=1") +const base = new Request(url, { method: "GET" }) +console.log("2 base.method:", base.method) +console.log("3 base.url:", base.url) +console.log("4 typeof base.headers:", typeof base.headers) + +// The exact OpenCode shape: re-wrap an existing Request, then touch .headers. +const next = new Request(url, base) +console.log("5 next.method:", next.method) +console.log("6 typeof next.headers:", typeof next.headers) +try { + next.headers.delete("x-opencode-directory") + console.log("7 headers.delete:", "ok") +} catch (e: any) { + console.log("7 headers.delete:", "THREW " + e.message) +} +// The leak is visible as a field from a class that was never imported. +console.log("8 kind-leak:", (base as any).kind) + +// `Response` is exported by mod.ts too and is likewise never imported. +console.log("9 response-status:", new Response("hi", { status: 201 }).status) +console.log("10 response-leak:", (new Response("hi") as any).kind) + +// Control: an EXPLICITLY imported class of the same name must still win. +console.log("11 explicit-import:", explicitKind) +"#; + +/// The explicit-import control lives in its own module so `main.ts` keeps the +/// global binding under test unpolluted. +const SHADOW_SOURCE: &str = r#" +import { Request } from "./explicit.js" +export const explicitKind = new Request("http://example.com/", {}).kind +"#; + +/// Byte-for-byte what bun 1.3.14 prints. +const EXPECTED: &str = "\ +1 import-works: OpencodeClient +2 base.method: GET +3 base.url: http://example.com/x?a=1 +4 typeof base.headers: object +5 next.method: GET +6 typeof next.headers: object +7 headers.delete: ok +8 kind-leak: undefined +9 response-status: 201 +10 response-leak: undefined +11 explicit-import: explicitly-imported +"; + +#[test] +fn unimported_export_does_not_shadow_a_global_intrinsic() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::write(root.join("mod.ts"), MOD_SOURCE).unwrap(); + std::fs::write(root.join("explicit.ts"), EXPLICIT_SOURCE).unwrap(); + std::fs::write(root.join("shadow.ts"), SHADOW_SOURCE).unwrap(); + std::fs::write(root.join("main.ts"), MAIN_SOURCE).unwrap(); + + let output = root.join("main_bin"); + let out = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg(root.join("main.ts")) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", runtime_dir()) + .output() + .expect("run perry compile"); + assert!( + out.status.success(), + "shadowing probe must compile; stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + + let run = Command::new(&output).output().expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary must run; stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + let stdout = String::from_utf8(run.stdout).expect("UTF-8 stdout"); + assert!( + !stdout.contains("user-sdk-"), + "no field of an un-imported class may appear on a global-intrinsic \ + instance; stdout:\n{stdout}" + ); + assert_eq!( + stdout, EXPECTED, + "a named import binds exactly the names it lists — the module's other \ + exports must not shadow globals in the importer" + ); +}