From 7d509e31dbae96506d04a706482c88cdc21fb677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 06:08:31 +0200 Subject: [PATCH] fix: complete Node TLS parity and finish the class-semantics follow-up Lands #8663 and #8645, both rebased by their authors onto current main. #8663 (TLS): completes Node TLS parity. The blocker I raised earlier is resolved -- the author dropped the `typed_feedback.rs` change outright, so `typed_feedback_array_set_guards_reject_frozen_arrays` no longer aborts the runtime suite with an escaping TypeError. Verified passing on the merged tree, not just on the branch. #8645 (class semantics): follow-up to #8643, completing per-evaluation class semantics for static accessors, captured mutable state, derived construction, prototype identity and private methods, and remapping SWC class-syntax normalization spans back to original source. Carries `skip-changelog`. Adds a direct `swc_ecma_visit` dependency to perry-parser for the already-locked visitor crate -- no workspace version bump. Two mechanical fixes were needed on top: - `pending_tls_aborts()`'s `ABORTS: OnceLock>>` is a new rule-V GC root holder. Recorded as `not_a_gc_pointer`: each i64 is a socket handle -- the key into `crate::statics::sockets()` and the payload of `PendingNetEvent::AbortError`/`Close` -- an index into the handle side table, stable across collection, never a heap address. - #8645 pushed `codegen/helpers.rs` to 2013 lines, past the file-size gate. Split the static class-field / static-block initialization group into `codegen/static_fields.rs`; `helpers.rs` re-exports both entry points so existing paths still resolve. 2013 -> 1507. --- Cargo.lock | 3 + Cargo.toml | 1 + changelog.d/8663-node-tls-parity.md | 6 + .../perry-api-manifest/src/entries/part_1.rs | 8 + crates/perry-codegen/src/codegen/helpers.rs | 499 +------ crates/perry-codegen/src/codegen/method.rs | 11 +- crates/perry-codegen/src/codegen/mod.rs | 1 + .../src/codegen/static_fields.rs | 519 +++++++ crates/perry-codegen/src/expr/binary.rs | 16 +- crates/perry-codegen/src/expr/compare.rs | 75 +- .../perry-codegen/src/expr/i32_fast_path.rs | 105 +- crates/perry-codegen/src/expr/mod.rs | 17 +- .../src/expr/property_get/globalget.rs | 8 + crates/perry-codegen/src/expr/slot_rep.rs | 8 +- .../src/expr/static_field_meta.rs | 139 +- .../perry-codegen/src/expr/this_super_call.rs | 21 +- .../src/lower_call/field_init.rs | 22 + .../src/lower_call/native/mod.rs | 54 + .../lower_call/native_module_rooting_tests.rs | 39 +- .../node_core/module_sea_tls_test.rs | 9 + .../src/lower_call/native_table/tls_events.rs | 69 +- crates/perry-codegen/src/lower_call/new.rs | 1 + crates/perry-codegen/src/stmt/let_stmt.rs | 8 +- .../tests/native_proof_regressions.rs | 77 ++ crates/perry-codegen/tests/typed_feedback.rs | 10 +- crates/perry-ext-net/Cargo.toml | 1 + crates/perry-ext-net/src/jsvalue.rs | 34 +- crates/perry-ext-net/src/lib.rs | 138 +- crates/perry-ext-net/src/tls.rs | 741 +++++++++- .../src/analysis/value_types_tests.rs | 1 + crates/perry-hir/src/ir/decl.rs | 3 + crates/perry-hir/src/ir/expr.rs | 13 + crates/perry-hir/src/ir/mod.rs | 3 +- crates/perry-hir/src/lower/expr_assign.rs | 20 +- crates/perry-hir/src/lower/expr_member.rs | 12 +- crates/perry-hir/src/lower/expr_misc.rs | 8 +- crates/perry-hir/src/lower/expr_new.rs | 6 +- crates/perry-hir/src/lower/fn_ctor_env.rs | 33 + .../src/lower/lower_expr/arm_class.rs | 113 +- crates/perry-hir/src/lower/module_decl.rs | 93 +- .../src/lower/shared_mutable_capture.rs | 158 ++- crates/perry-hir/src/lower/stmt.rs | 19 +- crates/perry-hir/src/lower/tests.rs | 135 ++ crates/perry-hir/src/lower_decl/body_stmt.rs | 59 +- .../src/lower_decl/class_computed.rs | 105 +- crates/perry-hir/src/lower_decl/class_decl.rs | 63 +- crates/perry-hir/src/lower_decl/mod.rs | 8 +- .../perry-hir/src/lower_decl/static_init.rs | 78 +- crates/perry-hir/src/monomorph/specialize.rs | 1 + crates/perry-hir/src/stable_hash/decls.rs | 1 + crates/perry-hir/src/stable_hash/expr.rs | 2 +- crates/perry-parser/Cargo.toml | 1 + crates/perry-parser/src/lib.rs | 271 +++- crates/perry-runtime/Cargo.toml | 5 + crates/perry-runtime/src/array/sort.rs | 2 +- crates/perry-runtime/src/array/subclass.rs | 2 +- crates/perry-runtime/src/closure/mod.rs | 2 +- crates/perry-runtime/src/exception.rs | 13 + crates/perry-runtime/src/gc/mod.rs | 2 + .../src/gc/tests/copying_side_tables.rs | 8 + .../src/node_stream_constructors/builders.rs | 4 +- .../perry-runtime/src/node_submodules/mod.rs | 2 +- .../perry-runtime/src/node_submodules/test.rs | 3 +- .../src/node_submodules/test_runner.rs | 9 +- .../src/object/class_constructors.rs | 74 +- .../perry-runtime/src/object/class_handles.rs | 19 + .../src/object/class_registry.rs | 41 +- .../src/object/class_registry/construct.rs | 106 +- .../class_registry/construct/class_object.rs | 19 + .../class_registry/construct/class_return.rs | 5 + .../construct/promise_subclass.rs | 52 + .../src/object/class_registry/gc_roots.rs | 53 + .../object/class_registry/parent_static.rs | 18 +- .../parent_static/private_and_dynamic.rs | 143 +- .../class_registry/prototype_objects.rs | 14 +- .../perry-runtime/src/object/descriptors.rs | 53 +- .../perry-runtime/src/object/field_get_set.rs | 4 +- .../field_get_set/class_object_props.rs | 52 +- .../src/object/field_get_set/enumeration.rs | 11 +- .../object/field_get_set/get_field_by_name.rs | 19 +- .../ic_miss/private_member_access.rs | 24 +- .../src/object/field_set_by_name.rs | 11 +- .../object/field_set_by_name/fast_paths.rs | 6 + .../src/object/field_set_by_name/tail.rs | 25 + .../object/field_set_by_name/write_helpers.rs | 4 + .../src/object/global_this/fetch_globals.rs | 28 +- crates/perry-runtime/src/object/instanceof.rs | 39 +- crates/perry-runtime/src/object/mod.rs | 1 + .../src/object/native_call_method.rs | 48 +- .../native_call_method/string_methods.rs | 2 +- .../perry-runtime/src/object/native_module.rs | 46 +- .../callable_export_arity_table.rs | 3 +- .../native_module/callable_export_check.rs | 1 + .../native_module/callable_export_table.rs | 1 + .../object/native_module/callable_exports.rs | 108 +- .../native_module/class_method_values.rs | 40 +- .../object/native_module/class_ref_values.rs | 2 +- .../src/object/native_module/module_keys.rs | 1 + .../native_module_dispatch/dispatch_q_u.rs | 3 + .../src/object/object_ops/define_property.rs | 51 +- .../object/object_ops/descriptor_helpers.rs | 11 +- .../src/object/polymorphic_index.rs | 12 +- .../perry-runtime/src/object/property_key.rs | 32 +- .../src/object/prototype_chain.rs | 6 +- crates/perry-runtime/src/promise/subclass.rs | 45 +- crates/perry-runtime/src/proxy.rs | 6 +- crates/perry-runtime/src/proxy/put_value.rs | 3 + crates/perry-runtime/src/tls.rs | 968 +++++++++++-- crates/perry-runtime/src/value/dyn_index.rs | 4 +- crates/perry-runtime/src/value/equality.rs | 12 +- crates/perry-runtime/src/value/tests.rs | 11 + crates/perry-runtime/src/weakref/subclass.rs | 5 +- crates/perry-stdlib/Cargo.toml | 1 + .../perry-stdlib/src/common/dispatch/init.rs | 27 + crates/perry-stdlib/src/crypto/util.rs | 26 +- crates/perry-stdlib/src/crypto/x509.rs | 94 +- crates/perry-stdlib/src/net/mod.rs | 739 +++++++++- crates/perry-stdlib/src/net/tls_verifier.rs | 98 ++ crates/perry-stdlib/src/tls.rs | 1208 ++++++++++++++--- .../perry-stdlib/src/tls/client_verifier.rs | 76 ++ crates/perry-stdlib/src/tls/dispatch.rs | 280 +++- crates/perry-stdlib/src/tls/module_api.rs | 229 +--- crates/perry-stdlib/src/tls/secure_context.rs | 227 ---- crates/perry-stdlib/src/tls/socket_api.rs | 385 ++++++ .../perry-transform/src/async_to_generator.rs | 1 + .../commands/compile/optimized_libs/driver.rs | 13 + ...ue_5579_indirect_eval_global_completion.rs | 21 + docs/src/api/reference.md | 8 +- scripts/addr_class_ratchet_baseline.txt | 37 +- scripts/gc_runtime_root_holders.json | 6 + scripts/raw_handle_debt_baseline.txt | 2 +- scripts/raw_handle_debt_files.txt | 6 +- ...test_issue_5893_private_brand_freshness.ts | 281 ++++ 133 files changed, 7734 insertions(+), 2171 deletions(-) create mode 100644 changelog.d/8663-node-tls-parity.md create mode 100644 crates/perry-codegen/src/codegen/static_fields.rs create mode 100644 crates/perry-runtime/src/object/class_registry/construct/class_object.rs create mode 100644 crates/perry-runtime/src/object/class_registry/construct/promise_subclass.rs create mode 100644 crates/perry-stdlib/src/net/tls_verifier.rs create mode 100644 crates/perry-stdlib/src/tls/client_verifier.rs delete mode 100644 crates/perry-stdlib/src/tls/secure_context.rs create mode 100644 crates/perry-stdlib/src/tls/socket_api.rs diff --git a/Cargo.lock b/Cargo.lock index c1f2dc8119..893575ebc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6099,6 +6099,7 @@ dependencies = [ "perry-runtime", "rustls", "rustls-native-certs", + "rustls-pemfile", "serde_json", "tokio", "tokio-rustls", @@ -6291,6 +6292,7 @@ dependencies = [ "swc_common", "swc_ecma_ast", "swc_ecma_parser 32.0.0", + "swc_ecma_visit", "thiserror 1.0.69", ] @@ -6335,6 +6337,7 @@ dependencies = [ "unicode-segmentation", "url", "windows-sys 0.61.2", + "x509-cert", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0826ae1c39..655dd0fc4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -343,6 +343,7 @@ not_unsafe_ptr_arg_deref = "allow" # SWC for TypeScript parsing swc_ecma_parser = "32.0" swc_ecma_ast = "19.0" +swc_ecma_visit = "19.0" swc_common = "18.0" swc_ecma_codegen = "21.0" swc_ecma_transforms_base = "32.0" diff --git a/changelog.d/8663-node-tls-parity.md b/changelog.d/8663-node-tls-parity.md new file mode 100644 index 0000000000..921fd0c9b2 --- /dev/null +++ b/changelog.d/8663-node-tls-parity.md @@ -0,0 +1,6 @@ +Completed `node:tls` compatibility across the full node-suite inventory. TLS +servers and sockets now support real loopback handshakes, ALPN and SNI +selection, certificate and secure-context rotation, custom trust stores, +client certificates, identity callbacks, negotiated state, orderly shutdown, +and Node-compatible validation and error shapes in both bundled and optimized +external-net builds. The current TLS inventory passes 100/100 fixtures. diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index bdcc7b413a..e1f40d8790 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -778,6 +778,13 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ method("net", "isSessionReused", true, Some("Socket")), method("net", "exportKeyingMaterial", true, Some("Socket")), method("net", "setMaxSendFragment", true, Some("Socket")), + method("net", "getEphemeralKeyInfo", true, Some("Socket")), + method("net", "getFinished", true, Some("Socket")), + method("net", "getPeerFinished", true, Some("Socket")), + method("net", "getSharedSigalgs", true, Some("Socket")), + method("net", "getX509Certificate", true, Some("Socket")), + method("net", "getPeerX509Certificate", true, Some("Socket")), + method("net", "setKeyCert", true, Some("Socket")), // Issue #1123 followup — `net.Server` instance methods backing // `createServer(...).listen/.close/.address/.on`. Mirrors the // shape of the http-server rows at entries.rs:2298. The @@ -861,6 +868,7 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ TypeSpec::Any, ), method("tls", "getCiphers", false, None), + method("tls", "getCertificateCompressionAlgorithms", false, None), method_sig( "tls", "setDefaultCACertificates", diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 5567aace3f..12b8c43d6b 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -15,6 +15,10 @@ use crate::types::{DOUBLE, I32, I64, PTR}; use super::opts::{NamespaceEntry, NamespaceEntryKind}; +// Relocated to `static_fields.rs` (file-size cap); re-exported so the +// existing `helpers::init_static_fields_*` paths keep resolving. +pub(super) use super::static_fields::{init_static_fields_early, init_static_fields_late}; + pub(crate) fn function_body_returns_generator_object(body: &[perry_hir::Stmt]) -> bool { let has_gen_state = body .iter() @@ -1156,501 +1160,6 @@ pub(super) fn register_module_globals_as_gc_roots( } } -/// Early static-field setup: registrations that don't read any -/// module-level binding's value (Error-extending classes, well-known -/// symbol method hooks). Safe to emit before `stmt::lower_stmts` — -/// values referenced are either compile-time constants (class ids, -/// function pointers) or computed entirely from `hir` metadata. -/// -/// The split (early vs. late) was introduced for issue #894 (effect's -/// `make()` factory's `static [TypeId] = variance` — both the key and -/// the init reference module-level lets that haven't been initialized -/// at the point the old combined `init_static_fields` ran). -pub(super) fn init_static_fields_early( - ctx: &mut crate::expr::FnCtx<'_>, - hir: &HirModule, -) -> Result<()> { - // Phase C.3: register user classes that extend the built-in Error - // (or any of its subclasses) with the runtime, so `instanceof Error` - // walks the chain and returns true. Without this, `new HttpError(...) - // instanceof Error` returns false because the runtime's - // `EXTENDS_ERROR_REGISTRY` is empty for user classes. - for c in &hir.classes { - // Walk this class's extends_name chain; if any ancestor is a - // built-in error subclass, register this class's id. - let mut cur: Option = c.extends_name.clone(); - let mut extends_error = false; - let mut extends_data_view = false; - let mut extends_typed_array = false; - let mut depth = 0usize; - while let Some(name) = cur { - if matches!( - name.as_str(), - "Error" - | "TypeError" - | "RangeError" - | "ReferenceError" - | "SyntaxError" - | "URIError" - | "EvalError" - | "AggregateError" - ) { - extends_error = true; - break; - } - if name == "DataView" { - extends_data_view = true; - break; - } - if crate::type_analysis::is_typed_array_class(&name) { - extends_typed_array = true; - break; - } - // Walk user-defined ancestor chain. - if let Some(parent) = ctx.classes.get(&name) { - cur = parent.extends_name.clone(); - depth += 1; - if depth > 32 { - break; - } - } else { - cur = None; - } - } - if extends_error { - if let Some(&cid) = ctx.class_ids.get(&c.name) { - let cid_str = cid.to_string(); - ctx.block().call_void( - "js_register_class_extends_error", - &[(crate::types::I32, &cid_str)], - ); - } - } - if extends_data_view { - if let Some(&cid) = ctx.class_ids.get(&c.name) { - let cid_str = cid.to_string(); - ctx.block().call_void( - "js_register_class_extends_data_view", - &[(crate::types::I32, &cid_str)], - ); - } - } - if extends_typed_array { - if let Some(&cid) = ctx.class_ids.get(&c.name) { - ctx.block().call_void( - "js_register_class_extends_typed_array", - &[(crate::types::I32, &cid.to_string())], - ); - } - } - } - // Well-known symbol class hooks: HIR lifts `static [Symbol.hasInstance]` - // and `get [Symbol.toStringTag]` to top-level functions with the - // prefixes `__perry_wk_hasinstance_` / `__perry_wk_tostringtag_`. - // Scan `hir.functions`, compute the LLVM symbol via `scoped_fn_name`, - // and emit `js_register_class_(class_id, ptrtoint(@func, i64))` - // at module init so the runtime's `js_instanceof` / `js_object_to_string` - // can dispatch through them. - let module_prefix = ctx.strings.module_prefix().to_string(); - for f in &hir.functions { - let (registrar, class_name): (&str, &str) = - if let Some(rest) = f.name.strip_prefix("__perry_wk_hasinstance_") { - ("js_register_class_has_instance", rest) - } else if let Some(rest) = f.name.strip_prefix("__perry_wk_tostringtag_") { - ("js_register_class_to_string_tag", rest) - } else { - continue; - }; - let Some(&cid) = ctx.class_ids.get(class_name) else { - continue; - }; - let cid_str = cid.to_string(); - let llvm_sym = format!("perry_fn_{}__{}", module_prefix, sanitize(&f.name)); - let func_ref = format!("@{}", llvm_sym); - let blk = ctx.block(); - let func_ptr_i64 = blk.ptrtoint(&func_ref, I64); - blk.call_void( - registrar, - &[(crate::types::I32, &cid_str), (I64, &func_ptr_i64)], - ); - } - // Uninitialized, non-computed static fields (`static foo;`, `static "g";`, - // `static 0;`) are own data properties of the constructor with value - // `undefined` per ClassDefinitionEvaluation. Their value is a compile-time - // constant (`undefined`) with no dependency on user lets, and a class name - // is in TDZ before its declaration, so registering them here — before user - // code — is observably identical to registering at the class-decl position - // and strictly earlier than the `init_static_fields_late` fallback that - // previously handled them (which ran AFTER user statements, so - // `Object.keys(C)` / `getOwnPropertyDescriptor(C, "foo")` immediately after - // the declaration saw nothing). test262 class/elements static-as-valid- - // static-field & friends. Initialized and computed-key fields are emitted - // inline at their source position elsewhere and are skipped here. - for c in &hir.classes { - // Next.js wall 54: a nested class (declared inside a function) has its - // static-field initializers run when the enclosing function evaluates - // the class, NOT at module init — skip them here. - if c.is_nested { - continue; - } - let Some(&class_id) = ctx.class_ids.get(&c.name) else { - continue; - }; - if class_id == 0 { - continue; - } - for sf in &c.static_fields { - if sf.key_expr.is_some() || sf.init.is_some() || sf.name.starts_with('#') { - continue; - } - let idx = ctx.strings.intern(&sf.name); - let entry = ctx.strings.entry(idx); - let bytes_ref = format!("@{}", entry.bytes_global); - let len_str = entry.byte_len.to_string(); - let cid_str = class_id.to_string(); - let undef = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); - ctx.block().call_void( - "js_class_register_static_field", - &[ - (crate::types::I32, &cid_str), - (crate::types::PTR, &bytes_ref), - (crate::types::I64, &len_str), - (DOUBLE, &undef), - ], - ); - } - } - Ok(()) -} - -/// Late static-field setup: per-class static-field initializer evaluation, -/// computed-Symbol-key registration, and static-block invocation. Must -/// run AFTER `stmt::lower_stmts` so module-level lets referenced by -/// these initializers (e.g. `static [TypeId] = variance` where both -/// `TypeId` and `variance` are top-level `const`s) read their populated -/// global slots rather than the zero default. -/// -/// Issue #894: effect's `function make(ast) { return class { static -/// [TypeId] = variance } }` factory pattern hit this; the `TypeId` -/// symbol and `variance` value were both top-level module lets, and -/// the pre-#894 combined `init_static_fields` ran before user init, -/// so `js_class_register_static_symbol(class_id, 0.0, 0.0)` registered -/// nothing reachable. `isSchema(C)` then returned false on a class -/// returned from `make`, dual()'s predicate failed, and the failing -/// `.annotations({...})` chain eventually fed `undefined` to a `make` -/// call that read `ast._tag` → `TypeError: Cannot read properties of -/// undefined (reading '_tag')` during Schema.ts module init. -pub(super) fn init_static_fields_late( - ctx: &mut crate::expr::FnCtx<'_>, - hir: &HirModule, -) -> Result<()> { - // Issue #685: nested classes (declared as expressions inside a - // factory function body, e.g. `return class X extends Y { static - // params = params.slice() }` in effect's `TemplateLiteralParser`) - // are hoisted into `module.classes` by HIR lowering, but their - // static-field initializers may reference parameters of the - // enclosing function — those LocalIds aren't in the module-init - // scope. The fallback at `expr.rs::LocalGet` returns `0.0`, so the - // hoisted init becomes `(0.0).slice()` and throws - // `TypeError: (number).slice is not a function` deep in - // `__init`, before any user code runs. - // - // Skip such inits at module level — the static field's storage - // remains the zero default, which is wrong but harmless (the class - // is built fresh on each factory invocation and the static slot - // would need re-emitting per-invocation to be correct). The full - // fix is to emit the init at the class-expression site inside the - // factory body; tracking the eager-eval-of-inner-class-statics - // separately. - let mut module_local_scope: std::collections::HashSet = - ctx.module_globals.keys().copied().collect(); - // Top-level `let` / `const` bindings may not appear in - // `module_globals` (the global table only includes vars referenced - // from inner functions or exported). For the purpose of "is this - // LocalId in the module's own scope," count every top-level - // `Stmt::Let` id too — otherwise a valid - // `static foo = topLevelConst` would be wrongly skipped. - for s in &hir.init { - if let perry_hir::Stmt::Let { id, .. } = s { - module_local_scope.insert(*id); - } - } - let init_references_out_of_scope_local = |init_expr: &perry_hir::Expr| -> bool { - let mut refs: std::collections::HashSet = std::collections::HashSet::new(); - crate::collectors::collect_ref_ids_in_expr(init_expr, &mut refs); - refs.iter().any(|id| !module_local_scope.contains(id)) - }; - for c in &hir.classes { - // Next.js wall 54: a nested class's static-field initializers must run - // when the enclosing function evaluates the class, not at module init. - // Running a side-effectful one eagerly (e.g. `static #a = new Self()`) - // both mistimes it and can crash before user code. - if c.is_nested { - continue; - } - for sf in &c.static_fields { - // Computed-key static fields go through the class-static-symbol - // side table. Refs #420 — drizzle's `static [entityKind] = - // "Table"` is consulted by `Object.prototype.hasOwnProperty.call( - // type, entityKind)` in drizzle's `is(value, type)`. - if let (Some(key_expr), Some(init_expr)) = (sf.key_expr.as_ref(), sf.init.as_ref()) { - if init_references_out_of_scope_local(init_expr) - || init_references_out_of_scope_local(key_expr) - { - continue; - } - let Some(&class_id) = ctx.class_ids.get(&c.name) else { - continue; - }; - let key_v = crate::expr::lower_expr(ctx, key_expr)?; - let val_v = crate::expr::lower_expr(ctx, init_expr)?; - let cid_str = class_id.to_string(); - ctx.block().call_void( - "js_class_register_static_symbol", - &[ - (crate::types::I32, &cid_str), - (DOUBLE, &key_v), - (DOUBLE, &val_v), - ], - ); - continue; - } - let key = (c.name.clone(), sf.name.clone()); - // Register the field in the runtime CLASS_DYNAMIC_PROPS side - // table (mirroring the StaticFieldSet lowering) so dynamic - // class-ref reads and `getOwnPropertyDescriptor(C, name)` see an - // own data property. Uninitialized fields (`static h;`) register - // `undefined` — per spec they are still own properties. - let emit_static_field_registration = |ctx: &mut crate::expr::FnCtx<'_>, value: &str| { - if let Some(&class_id) = ctx.class_ids.get(&c.name) { - if class_id != 0 { - let idx = ctx.strings.intern(&sf.name); - let entry = ctx.strings.entry(idx); - let bytes_ref = format!("@{}", entry.bytes_global); - let len_str = entry.byte_len.to_string(); - let cid_str = class_id.to_string(); - ctx.block().call_void( - "js_class_register_static_field", - &[ - (crate::types::I32, &cid_str), - (crate::types::PTR, &bytes_ref), - (crate::types::I64, &len_str), - (DOUBLE, value), - ], - ); - } - } - }; - let Some(global_name) = ctx.static_field_globals.get(&key).cloned() else { - continue; - }; - if let Some(init_expr) = &sf.init { - if init_references_out_of_scope_local(init_expr) { - continue; - } - // Skip fields whose initializer the HIR already emitted as an - // inline `StaticFieldSet` at the class's source position (the - // spec evaluation point). Re-running it here would (a) fire - // initializer side effects twice and (b) clobber any user - // reassignment made between the class decl and end of module - // init. Mirrors the static-block dedup below. The inline - // lowering also registers the field in CLASS_DYNAMIC_PROPS. - let inline_initialized = hir.init.iter().any(|s| { - matches!( - s, - perry_hir::Stmt::Expr(perry_hir::Expr::StaticFieldSet { - class_name, - field_name, - .. - }) if *class_name == c.name && *field_name == sf.name - ) - }); - if inline_initialized { - continue; - } - // `this` in a static field initializer is the class - // constructor (`static g = this.f + '262'`). Seed the same - // class-ref NaN-box a static method binds (see - // `compile_static_method`) for the init's duration. - let seeded_this = ctx.class_ids.get(&c.name).copied().map(|cid| { - let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF); - let class_ref_lit = crate::nanbox::double_literal(f64::from_bits(bits)); - let this_slot = ctx.func.alloca_entry(DOUBLE); - ctx.block().store(DOUBLE, &class_ref_lit, &this_slot); - ctx.this_stack.push(this_slot); - }); - let v = crate::expr::lower_expr(ctx, init_expr); - if seeded_this.is_some() { - ctx.this_stack.pop(); - } - let v = v?; - let g_ref = format!("@{}", global_name); - crate::expr::emit_root_nanbox_store_on_block(ctx.block(), &v, &g_ref); - emit_static_field_registration(ctx, &v); - } - // Uninitialized non-computed static fields are now registered in - // `init_static_fields_early` (before user code) with value - // `undefined`. Re-registering here — after user statements — would - // clobber any `C.foo = …` the program performed between the class - // declaration and module-init end, so the no-init `else` branch was - // intentionally removed. - } - } - // Static blocks — emitted as synthetic static methods with the - // name prefix `__perry_static_init_`. HIR lowering injects an inline - // `StaticMethodCall` for each one at the class-decl source position - // (right after that class's static-field-init stmts), so blocks - // normally run from `hir.init`. This loop is a fallback for any - // class whose static_methods include a block not yet hooked via - // init (e.g. class expressions that bypass the stmt-decl path); - // calling it here keeps the legacy behavior of "always run, just - // late" for those. (#2278) - // #5989: blocks already invoked inline at their class's evaluation point — - // module top level (a top-level class decl), a function body, OR a nested - // closure (a function-nested class decl, whose block call `lower_decl:: - // body_stmt` emits into its factory/closure body). The module-init fallback - // below must NOT ALSO run those: a nested class's block would fire at module - // init, before its factory binds the block's captured factory-locals, so a - // block reading a lazy import (`class m { static { this.contextType = - // g.AppRouterContext } }`, `g = a.i(N)`) threw in `__init` (Next.js - // /plain App Router chunk). Class EXPRESSIONS with no inline invocation are - // absent from this set and still run at module init (the fallback's purpose). - let inline_invoked = collect_inline_invoked_static_blocks(hir); - for c in &hir.classes { - for sm in &c.static_methods { - if !sm.name.starts_with("__perry_static_init_") { - continue; - } - if inline_invoked.contains(&(c.name.clone(), sm.name.clone())) { - continue; - } - let key = ( - c.name.clone(), - crate::codegen::static_method_registry_key(&sm.name), - ); - if let Some(llvm_name) = ctx.methods.get(&key).cloned() { - ctx.block().call(DOUBLE, &llvm_name, &[]); - } - } - } - Ok(()) -} - -/// #5989: collect every `(class, method)` invoked via a `StaticMethodCall` -/// ANYWHERE in the module — module init, top-level function bodies, and -/// (crucially) recursively inside nested closures. `init_calls_static_block` -/// only walks statement-level control flow, so a block call buried in a -/// factory/closure body (a function-nested class decl's inline invocation, -/// emitted by `lower_decl::body_stmt`) is invisible to it. -/// -/// `init_static_fields_late` uses this to skip a static block that already has -/// an inline invocation at the point its class is evaluated. Without it, such a -/// block ALSO ran at module init — before its factory bound the block's captured -/// factory-locals — so a nested class whose block reads a lazy import -/// (`class m { static { this.contextType = g.AppRouterContext } }`, `g = a.i(N)`) -/// threw in `__init` (Next.js /plain App Router chunk). Class EXPRESSIONS -/// with no inline invocation are NOT collected and still run at module init. -fn collect_inline_invoked_static_blocks( - hir: &HirModule, -) -> std::collections::HashSet<(String, String)> { - use perry_hir::{Expr, Stmt}; - let mut out: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); - - fn walk_expr(e: &Expr, out: &mut std::collections::HashSet<(String, String)>) { - if let Expr::StaticMethodCall { - class_name, - method_name, - .. - } = e - { - out.insert((class_name.clone(), method_name.clone())); - } - if let Expr::Closure { body, .. } = e { - for s in body { - walk_stmt(s, out); - } - } - perry_hir::walker::walk_expr_children(e, &mut |c| walk_expr(c, out)); - } - - fn walk_stmt(s: &Stmt, out: &mut std::collections::HashSet<(String, String)>) { - match s { - Stmt::Let { init: Some(e), .. } => walk_expr(e, out), - Stmt::Expr(e) | Stmt::Throw(e) => walk_expr(e, out), - Stmt::Return(Some(e)) => walk_expr(e, out), - Stmt::If { - condition, - then_branch, - else_branch, - } => { - walk_expr(condition, out); - then_branch.iter().for_each(|s| walk_stmt(s, out)); - if let Some(eb) = else_branch { - eb.iter().for_each(|s| walk_stmt(s, out)); - } - } - Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { - walk_expr(condition, out); - body.iter().for_each(|s| walk_stmt(s, out)); - } - Stmt::For { - init, - condition, - update, - body, - } => { - if let Some(i) = init { - walk_stmt(i, out); - } - if let Some(c) = condition { - walk_expr(c, out); - } - if let Some(u) = update { - walk_expr(u, out); - } - body.iter().for_each(|s| walk_stmt(s, out)); - } - Stmt::Labeled { body, .. } => walk_stmt(body, out), - Stmt::Try { - body, - catch, - finally, - } => { - body.iter().for_each(|s| walk_stmt(s, out)); - if let Some(c) = catch { - c.body.iter().for_each(|s| walk_stmt(s, out)); - } - if let Some(f) = finally { - f.iter().for_each(|s| walk_stmt(s, out)); - } - } - Stmt::Switch { - discriminant, - cases, - } => { - walk_expr(discriminant, out); - cases.iter().for_each(|case| { - if let Some(t) = &case.test { - walk_expr(t, out); - } - case.body.iter().for_each(|s| walk_stmt(s, out)); - }); - } - _ => {} - } - } - - for s in &hir.init { - walk_stmt(s, &mut out); - } - for f in &hir.functions { - for s in &f.body { - walk_stmt(s, &mut out); - } - } - out -} - /// Issue #100: emit the IR that populates this module's /// `@__perry_ns_` global from the resolved namespace /// entry list. Called at the end of `__perry_init_` (or `main` diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index daa864bc27..10f57df939 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -1048,7 +1048,16 @@ pub(super) fn compile_method( // .pathname` threw. Forward this synthesized ctor's params to the // runtime dynamic-parent super dispatcher, mirroring the explicit // `Expr::SuperCall` dynamic-parent path in `expr/this_super_call.rs`. - if builtin_parent_runtime.is_none() && class.extends_expr.is_some() { + let parent_is_uncallable_builtin = class + .extends_name + .as_deref() + .map(crate::expr::is_other_builtin_constructor_name) + .unwrap_or(false) + && class.extends_name.as_deref() != Some("SharedArrayBuffer"); + if builtin_parent_runtime.is_none() + && class.extends_expr.is_some() + && !parent_is_uncallable_builtin + { if let Some(cid) = ctx.class_ids.get(&class.name).copied().filter(|c| *c != 0) { let undef_lit = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index ec2993ba9e..ea5f58e119 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -213,6 +213,7 @@ mod spec_preserve_none_tests; mod spec_return_proof; #[cfg(test)] mod spec_self_recursion_tests; +pub(crate) mod static_fields; mod string_pool; #[cfg(test)] mod testing_feature_gate_tests; diff --git a/crates/perry-codegen/src/codegen/static_fields.rs b/crates/perry-codegen/src/codegen/static_fields.rs new file mode 100644 index 0000000000..4bde689e5a --- /dev/null +++ b/crates/perry-codegen/src/codegen/static_fields.rs @@ -0,0 +1,519 @@ +//! Static class-field and static-block initialization. +//! +//! Split out of `helpers.rs` (2000-line-per-file cap). Pure relocation -- +//! `init_static_fields_early` / `init_static_fields_late` and the +//! `collect_inline_invoked_static_blocks` helper they share. + +use super::helpers::*; +use super::*; + +/// Early static-field setup: registrations that don't read any +/// module-level binding's value (Error-extending classes, well-known +/// symbol method hooks). Safe to emit before `stmt::lower_stmts` — +/// values referenced are either compile-time constants (class ids, +/// function pointers) or computed entirely from `hir` metadata. +/// +/// The split (early vs. late) was introduced for issue #894 (effect's +/// `make()` factory's `static [TypeId] = variance` — both the key and +/// the init reference module-level lets that haven't been initialized +/// at the point the old combined `init_static_fields` ran). +pub(super) fn init_static_fields_early( + ctx: &mut crate::expr::FnCtx<'_>, + hir: &HirModule, +) -> Result<()> { + // Phase C.3: register user classes that extend the built-in Error + // (or any of its subclasses) with the runtime, so `instanceof Error` + // walks the chain and returns true. Without this, `new HttpError(...) + // instanceof Error` returns false because the runtime's + // `EXTENDS_ERROR_REGISTRY` is empty for user classes. + for c in &hir.classes { + // Walk this class's extends_name chain; if any ancestor is a + // built-in error subclass, register this class's id. + let mut cur: Option = c.extends_name.clone(); + let mut extends_error = false; + let mut extends_data_view = false; + let mut extends_typed_array = false; + let mut depth = 0usize; + while let Some(name) = cur { + if matches!( + name.as_str(), + "Error" + | "TypeError" + | "RangeError" + | "ReferenceError" + | "SyntaxError" + | "URIError" + | "EvalError" + | "AggregateError" + ) { + extends_error = true; + break; + } + if name == "DataView" { + extends_data_view = true; + break; + } + if crate::type_analysis::is_typed_array_class(&name) { + extends_typed_array = true; + break; + } + // Walk user-defined ancestor chain. + if let Some(parent) = ctx.classes.get(&name) { + cur = parent.extends_name.clone(); + depth += 1; + if depth > 32 { + break; + } + } else { + cur = None; + } + } + if extends_error { + if let Some(&cid) = ctx.class_ids.get(&c.name) { + let cid_str = cid.to_string(); + ctx.block().call_void( + "js_register_class_extends_error", + &[(crate::types::I32, &cid_str)], + ); + } + } + if extends_data_view { + if let Some(&cid) = ctx.class_ids.get(&c.name) { + let cid_str = cid.to_string(); + ctx.block().call_void( + "js_register_class_extends_data_view", + &[(crate::types::I32, &cid_str)], + ); + } + } + if extends_typed_array { + if let Some(&cid) = ctx.class_ids.get(&c.name) { + ctx.block().call_void( + "js_register_class_extends_typed_array", + &[(crate::types::I32, &cid.to_string())], + ); + } + } + } + // Well-known symbol class hooks: HIR lifts `static [Symbol.hasInstance]` + // and `get [Symbol.toStringTag]` to top-level functions with the + // prefixes `__perry_wk_hasinstance_` / `__perry_wk_tostringtag_`. + // Scan `hir.functions`, compute the LLVM symbol via `scoped_fn_name`, + // and emit `js_register_class_(class_id, ptrtoint(@func, i64))` + // at module init so the runtime's `js_instanceof` / `js_object_to_string` + // can dispatch through them. + let module_prefix = ctx.strings.module_prefix().to_string(); + for f in &hir.functions { + let (registrar, class_name): (&str, &str) = + if let Some(rest) = f.name.strip_prefix("__perry_wk_hasinstance_") { + ("js_register_class_has_instance", rest) + } else if let Some(rest) = f.name.strip_prefix("__perry_wk_tostringtag_") { + ("js_register_class_to_string_tag", rest) + } else { + continue; + }; + let Some(&cid) = ctx.class_ids.get(class_name) else { + continue; + }; + let cid_str = cid.to_string(); + let llvm_sym = format!("perry_fn_{}__{}", module_prefix, sanitize(&f.name)); + let func_ref = format!("@{}", llvm_sym); + let blk = ctx.block(); + let func_ptr_i64 = blk.ptrtoint(&func_ref, I64); + blk.call_void( + registrar, + &[(crate::types::I32, &cid_str), (I64, &func_ptr_i64)], + ); + } + // Uninitialized, non-computed static fields (`static foo;`, `static "g";`, + // `static 0;`) are own data properties of the constructor with value + // `undefined` per ClassDefinitionEvaluation. Their value is a compile-time + // constant (`undefined`) with no dependency on user lets, and a class name + // is in TDZ before its declaration, so registering them here — before user + // code — is observably identical to registering at the class-decl position + // and strictly earlier than the `init_static_fields_late` fallback that + // previously handled them (which ran AFTER user statements, so + // `Object.keys(C)` / `getOwnPropertyDescriptor(C, "foo")` immediately after + // the declaration saw nothing). test262 class/elements static-as-valid- + // static-field & friends. Initialized and computed-key fields are emitted + // inline at their source position elsewhere and are skipped here. + for c in &hir.classes { + // Next.js wall 54: a nested class (declared inside a function) has its + // static-field initializers run when the enclosing function evaluates + // the class, NOT at module init — skip them here. + if c.is_nested { + continue; + } + let Some(&class_id) = ctx.class_ids.get(&c.name) else { + continue; + }; + if class_id == 0 { + continue; + } + for sf in &c.static_fields { + if sf.key_expr.is_some() || sf.init.is_some() || sf.name.starts_with('#') { + continue; + } + let idx = ctx.strings.intern(&sf.name); + let entry = ctx.strings.entry(idx); + let bytes_ref = format!("@{}", entry.bytes_global); + let len_str = entry.byte_len.to_string(); + let cid_str = class_id.to_string(); + let undef = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + ctx.block().call_void( + "js_class_register_static_field", + &[ + (crate::types::I32, &cid_str), + (crate::types::PTR, &bytes_ref), + (crate::types::I64, &len_str), + (DOUBLE, &undef), + ], + ); + } + } + Ok(()) +} + +/// Late static-field setup: per-class static-field initializer evaluation, +/// computed-Symbol-key registration, and static-block invocation. Must +/// run AFTER `stmt::lower_stmts` so module-level lets referenced by +/// these initializers (e.g. `static [TypeId] = variance` where both +/// `TypeId` and `variance` are top-level `const`s) read their populated +/// global slots rather than the zero default. +/// +/// Issue #894: effect's `function make(ast) { return class { static +/// [TypeId] = variance } }` factory pattern hit this; the `TypeId` +/// symbol and `variance` value were both top-level module lets, and +/// the pre-#894 combined `init_static_fields` ran before user init, +/// so `js_class_register_static_symbol(class_id, 0.0, 0.0)` registered +/// nothing reachable. `isSchema(C)` then returned false on a class +/// returned from `make`, dual()'s predicate failed, and the failing +/// `.annotations({...})` chain eventually fed `undefined` to a `make` +/// call that read `ast._tag` → `TypeError: Cannot read properties of +/// undefined (reading '_tag')` during Schema.ts module init. +pub(super) fn init_static_fields_late( + ctx: &mut crate::expr::FnCtx<'_>, + hir: &HirModule, +) -> Result<()> { + // Issue #685: nested classes (declared as expressions inside a + // factory function body, e.g. `return class X extends Y { static + // params = params.slice() }` in effect's `TemplateLiteralParser`) + // are hoisted into `module.classes` by HIR lowering, but their + // static-field initializers may reference parameters of the + // enclosing function — those LocalIds aren't in the module-init + // scope. The fallback at `expr.rs::LocalGet` returns `0.0`, so the + // hoisted init becomes `(0.0).slice()` and throws + // `TypeError: (number).slice is not a function` deep in + // `__init`, before any user code runs. + // + // Skip such inits at module level — the static field's storage + // remains the zero default, which is wrong but harmless (the class + // is built fresh on each factory invocation and the static slot + // would need re-emitting per-invocation to be correct). The full + // fix is to emit the init at the class-expression site inside the + // factory body; tracking the eager-eval-of-inner-class-statics + // separately. + let mut module_local_scope: std::collections::HashSet = + ctx.module_globals.keys().copied().collect(); + // Top-level `let` / `const` bindings may not appear in + // `module_globals` (the global table only includes vars referenced + // from inner functions or exported). For the purpose of "is this + // LocalId in the module's own scope," count every top-level + // `Stmt::Let` id too — otherwise a valid + // `static foo = topLevelConst` would be wrongly skipped. + for s in &hir.init { + if let perry_hir::Stmt::Let { id, .. } = s { + module_local_scope.insert(*id); + } + } + let init_references_out_of_scope_local = |init_expr: &perry_hir::Expr| -> bool { + let mut refs: std::collections::HashSet = std::collections::HashSet::new(); + crate::collectors::collect_ref_ids_in_expr(init_expr, &mut refs); + refs.iter().any(|id| !module_local_scope.contains(id)) + }; + for c in &hir.classes { + // Next.js wall 54: a nested class's static-field initializers must run + // when the enclosing function evaluates the class, not at module init. + // Running a side-effectful one eagerly (e.g. `static #a = new Self()`) + // both mistimes it and can crash before user code. + if c.is_nested { + continue; + } + for sf in &c.static_fields { + // Computed-key static fields go through the class-static-symbol + // side table. Refs #420 — drizzle's `static [entityKind] = + // "Table"` is consulted by `Object.prototype.hasOwnProperty.call( + // type, entityKind)` in drizzle's `is(value, type)`. + if let (Some(key_expr), Some(init_expr)) = (sf.key_expr.as_ref(), sf.init.as_ref()) { + if init_references_out_of_scope_local(init_expr) + || init_references_out_of_scope_local(key_expr) + { + continue; + } + let Some(&class_id) = ctx.class_ids.get(&c.name) else { + continue; + }; + let key_v = crate::expr::lower_expr(ctx, key_expr)?; + let val_v = crate::expr::lower_expr(ctx, init_expr)?; + let cid_str = class_id.to_string(); + ctx.block().call_void( + "js_class_register_static_symbol", + &[ + (crate::types::I32, &cid_str), + (DOUBLE, &key_v), + (DOUBLE, &val_v), + ], + ); + continue; + } + let key = (c.name.clone(), sf.name.clone()); + // Register the field in the runtime CLASS_DYNAMIC_PROPS side + // table (mirroring the StaticFieldSet lowering) so dynamic + // class-ref reads and `getOwnPropertyDescriptor(C, name)` see an + // own data property. Uninitialized fields (`static h;`) register + // `undefined` — per spec they are still own properties. + let emit_static_field_registration = |ctx: &mut crate::expr::FnCtx<'_>, value: &str| { + if let Some(&class_id) = ctx.class_ids.get(&c.name) { + if class_id != 0 { + let idx = ctx.strings.intern(&sf.name); + let entry = ctx.strings.entry(idx); + let bytes_ref = format!("@{}", entry.bytes_global); + let len_str = entry.byte_len.to_string(); + let cid_str = class_id.to_string(); + ctx.block().call_void( + "js_class_register_static_field", + &[ + (crate::types::I32, &cid_str), + (crate::types::PTR, &bytes_ref), + (crate::types::I64, &len_str), + (DOUBLE, value), + ], + ); + } + } + }; + let Some(global_name) = ctx.static_field_globals.get(&key).cloned() else { + continue; + }; + if let Some(init_expr) = &sf.init { + if init_references_out_of_scope_local(init_expr) { + continue; + } + // Skip fields whose initializer the HIR already emitted as an + // inline `StaticFieldSet` at the class's source position (the + // spec evaluation point). Re-running it here would (a) fire + // initializer side effects twice and (b) clobber any user + // reassignment made between the class decl and end of module + // init. Mirrors the static-block dedup below. The inline + // lowering also registers the field in CLASS_DYNAMIC_PROPS. + let inline_initialized = hir.init.iter().any(|s| { + matches!( + s, + perry_hir::Stmt::Expr(perry_hir::Expr::StaticFieldSet { + class_name, + field_name, + .. + }) if *class_name == c.name && *field_name == sf.name + ) + }); + if inline_initialized { + continue; + } + // `this` in a static field initializer is the class + // constructor (`static g = this.f + '262'`). Seed the same + // class-ref NaN-box a static method binds (see + // `compile_static_method`) for the init's duration. + let seeded_this = ctx.class_ids.get(&c.name).copied().map(|cid| { + let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF); + let class_ref_lit = crate::nanbox::double_literal(f64::from_bits(bits)); + let this_slot = ctx.func.alloca_entry(DOUBLE); + ctx.block().store(DOUBLE, &class_ref_lit, &this_slot); + ctx.this_stack.push(this_slot); + }); + let v = crate::expr::lower_expr(ctx, init_expr); + if seeded_this.is_some() { + ctx.this_stack.pop(); + } + let v = v?; + let g_ref = format!("@{}", global_name); + crate::expr::emit_root_nanbox_store_on_block(ctx.block(), &v, &g_ref); + emit_static_field_registration(ctx, &v); + } + // Uninitialized non-computed static fields are now registered in + // `init_static_fields_early` (before user code) with value + // `undefined`. Re-registering here — after user statements — would + // clobber any `C.foo = …` the program performed between the class + // declaration and module-init end, so the no-init `else` branch was + // intentionally removed. + } + } + // Static blocks — emitted as synthetic static methods with the + // name prefix `__perry_static_init_`. HIR lowering injects an inline + // `StaticMethodCall` for each one at the class-decl source position + // (right after that class's static-field-init stmts), so blocks + // normally run from `hir.init`. This loop is a fallback for any + // class whose static_methods include a block not yet hooked via + // init (e.g. class expressions that bypass the stmt-decl path); + // calling it here keeps the legacy behavior of "always run, just + // late" for those. (#2278) + // #5989: blocks already invoked inline at their class's evaluation point — + // module top level (a top-level class decl), a function body, OR a nested + // closure (a function-nested class decl, whose block call `lower_decl:: + // body_stmt` emits into its factory/closure body). The module-init fallback + // below must NOT ALSO run those: a nested class's block would fire at module + // init, before its factory binds the block's captured factory-locals, so a + // block reading a lazy import (`class m { static { this.contextType = + // g.AppRouterContext } }`, `g = a.i(N)`) threw in `__init` (Next.js + // /plain App Router chunk). Class EXPRESSIONS with no inline invocation are + // absent from this set and still run at module init (the fallback's purpose). + let inline_invoked = collect_inline_invoked_static_blocks(hir); + for c in &hir.classes { + for sm in &c.static_methods { + if !sm.name.starts_with("__perry_static_init_") { + continue; + } + if inline_invoked.contains(&(c.name.clone(), sm.name.clone())) { + continue; + } + let key = ( + c.name.clone(), + crate::codegen::static_method_registry_key(&sm.name), + ); + if let Some(llvm_name) = ctx.methods.get(&key).cloned() { + ctx.block().call(DOUBLE, &llvm_name, &[]); + } + } + } + Ok(()) +} + +/// #5989: collect every `(class, method)` invoked via a `StaticMethodCall` +/// ANYWHERE in the module — module init, top-level function bodies, and +/// (crucially) recursively inside nested closures. `init_calls_static_block` +/// only walks statement-level control flow, so a block call buried in a +/// factory/closure body (a function-nested class decl's inline invocation, +/// emitted by `lower_decl::body_stmt`) is invisible to it. +/// +/// `init_static_fields_late` uses this to skip a static block that already has +/// an inline invocation at the point its class is evaluated. Without it, such a +/// block ALSO ran at module init — before its factory bound the block's captured +/// factory-locals — so a nested class whose block reads a lazy import +/// (`class m { static { this.contextType = g.AppRouterContext } }`, `g = a.i(N)`) +/// threw in `__init` (Next.js /plain App Router chunk). Class EXPRESSIONS +/// with no inline invocation are NOT collected and still run at module init. +fn collect_inline_invoked_static_blocks( + hir: &HirModule, +) -> std::collections::HashSet<(String, String)> { + use perry_hir::{Expr, Stmt}; + let mut out: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); + + fn walk_expr(e: &Expr, out: &mut std::collections::HashSet<(String, String)>) { + if let Expr::StaticMethodCall { + class_name, + method_name, + .. + } = e + { + out.insert((class_name.clone(), method_name.clone())); + } + // `ClassExprFresh` invokes its static blocks directly from the + // per-evaluation source-order plan. Treat those calls as inline too; + // otherwise the module-init fallback below invokes every block once + // more with no fresh class object armed as `this`. + if let Expr::ClassExprFresh { + template, + static_init_order, + .. + } = e + { + for step in static_init_order { + if let perry_hir::ClassFreshStaticInit::Block(index) = step { + out.insert((template.clone(), format!("__perry_static_init_{index}"))); + } + } + } + if let Expr::Closure { body, .. } = e { + for s in body { + walk_stmt(s, out); + } + } + perry_hir::walker::walk_expr_children(e, &mut |c| walk_expr(c, out)); + } + + fn walk_stmt(s: &Stmt, out: &mut std::collections::HashSet<(String, String)>) { + match s { + Stmt::Let { init: Some(e), .. } => walk_expr(e, out), + Stmt::Expr(e) | Stmt::Throw(e) => walk_expr(e, out), + Stmt::Return(Some(e)) => walk_expr(e, out), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + walk_expr(condition, out); + then_branch.iter().for_each(|s| walk_stmt(s, out)); + if let Some(eb) = else_branch { + eb.iter().for_each(|s| walk_stmt(s, out)); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + walk_expr(condition, out); + body.iter().for_each(|s| walk_stmt(s, out)); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(i) = init { + walk_stmt(i, out); + } + if let Some(c) = condition { + walk_expr(c, out); + } + if let Some(u) = update { + walk_expr(u, out); + } + body.iter().for_each(|s| walk_stmt(s, out)); + } + Stmt::Labeled { body, .. } => walk_stmt(body, out), + Stmt::Try { + body, + catch, + finally, + } => { + body.iter().for_each(|s| walk_stmt(s, out)); + if let Some(c) = catch { + c.body.iter().for_each(|s| walk_stmt(s, out)); + } + if let Some(f) = finally { + f.iter().for_each(|s| walk_stmt(s, out)); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + walk_expr(discriminant, out); + cases.iter().for_each(|case| { + if let Some(t) = &case.test { + walk_expr(t, out); + } + case.body.iter().for_each(|s| walk_stmt(s, out)); + }); + } + _ => {} + } + } + + for s in &hir.init { + walk_stmt(s, &mut out); + } + for f in &hir.functions { + for s in &f.body { + walk_stmt(s, &mut out); + } + } + out +} diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index 27e897139c..a6581cd15f 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -25,7 +25,7 @@ use crate::types::{DOUBLE, I1, I128, I32, I64}; use crate::rooting::with_operands_rooted; -use super::{is_known_finite, lower_expr, FnCtx}; +use super::{is_known_i32_range, lower_expr, FnCtx}; /// `helper(left, right)` with each operand rooted across the other's lowering /// and the group released on every path out (#6951). @@ -1101,7 +1101,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // entirely — just fptosi + sitofp (identity for in-range // values, LLVM eliminates via instcombine). BinaryOp::BitOr - if matches!(right.as_ref(), Expr::Integer(0)) && is_known_finite(ctx, left) => + if matches!(right.as_ref(), Expr::Integer(0)) + && is_known_i32_range(ctx, left) => { let blk = ctx.block(); let li = blk.toint32_fast(&l); @@ -1112,8 +1113,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { | BinaryOp::BitXor | BinaryOp::Shl | BinaryOp::Shr => { - let l_safe = is_known_finite(ctx, left); - let r_safe = is_known_finite(ctx, right); + let l_safe = is_known_i32_range(ctx, left); + let r_safe = is_known_i32_range(ctx, right); let blk = ctx.block(); let li = if l_safe { blk.toint32_fast(&l) @@ -1136,15 +1137,16 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { blk.sitofp(I32, &v, DOUBLE) } BinaryOp::UShr - if matches!(right.as_ref(), Expr::Integer(0)) && is_known_finite(ctx, left) => + if matches!(right.as_ref(), Expr::Integer(0)) + && is_known_i32_range(ctx, left) => { let blk = ctx.block(); let li = blk.toint32_fast(&l); blk.uitofp(I32, &li, DOUBLE) } BinaryOp::UShr => { - let l_safe = is_known_finite(ctx, left); - let r_safe = is_known_finite(ctx, right); + let l_safe = is_known_i32_range(ctx, left); + let r_safe = is_known_i32_range(ctx, right); let blk = ctx.block(); let li = if l_safe { blk.toint32_fast(&l) diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index 339208ad2d..50cb97dd32 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -257,16 +257,6 @@ fn lower_string_literal_strict_eq( ctx.block().phi(I1, &incoming) } -/// Magnitude comparands for the inline heap-address test in -/// [`lower_strict_eq_inline_any`]. These mirror -/// `perry-runtime::value::addr_class::{HANDLE_BAND_MAX, is_valid_obj_ptr}`: -/// a `POINTER_TAG` payload below `HANDLE_BAND_MAX` is a registry id -/// (net.Socket, fetch, zlib, revocable Proxy, UI widget), NOT an address, and -/// dereferencing one reads unmapped low memory. Anything outside the window -/// takes the runtime call instead of a header load. -const HANDLE_BAND_MAX_I64: &str = "1048576"; -const HEAP_ADDR_CEILING_I64: &str = "140737488355328"; - /// Inline prefix for the generic `===`/`!==` tail — the arm where BOTH /// operands are statically unconstrained, which emitted one /// `js_eq` → `js_jsvalue_equals` call per comparison and nothing else. @@ -277,7 +267,7 @@ const HEAP_ADDR_CEILING_I64: &str = "140737488355328"; /// **misses**, so a fast path that settles only the hit is worth nothing — /// each case below settles one direction of the real traffic. /// -/// Four cases leave without a call. Each is an exact restatement of what +/// Three cases leave without a call. Each is an exact restatement of what /// `js_jsvalue_equals` computes for that input, not an approximation: /// /// * **identical bits** ⇒ equal, *unless* the value is a plain (untagged) @@ -291,16 +281,12 @@ const HEAP_ADDR_CEILING_I64: &str = "140737488355328"; /// pattern — which is the argument `lower_string_strict_eq_inline` and the /// runtime's own both-short-string arm already rely on. /// * **both INT32, different bits** ⇒ different integers, same argument. -/// * **both `POINTER_TAG`, different payloads, and neither header carries -/// `GC_FLAG_FORWARDED`** ⇒ distinct objects. The runtime's pointer arm is -/// `resolve_forwarding(a) == resolve_forwarding(b)`, and -/// `resolve_forwarding` returns its argument unchanged when the forwarding -/// bit is clear — so two *unforwarded* distinct addresses are exactly its -/// `0` case. Anything forwarded (a post-`js_array_grow` alias, a stale -/// pre-evacuation pointer) takes the call and gets the full walk. The -/// header read is the same one `expr/array_push.rs` emits — `gc_flags` at -/// `ptr - 7`, mask `GC_FLAG_FORWARDED` (0x80) — behind the same magnitude -/// guard the runtime applies before any `GcHeader` dereference. +/// +/// Distinct `POINTER_TAG` values always take the runtime call. Not every +/// pointer-tag payload is a GC allocation: registered and well-known symbols, +/// for example, are process-lifetime `Box` allocations with no `GcHeader`. +/// Generated code has no access to the runtime's allocation registries, so an +/// address-magnitude check cannot make reading `ptr - GC_HEADER_SIZE` safe. /// /// Everything else — a raw-bits module-level object slot (top16 zero), a heap /// string, a bigint, a mixed pair, a boxed wrapper — falls through to @@ -314,18 +300,12 @@ fn lower_strict_eq_inline_any(ctx: &mut FnCtx<'_>, l: &str, r: &str) -> String { let same_idx = ctx.new_block("anyeq.same"); let diff_idx = ctx.new_block("anyeq.diff"); - let canon_idx = ctx.new_block("anyeq.canon"); - let band_idx = ctx.new_block("anyeq.band"); - let fwd_idx = ctx.new_block("anyeq.fwd"); let slow_idx = ctx.new_block("anyeq.slow"); let true_idx = ctx.new_block("anyeq.true"); let false_idx = ctx.new_block("anyeq.false"); let merge_idx = ctx.new_block("anyeq.merge"); let same_l = ctx.block_label(same_idx); let diff_l = ctx.block_label(diff_idx); - let canon_l = ctx.block_label(canon_idx); - let band_l = ctx.block_label(band_idx); - let fwd_l = ctx.block_label(fwd_idx); let slow_l = ctx.block_label(slow_idx); let true_l = ctx.block_label(true_idx); let false_l = ctx.block_label(false_idx); @@ -349,21 +329,12 @@ fn lower_strict_eq_inline_any(ctx: &mut FnCtx<'_>, l: &str, r: &str) -> String { let same_ok = ctx.block().or(I1, &tagged, ¬_nan); ctx.block().cond_br(&same_ok, &true_l, &slow_l); - // Different bits: only a same-tag pair whose encoding is canonical, or a - // pair of unforwarded heap pointers, is decidable here. + // Different bits: only a same-tag pair whose encoding is canonical is + // decidable here. Pointer pairs need the runtime's allocation registries + // before either payload can safely be treated as a GC allocation. ctx.current_block = diff_idx; let l_tag = ctx.block().lshr(I64, &l_bits, "48"); let r_tag = ctx.block().lshr(I64, &r_bits, "48"); - let l_ptr = ctx - .block() - .icmp_eq(I64, &l_tag, crate::nanbox::POINTER_TAG_TOP16_I64); - let r_ptr = ctx - .block() - .icmp_eq(I64, &r_tag, crate::nanbox::POINTER_TAG_TOP16_I64); - let both_ptr = ctx.block().and(I1, &l_ptr, &r_ptr); - ctx.block().cond_br(&both_ptr, &band_l, &canon_l); - - ctx.current_block = canon_idx; let l_sso = ctx .block() .icmp_eq(I64, &l_tag, crate::nanbox::SHORT_STRING_TAG_TOP16_I64); @@ -381,32 +352,6 @@ fn lower_strict_eq_inline_any(ctx: &mut FnCtx<'_>, l: &str, r: &str) -> String { let canonical = ctx.block().or(I1, &both_sso, &both_i32); ctx.block().cond_br(&canonical, &false_l, &slow_l); - // Both POINTER_TAG. Classify by magnitude before touching a header. - ctx.current_block = band_idx; - let l_addr = ctx.block().and(I64, &l_bits, POINTER_MASK_I64); - let r_addr = ctx.block().and(I64, &r_bits, POINTER_MASK_I64); - let l_above = ctx.block().icmp_uge(I64, &l_addr, HANDLE_BAND_MAX_I64); - let l_below = ctx.block().icmp_ult(I64, &l_addr, HEAP_ADDR_CEILING_I64); - let r_above = ctx.block().icmp_uge(I64, &r_addr, HANDLE_BAND_MAX_I64); - let r_below = ctx.block().icmp_ult(I64, &r_addr, HEAP_ADDR_CEILING_I64); - let l_heap = ctx.block().and(I1, &l_above, &l_below); - let r_heap = ctx.block().and(I1, &r_above, &r_below); - let both_heap = ctx.block().and(I1, &l_heap, &r_heap); - ctx.block().cond_br(&both_heap, &fwd_l, &slow_l); - - ctx.current_block = fwd_idx; - let l_flags_addr = ctx.block().sub(I64, &l_addr, "7"); - let l_flags_ptr = ctx.block().inttoptr(I64, &l_flags_addr); - let l_flags = ctx.block().load(I8, &l_flags_ptr); - let r_flags_addr = ctx.block().sub(I64, &r_addr, "7"); - let r_flags_ptr = ctx.block().inttoptr(I64, &r_flags_addr); - let r_flags = ctx.block().load(I8, &r_flags_ptr); - let either = ctx.block().or(I8, &l_flags, &r_flags); - // GC_FLAG_FORWARDED = 0x80; LLVM i8 literals are signed. - let fwd_bits = ctx.block().and(I8, &either, "-128"); - let no_fwd = ctx.block().icmp_eq(I8, &fwd_bits, "0"); - ctx.block().cond_br(&no_fwd, &false_l, &slow_l); - ctx.current_block = slow_idx; let slow_res = ctx .block() diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index 76240710f2..76264063d1 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -27,89 +27,19 @@ use native_narrow::{ lower_expr_native_u8, }; -/// Returns true if `e` provably produces a finite double whose magnitude is -/// small enough (`|v| < 2^63`) for the unguarded `toint32_fast` lowering. -/// Used to skip the NaN/Inf/range guard in `toint32` for integer-arithmetic -/// hot paths — saving 5 instructions per bitwise op. -pub(crate) fn is_known_finite(ctx: &FnCtx<'_>, e: &Expr) -> bool { - known_finite_magnitude_bits(ctx, e).is_some_and(|bits| bits <= 62) -} - -/// Conservative magnitude bound for `e`'s numeric value: `Some(b)` proves the -/// value is finite AND `|v| < 2^b`. `toint32_fast` is a bare -/// `fptosi f64 → i64` + `trunc` — exactly JS ToInt32 for every `|v| < 2^63`, -/// but LLVM *poison* at or beyond it. Finiteness alone is NOT enough: -/// `(1e20) | 0` and nested integer multiplies (`(a*a)*a | 0` with i32-range -/// `a`) are finite yet exceed 2^63, and pre-fix produced NaN instead of the -/// ToInt32-wrapped value (CodeRabbit review on #5466; the same hole shipped -/// on main). Composition keeps the proof airtight where the old boolean -/// recursion silently escalated: Add/Sub grow the bound by one bit, Mul sums -/// the operand bounds, and anything unprovable returns `None` so callers fall -/// back to the guarded `toint32` runtime helper. -fn known_finite_magnitude_bits(ctx: &FnCtx<'_>, e: &Expr) -> Option { - match e { - Expr::Integer(n) => Some(64 - n.unsigned_abs().leading_zeros()), - // Pod layout sizes/alignments/offsets are u32-class quantities. - Expr::PodLayoutSizeOf { .. } - | Expr::PodLayoutAlignOf { .. } - | Expr::PodLayoutOffsetOf { .. } => Some(32), - // Number literals can be NaN or ±Infinity (e.g., `Number(NaN)`, - // `Number(f64::INFINITY)`). Inspect the value: `fptosi NaN` is - // poison in LLVM and produced subnormal-double output (which - // downstream code interpreted as a NaN-boxed string with - // STRING_TAG bits, leading to garbled `console.log` output). - Expr::Number(n) => { - if !n.is_finite() { - return None; - } - let magnitude = n.abs(); - if magnitude < 1.0 { - Some(0) - } else { - Some(magnitude.log2() as u32 + 1) - } - } - Expr::LocalGet(id) | Expr::Update { id, .. } => (ctx.integer_locals.contains(id) - || ctx.unsigned_i32_locals.contains(id)) - .then_some(32), - Expr::Uint8ArrayGet { .. } | Expr::BufferIndexGet { .. } => Some(8), - // In-bounds loads from an int-element typed array are integers in - // i32 range by construction (see `ta_int_elem_load_is_i32_provable`), - // as are i32-tier masked-window plain-array loads (the dense-i32 - // range guard proved every window value is an i32 integer). - Expr::IndexGet { object, index } - if ta_int_elem_load_is_i32_provable(ctx, object, index) - || super::masked_window::masked_window_i32_load_is_provable(ctx, object, index) => - { - Some(32) - } - Expr::MathImul(_, _) => Some(32), // Math.imul returns i32 → always finite - Expr::Call { callee, .. } => { - matches!(callee.as_ref(), Expr::FuncRef(fid) if ctx.integer_returning_functions.contains(fid)) - .then_some(32) - } - Expr::Binary { op, left, right } => match op { - BinaryOp::Add | BinaryOp::Sub => { - let l = known_finite_magnitude_bits(ctx, left)?; - let r = known_finite_magnitude_bits(ctx, right)?; - Some(l.max(r) + 1) - } - BinaryOp::Mul => { - let l = known_finite_magnitude_bits(ctx, left)?; - let r = known_finite_magnitude_bits(ctx, right)?; - Some(l + r) - } - // Bitwise results are already ToInt32/ToUint32-wrapped. - BinaryOp::BitAnd - | BinaryOp::BitOr - | BinaryOp::BitXor - | BinaryOp::Shl - | BinaryOp::Shr - | BinaryOp::UShr => Some(32), - _ => None, - }, - _ => None, - } +/// Returns true if `e` is proven to fit a signed i32 at this program point. +/// +/// `toint32_fast` currently emits `fptosi f64 -> i64` followed by `trunc`, but +/// LLVM's optimized output may combine that pair into `fptosi f64 -> i32`. +/// Its caller therefore needs an i32-range proof, not merely finiteness or an +/// i64-range magnitude bound. In particular, `integer_locals` proves only that +/// every write is integer-valued: a mutable local can hold the out-of-i32 +/// result of a prior `*=` or `+=`. Treating that coarse fact as a 32-bit bound +/// made the final `c &= 0x7fffffff` in #7232 convert a ~1.5e18 double with +/// poison and print `0` instead of applying ECMAScript ToInt32 wrapping. +pub(crate) fn is_known_i32_range(ctx: &FnCtx<'_>, e: &Expr) -> bool { + super::range_facts::int_range_expr(ctx, e) + .is_some_and(|range| range.min >= i64::from(i32::MIN) && range.max <= i64::from(i32::MAX)) } /// (Issue #50) If `IndexGet { object, index }` is a flat-const access @@ -355,10 +285,9 @@ fn is_i32_chain_op(op: BinaryOp) -> bool { /// Magnitude bound of `left right` from the operands' bounds. /// -/// `Add`/`Sub` grow the bound by one bit and `Mul` sums them — the same -/// composition [`known_finite_magnitude_bits`] uses — but capped at 2^53 -/// instead of 2^63, because this bound gates *exact integer arithmetic* rather -/// than a single `fptosi`. +/// `Add`/`Sub` grow the bound by one bit and `Mul` sums them, capped at 2^53 +/// because this bound gates exact integer arithmetic rather than ToInt32 +/// materialization. /// /// The ToInt32/ToUint32-wrapped operators reset the bound to 32. Two of them /// carry a tighter one, which is what keeps masked/shifted hash mixing on the @@ -1438,7 +1367,7 @@ fn lower_expr_native_i32(ctx: &mut FnCtx<'_>, e: &Expr) -> Result // Index/internal i32 materialization — packed-store RHS and // numeric-index consumers prove their ranges upstream, so // keep the lean guard here (see toint32 vs toint32_wrap). - if is_known_finite(ctx, e) { + if is_known_i32_range(ctx, e) { Some(ctx.block().toint32_fast(&lowered.value)) } else { Some(ctx.block().toint32(&lowered.value)) diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 1df50da00a..1b91d93e68 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -87,7 +87,7 @@ pub(crate) use helpers::{ }; pub(crate) use i32_fast_path::{ can_lower_expr_as_i32, can_lower_expr_as_i32_in_current_region, - imul_operand_i32_lowerable_in_current_region, is_known_finite, lower_expr_as_i32, + imul_operand_i32_lowerable_in_current_region, is_known_i32_range, lower_expr_as_i32, lower_expr_native, lower_imul_operand_i32, lower_packed_u32_loop_index_get, try_flat_const_2d_int, try_lower_flat_const_index_get, }; @@ -2921,10 +2921,17 @@ fn lower_bitwise_operand_i32(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result i32`. After `c *= 1103515245; c += 12345`, that is + // poison rather than ECMAScript ToInt32. Let the F64 arm below apply its + // program-point range proof and otherwise use `toint32_wrap`. + if !matches!(expr, Expr::LocalGet(_)) && can_lower_expr_as_i32_in_current_region(ctx, expr) { return Ok(Some( lower_expr_native(ctx, expr, ExpectedNativeRep::I32)?.value, )); @@ -2942,7 +2949,7 @@ fn lower_bitwise_operand_i32(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let value = lower_expr(ctx, expr)?; - return Ok(Some(if is_known_finite(ctx, expr) { + return Ok(Some(if is_known_i32_range(ctx, expr) { ctx.block().toint32_fast(&value) } else { ctx.block().toint32_wrap(&value) @@ -2973,7 +2980,7 @@ fn lower_bitwise_operand_i32(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { - if is_known_finite(ctx, expr) { + if is_known_i32_range(ctx, expr) { ctx.block().toint32_fast(&lowered.value) } else { ctx.block().toint32_wrap(&lowered.value) diff --git a/crates/perry-codegen/src/expr/property_get/globalget.rs b/crates/perry-codegen/src/expr/property_get/globalget.rs index abc305ed43..76ea825abf 100644 --- a/crates/perry-codegen/src/expr/property_get/globalget.rs +++ b/crates/perry-codegen/src/expr/property_get/globalget.rs @@ -174,6 +174,14 @@ pub(crate) fn lower_globalget_property(ctx: &mut FnCtx<'_>, property: &str) -> R &[(I64, &ctor_handle), (I64, &key_raw)], )); } + // `Buffer.isBuffer` used as a callback (for example + // `values.every(Buffer.isBuffer)`) needs the callable value, not only the + // direct-call intrinsic. Bare builtin receivers are represented by the + // shared `GlobalGet(0)` sentinel, and `isBuffer` is distinctive among the + // builtin statics, so recover it from the populated Buffer constructor. + if property == "isBuffer" { + return Ok(lower_global_builtin_static_value(ctx, "Buffer", property)); + } // #6674: `Uint8Array.fromBase64` / `fromHex` read as a VALUE (not a direct // call) — jose/Auth.js feature-detect with `Uint8Array.fromBase64 ? native // : fallback`. The bare `Uint8Array` receiver collapses to `GlobalGet(0)` diff --git a/crates/perry-codegen/src/expr/slot_rep.rs b/crates/perry-codegen/src/expr/slot_rep.rs index b51d5788e9..f8964cea22 100644 --- a/crates/perry-codegen/src/expr/slot_rep.rs +++ b/crates/perry-codegen/src/expr/slot_rep.rs @@ -597,8 +597,8 @@ pub(crate) fn load_canonical_local_boxed(ctx: &mut FnCtx<'_>, id: u32) -> Option /// finite value (an OOB int-typed-array read is a NaN-boxed `undefined`) must /// enter the slot as spec `ToInt32` — raw `fptosi` of a NaN is poison on /// x86-64. `rhs` (when available) lets known-finite writes keep the cheaper -/// `fptosi→i64→trunc`, bit-identical for finite values; pass `None` for -/// values of unknown provenance (always `toint32_wrap`). +/// `fptosi→i64→trunc`, bit-identical for signed-i32-range values; pass +/// `None` for values of unknown provenance (always `toint32_wrap`). /// /// Returns `true` when the local was canonical and the store was emitted. pub(crate) fn store_canonical_local_from_double( @@ -610,8 +610,8 @@ pub(crate) fn store_canonical_local_from_double( let Some((slot, _rep)) = canonical_local_i32_slot(ctx, id) else { return false; }; - let known_finite = rhs.is_some_and(|e| super::is_known_finite(ctx, e)); - let v_i32 = if known_finite { + let known_i32_range = rhs.is_some_and(|e| super::is_known_i32_range(ctx, e)); + let v_i32 = if known_i32_range { let v_i64 = ctx.block().fptosi(DOUBLE, value, I64); ctx.block().trunc(I64, &v_i64, I32) } else { diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index ec4f748b11..1b598c2ac3 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -41,6 +41,10 @@ fn static_block_fns(ctx: &FnCtx<'_>, template: &str) -> Vec { .unwrap_or_default() } +fn private_static_storage_name(class_id: u32, field_name: &str) -> String { + format!("#") +} + pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { match expr { Expr::StaticFieldGet { @@ -76,7 +80,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // Refs #420 / #618 followup. if let Some(&class_id) = ctx.class_ids.get(class_name) { let runtime_field_name = if field_name.starts_with('#') { - format!("#") + private_static_storage_name(class_id, field_name) } else { field_name.clone() }; @@ -431,6 +435,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { named_statics, computed_keys, computed_statics, + static_init_order, captured_args, } => { let template_cid = ctx.class_ids.get(template).copied().unwrap_or(0); @@ -535,26 +540,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &[(I64, &obj), (I64, &storage_raw), (DOUBLE, &key_value)], ); } - for (name, init) in named_statics { - let storage_name = if name.starts_with('#') { - format!("#") - } else { - name.clone() - }; - let key_idx = ctx.strings.intern(&storage_name); - let key_handle_global = - format!("@{}", ctx.strings.entry(key_idx).handle_global); - let v = lower_expr(ctx, init)?; - let obj = group.reread_emitted(ctx, rooted); - let blk = ctx.block(); - let key_box = blk.load(DOUBLE, &key_handle_global); - let key_bits = blk.bitcast_double_to_i64(&key_box); - let key_raw = blk.and(I64, &key_bits, crate::nanbox::POINTER_MASK_I64); - blk.call_void( - "js_object_set_field_by_name", - &[(I64, &obj), (I64, &key_raw), (DOUBLE, &v)], - ); - } // #1787: snapshot the captured outer-scope values onto the class // object as the `__perry_ctor_caps` own array (in the constructor's // capture-param order). `new ()` reads it back @@ -624,52 +609,72 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &[(I64, &obj), (I64, &key_raw), (DOUBLE, &caps_box)], ); } - for (key_slot, init) in computed_statics { - let value = lower_expr(ctx, init)?; - let key_idx = ctx.strings.intern(key_slot); - let entry = ctx.strings.entry(key_idx); - let key_bytes = format!("@{}", entry.bytes_global); - let key_len = entry.byte_len.to_string(); - let obj = group.reread_emitted(ctx, rooted); - let obj_box = nanbox_pointer_inline(ctx.block(), &obj); - let resolved_key = ctx.block().call( - DOUBLE, - "js_object_get_own_field_or_undef", - &[(DOUBLE, &obj_box), (PTR, &key_bytes), (I64, &key_len)], - ); - ctx.block().call( - DOUBLE, - "js_object_set_property_key", - &[ - (DOUBLE, &obj_box), - (DOUBLE, &resolved_key), - (DOUBLE, &value), - ], - ); - } - // #685: run the class's `static { … }` blocks NOW — at the class - // expression's evaluation, with `this` = THIS fresh class object. - // The `ClassExprFresh` fast path previously never invoked them - // (they are also skipped by the module-init fallback when another - // evaluation site invokes them inline), so `return class { static - // { this.viaBlock = tag } }` factories produced objects whose - // blocks simply never ran. Arm the one-shot static-`this` - // override before each call so the compiled body's - // `js_static_this_resolve` prologue binds `this` to the fresh - // object (writes land as own properties of this evaluation's - // object, not the shared template). Blocks run after the named - // static fields above — the source interleaving of fields and - // blocks is not reproduced on this path (pre-existing limitation). - // - // `block_fns` is computed above, next to `protect_handle`. - for fn_name in block_fns { - // #7154: a static block runs arbitrary user code, so re-derive - // the receiver from the root before each one. - let obj = group.reread_emitted(ctx, rooted); - let obj_box = nanbox_pointer_inline(ctx.block(), &obj); - ctx.block() - .call_void("js_static_this_arm_value", &[(DOUBLE, &obj_box)]); - ctx.block().call(DOUBLE, &fn_name, &[]); + // Static fields and blocks execute only after every computed + // name has been resolved, then in their original ClassBody + // order. Each vector index is recorded by HIR lowering. + for step in static_init_order { + match step { + perry_hir::ClassFreshStaticInit::Named(index) => { + let Some((name, init)) = named_statics.get(*index as usize) else { + continue; + }; + let storage_name = if name.starts_with('#') { + private_static_storage_name(template_cid, name) + } else { + name.clone() + }; + let key_idx = ctx.strings.intern(&storage_name); + let key_handle_global = + format!("@{}", ctx.strings.entry(key_idx).handle_global); + let value = lower_expr(ctx, init)?; + let obj = group.reread_emitted(ctx, rooted); + let blk = ctx.block(); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_bits = blk.bitcast_double_to_i64(&key_box); + let key_raw = blk.and(I64, &key_bits, crate::nanbox::POINTER_MASK_I64); + blk.call_void( + "js_object_set_field_by_name", + &[(I64, &obj), (I64, &key_raw), (DOUBLE, &value)], + ); + } + perry_hir::ClassFreshStaticInit::Computed(index) => { + let Some((key_slot, init)) = computed_statics.get(*index as usize) + else { + continue; + }; + let value = lower_expr(ctx, init)?; + let key_idx = ctx.strings.intern(key_slot); + let entry = ctx.strings.entry(key_idx); + let key_bytes = format!("@{}", entry.bytes_global); + let key_len = entry.byte_len.to_string(); + let obj = group.reread_emitted(ctx, rooted); + let obj_box = nanbox_pointer_inline(ctx.block(), &obj); + let resolved_key = ctx.block().call( + DOUBLE, + "js_object_get_own_field_or_undef", + &[(DOUBLE, &obj_box), (PTR, &key_bytes), (I64, &key_len)], + ); + ctx.block().call( + DOUBLE, + "js_object_set_property_key", + &[ + (DOUBLE, &obj_box), + (DOUBLE, &resolved_key), + (DOUBLE, &value), + ], + ); + } + perry_hir::ClassFreshStaticInit::Block(index) => { + let Some(fn_name) = block_fns.get(*index as usize) else { + continue; + }; + let obj = group.reread_emitted(ctx, rooted); + let obj_box = nanbox_pointer_inline(ctx.block(), &obj); + ctx.block() + .call_void("js_static_this_arm_value", &[(DOUBLE, &obj_box)]); + ctx.block().call(DOUBLE, fn_name, &[]); + } + } } let obj = group.reread_emitted(ctx, rooted); let obj_box = nanbox_pointer_inline(ctx.block(), &obj); diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index abf55e6313..4eb5c09e51 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -289,6 +289,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (DOUBLE, &first), ], ); + bind_derived_this_after_super(ctx); crate::lower_call::apply_field_initializers_recursive( ctx, ¤t_class_name, @@ -317,6 +318,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_url_search_params_subclass_init", &[(DOUBLE, &this_box), (DOUBLE, &first)], ); + bind_derived_this_after_super(ctx); crate::lower_call::apply_field_initializers_recursive( ctx, ¤t_class_name, @@ -349,6 +351,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_dom_exception_subclass_init", &[(DOUBLE, &this_box), (DOUBLE, &message), (DOUBLE, &name)], ); + bind_derived_this_after_super(ctx); crate::lower_call::apply_field_initializers_recursive( ctx, ¤t_class_name, @@ -365,6 +368,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &[(I32, &cid_str), (DOUBLE, &this_box), (DOUBLE, &arr_box)], ); } + bind_derived_this_after_super(ctx); // Spec: subclass field initializers run AFTER super() returns // (mirrors every other super arm). crate::lower_call::apply_field_initializers_recursive( @@ -459,6 +463,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_url_search_params_subclass_init", &[(DOUBLE, &this_box), (DOUBLE, &init)], ); + bind_derived_this_after_super(ctx); crate::lower_call::apply_field_initializers_recursive( ctx, ¤t_class_name, @@ -674,6 +679,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { }; if let Some(kind) = node_stream_kind { let result = lower_node_stream_super_init(ctx, kind, super_args)?; + bind_derived_this_after_super(ctx); let current_class_name = ctx.class_stack.last().cloned().unwrap_or_default(); crate::lower_call::apply_field_initializers_recursive( @@ -690,6 +696,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // would otherwise leave it length-less with no Array methods. if parent_name == "Array" { let result = lower_array_super_init(ctx, super_args)?; + bind_derived_this_after_super(ctx); let current_class_name = ctx.class_stack.last().cloned().unwrap_or_default(); crate::lower_call::apply_field_initializers_recursive( @@ -713,6 +720,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { }; if let Some(kind) = stream_kind { let result = lower_stream_super_init(ctx, kind, super_args)?; + bind_derived_this_after_super(ctx); // Per JS spec field initializers run AFTER super() // returns. Without this, `this.foo = []` declared // on the subclass never executes — instance reads @@ -737,6 +745,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { }; if let Some(kind) = node_stream_kind { let result = lower_node_stream_super_init(ctx, kind, super_args)?; + bind_derived_this_after_super(ctx); let current_class_name = ctx.class_stack.last().cloned().unwrap_or_default(); crate::lower_call::apply_field_initializers_recursive( @@ -783,6 +792,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (DOUBLE, &iterable), ], ); + bind_derived_this_after_super(ctx); let current_class_name = ctx.class_stack.last().cloned().unwrap_or_default(); crate::lower_call::apply_field_initializers_recursive( @@ -862,6 +872,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (I32, &is_custom), ], ); + bind_derived_this_after_super(ctx); let current_class_name = ctx.class_stack.last().cloned().unwrap_or_default(); crate::lower_call::apply_field_initializers_recursive( @@ -893,6 +904,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_dom_exception_subclass_init", &[(DOUBLE, &this_box), (DOUBLE, &arg0), (DOUBLE, &arg1)], ); + bind_derived_this_after_super(ctx); let current_class_name = ctx.class_stack.last().cloned().unwrap_or_default(); crate::lower_call::apply_field_initializers_recursive( @@ -925,6 +937,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_promise_subclass_init", &[(DOUBLE, &this_box), (DOUBLE, &executor)], ); + bind_derived_this_after_super(ctx); let current_class_name = ctx.class_stack.last().cloned().unwrap_or_default(); crate::lower_call::apply_field_initializers_recursive( @@ -956,6 +969,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { runtime_fn, &[(DOUBLE, &this_box), (DOUBLE, &arg0), (DOUBLE, &arg1)], ); + bind_derived_this_after_super(ctx); // Per JS spec, subclass field initializers run after // super() returns (mirrors the stream/error arms above). let current_class_name = @@ -1129,6 +1143,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } } } + bind_derived_this_after_super(ctx); return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); } }; @@ -1334,9 +1349,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let lower_result = crate::stmt::lower_stmts(ctx, &parent_ctor.body); ctx.try_depth = caller_try_depth; lower_result?; - if parent_is_derived { - pop_shared_super_called_slot(ctx); - } ctx.class_stack.pop(); let parent_return = ctx .inline_ctor_return @@ -1346,6 +1358,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ctx.block().br(&parent_after_label); } ctx.current_block = parent_after_idx; + if parent_is_derived { + pop_shared_super_called_slot(ctx); + } let parent_raw = ctx.block().load(DOUBLE, &parent_return.result_slot); if let Some(this_slot) = ctx.this_stack.last().cloned() { let inherited_this = ctx.block().load(DOUBLE, &this_slot); diff --git a/crates/perry-codegen/src/lower_call/field_init.rs b/crates/perry-codegen/src/lower_call/field_init.rs index c61826233c..fd14763c34 100644 --- a/crates/perry-codegen/src/lower_call/field_init.rs +++ b/crates/perry-codegen/src/lower_call/field_init.rs @@ -718,6 +718,28 @@ pub(crate) fn apply_field_initializers_recursive( ); } for (prop, init_expr, is_private) in init_pairs { + // A scalar-replaced `new C()` has no heap receiver. Its fields are + // represented by the allocas in `ctx.scalar_replaced`, and the + // dummy `this_stack` slot exists only so ordinary constructor + // assignments can reach the scalar PropertySet fast path. DefineField + // lowering bypasses PropertySet, so route public named initializers + // to those allocas directly as well. Otherwise `js_class_field_add` + // receives the uninitialized dummy `this` value. + if !is_private { + if let Some(target_id) = ctx.scalar_ctor_target.last().copied() { + let slot = ctx + .scalar_replaced + .get(&target_id) + .and_then(|fields| fields.get(&prop)) + .cloned(); + let value = lower_expr(ctx, &init_expr)?; + if let Some(slot) = slot { + ctx.block().store(DOUBLE, &value, &slot); + crate::expr::root_scalar_replaced_slot(ctx, &slot, &init_expr); + } + continue; + } + } if is_private { let value = lower_expr(ctx, &init_expr)?; let this_val = ctx diff --git a/crates/perry-codegen/src/lower_call/native/mod.rs b/crates/perry-codegen/src/lower_call/native/mod.rs index f6ca86807f..cdfdc4812b 100644 --- a/crates/perry-codegen/src/lower_call/native/mod.rs +++ b/crates/perry-codegen/src/lower_call/native/mod.rs @@ -409,6 +409,60 @@ pub(crate) fn lower_native_method_call( } } + // `X509Certificate` instances are compact native handles. Their method + // calls normally miss the static native table and used to fall through to + // `js_native_call_method_nullsafe`. That entry point also serves native + // *property reads*, so its zero-argument path asks the handle-property + // dispatcher first. For `cert.toLegacyObject()` this returned the bound + // method closure instead of invoking it; valid-host identity checks then + // appeared to pass only because a closure is not a certificate object. + // + // Bare method-value reads (`const f = cert.toLegacyObject`) remain ordinary + // `PropertyGet`s in HIR (`is_native_dispatch_member` deliberately excludes + // crypto), so an actual `NativeMethodCall` for this exact class is + // unambiguously a call. Route it through the non-property dispatcher just + // like the Console instance arm above. + if module == "crypto" && class_name == Some("X509Certificate") { + if let Some(recv) = object { + let recv_box = lower_expr(ctx, recv)?; + let mut lowered_args: Vec = Vec::with_capacity(args.len()); + for arg in args { + lowered_args.push(lower_expr(ctx, arg)?); + } + + let (args_ptr, args_len) = if lowered_args.is_empty() { + ("null".to_string(), "0".to_string()) + } else { + let n = lowered_args.len(); + let buf = ctx.func.alloca_entry_array(DOUBLE, n); + { + let blk = ctx.block(); + for (i, value) in lowered_args.iter().enumerate() { + let slot = blk.gep(DOUBLE, &buf, &[(I64, &i.to_string())]); + blk.store(DOUBLE, value, &slot); + } + } + (buf, n.to_string()) + }; + + let method_idx = ctx.strings.intern(method); + let entry = ctx.strings.entry(method_idx); + let bytes_global = format!("@{}", entry.bytes_global); + let name_len = entry.byte_len.to_string(); + return Ok(ctx.block().call( + DOUBLE, + "js_native_call_method", + &[ + (DOUBLE, &recv_box), + (PTR, &bytes_global), + (I64, &name_len), + (PTR, &args_ptr), + (I64, &args_len), + ], + )); + } + } + // Receiver-less native method calls (e.g. plugin::setConfig(...) // as a static module function): lower args for side effects and // return TAG_UNDEFINED. Using TAG_UNDEFINED (not 0.0) so that diff --git a/crates/perry-codegen/src/lower_call/native_module_rooting_tests.rs b/crates/perry-codegen/src/lower_call/native_module_rooting_tests.rs index 81d39327fe..f5c6b52b52 100644 --- a/crates/perry-codegen/src/lower_call/native_module_rooting_tests.rs +++ b/crates/perry-codegen/src/lower_call/native_module_rooting_tests.rs @@ -11,6 +11,16 @@ use perry_hir::types::Type; use perry_hir::{Expr, Function, Module, Stmt}; fn compile_native_call(args: Vec) -> String { + compile_native_instance_call("https", None, None, "createServer", args) +} + +fn compile_native_instance_call( + native_module: &str, + class_name: Option<&str>, + object: Option, + method: &str, + args: Vec, +) -> String { let mut module = Module::new("native_module_rooting_test.ts"); module.functions.push(Function { id: 0, @@ -19,10 +29,10 @@ fn compile_native_call(args: Vec) -> String { params: Vec::new(), return_type: Type::Any, body: vec![Stmt::Expr(Expr::NativeMethodCall { - module: "https".to_string(), - class_name: None, - object: None, - method: "createServer".to_string(), + module: native_module.to_string(), + class_name: class_name.map(str::to_string), + object: object.map(Box::new), + method: method.to_string(), args, })], is_async: false, @@ -84,3 +94,24 @@ fn native_module_first_argument_is_rooted_across_allocating_second_argument() { "native-module options argument", ); } + +#[test] +fn x509_zero_argument_method_call_uses_invoking_dispatch() { + let module_ir = compile_native_instance_call( + "crypto", + Some("X509Certificate"), + Some(Expr::Number(1.0)), + "toLegacyObject", + Vec::new(), + ); + let ir = build_function_ir(&module_ir); + + assert!( + ir.contains("call double @js_native_call_method("), + "X509Certificate method calls must use the invoking dispatcher:\n{ir}" + ); + assert!( + !ir.contains("@js_native_call_method_nullsafe("), + "the zero-argument property-read fallback returns a bound method closure:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs b/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs index f7f1f7aeca..675103c95a 100644 --- a/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs +++ b/crates/perry-codegen/src/lower_call/native_table/node_core/module_sea_tls_test.rs @@ -255,6 +255,15 @@ pub(crate) const NODE_CORE_MODULE_SEA_TLS_TEST_ROWS: &[NativeModSig] = &[ args: &[], ret: NR_F64, }, + NativeModSig { + module: "tls", + has_receiver: false, + method: "getCertificateCompressionAlgorithms", + class_filter: None, + runtime: "js_tls_get_certificate_compression_algorithms", + args: &[], + ret: NR_F64, + }, NativeModSig { module: "tls", has_receiver: false, diff --git a/crates/perry-codegen/src/lower_call/native_table/tls_events.rs b/crates/perry-codegen/src/lower_call/native_table/tls_events.rs index b0b31953af..b7a210488a 100644 --- a/crates/perry-codegen/src/lower_call/native_table/tls_events.rs +++ b/crates/perry-codegen/src/lower_call/native_table/tls_events.rs @@ -141,7 +141,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_tls_server_set_secure_context", args: &[NA_JSV], - ret: NR_PTR, + ret: NR_VOID, }, NativeModSig { module: "tls", @@ -159,7 +159,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_tls_server_set_ticket_keys", args: &[NA_JSV], - ret: NR_PTR, + ret: NR_VOID, }, NativeModSig { module: "net", @@ -221,7 +221,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ method: "exportKeyingMaterial", class_filter: Some("Socket"), runtime: "js_tls_socket_export_keying_material", - args: &[NA_F64, NA_STR], + args: &[NA_F64, NA_JSV, NA_JSV], ret: NR_F64, }, NativeModSig { @@ -233,4 +233,67 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ args: &[NA_F64], ret: NR_F64, }, + NativeModSig { + module: "net", + has_receiver: true, + method: "getEphemeralKeyInfo", + class_filter: Some("Socket"), + runtime: "js_tls_socket_get_ephemeral_key_info", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "getFinished", + class_filter: Some("Socket"), + runtime: "js_tls_socket_get_finished", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "getPeerFinished", + class_filter: Some("Socket"), + runtime: "js_tls_socket_get_peer_finished", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "getSharedSigalgs", + class_filter: Some("Socket"), + runtime: "js_tls_socket_get_shared_sigalgs", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "getX509Certificate", + class_filter: Some("Socket"), + runtime: "js_tls_socket_get_x509_certificate", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "getPeerX509Certificate", + class_filter: Some("Socket"), + runtime: "js_tls_socket_get_peer_x509_certificate", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "net", + has_receiver: true, + method: "setKeyCert", + class_filter: Some("Socket"), + runtime: "js_tls_socket_set_key_cert", + args: &[NA_JSV], + ret: NR_F64, + }, ]; diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 6c38c10f07..4576bcf463 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -1360,6 +1360,7 @@ fn lower_new_impl_inner<'a>( | "BigUint64Array" ) }) { + lowered_args = refresh_rooted_args(ctx, group)?; let (args_ptr, args_len) = lower_js_args_array(ctx, &lowered_args); let class_id = ctx .class_ids diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 01181fc960..c0d8e4224a 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -1936,10 +1936,10 @@ pub(crate) fn lower_let( // sentinel on x86-64 — so it is NOT portable. `int_valued_ta` // locals (and any other i32-shadow local with a non-known-finite // init) are only ever observed through ToInt32, so seeding with - // the exact ToInt32 keeps every arm identical. Known-finite - // inits keep the cheaper `fptosi→i64→trunc` (bit-identical for - // finite values), so existing i32-shadow locals are unchanged. - let v_i32 = if crate::expr::is_known_finite(ctx, init_expr) { + // the exact ToInt32 keeps every arm identical. Proven-i32-range + // inits keep the cheaper `fptosi→i64→trunc`, so existing + // i32-shadow locals are unchanged. + let v_i32 = if crate::expr::is_known_i32_range(ctx, init_expr) { let v_i64 = ctx.block().fptosi(DOUBLE, &v, crate::types::I64); ctx.block().trunc(crate::types::I64, &v_i64, I32) } else { diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index edb39054ee..140c8d2266 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -151,6 +151,31 @@ fn compile_ir_for_module_with_opts(module: Module, opts: CompileOptions) -> anyh Ok(String::from_utf8(compile_module(&module, opts)?)?) } +#[test] +fn generic_strict_equality_does_not_read_unverified_pointer_headers() { + let module = module_with_classes_and_params( + "generic_strict_equality_pointer_safety.ts", + Vec::new(), + vec![param(1, "left", Type::Any), param(2, "right", Type::Any)], + Type::Boolean, + vec![Stmt::Return(Some(Expr::Compare { + op: CompareOp::Eq, + left: Box::new(local(1)), + right: Box::new(local(2)), + }))], + ); + let ir = compile_ir_for_module_with_opts(module, empty_opts()).unwrap(); + + assert!( + ir.contains("anyeq.slow") && ir.contains("call i64 @js_eq"), + "distinct generic pointer values need the registry-aware runtime fallback:\n{ir}" + ); + assert!( + !ir.contains("anyeq.fwd"), + "generic equality must not read a GC header after only a pointer-tag/magnitude check:\n{ir}" + ); +} + fn contains_inline_direct_method_shape_guard(ir: &str) -> bool { ir.contains("method_direct.inline_deref") && ir.contains("load atomic i8, ptr @PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED acquire") @@ -272,6 +297,7 @@ fn class_with_computed_member(id: u32, name: &str, fields: Vec) -> C }, is_static: false, kind: ClassComputedMemberKind::Method, + source_order: 0, }); class } @@ -9781,6 +9807,39 @@ fn scalar_method_summary_module() -> Module { ) } +fn scalar_field_initializer_module() -> Module { + let mut value_field = class_field("value", Type::Number); + value_field.init = Some(number(42.0)); + let holder = class(109, "Holder", vec![value_field]); + + module_with_classes_and_params( + "scalar_field_initializer.ts", + vec![holder], + Vec::new(), + Type::Number, + vec![ + Stmt::Let { + id: 20, + name: "holder".to_string(), + ty: Type::Named("Holder".to_string()), + mutable: false, + init: Some(Expr::New { + class_name: "Holder".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + }, + Stmt::Return(Some(Expr::PropertyGet { + byte_offset: 0, + object: Box::new(local(20)), + property: "value".to_string(), + })), + ], + ) +} + fn scalar_method_field_write_module() -> Module { let mut counter = class(111, "Counter", vec![class_field("value", Type::Number)]); counter.constructor = Some(Function { @@ -10421,6 +10480,7 @@ fn scalar_method_boolean_negative_module(case: &str) -> Module { }, is_static: false, kind: ClassComputedMemberKind::Method, + source_order: 0, }); } "inherited_field_shadow" => { @@ -13279,6 +13339,23 @@ fn scalar_replaced_simple_method_call_inlines_summary_without_dispatch() { ); } +#[test] +fn scalar_replaced_class_field_initializer_uses_its_field_slot() { + let ir = String::from_utf8( + compile_module(&scalar_field_initializer_module(), empty_opts()).unwrap(), + ) + .unwrap(); + let probe_ir = function_ir_section(&ir, "perry_fn_scalar_field_initializer_ts__probe"); + assert!( + !probe_ir.contains("call double @js_class_field_add"), + "a scalar-replaced construction has no receiver for DefineField:\n{probe_ir}" + ); + assert!( + probe_ir.contains("store double 42.0"), + "the initializer must populate the scalar field slot:\n{probe_ir}" + ); +} + #[test] fn artifact_records_scalar_replaced_method_summary_inline() { let artifact = compile_artifact_json_for_module(scalar_method_summary_module()); diff --git a/crates/perry-codegen/tests/typed_feedback.rs b/crates/perry-codegen/tests/typed_feedback.rs index 187b6fd6f0..cf928819eb 100644 --- a/crates/perry-codegen/tests/typed_feedback.rs +++ b/crates/perry-codegen/tests/typed_feedback.rs @@ -818,12 +818,10 @@ fn typed_feedback_guards_direct_class_method_specialization() { assert!(ir.contains("js_typed_feedback_method_direct_call_guard")); assert!(ir.contains("method_direct.fast")); assert!(ir.contains("method_direct.fallback")); - // #5334 lever A: this class has a field `x` whose synthesized field-set - // routes its guard-miss arm through the outlined fallback. (The - // method-direct fallback only records when its site_id is Some, which it - // isn't here — the old `record_fallback_call` assertion was incidentally - // satisfied by the field-set fallback that is now folded into this call.) - assert!(ir.contains("call void @js_class_field_set_fallback")); + // Class field initialization follows DefineField semantics, so the + // synthesized initializer uses the class-field add helper rather than the + // ordinary property-set fallback. + assert!(ir.contains("call double @js_class_field_add")); assert!(ir.contains("call double @js_native_call_method")); } diff --git a/crates/perry-ext-net/Cargo.toml b/crates/perry-ext-net/Cargo.toml index 68d831f44d..deefbcccd3 100644 --- a/crates/perry-ext-net/Cargo.toml +++ b/crates/perry-ext-net/Cargo.toml @@ -24,6 +24,7 @@ tokio-rustls.workspace = true rustls.workspace = true rustls-native-certs = "0.8" serde_json.workspace = true +rustls-pemfile.workspace = true [dev-dependencies] perry-ffi = { workspace = true, features = ["runtime-link"] } diff --git a/crates/perry-ext-net/src/jsvalue.rs b/crates/perry-ext-net/src/jsvalue.rs index b1bd4929ea..9382757f38 100644 --- a/crates/perry-ext-net/src/jsvalue.rs +++ b/crates/perry-ext-net/src/jsvalue.rs @@ -187,6 +187,18 @@ pub(crate) unsafe fn get_object_string_field(obj_f64: f64, field_name: &str) -> None } +pub(crate) unsafe fn get_object_value_field(obj_f64: f64, field_name: &str) -> Option { + if !is_nanboxed_pointer(obj_f64) { + return None; + } + let obj_ptr = unbox_pointer(obj_f64) as *const ObjectHeader; + if (obj_ptr as usize) < 0x100000 { + return None; + } + let key = js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32); + Some(js_object_get_field_by_name_f64(obj_ptr, key)) +} + pub(crate) unsafe fn get_object_number_field(obj_f64: f64, field_name: &str) -> Option { if !is_nanboxed_pointer(obj_f64) { return None; @@ -253,10 +265,10 @@ pub(crate) unsafe fn get_object_bool_field(obj_f64: f64, field_name: &str) -> Op /// instances, not raw strings. Returns a NaN-boxed `f64` pointing at /// the object. Issue #770. pub(crate) unsafe fn build_error_object(msg: &str) -> f64 { - let keys: [&str; 1] = ["message"]; + let keys: [&str; 3] = ["message", "code", "name"]; let (packed, shape_id) = build_object_shape(&keys); let obj: *mut ObjectHeader = - js_object_alloc_with_shape(shape_id, 1, packed.as_ptr(), packed.len() as u32); + js_object_alloc_with_shape(shape_id, 3, packed.as_ptr(), packed.len() as u32); if obj.is_null() { // Fall back to the bare string so the listener still receives // *something* if the object alloc failed. @@ -266,6 +278,24 @@ pub(crate) unsafe fn build_error_object(msg: &str) -> f64 { let s = alloc_string(msg); let v = JsValue::from_string_ptr(s.as_raw()); js_object_set_field(obj, 0, v); + let code = if msg.starts_with("ERR_") { + Some(msg) + } else if msg.contains("UnknownIssuer") + || msg.contains("unknown issuer") + || msg.contains("invalid peer certificate") + { + Some("DEPTH_ZERO_SELF_SIGNED_CERT") + } else if msg.to_ascii_lowercase().contains("connection refused") { + Some("ECONNREFUSED") + } else { + None + }; + if let Some(code) = code { + let code = alloc_string(code); + js_object_set_field(obj, 1, JsValue::from_string_ptr(code.as_raw())); + } + let name = alloc_string("Error"); + js_object_set_field(obj, 2, JsValue::from_string_ptr(name.as_raw())); let obj_v = JsValue::from_object_ptr(obj as *mut u8); f64::from_bits(obj_v.bits()) } diff --git a/crates/perry-ext-net/src/lib.rs b/crates/perry-ext-net/src/lib.rs index cd455c1df7..73372b5626 100644 --- a/crates/perry-ext-net/src/lib.rs +++ b/crates/perry-ext-net/src/lib.rs @@ -117,10 +117,11 @@ pub use server_state::*; mod jsvalue; pub(crate) use jsvalue::{ build_error_object, get_object_bool_field, get_object_number_field, get_object_string_field, - is_nanboxed_pointer, jsvalue_to_socket_bytes, string_from_header_i64, unbox_pointer, + get_object_value_field, is_nanboxed_pointer, jsvalue_to_socket_bytes, string_from_header_i64, + unbox_pointer, }; -use crate::tls::do_tls_handshake; +use crate::tls::{do_tls_handshake, record_tls_handshake, TlsClientConfigData}; // ─── Transport enum (plain or TLS, swappable at runtime) ───────────────────── // @@ -373,6 +374,7 @@ pub(crate) enum SocketCommand { UpgradeTls { servername: String, verify: bool, + config: TlsClientConfigData, reply: oneshot::Sender>, }, } @@ -382,6 +384,7 @@ enum PendingNetEvent { /// `.1` identifies a same-process server target and whether its admission /// is expected to hit `dropMaxConnection`; external connects use `None`. Connect(i64, Option<(i64, bool)>), + SecureConnect(i64), /// One chunk of read data. Carried as a refcounted `Bytes` — a zero-copy /// view sliced out of the socket task's reused read buffer (`split_to`) — /// so the path from the receive buffer to the main-thread drain handler @@ -1103,8 +1106,23 @@ pub unsafe extern "C" fn js_net_socket_method_connect(handle: i64, port: f64, ho pub(crate) fn spawn_socket_task( host: String, port: u16, - direct_tls: Option<(String, bool)>, + direct_tls: Option<(String, bool, TlsClientConfigData)>, ) -> i64 { + spawn_socket_task_initialized(host, port, direct_tls, |_| {}) +} + +/// Allocate a socket and run `initialize` after its registries exist but before +/// the async connect task can complete. TLS uses this boundary to publish its +/// runtime metadata without racing a fast loopback handshake. +pub(crate) fn spawn_socket_task_initialized( + host: String, + port: u16, + direct_tls: Option<(String, bool, TlsClientConfigData)>, + initialize: F, +) -> i64 +where + F: FnOnce(i64), +{ ensure_gc_scanner_registered(); dispatch::ensure_runtime_dispatch_registered(); let id = next_id_or_throw(); @@ -1137,6 +1155,7 @@ pub(crate) fn spawn_socket_task( .lock() .unwrap() .insert(id, HashMap::new()); + initialize(id); spawn_socket_runner(move || { Box::pin(async move { @@ -1162,9 +1181,12 @@ pub(crate) fn spawn_socket_task( let local = tcp.local_addr().ok(); let transport = match direct_tls { - Some((servername, verify)) => { - match do_tls_handshake(tcp, &servername, verify).await { - Ok(tls) => Transport::Tls(Box::new(tls)), + Some((servername, verify, config)) => { + match do_tls_handshake(tcp, &servername, verify, Some(&config)).await { + Ok(tls) => { + record_tls_handshake(id, &tls, &servername, verify, Some(&config)); + Transport::Tls(Box::new(tls)) + } Err(e) => { server_state::cancel_local_connect(local_server); push_event(PendingNetEvent::Error(id, e)); @@ -1242,6 +1264,11 @@ pub(crate) async fn run_socket_task( // #2154 raw mode: signal EOF on the buffer, suppress // JS events. Else (#1852) fire 'end' then 'close' per // Node's default `allowHalfOpen: false` teardown order. + // Complete the writable half before dropping the + // transport. For TLS this sends close_notify; without + // it the peer observes an unclean EOF and emits only + // 'close', skipping its 'end' event. + let _ = t.shutdown().await; if !raw_bridge::mark_terminal(id, None) { push_event(PendingNetEvent::End(id)); push_event(PendingNetEvent::Close(id)); @@ -1317,14 +1344,22 @@ pub(crate) async fn run_socket_task( mark_closed(id); break; } - Some(SocketCommand::UpgradeTls { servername, verify, reply }) => { + Some(SocketCommand::UpgradeTls { servername, verify, config, reply }) => { let old = transport.take(); match old { Some(Transport::Plain(tcp)) => { - match do_tls_handshake(tcp, &servername, verify).await { + match do_tls_handshake(tcp, &servername, verify, Some(&config)).await { Ok(tls) => { + record_tls_handshake( + id, + &tls, + &servername, + verify, + Some(&config), + ); transport = Some(Transport::Tls(Box::new(tls))); let _ = reply.send(Ok(())); + push_event(PendingNetEvent::SecureConnect(id)); } Err(e) => { let _ = reply.send(Err(e.clone())); @@ -1371,9 +1406,14 @@ pub unsafe extern "C" fn js_net_socket_on(handle: i64, event_ptr: i64, cb: i64) Some(e) => e, None => return, }; - let mut listeners = statics::listeners().lock().unwrap(); - let entry = listeners.entry(handle).or_default(); - entry.entry(event).or_default().push(cb); + { + let mut listeners = statics::listeners().lock().unwrap(); + let entry = listeners.entry(handle).or_default(); + entry.entry(event.clone()).or_default().push(cb); + } + if event == "close" { + tls::fire_pending_tls_abort(handle); + } } // ─── FFI: socket.upgradeToTLS(servername, verify) -> Promise ───────────────── @@ -1422,6 +1462,7 @@ pub unsafe extern "C" fn js_net_socket_upgrade_tls( .send(SocketCommand::UpgradeTls { servername, verify: verify_bool, + config: TlsClientConfigData::default(), reply: reply_tx, }) .is_err() @@ -1471,6 +1512,52 @@ pub unsafe extern "C" fn js_net_process_pending() -> i32 { js_ext_net_drain_pending() } +fn socket_receiver(handle: i64) -> f64 { + f64::from_bits(0x7FFD_0000_0000_0000 | (handle as u64 & 0x0000_FFFF_FFFF_FFFF)) +} + +unsafe fn emit_socket_no_arg(handle: i64, event: &str) { + extern "C" { + fn js_implicit_this_set(value: f64) -> f64; + } + let frame = dispatch_custody::DispatchFrame::park(listeners_for(handle, event)); + let previous_this = js_implicit_this_set(socket_receiver(handle)); + for index in 0..frame.len() { + let callback = frame.cb(index); + if callback != 0 { + let _ = JsClosure::from_raw(callback as *const RawClosureHeader).call0(); + } + } + js_implicit_this_set(previous_this); + drop(frame); + lifecycle::drain_once_listeners(handle, event); +} + +unsafe fn emit_tls_secure_connect(handle: i64) { + extern "C" { + fn js_tls_client_check_identity_from_metadata(handle: i64) -> f64; + } + let identity_error = js_tls_client_check_identity_from_metadata(handle); + if !JsValue::from_bits(identity_error.to_bits()).is_undefined() { + let mut frame = dispatch_custody::DispatchFrame::park(listeners_for(handle, "error")); + frame.set_payload(identity_error.to_bits()); + for index in 0..frame.len() { + let callback = frame.cb(index); + if callback != 0 { + let _ = JsClosure::from_raw(callback as *const RawClosureHeader) + .call1(f64::from_bits(frame.payload_bits())); + } + } + drop(frame); + lifecycle::drain_once_listeners(handle, "error"); + if let Some(socket) = statics::sockets().lock().unwrap().get(&handle) { + let _ = socket.cmd_tx.send(SocketCommand::Destroy); + } + return; + } + emit_socket_no_arg(handle, "secureConnect"); +} + /// Drain ext-net's own pending-event queue. /// /// This carries a DISTINCT `#[no_mangle]` symbol (`js_ext_net_drain_pending`), @@ -1507,30 +1594,19 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { server_state::finish_local_connect(local_server); // #8259: park the snapshot so callback N stays rooted (and is // rewritten on evacuation) while callback N-1 runs user JS. - let frame = dispatch_custody::DispatchFrame::park(listeners_for(id, "connect")); - for i in 0..frame.len() { - let cb = frame.cb(i); - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0(); - } - } - drop(frame); - lifecycle::drain_once_listeners(id, "connect"); + emit_socket_no_arg(id, "connect"); // TLS sockets additionally fire 'secureConnect' once the // handshake completes — the direct-TLS connect path only // signals Connect after the handshake, so this is the right // tick. Plain sockets simply have no listeners here. #4971. - let frame = - dispatch_custody::DispatchFrame::park(listeners_for(id, "secureConnect")); - for i in 0..frame.len() { - let cb = frame.cb(i); - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call0(); - } + extern "C" { + fn js_tls_client_is_connected(handle: i64) -> i32; + } + if js_tls_client_is_connected(id) != 0 { + emit_tls_secure_connect(id); } - drop(frame); - lifecycle::drain_once_listeners(id, "secureConnect"); } + PendingNetEvent::SecureConnect(id) => emit_tls_secure_connect(id), PendingNetEvent::Data(id, bytes) => { let cbs = listeners_for(id, "data"); if cbs.is_empty() { @@ -1635,6 +1711,10 @@ pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { lifecycle::drain_once_listeners(id, "end"); } PendingNetEvent::Close(id) => { + extern "C" { + fn js_tls_client_record_closed(handle: i64); + } + js_tls_client_record_closed(id); let had_error = f64::from_bits(JsValue::from_bool(false).bits()); let frame = dispatch_custody::DispatchFrame::park(listeners_for(id, "close")); for i in 0..frame.len() { diff --git a/crates/perry-ext-net/src/tls.rs b/crates/perry-ext-net/src/tls.rs index 320a1f9929..9570dba193 100644 --- a/crates/perry-ext-net/src/tls.rs +++ b/crates/perry-ext-net/src/tls.rs @@ -2,12 +2,373 @@ //! `socket.upgradeToTLS` mid-stream upgrade. Split out of `lib.rs` (#1852) //! to keep that file under the 2000-line gate; the logic is unchanged. -use std::sync::Arc; +use std::sync::{Arc, Mutex, OnceLock}; +use perry_ffi::{js_array_get, js_array_length, ArrayHeader, JsValue}; use tokio::net::TcpStream; +use tokio_rustls::rustls::client::danger::{ + HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier, +}; use tokio_rustls::{client::TlsStream, rustls, TlsConnector}; -fn build_tls_connector(verify: bool) -> Result { +#[derive(Clone, Default)] +pub(crate) struct TlsClientConfigData { + ca: Option>>, + cert: Vec, + key: Vec, + alpn_protocols: Vec>, + version_mask: i32, + custom_identity: bool, +} + +fn pending_tls_aborts() -> &'static Mutex> { + static ABORTS: OnceLock>> = OnceLock::new(); + ABORTS.get_or_init(|| Mutex::new(std::collections::HashSet::new())) +} + +pub(crate) fn fire_pending_tls_abort(handle: i64) { + if pending_tls_aborts().lock().unwrap().remove(&handle) { + crate::push_event(crate::PendingNetEvent::AbortError(handle)); + crate::push_event(crate::PendingNetEvent::Close(handle)); + } +} + +/// Node reports an already-aborted connect asynchronously, after callers have +/// had a chance to attach `error` and `close` listeners to the returned socket. +unsafe fn schedule_tls_abort(handle: i64) { + // An alloc-only SocketState normally has `pending_rx: Some` and is not + // considered live by ext-net until connect() consumes that receiver. This + // TLS fast path never starts connect(), so mark the synthetic socket live + // until its deferred Close event removes it from the registry. + if let Some(socket) = crate::statics::sockets().lock().unwrap().get_mut(&handle) { + socket.is_open = true; + } + pending_tls_aborts().lock().unwrap().insert(handle); + perry_ffi::spawn_async(async move { + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + fire_pending_tls_abort(handle); + }); +} + +pub(crate) fn begin_tls_upgrade( + handle: i64, + servername: String, + verify: bool, + config: TlsClientConfigData, +) -> Result<(), String> { + let cmd_tx = crate::statics::sockets() + .lock() + .unwrap() + .get(&handle) + .map(|socket| socket.cmd_tx.clone()) + .ok_or_else(|| "socket is closed".to_string())?; + let (reply, _reply_rx) = tokio::sync::oneshot::channel(); + cmd_tx + .send(crate::SocketCommand::UpgradeTls { + servername, + verify, + config, + reply, + }) + .map_err(|_| "socket task is gone".to_string()) +} + +unsafe fn is_array(value: f64) -> bool { + extern "C" { + fn js_array_is_array(value: f64) -> f64; + } + JsValue::from_bits(js_array_is_array(value).to_bits()).to_bool() +} + +unsafe fn value_bytes(value: f64) -> Option> { + let js = JsValue::from_bits(value.to_bits()); + if js.is_any_string() { + return crate::jsvalue_to_socket_bytes(value); + } + // Read through the canonical runtime registry. `perry-ext-net` is a + // separately linked archive, so perry-ffi's local Buffer registry cannot + // see Buffers allocated by the program runtime (notably `ca`, `cert`, and + // `key` values returned by fs.readFileSync). + extern "C" { + fn js_value_buffer_or_typedarray_data(value: f64, out_len: *mut u32) -> *const u8; + } + let mut len = 0u32; + let data = js_value_buffer_or_typedarray_data(value, &mut len); + if data.is_null() { + None + } else { + Some(std::slice::from_raw_parts(data, len as usize).to_vec()) + } +} + +unsafe fn material_list(value: f64) -> Option>> { + let js = JsValue::from_bits(value.to_bits()); + if js.is_undefined() || js.is_null() { + return Some(Vec::new()); + } + if is_array(value) { + let array = crate::unbox_pointer(value) as *const ArrayHeader; + let mut out = Vec::new(); + for index in 0..js_array_length(array) { + out.extend(material_list(f64::from_bits( + js_array_get(array, index).bits(), + ))?); + } + return Some(out); + } + value_bytes(value).map(|bytes| vec![bytes]) +} + +unsafe fn option_value(options: f64, secure_context: f64, name: &str) -> Option { + crate::get_object_value_field(options, name).and_then(|value| { + let js = JsValue::from_bits(value.to_bits()); + if js.is_undefined() { + crate::get_object_value_field(secure_context, name) + } else { + Some(value) + } + }) +} + +unsafe fn parse_alpn(value: f64) -> Vec> { + if is_array(value) { + let array = crate::unbox_pointer(value) as *const ArrayHeader; + return (0..js_array_length(array)) + .filter_map(|index| { + let item = f64::from_bits(js_array_get(array, index).bits()); + crate::jsvalue_to_socket_bytes(item) + }) + .collect(); + } + let Some(encoded) = value_bytes(value) else { + return Vec::new(); + }; + let mut offset = 0usize; + let mut out = Vec::new(); + while offset < encoded.len() { + let len = encoded[offset] as usize; + offset += 1; + if len == 0 || offset + len > encoded.len() { + break; + } + out.push(encoded[offset..offset + len].to_vec()); + offset += len; + } + out +} + +unsafe fn tls_client_config_data(options: f64) -> TlsClientConfigData { + let secure_context = crate::get_object_value_field(options, "secureContext") + .unwrap_or_else(|| f64::from_bits(JsValue::UNDEFINED.bits())); + let mut ca = option_value(options, secure_context, "ca").and_then(|value| material_list(value)); + if ca.is_none() { + extern "C" { + fn js_tls_default_ca_is_configured() -> i32; + fn js_tls_get_ca_certificates(ca_type: f64) -> f64; + } + if js_tls_default_ca_is_configured() != 0 { + ca = material_list(js_tls_get_ca_certificates(f64::from_bits( + JsValue::UNDEFINED.bits(), + ))); + } + } + let cert = option_value(options, secure_context, "cert") + .and_then(|value| value_bytes(value)) + .unwrap_or_default(); + let key = option_value(options, secure_context, "key") + .and_then(|value| value_bytes(value)) + .unwrap_or_default(); + let alpn_protocols = option_value(options, secure_context, "ALPNProtocols") + .map(|value| parse_alpn(value)) + .unwrap_or_default(); + extern "C" { + fn js_tls_effective_version_mask(options: f64) -> i32; + } + TlsClientConfigData { + ca, + cert, + key, + alpn_protocols, + version_mask: js_tls_effective_version_mask(options), + custom_identity: option_value(options, secure_context, "checkServerIdentity").is_some_and( + |value| { + let js = JsValue::from_bits(value.to_bits()); + !js.is_undefined() && !js.is_null() + }, + ), + } +} + +fn protocol_versions(mask: i32) -> Vec<&'static rustls::SupportedProtocolVersion> { + let mask = if mask == 0 { 0b11 } else { mask }; + let mut versions = Vec::new(); + if mask & 0b10 != 0 { + versions.push(&rustls::version::TLS13); + } + if mask & 0b01 != 0 { + versions.push(&rustls::version::TLS12); + } + versions +} + +unsafe fn signal_is_pre_aborted(options: f64) -> bool { + let Some(signal) = crate::get_object_value_field(options, "signal") else { + return false; + }; + extern "C" { + fn js_abort_signal_resolve_ptr(value: f64) -> *mut u8; + fn js_abort_signal_is_aborted(signal: *mut u8) -> i32; + } + let signal = js_abort_signal_resolve_ptr(signal); + !signal.is_null() && js_abort_signal_is_aborted(signal) != 0 +} + +unsafe fn tls_preflight(port: u16, servername: &str, options: f64) -> i32 { + extern "C" { + fn js_tls_client_preflight( + port: f64, + servername_ptr: *const u8, + servername_len: usize, + options: f64, + ) -> i32; + } + js_tls_client_preflight(port as f64, servername.as_ptr(), servername.len(), options) +} + +fn preflight_error(code: i32) -> &'static str { + match code { + 1 => "ERR_TLS_ALPN_CALLBACK_INVALID_RESULT", + 2 => "ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL", + 3 => "ERR_TLS_SNI_CALLBACK_FAILED", + _ => "ERR_TLS_HANDSHAKE_FAILED", + } +} + +fn add_pem_roots(store: &mut rustls::RootCertStore, materials: &[Vec]) { + for material in materials { + let mut cursor = std::io::Cursor::new(material); + for cert in rustls_pemfile::certs(&mut cursor).flatten() { + let _ = store.add(cert); + } + } +} + +fn configured_ca_certificates(data: Option<&TlsClientConfigData>) -> Vec> { + data.and_then(|data| data.ca.as_ref()) + .into_iter() + .flatten() + .flat_map(|material| { + let mut cursor = std::io::Cursor::new(material); + rustls_pemfile::certs(&mut cursor) + .flatten() + .map(|cert| cert.as_ref().to_vec()) + .collect::>() + }) + .collect() +} + +/// Node accepts an explicitly trusted self-signed certificate as a server +/// leaf even when its BasicConstraints extension also marks it as a CA. +/// rustls-webpki rejects that narrow shape as `CaUsedAsEndEntity`. Delegate +/// every normal check to rustls and recover only when the presented leaf is +/// byte-for-byte one of the configured CA certificates, retaining hostname +/// validation and rustls's TLS handshake-signature checks. +#[derive(Debug)] +struct NodeConfiguredCaVerifier { + inner: Arc, + roots: rustls::RootCertStore, + configured: Vec>, + custom_identity: bool, +} + +fn is_ca_used_as_end_entity(error: &rustls::Error) -> bool { + let rustls::Error::InvalidCertificate(rustls::CertificateError::Other(other)) = error else { + return false; + }; + other.0.to_string() == "CaUsedAsEndEntity" +} + +impl ServerCertVerifier for NodeConfiguredCaVerifier { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + intermediates: &[rustls::pki_types::CertificateDer<'_>], + server_name: &rustls::pki_types::ServerName<'_>, + ocsp_response: &[u8], + now: rustls::pki_types::UnixTime, + ) -> Result { + if self.custom_identity { + let parsed = rustls::server::ParsedCertificate::try_from(end_entity)?; + let provider = rustls::crypto::aws_lc_rs::default_provider(); + match rustls::client::verify_server_cert_signed_by_trust_anchor( + &parsed, + &self.roots, + intermediates, + now, + provider.signature_verification_algorithms.all, + ) { + Ok(()) => return Ok(ServerCertVerified::assertion()), + Err(error) + if is_ca_used_as_end_entity(&error) + && self + .configured + .iter() + .any(|cert| cert.as_slice() == end_entity.as_ref()) => + { + return Ok(ServerCertVerified::assertion()); + } + Err(error) => return Err(error), + } + } + match self.inner.verify_server_cert( + end_entity, + intermediates, + server_name, + ocsp_response, + now, + ) { + Err(error) + if is_ca_used_as_end_entity(&error) + && self + .configured + .iter() + .any(|cert| cert.as_slice() == end_entity.as_ref()) => + { + let parsed = rustls::server::ParsedCertificate::try_from(end_entity)?; + rustls::client::verify_server_name(&parsed, server_name)?; + Ok(ServerCertVerified::assertion()) + } + result => result, + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} + +fn build_tls_connector( + verify: bool, + data: Option<&TlsClientConfigData>, +) -> Result { // rustls panics resolving the process-level CryptoProvider when both // `ring` and `aws-lc-rs` end up in the dep graph. Server paths install // one before their first handshake; a client-only program (no tls/https @@ -16,21 +377,77 @@ fn build_tls_connector(verify: bool) -> Result { // `install_default` errors (ignored) if a provider is already set. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); if !verify { - return build_tls_connector_insecure(); + return build_tls_connector_insecure(data); } let mut root_store = rustls::RootCertStore::empty(); - let native = rustls_native_certs::load_native_certs(); - for cert in native.certs { - let _ = root_store.add(cert); + if let Some(ca) = data.and_then(|data| data.ca.as_ref()) { + add_pem_roots(&mut root_store, ca); + } else { + let native = rustls_native_certs::load_native_certs(); + for cert in native.certs { + let _ = root_store.add(cert); + } + } + let configured = configured_ca_certificates(data); + let custom_identity = data.is_some_and(|data| data.custom_identity); + let node_verifier = if configured.is_empty() && !custom_identity { + None + } else { + Some(NodeConfiguredCaVerifier { + inner: rustls::client::WebPkiServerVerifier::builder(Arc::new(root_store.clone())) + .build() + .map_err(|error| format!("tls certificate verifier: {error}"))?, + roots: root_store.clone(), + configured, + custom_identity, + }) + }; + let versions = protocol_versions(data.map_or(0b11, |data| data.version_mask)); + let builder = rustls::ClientConfig::builder_with_provider( + rustls::crypto::aws_lc_rs::default_provider().into(), + ) + .with_protocol_versions(&versions) + .map_err(|error| format!("tls protocol versions: {error}"))? + .with_root_certificates(root_store); + let mut config = if let Some((certs, key)) = data.and_then(client_auth_material) { + builder + .with_client_auth_cert(certs, key) + .map_err(|error| format!("tls client certificate: {error}"))? + } else { + builder.with_no_client_auth() + }; + if let Some(data) = data { + config.alpn_protocols = data.alpn_protocols.clone(); + } + if let Some(verifier) = node_verifier { + config + .dangerous() + .set_certificate_verifier(Arc::new(verifier)); } - let config = rustls::ClientConfig::builder() - .with_root_certificates(root_store) - .with_no_client_auth(); Ok(TlsConnector::from(Arc::new(config))) } -fn build_tls_connector_insecure() -> Result { - use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +fn client_auth_material( + data: &TlsClientConfigData, +) -> Option<( + Vec>, + rustls::pki_types::PrivateKeyDer<'static>, +)> { + let mut cert_cursor = std::io::Cursor::new(&data.cert); + let certs: Vec<_> = rustls_pemfile::certs(&mut cert_cursor).flatten().collect(); + if certs.is_empty() { + return None; + } + let mut key_cursor = std::io::Cursor::new(&data.key); + let key = rustls_pemfile::private_key(&mut key_cursor) + .ok() + .flatten()?; + Some((certs, key)) +} + +fn build_tls_connector_insecure( + data: Option<&TlsClientConfigData>, +) -> Result { use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use rustls::{DigitallySignedStruct, SignatureScheme}; @@ -79,10 +496,24 @@ fn build_tls_connector_insecure() -> Result { } } - let config = rustls::ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(NoVerify)) - .with_no_client_auth(); + let versions = protocol_versions(data.map_or(0b11, |data| data.version_mask)); + let builder = rustls::ClientConfig::builder_with_provider( + rustls::crypto::aws_lc_rs::default_provider().into(), + ) + .with_protocol_versions(&versions) + .map_err(|error| format!("tls protocol versions: {error}"))? + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerify)); + let mut config = if let Some((certs, key)) = data.and_then(client_auth_material) { + builder + .with_client_auth_cert(certs, key) + .map_err(|error| format!("tls client certificate: {error}"))? + } else { + builder.with_no_client_auth() + }; + if let Some(data) = data { + config.alpn_protocols = data.alpn_protocols.clone(); + } Ok(TlsConnector::from(Arc::new(config))) } @@ -90,8 +521,13 @@ pub(crate) async fn do_tls_handshake( tcp: TcpStream, servername: &str, verify: bool, + data: Option<&TlsClientConfigData>, ) -> Result, String> { - let connector = build_tls_connector(verify)?; + let connector = if verify { + build_tls_connector(true, data)? + } else { + build_tls_connector_insecure(data)? + }; let server_name = rustls::pki_types::ServerName::try_from(servername.to_string()) .map_err(|e| format!("invalid servername '{}': {}", servername, e))?; connector @@ -100,6 +536,92 @@ pub(crate) async fn do_tls_handshake( .map_err(|e| format!("tls handshake: {}", e)) } +pub(crate) fn record_tls_handshake( + handle: i64, + stream: &TlsStream, + servername: &str, + verify: bool, + data: Option<&TlsClientConfigData>, +) { + let connection = stream.get_ref().1; + let protocol = match connection.protocol_version() { + Some(rustls::ProtocolVersion::TLSv1_2) => "TLSv1.2", + Some(rustls::ProtocolVersion::TLSv1_3) => "TLSv1.3", + _ => "", + }; + let alpn = connection.alpn_protocol().unwrap_or_default(); + let peer = connection + .peer_certificates() + .and_then(|certs| certs.first()) + .map(|cert| cert.as_ref()) + .unwrap_or_default(); + let trusted_by_configured_ca = + data.and_then(|data| data.ca.as_ref()) + .is_some_and(|materials| { + materials.iter().any(|material| { + let mut cursor = std::io::Cursor::new(material); + let trusted = rustls_pemfile::certs(&mut cursor) + .flatten() + .any(|cert| cert.as_ref() == peer); + trusted + }) + }); + let authorized = verify || trusted_by_configured_ca; + let authorization_error = if authorized { + "" + } else { + "DEPTH_ZERO_SELF_SIGNED_CERT" + }; + if let Some(socket) = crate::statics::sockets().lock().unwrap().get_mut(&handle) { + socket.tls.encrypted = true; + socket.tls.authorized = authorized; + socket.tls.servername = Some(servername.to_string()); + } + let own_certificate = data + .map(|data| { + let mut cursor = std::io::Cursor::new(&data.cert); + let certificate = rustls_pemfile::certs(&mut cursor) + .flatten() + .next() + .map(|cert| cert.as_ref().to_vec()) + .unwrap_or_default(); + certificate + }) + .unwrap_or_default(); + extern "C" { + fn js_tls_client_record_connected( + handle: i64, + authorized: i32, + authorization_error_ptr: *const u8, + authorization_error_len: usize, + protocol_ptr: *const u8, + protocol_len: usize, + alpn_ptr: *const u8, + alpn_len: usize, + peer_cert_ptr: *const u8, + peer_cert_len: usize, + own_cert_ptr: *const u8, + own_cert_len: usize, + ); + } + unsafe { + js_tls_client_record_connected( + handle, + authorized as i32, + authorization_error.as_ptr(), + authorization_error.len(), + protocol.as_ptr(), + protocol.len(), + alpn.as_ptr(), + alpn.len(), + peer.as_ptr(), + peer.len(), + own_certificate.as_ptr(), + own_certificate.len(), + ); + } +} + // ─── FFI: tls.connect ──────────────────────────────────────────────────────── /// `tls.connect(...)` — opens a plain TCP socket and runs the TLS handshake @@ -127,10 +649,15 @@ pub(crate) async fn do_tls_handshake( /// ABI — see `NA_F64` lowering in perry-codegen. #[no_mangle] pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f64) -> i64 { + extern "C" { + fn js_tls_prepare_connect(); + } + js_tls_prepare_connect(); use crate::option_setters::js_net_validate_connect_port; use crate::{ get_object_bool_field, get_object_number_field, get_object_string_field, - is_nanboxed_pointer, spawn_socket_task, statics, string_from_header_i64, unbox_pointer, + is_nanboxed_pointer, spawn_socket_task_initialized, statics, string_from_header_i64, + unbox_pointer, }; use perry_ffi::JsValue; @@ -158,11 +685,11 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f (j.is_bool() && !j.to_bool()) || (j.is_number() && j.to_number() == 0.0) }; - let (host, port, servername, verify, cb_f64); + let (host, port, servername, verify, cb_f64, metadata_options); if let Some(h) = as_string(arg1) { // Legacy Perry positional: (host, port, servername?, verify?). let p = JsValue::from_bits(arg2.to_bits()); - if !p.is_number() { + if !p.is_number() && !p.is_int32() { return 0; } port = p.to_number() as u16; @@ -170,28 +697,13 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f host = h; verify = !explicitly_off(arg4); cb_f64 = None; - } else if is_nanboxed_pointer(arg1) && !is_closure(arg1) { - // Node options form: tls.connect(options[, callback]). - port = match get_object_number_field(arg1, "port") { - Some(p) => { - js_net_validate_connect_port(p); - p as u16 - } - None => return 0, - }; - host = match get_object_string_field(arg1, "host") - .or_else(|| get_object_string_field(arg1, "hostname")) - { - Some(h) if !h.is_empty() => h, - _ => "localhost".to_string(), - }; - servername = get_object_string_field(arg1, "servername").unwrap_or_else(|| host.clone()); - verify = get_object_bool_field(arg1, "rejectUnauthorized").unwrap_or(true); - cb_f64 = is_closure(arg2).then_some(arg2); - } else if JsValue::from_bits(arg1.to_bits()).is_number() { + metadata_options = f64::from_bits(0x7FFC_0000_0000_0001); + } else if JsValue::from_bits(arg1.to_bits()).is_number() + || JsValue::from_bits(arg1.to_bits()).is_int32() + { // Node positional form: tls.connect(port[, host][, options][, cb]). js_net_validate_connect_port(arg1); - port = arg1 as u16; + port = JsValue::from_bits(arg1.to_bits()).to_number() as u16; let mut opt_host: Option = None; let mut opts: Option = None; let mut cb: Option = None; @@ -208,6 +720,12 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f opts = opts.or(Some(v)); } } + if let Some(options) = opts { + extern "C" { + fn js_tls_validate_positional_connect_options(options: f64); + } + js_tls_validate_positional_connect_options(options); + } host = opt_host .or_else(|| { opts.and_then(|o| { @@ -224,11 +742,154 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f .and_then(|o| get_object_bool_field(o, "rejectUnauthorized")) .unwrap_or(true); cb_f64 = cb; + metadata_options = opts.unwrap_or_else(|| f64::from_bits(0x7FFC_0000_0000_0001)); + } else if is_nanboxed_pointer(arg1) && !is_closure(arg1) { + // Node options form: tls.connect(options[, callback]). + extern "C" { + fn js_tls_validate_connect_options(options: f64); + } + js_tls_validate_connect_options(arg1); + if let Some(socket_value) = crate::get_object_value_field(arg1, "socket") { + let socket_js = JsValue::from_bits(socket_value.to_bits()); + let handle = if socket_js.is_pointer() { + crate::unbox_pointer(socket_value) as i64 + } else { + 0 + }; + if handle != 0 { + host = get_object_string_field(arg1, "host") + .or_else(|| get_object_string_field(arg1, "hostname")) + .unwrap_or_else(|| "localhost".to_string()); + servername = + get_object_string_field(arg1, "servername").unwrap_or_else(|| host.clone()); + verify = get_object_bool_field(arg1, "rejectUnauthorized").unwrap_or(true); + cb_f64 = is_closure(arg2).then_some(arg2); + metadata_options = arg1; + let config = tls_client_config_data(metadata_options); + extern "C" { + fn js_tls_client_record_start( + handle: i64, + options: f64, + servername_ptr: *const u8, + servername_len: usize, + ); + } + js_tls_client_record_start( + handle, + metadata_options, + servername.as_ptr(), + servername.len(), + ); + if let Some(cb) = cb_f64 { + let cb_ptr = unbox_pointer(cb) as i64; + if cb_ptr != 0 { + statics::listeners() + .lock() + .unwrap() + .entry(handle) + .or_default() + .entry("secureConnect".to_string()) + .or_default() + .push(cb_ptr); + } + } + let preflight = tls_preflight(0, &servername, metadata_options); + if preflight != 0 { + crate::push_event(crate::PendingNetEvent::Error( + handle, + preflight_error(preflight).to_string(), + )); + crate::push_event(crate::PendingNetEvent::Close(handle)); + } else if let Err(error) = begin_tls_upgrade(handle, servername, verify, config) { + crate::push_event(crate::PendingNetEvent::Error(handle, error)); + crate::push_event(crate::PendingNetEvent::Close(handle)); + } + return handle; + } + } + port = match get_object_number_field(arg1, "port") { + Some(p) => { + js_net_validate_connect_port(p); + p as u16 + } + None => return 0, + }; + host = match get_object_string_field(arg1, "host") + .or_else(|| get_object_string_field(arg1, "hostname")) + { + Some(h) if !h.is_empty() => h, + _ => "localhost".to_string(), + }; + servername = get_object_string_field(arg1, "servername").unwrap_or_else(|| host.clone()); + verify = get_object_bool_field(arg1, "rejectUnauthorized").unwrap_or(true); + cb_f64 = is_closure(arg2).then_some(arg2); + metadata_options = arg1; } else { return 0; } - let handle = spawn_socket_task(host, port, Some((servername, verify))); + let config = tls_client_config_data(metadata_options); + if signal_is_pre_aborted(metadata_options) { + let handle = crate::js_net_socket_alloc(); + extern "C" { + fn js_tls_client_record_start( + handle: i64, + options: f64, + servername_ptr: *const u8, + servername_len: usize, + ); + } + js_tls_client_record_start( + handle, + metadata_options, + servername.as_ptr(), + servername.len(), + ); + schedule_tls_abort(handle); + return handle; + } + let preflight = tls_preflight(port, &servername, metadata_options); + if preflight != 0 { + let handle = crate::js_net_socket_alloc(); + extern "C" { + fn js_tls_client_record_start( + handle: i64, + options: f64, + servername_ptr: *const u8, + servername_len: usize, + ); + } + js_tls_client_record_start( + handle, + metadata_options, + servername.as_ptr(), + servername.len(), + ); + crate::push_event(crate::PendingNetEvent::Error( + handle, + preflight_error(preflight).to_string(), + )); + crate::push_event(crate::PendingNetEvent::Close(handle)); + return handle; + } + let metadata_servername = servername.clone(); + let handle = + spawn_socket_task_initialized(host, port, Some((servername, verify, config)), |handle| { + extern "C" { + fn js_tls_client_record_start( + handle: i64, + options: f64, + servername_ptr: *const u8, + servername_len: usize, + ); + } + js_tls_client_record_start( + handle, + metadata_options, + metadata_servername.as_ptr(), + metadata_servername.len(), + ); + }); if let Some(cb) = cb_f64 { if handle != 0 { let cb_ptr = unbox_pointer(cb) as i64; diff --git a/crates/perry-hir/src/analysis/value_types_tests.rs b/crates/perry-hir/src/analysis/value_types_tests.rs index b5cc4a9551..98222004df 100644 --- a/crates/perry-hir/src/analysis/value_types_tests.rs +++ b/crates/perry-hir/src/analysis/value_types_tests.rs @@ -1181,6 +1181,7 @@ fn infers_class_prototype_and_super_meta_value_shapes() { named_statics: Vec::new(), computed_keys: Vec::new(), computed_statics: Vec::new(), + static_init_order: Vec::new(), captured_args: Vec::new(), }, &env, diff --git a/crates/perry-hir/src/ir/decl.rs b/crates/perry-hir/src/ir/decl.rs index b684ed6be5..4683d1b220 100644 --- a/crates/perry-hir/src/ir/decl.rs +++ b/crates/perry-hir/src/ir/decl.rs @@ -331,6 +331,9 @@ pub struct ClassComputedMember { pub function: Function, pub is_static: bool, pub kind: ClassComputedMemberKind, + /// Zero-based position in the source ClassBody. Computed field and member + /// names share this ordering during ClassDefinitionEvaluation. + pub source_order: usize, } /// A class field diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index 4174de29cb..3a72c08a1c 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -17,6 +17,15 @@ pub enum WithSetFallback { SloppyImplicit(LocalId), } +/// One source-ordered static initialization step on a per-evaluation class +/// object. Computed names have already been evaluated before these steps run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ClassFreshStaticInit { + Named(u32), + Computed(u32), + Block(u32), +} + /// Expression #[derive(Debug, Clone)] pub enum Expr { @@ -554,6 +563,10 @@ pub enum Expr { computed_keys: Vec<(String, Expr)>, /// (hidden resolved-key slot name, initializer) computed_statics: Vec<(String, Expr)>, + /// Static fields and blocks in ClassBody source order. Indices address + /// `named_statics`, `computed_statics`, or the template's static-block + /// function list respectively. + static_init_order: Vec, /// #1787: the captured outer-scope values this class expression /// closes over, in the synthesized constructor's capture-param /// order (see `synthesize_class_captures`). Each entry is a diff --git a/crates/perry-hir/src/ir/mod.rs b/crates/perry-hir/src/ir/mod.rs index a51ab856c9..cdc83cca55 100644 --- a/crates/perry-hir/src/ir/mod.rs +++ b/crates/perry-hir/src/ir/mod.rs @@ -60,7 +60,8 @@ pub use stmt::{CatchClause, Stmt, SwitchCase}; // ---- expr.rs ---- pub use expr::{ - BoxedPrimitiveKind, Expr, PathWin32Method, ProcessStdinLifecycleMethod, WithSetFallback, + BoxedPrimitiveKind, ClassFreshStaticInit, Expr, PathWin32Method, ProcessStdinLifecycleMethod, + WithSetFallback, }; // ---- ops.rs ---- diff --git a/crates/perry-hir/src/lower/expr_assign.rs b/crates/perry-hir/src/lower/expr_assign.rs index b2c9799908..f5f1f74f8f 100644 --- a/crates/perry-hir/src/lower/expr_assign.rs +++ b/crates/perry-hir/src/lower/expr_assign.rs @@ -457,7 +457,25 @@ pub(crate) fn lower_ident_assignment( throw_type_error_const_assignment(&name), ])); } - Ok(Expr::LocalSet(id, value)) + let local_set = Expr::LocalSet(id, value); + let mirrors_script_var = super::lower_expr::global_script_this_enabled() + && ctx.script_var_decl_names.contains(&name) + && ctx.local_decl_scope_depth(&name) == Some(0); + if mirrors_script_var { + let global_this = Box::new(Expr::GlobalThisExpr); + Ok(Expr::Sequence(vec![ + local_set, + Expr::PutValueSet { + target: global_this.clone(), + key: Box::new(Expr::String(name)), + value: Box::new(Expr::LocalGet(id)), + receiver: global_this, + strict: ctx.current_strict, + }, + ])) + } else { + Ok(local_set) + } } else if ctx.lookup_class(&name).is_some() || ctx.forward_class_shadows_local(&name) { let class_name = ctx.resolve_class_name(&name); Ok(Expr::Call { diff --git a/crates/perry-hir/src/lower/expr_member.rs b/crates/perry-hir/src/lower/expr_member.rs index 837ac06067..e71f411bfe 100644 --- a/crates/perry-hir/src/lower/expr_member.rs +++ b/crates/perry-hir/src/lower/expr_member.rs @@ -1034,8 +1034,16 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re // body-local colliding `class X` registers under `class_renames`, and // the raw name would bind the FIRST same-named registrant's statics. if let ast::Expr::Ident(obj_ident) = member.obj.as_ref() { - let obj_name = ctx.resolve_class_name(obj_ident.sym.as_ref()); - if ctx.lookup_class(&obj_name).is_some() { + let source_name = obj_ident.sym.as_ref(); + // A fresh nested class declaration binds its evaluated heap class + // object to a real local. That local's own statics are per evaluation, + // so reading through the shared template's `StaticFieldGet` loses both + // its value and its property-presence semantics. This mirrors the + // static-call guard in `expr_call/static_and_instance.rs`. + let local_shadows_class = ctx.lookup_local(source_name).is_some() + && !ctx.inferred_class_bindings.contains(source_name); + let obj_name = ctx.resolve_class_name(source_name); + if !local_shadows_class && ctx.lookup_class(&obj_name).is_some() { if let ast::MemberProp::Ident(prop_ident) = &member.prop { let field_name = prop_ident.sym.to_string(); if ctx.has_static_field(&obj_name, &field_name) { diff --git a/crates/perry-hir/src/lower/expr_misc.rs b/crates/perry-hir/src/lower/expr_misc.rs index 6276c2c6e0..f0a176f850 100644 --- a/crates/perry-hir/src/lower/expr_misc.rs +++ b/crates/perry-hir/src/lower/expr_misc.rs @@ -106,8 +106,12 @@ pub(super) fn lower_super_prop( ast::Expr::Lit(ast::Lit::Num(n)) if n.value.is_finite() && n.value.fract() == 0.0 - && n.value >= i64::MIN as f64 - && n.value <= i64::MAX as f64 => + // Outside the safe-integer range, formatting an exact + // f64 integer through i64 is not ECMAScript Number:: + // toString (for example 2^63 becomes the property key + // "9223372036854776000"). Let runtime ToPropertyKey + // perform the shortest-decimal conversion instead. + && n.value.abs() <= 9_007_199_254_740_991.0 => { Some(if n.value == 0.0 { "0".to_string() diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 564d811065..0983973c4d 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -225,6 +225,7 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R // Try to extract class name from callee match callee_expr { ast::Expr::Ident(ident) => { + let source_class_name = ident.sym.as_str(); // Hidden dynamic-function constructors reached through // `.constructor` are pre-classified by // `fn_ctor_env`. Their call form already const-folds; construction @@ -273,7 +274,7 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R let mut class_name = if is_current_class_self { ctx.current_class.clone().unwrap() } else { - ctx.resolve_class_name(ident.sym.as_str()) + ctx.resolve_class_name(source_class_name) }; // Snapshot the callee identifier's local/param binding at the TOP // of the ident arm, before any argument lowering or native-module @@ -365,7 +366,7 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R || callee_local_at_entry.is_some() || ctx.lookup_func(&class_name).is_some() || ctx.lookup_imported_func(&class_name).is_some() - || ctx.forward_class_names.contains(class_name.as_str())); + || ctx.forward_class_names.contains(source_class_name)); if matches!( ctx.lookup_native_module(&class_name), Some(("url", Some("Url"))) @@ -1573,6 +1574,7 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R && ctx.lookup_func(&class_name).is_none() && ctx.lookup_imported_func(&class_name).is_none() && ctx.lookup_native_module(&class_name).is_none() + && !ctx.forward_class_names.contains(source_class_name) && !is_reified_global_builtin_constructor(&class_name) { return Ok(Expr::NewDynamic { diff --git a/crates/perry-hir/src/lower/fn_ctor_env.rs b/crates/perry-hir/src/lower/fn_ctor_env.rs index 8f8057e4a1..cb75722c01 100644 --- a/crates/perry-hir/src/lower/fn_ctor_env.rs +++ b/crates/perry-hir/src/lower/fn_ctor_env.rs @@ -442,6 +442,13 @@ fn indirect_eval_factory_shape(expr: &ast::Expr) -> Option<(String, bool)> { let ast::Expr::Fn(function) = expr else { return None; }; + // The direct-eval rewrite below executes the wrapper body immediately and + // returns the evaluated value. That is equivalent only for an ordinary + // synchronous function: async wrappers must return a Promise, while a + // generator body must not run until the iterator is advanced. + if function.function.is_async || function.function.is_generator { + return None; + } if function.function.params.len() != 1 { return None; } @@ -1370,3 +1377,29 @@ fn scan_expr_writes(expr: &ast::Expr, writes: &mut HashMap, shado _ => {} } } + +#[cfg(test)] +mod tests { + use super::*; + + fn first_var_initializer(source: &str) -> Box { + let module = perry_parser::parse_typescript(source, "factory-shape.js").unwrap(); + let ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Var(var))) = &module.body[0] else { + panic!("expected variable declaration"); + }; + var.decls[0].init.clone().expect("expected initializer") + } + + #[test] + fn indirect_eval_factory_rejects_async_wrapper() { + let init = + first_var_initializer("const factory = async function (ev) { return ev(src); };"); + assert!(indirect_eval_factory_shape(&init).is_none()); + } + + #[test] + fn indirect_eval_factory_rejects_generator_wrapper() { + let init = first_var_initializer("const factory = function* (ev) { return ev(src); };"); + assert!(indirect_eval_factory_shape(&init).is_none()); + } +} diff --git a/crates/perry-hir/src/lower/lower_expr/arm_class.rs b/crates/perry-hir/src/lower/lower_expr/arm_class.rs index 5716b02d8e..c7367e650e 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_class.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_class.rs @@ -137,11 +137,12 @@ pub(crate) fn lower_class_expr( // canonical case: `isSchema(C)` was called from Schema.ts's // own top-level `class extends transform(...)` chains, which // run before the module's `init_static_fields_late`. - let computed_keys = crate::lower_decl::computed_field_key_initializers( - &class_expr.class.body, - &class.fields, - &class.static_fields, - ); + let (computed_name_evaluations, computed_keys) = + crate::lower_decl::prepare_ordered_class_computed_names( + &class_expr.class.body, + &class, + &synthetic_name, + ); let computed_statics: Vec<(String, Expr)> = class .static_fields .iter() @@ -151,6 +152,10 @@ pub(crate) fn lower_class_expr( .map(|_| (sf.name.clone(), sf.init.clone().unwrap_or(Expr::Undefined))) }) .collect(); + let static_init_order = crate::lower_decl::fresh_class_static_init_order( + &class_expr.class.body, + &class.static_fields, + ); // Issue #1772: regular-named static fields with an initializer // (`static ast = ast`). #894 only handled the Symbol-key case; // these need the same per-evaluation treatment, otherwise a class @@ -159,16 +164,11 @@ pub(crate) fn lower_class_expr( let named_statics: Vec<(String, Expr)> = class .static_fields .iter() - .filter_map(|sf| match (sf.key_expr.as_ref(), sf.init.as_ref()) { - (None, Some(v)) => Some((sf.name.clone(), v.clone())), - _ => None, + .filter_map(|sf| match sf.key_expr.as_ref() { + None => Some((sf.name.clone(), sf.init.clone().unwrap_or(Expr::Undefined))), + Some(_) => None, }) .collect(); - let computed_member_registrations: Vec = class - .computed_members - .iter() - .map(|member| class_computed_member_registration_expr(&synthetic_name, member)) - .collect(); let captured_args: Vec = ctx .lookup_class_captures(&synthetic_name) .map(|ids| ids.iter().map(|id| Expr::LocalGet(*id)).collect()) @@ -249,6 +249,7 @@ pub(crate) fn lower_class_expr( && (!named_statics.is_empty() || !computed_keys.is_empty() || !captured_args.is_empty() + || !static_block_names.is_empty() || has_private_elements) { // #6438: a class expression WITH heritage (`class extends `) used @@ -291,6 +292,7 @@ pub(crate) fn lower_class_expr( named_statics, computed_keys, computed_statics, + static_init_order, captured_args, }; let mut seq: Vec = Vec::new(); @@ -300,7 +302,7 @@ pub(crate) fn lower_class_expr( parent_expr: p, }); } - seq.extend(computed_member_registrations); + seq.extend(computed_name_evaluations); let fresh_expr = if let Some(owner) = capture_owner { Expr::Sequence(vec![ Expr::LocalSet(owner, Box::new(fresh_expr)), @@ -322,6 +324,7 @@ pub(crate) fn lower_class_expr( parent_expr: p, }); } + seq.extend(computed_name_evaluations); // #5437 (p-queue PQueue undefined-`.default` capture): a class EXPRESSION // that captures enclosing-scope locals AND reaches the shared-template // (`ClassRef`) path — i.e. one with heritage (`class extends t { … uses @@ -359,48 +362,46 @@ pub(crate) fn lower_class_expr( captures: captured_args.clone(), }); } - for (field_name, value) in computed_keys { - seq.push(Expr::StaticFieldSet { - class_name: synthetic_name.clone(), - field_name, - value: Box::new(value), - }); - } - seq.extend(computed_member_registrations); - for (slot, v) in computed_statics { - seq.push(Expr::RegisterClassStaticSymbol { - class_name: synthetic_name.clone(), - key_expr: Box::new(Expr::PropertyGet { - object: Box::new(Expr::ClassRef(synthetic_name.clone())), - property: slot, - byte_offset: 0, - }), - value_expr: Box::new(v), - }); - } - // Inline the named static field/element initializers at the point - // the class expression evaluates (source order), mirroring the - // class-declaration path. Without this the shared-template path - // relied solely on the late `init_static_fields_late` pass, which - // runs AFTER the surrounding top-level statements — so a read like - // `C.x` immediately after `var C = class { static x = 1 }` saw the - // uninitialized (0.0) slot. (Private statics carry a `#`-prefixed - // name and flow through the same StaticFieldSet path.) - for (name, v) in named_statics { - seq.push(Expr::StaticFieldSet { - class_name: synthetic_name.clone(), - field_name: name, - value: Box::new(v), - }); - } - // Static blocks run right after the static-field initializers, in - // source order, with the class as `this`. - for block_name in static_block_names { - seq.push(Expr::StaticMethodCall { - class_name: synthetic_name.clone(), - method_name: block_name, - args: Vec::new(), - }); + // The shared-template path must obey the same source-order plan as the + // fresh-object path. Computed names were all resolved above, but their + // initializers still interleave with named fields and static blocks. + for step in static_init_order { + match step { + ClassFreshStaticInit::Named(index) => { + let Some((name, value)) = named_statics.get(index as usize).cloned() else { + continue; + }; + seq.push(Expr::StaticFieldSet { + class_name: synthetic_name.clone(), + field_name: name, + value: Box::new(value), + }); + } + ClassFreshStaticInit::Computed(index) => { + let Some((slot, value)) = computed_statics.get(index as usize).cloned() else { + continue; + }; + seq.push(Expr::RegisterClassStaticSymbol { + class_name: synthetic_name.clone(), + key_expr: Box::new(Expr::PropertyGet { + object: Box::new(Expr::ClassRef(synthetic_name.clone())), + property: slot, + byte_offset: 0, + }), + value_expr: Box::new(value), + }); + } + ClassFreshStaticInit::Block(index) => { + let Some(block_name) = static_block_names.get(index as usize).cloned() else { + continue; + }; + seq.push(Expr::StaticMethodCall { + class_name: synthetic_name.clone(), + method_name: block_name, + args: Vec::new(), + }); + } + } } if seq.is_empty() { Ok(Expr::ClassRef(synthetic_name)) diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 07ff2a052e..4ebb06a232 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -1301,36 +1301,24 @@ pub(crate) fn lower_module_decl( parent_expr: extends_expr.clone(), })); } - for member in &class.computed_members { - module - .init - .push(Stmt::Expr(class_computed_member_registration_expr( - &class_name, - member, - ))); - } - // Inject static-field-init statements in source order - // (see non-export class arm below for rationale). - for sf in &class.static_fields { - if let Some(init) = &sf.init { - // Computed-key static fields (`static [sym] = v`) - // emit a runtime-register call instead of a - // string-keyed StaticFieldSet. Refs #420. - if let Some(key) = sf.key_expr.as_ref() { - module.init.push(Stmt::Expr(Expr::ClassStaticSymbolSet { - class_name: class_name.clone(), - key: Box::new(key.clone()), - value: Box::new(init.clone()), - })); - } else { - module.init.push(Stmt::Expr(Expr::StaticFieldSet { - class_name: class_name.clone(), - field_name: sf.name.clone(), - value: Box::new(init.clone()), - })); - } - } - } + let (computed_name_evaluations, _) = + crate::lower_decl::prepare_ordered_class_computed_names( + &class_decl.class.body, + &class, + &class.name, + ); + module + .init + .extend(computed_name_evaluations.into_iter().map(Stmt::Expr)); + module.init.extend( + crate::lower_decl::build_interleaved_static_init_stmts_after_computed_names( + &class_decl.class.body, + &class_name, + &class.fields, + &class.static_fields, + &class.static_methods, + ), + ); append_legacy_decorator_init_for_class(ctx, &mut module.init, &class); push_class_dedup(module, class); module.exports.push(Export::Named { @@ -1876,33 +1864,24 @@ pub(crate) fn lower_module_decl( parent_expr: extends_expr.clone(), })); } - for member in &class.computed_members { - module - .init - .push(Stmt::Expr(class_computed_member_registration_expr( - &class_name, - member, - ))); - } - // Inject static-field-init statements in source order - // (see non-export class arm for rationale). - for sf in &class.static_fields { - if let Some(init) = &sf.init { - if let Some(key) = sf.key_expr.as_ref() { - module.init.push(Stmt::Expr(Expr::ClassStaticSymbolSet { - class_name: class_name.clone(), - key: Box::new(key.clone()), - value: Box::new(init.clone()), - })); - } else { - module.init.push(Stmt::Expr(Expr::StaticFieldSet { - class_name: class_name.clone(), - field_name: sf.name.clone(), - value: Box::new(init.clone()), - })); - } - } - } + let (computed_name_evaluations, _) = + crate::lower_decl::prepare_ordered_class_computed_names( + &synth_class_decl.class.body, + &class, + &class.name, + ); + module + .init + .extend(computed_name_evaluations.into_iter().map(Stmt::Expr)); + module.init.extend( + crate::lower_decl::build_interleaved_static_init_stmts_after_computed_names( + &synth_class_decl.class.body, + &class_name, + &class.fields, + &class.static_fields, + &class.static_methods, + ), + ); append_legacy_decorator_init_for_class(ctx, &mut module.init, &class); push_class_dedup(module, class); // The `local != exported` shape lets the #485 alias loop diff --git a/crates/perry-hir/src/lower/shared_mutable_capture.rs b/crates/perry-hir/src/lower/shared_mutable_capture.rs index d8783e410f..0a90e6cac9 100644 --- a/crates/perry-hir/src/lower/shared_mutable_capture.rs +++ b/crates/perry-hir/src/lower/shared_mutable_capture.rs @@ -71,6 +71,12 @@ fn is_cap_name_of(name: &str, ids: &HashSet) -> bool { crate::cap_fields::cap_field_outer_id(name).is_some_and(|id| ids.contains(&id)) } +#[derive(Default)] +struct BodySharedCaptures { + ids: HashSet, + by_class: HashMap>, +} + pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { // Bisection escape hatch (#5951): disable the desugar to isolate its effect. if std::env::var("PERRY_NO_5951").is_ok() { @@ -103,7 +109,7 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { .iter() .map(|c| (c.name.as_str(), c)) .collect(); - let fn_shared: Vec> = module + let fn_shared: Vec = module .functions .iter() .map(|f| detect_shared_in_body(&f.body, &classes)) @@ -115,8 +121,8 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { // once across deep `Let`s + nested closure params — see // `retain_unambiguous`). Nested closures restart their id spaces, so a // numeric rewrite over the whole body is only sound for unique ids. - for (f, s) in module.functions.iter().zip(fn_shared.iter_mut()) { - if s.is_empty() { + for (f, shared) in module.functions.iter().zip(fn_shared.iter_mut()) { + if shared.ids.is_empty() { continue; } let mut counts: HashMap = HashMap::new(); @@ -126,31 +132,80 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { for st in &f.body { collect_declared_counts_stmt(st, &mut counts); } - retain_unambiguous(s, &counts); + retain_unambiguous(&mut shared.ids, &counts); + let retained = &shared.ids; + for ids in shared.by_class.values_mut() { + ids.retain(|id| retained.contains(id)); + } + shared.by_class.retain(|_, ids| !ids.is_empty()); } - if !init_shared.is_empty() { + if !init_shared.ids.is_empty() { let mut counts: HashMap = HashMap::new(); for st in &module.init { collect_declared_counts_stmt(st, &mut counts); } - retain_unambiguous(&mut init_shared, &counts); + retain_unambiguous(&mut init_shared.ids, &counts); + let retained = &init_shared.ids; + for ids in init_shared.by_class.values_mut() { + ids.retain(|id| retained.contains(id)); + } + init_shared.by_class.retain(|_, ids| !ids.is_empty()); } - let mut all_shared: HashSet = init_shared.iter().copied().collect(); - for s in &fn_shared { - all_shared.extend(s.iter().copied()); + let mut all_shared: HashSet = init_shared.ids.iter().copied().collect(); + for shared in &fn_shared { + all_shared.extend(shared.ids.iter().copied()); } if all_shared.is_empty() { return; } + let mut shared_by_class: HashMap> = HashMap::new(); + for shared in fn_shared.iter().chain(std::iter::once(&init_shared)) { + for (class_name, ids) in &shared.by_class { + shared_by_class + .entry(class_name.clone()) + .or_default() + .extend(ids.iter().copied()); + } + } // ---- declaring bodies: rewrite with ONLY the ids detected in them ------- - for (f, s) in module.functions.iter_mut().zip(fn_shared.iter()) { - if !s.is_empty() { - rewrite_stmts(&mut f.body, s, s); + for (f, shared) in module.functions.iter_mut().zip(fn_shared.iter()) { + let ids = &shared.ids; + if !ids.is_empty() { + // Parameters have no `Stmt::Let` for `rewrite_stmt` to wrap. Turn + // each flagged parameter into the same one-element shared cell at + // function entry, then let the already-rewritten body use + // `param[0]`. Add this after rewriting so the initializer's + // `LocalGet(param)` reads the incoming scalar rather than being + // rewritten into an index read before the cell exists. Retype the + // holder to `Any`: its slot now carries an array pointer, not the + // source parameter's scalar representation. + let shared_params: Vec = f + .params + .iter_mut() + .filter_map(|param| { + if ids.contains(¶m.id) { + param.ty = Type::Any; + Some(param.id) + } else { + None + } + }) + .collect(); + rewrite_stmts(&mut f.body, ids, ids); + for id in shared_params.into_iter().rev() { + f.body.insert( + 0, + Stmt::Expr(Expr::LocalSet( + id, + Box::new(Expr::Array(vec![Expr::LocalGet(id)])), + )), + ); + } } } - if !init_shared.is_empty() { - rewrite_stmts(&mut module.init, &init_shared, &init_shared); + if !init_shared.ids.is_empty() { + rewrite_stmts(&mut module.init, &init_shared.ids, &init_shared.ids); } // ---- lifted class members: per-member rebind ids ------------------------ @@ -161,9 +216,9 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { // Match them BY NAME within each member and rewrite only that member's body // with its own ids (never the declaring `shared` set — the declaring `Let` // that gets array-wrapped lives outside the class). - let targets: &HashSet = &all_shared; let no_shared: HashSet = HashSet::new(); for c in &mut module.classes { + let targets = shared_by_class.get(&c.name).unwrap_or(&no_shared); for m in &mut c.methods { rewrite_member_scoped(m, &targets, &no_shared); } @@ -243,17 +298,17 @@ pub(crate) fn desugar_shared_mutable_captures(module: &mut Module) { // handle — #5951 e4). Retype them to `Any` so they use the generic pointer // representation, matching the array they now hold. if std::env::var("PERRY_5951_NO_RETYPE").is_err() { - retype_capture_holders(module, &all_shared); + retype_capture_holders(module, &shared_by_class); } if std::env::var("PERRY_5951_TRACE").as_deref() == Ok("1") { let mut per_fn: Vec = Vec::new(); - for (f, s) in module.functions.iter().zip(fn_shared.iter()) { - if !s.is_empty() { - per_fn.push(format!("{}:{:?}", f.name, s)); + for (f, shared) in module.functions.iter().zip(fn_shared.iter()) { + if !shared.ids.is_empty() { + per_fn.push(format!("{}:{:?}", f.name, shared.ids)); } } - if !init_shared.is_empty() { - per_fn.push(format!(":{init_shared:?}")); + if !init_shared.ids.is_empty() { + per_fn.push(format!(":{:?}", init_shared.ids)); } eprintln!( "[5951] module={} desugared {}", @@ -355,9 +410,13 @@ fn collect_declared_counts_expr(expr: &Expr, out: &mut HashMap) { walk_expr_children(expr, &mut |e| collect_declared_counts_expr(e, out)); } -fn retype_capture_holders(module: &mut Module, shared: &HashSet) { - let targets: &HashSet = shared; +fn retype_capture_holders( + module: &mut Module, + shared_by_class: &HashMap>, +) { + let no_shared = HashSet::new(); for c in &mut module.classes { + let targets = shared_by_class.get(&c.name).unwrap_or(&no_shared); for f in &mut c.fields { if is_cap_name_of(&f.name, targets) { f.ty = Type::Any; @@ -463,8 +522,8 @@ fn retype_lets_in_expr(expr: &mut Expr, targets: &HashSet) { /// Detect the shared-mutable capture ids declared in ONE body. The returned /// ids are meaningful only within that body's scope — callers must not apply /// them to other functions (LocalIds repeat across scopes; see #6089). -fn detect_shared_in_body(body: &[Stmt], classes: &HashMap<&str, &Class>) -> HashSet { - let mut shared = HashSet::new(); +fn detect_shared_in_body(body: &[Stmt], classes: &HashMap<&str, &Class>) -> BodySharedCaptures { + let mut shared = BodySharedCaptures::default(); let mut regs = Vec::new(); for s in body { find_regs_stmt(s, &mut regs); @@ -476,21 +535,34 @@ fn detect_shared_in_body(body: &[Stmt], classes: &HashMap<&str, &Class>) -> Hash for s in body { collect_assigned_deep_stmt(s, &mut assigned); } - for (class_name, ids) in regs { + for (class_name, ids) in ®s { for id in ids { // Declaring-function-side mutation (`c = 99` after `new T()`). - if assigned.contains(&id) { - shared.insert(id); + if assigned.contains(id) { + shared.ids.insert(*id); continue; } // Class-side mutation: a member assigns rebind local `__perry_cap_`. if let Some(c) = classes.get(class_name.as_str()) { - if class_mutates_capture(c, id) { - shared.insert(id); + if class_mutates_capture(c, *id) { + shared.ids.insert(*id); } } } } + // Every class that captures a boxed id must treat its synthesized holder + // as the array handle, even if a sibling class is the one that mutates it. + for (class_name, ids) in regs { + for id in ids { + if shared.ids.contains(&id) { + shared + .by_class + .entry(class_name.clone()) + .or_default() + .insert(id); + } + } + } shared } @@ -614,11 +686,27 @@ fn find_regs_stmt(stmt: &Stmt, out: &mut Vec<(String, Vec)>) { } fn find_regs_expr(expr: &Expr, out: &mut Vec<(String, Vec)>) { - if let Expr::RegisterClassCaptures { - class_name, - captures, - } = expr - { + let registration = match expr { + Expr::RegisterClassCaptures { + class_name, + captures, + } => Some((class_name, captures)), + // A fresh class expression carries the same capture vector as a + // declaration snapshot, but it deliberately has no + // `RegisterClassCaptures`: each evaluation stores its environment on + // its own heap class object. Treat that vector as a registration for + // shared-mutable detection too. Otherwise a mutation nested in a + // fresh class member (for example a defineProperty setter created by + // a static method) receives a private scalar copy while sibling + // methods keep reading the class object's stale capture value. + Expr::ClassExprFresh { + template, + captured_args, + .. + } => Some((template, captured_args)), + _ => None, + }; + if let Some((class_name, captures)) = registration { let ids: Vec = captures .iter() .filter_map(|c| match c { diff --git a/crates/perry-hir/src/lower/stmt.rs b/crates/perry-hir/src/lower/stmt.rs index 9f15873ebe..be47e4de4e 100644 --- a/crates/perry-hir/src/lower/stmt.rs +++ b/crates/perry-hir/src/lower/stmt.rs @@ -1232,14 +1232,15 @@ pub(crate) fn lower_stmt( parent_expr: extends_expr.clone(), })); } - for member in &class.computed_members { - module - .init - .push(Stmt::Expr(class_computed_member_registration_expr( - &class.name, - member, - ))); - } + let (computed_name_evaluations, _) = + crate::lower_decl::prepare_ordered_class_computed_names( + &class_decl.class.body, + &class, + &class.name, + ); + module + .init + .extend(computed_name_evaluations.into_iter().map(Stmt::Expr)); // Inject static-field-init and static-block-call // statements at the source position of the class // declaration, INTERLEAVED in source order (see @@ -1259,7 +1260,7 @@ pub(crate) fn lower_stmt( // declaration path; it skips blocks already invoked via // this inline call. module.init.extend( - crate::lower_decl::build_interleaved_static_init_stmts( + crate::lower_decl::build_interleaved_static_init_stmts_after_computed_names( &class_decl.class.body, &class.name, &class.fields, diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 92b3df7cdb..bdc52af2a3 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -909,6 +909,141 @@ fn nested_class_shadowing_outer_var_constructs_the_class_not_the_local() { ); } +/// A sibling class declaration is already a known lexical binding while an +/// earlier class method is lowered, even though its registry entry is emitted +/// later. The unresolved-constructor guard must preserve that forward binding. +#[test] +fn nested_method_constructs_forward_declared_sibling_class() { + let source = r#" + function make() { + class Base { + makeChild(): any { + return new Child(); + } + } + class Child extends Base {} + return Base; + } + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let make_child = hir + .classes + .iter() + .find(|class| class.name == "Base") + .expect("Base class is lowered") + .methods + .iter() + .find(|method| method.name == "makeChild") + .expect("makeChild method is lowered"); + + assert!( + matches!( + make_child.body.as_slice(), + [crate::Stmt::Return(Some(crate::Expr::New { class_name, .. }))] + if class_name == "Child" + ), + "forward sibling construction must remain a static class construct: {:#?}", + make_child.body + ); +} + +/// Forward-declaration bookkeeping uses source identifiers, while a sibling +/// class may use a collision-safe registration name. Constructor resolution +/// must compare the source identifier before rejecting the forward binding. +#[test] +fn nested_method_constructs_collision_renamed_forward_sibling_class() { + let source = r#" + function first() { + class Child {} + return Child; + } + function make() { + class Base { + makeChild(): any { + return new Child(); + } + } + class Child extends Base {} + return Base; + } + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let make_child = hir + .classes + .iter() + .find(|class| class.name == "Base") + .expect("Base class is lowered") + .methods + .iter() + .find(|method| method.name == "makeChild") + .expect("makeChild method is lowered"); + + assert!( + matches!( + make_child.body.as_slice(), + [crate::Stmt::Return(Some(crate::Expr::New { class_name, .. }))] + if class_name.starts_with("Child$") + ), + "collision-renamed forward sibling construction must remain a static class construct: {:#?}", + make_child.body + ); +} + +/// A collision-safe registration key is compiler-internal; the evaluated +/// class declaration must still bind and read through its source-level name. +#[test] +fn fresh_class_declaration_collision_keeps_lexical_binding() { + let source = r#" + function first() { + class C { #x = 1; } + return C; + } + function second() { + class C { #x = 2; static missing; } + const value = C.missing; + return C; + } + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let second = hir + .functions + .iter() + .find(|function| function.name == "second") + .expect("second function lowers"); + let (binding_id, template) = second + .body + .iter() + .find_map(|stmt| match stmt { + crate::Stmt::Let { + id, + name, + init: Some(crate::Expr::ClassExprFresh { template, .. }), + .. + } if name == "C" => Some((*id, template.as_str())), + _ => None, + }) + .expect("fresh class is bound under source name"); + assert_ne!(template, "C", "second template should be collision-renamed"); + assert!(second.body.iter().any(|stmt| { + matches!(stmt, crate::Stmt::Return(Some(crate::Expr::LocalGet(id))) if *id == binding_id) + })); + assert!(second.body.iter().any(|stmt| { + matches!( + stmt, + crate::Stmt::Let { + name, + init: Some(crate::Expr::PropertyGet { object, property, .. }), + .. + } if name == "value" + && property == "missing" + && matches!(object.as_ref(), crate::Expr::LocalGet(id) if *id == binding_id) + ) + })); +} + /// Companion (the case the depth rule must NOT break): a module-scope `class e` /// and a factory-local `let e` holding a different constructor. JS says the /// nearer local wins, so `new e()` inside the factory must still construct the diff --git a/crates/perry-hir/src/lower_decl/body_stmt.rs b/crates/perry-hir/src/lower_decl/body_stmt.rs index 93ae4f1764..db318cd4c7 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt.rs @@ -13,9 +13,7 @@ use crate::lower::{ }; use crate::lower_patterns::*; -use super::class_computed::{ - class_computed_member_registration_expr, push_deduped_class_computed_keys, -}; +use super::class_computed::push_deduped_class_computed_keys; use super::helpers::{async_iterator_method_call, is_filehandle_readlines_for_await_target}; use super::*; @@ -285,12 +283,13 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result Result Some((field.name.clone(), value.clone())), - _ => None, + (None, init) => Some(( + field.name.clone(), + init.cloned().unwrap_or(Expr::Undefined), + )), + (Some(_), _) => None, }, ) .collect() @@ -370,6 +367,10 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result Result Result