From 586d43c37308693730c5ff075814592b9dce36f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 18:20:41 +0200 Subject: [PATCH 01/17] fix: preserve nested CJS constructor function names --- .../lower_decl/body_stmt/nested_fn_decl.rs | 5 ++ .../tests/issue_10702_cjs_function_name.rs | 66 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 crates/perry/tests/issue_10702_cjs_function_name.rs diff --git a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs index 230a46e4e3..096f106ee5 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs @@ -284,6 +284,11 @@ pub(super) fn lower_nested_fn_decl( // tag has to be applied here too. ctx.function_valued_locals.insert(local_id); + // Nested declarations become inline closures rather than entries in + // `module.functions`. Preserve the declared name for function.name; + // codegen cannot infer it from a Let inside another closure's body. + ctx.closure_display_names.insert(func_id, func_name.clone()); + let closure = Expr::Closure { func_id, params, diff --git a/crates/perry/tests/issue_10702_cjs_function_name.rs b/crates/perry/tests/issue_10702_cjs_function_name.rs new file mode 100644 index 0000000000..1f49d3aa17 --- /dev/null +++ b/crates/perry/tests/issue_10702_cjs_function_name.rs @@ -0,0 +1,66 @@ +//! A CJS default export preserves the name of its function constructor. + +use std::path::PathBuf; +use std::process::Command; + +#[test] +fn imported_cjs_function_constructor_keeps_its_name() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let library = dir.path().join("lib.cjs"); + let binary = dir.path().join("main_bin"); + std::fs::write( + &library, + r#" +function clone() { + function Ledger(value) { this.value = value; } + Ledger.prototype = { constructor: Ledger, getValue() { return this.value; } }; + return Ledger; +} +module.exports = clone(); +"#, + ) + .expect("write CJS library"); + std::fs::write( + &entry, + r#" +import Ledger from "./lib.cjs"; +const value = new Ledger(5); +console.log("NAMES", Ledger.name, value.constructor.name); +console.log("VALUE", value instanceof Ledger, value.getValue()); +"#, + ) + .expect("write entry"); + + let compiler = PathBuf::from(env!("CARGO_BIN_EXE_perry")); + let compile = Command::new(compiler) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&binary) + .arg("--no-cache") + .output() + .expect("compile fixture"); + assert!( + compile.status.success(), + "compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(binary) + .current_dir(dir.path()) + .output() + .expect("run fixture"); + assert!( + run.status.success(), + "fixture failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "NAMES Ledger Ledger\nVALUE true 5\n" + ); +} From cae1bf6d936087cb0f792b87406979de515a9213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 18:21:09 +0200 Subject: [PATCH 02/17] docs: note CJS function name fix --- changelog.d/11009-cjs-function-names.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/11009-cjs-function-names.md diff --git a/changelog.d/11009-cjs-function-names.md b/changelog.d/11009-cjs-function-names.md new file mode 100644 index 0000000000..ebe51fceb9 --- /dev/null +++ b/changelog.d/11009-cjs-function-names.md @@ -0,0 +1,3 @@ +### Fixed + +- Preserve the declared `.name` of nested function constructors exported from CommonJS modules, including values built by a factory and inspected through `constructor.name`. From fca9e3140396b8eaa2169f1d70aa9fc3291ac520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 18:33:13 +0200 Subject: [PATCH 03/17] fix: call getters inherited through dynamic class heritage --- .../property_get/static_dispatch.rs | 22 ++++++ crates/perry/tests/issue_10893_getter_call.rs | 71 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 crates/perry/tests/issue_10893_getter_call.rs diff --git a/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs index d49de12ce3..284e23b8d7 100644 --- a/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs @@ -378,6 +378,28 @@ pub(crate) fn try_lower_static_dispatch( ) { return Ok(None); } + // A class with a runtime-resolved parent may inherit a static getter + // whose value is callable. The by-name static-method dispatcher sees + // only a method name and cannot call that getter result. Read the + // property first, then call the value with the class as `this`. + let mut current = Some(cls_name.clone()); + let mut has_dynamic_parent = false; + for _ in 0..64 { + let Some(name) = current else { break }; + let Some(class) = ctx.classes.get(&name) else { + break; + }; + if class.extends_expr.is_some() { + has_dynamic_parent = true; + break; + } + current = class.extends_name.clone(); + } + if has_dynamic_parent { + return crate::lower_call::console_promise::try_lower_closure_call_fallthrough( + ctx, callee, args, + ); + } let receiver_is_dispatchable_class = matches!(object, Expr::ClassRef(_)) || matches!(object, Expr::ExternFuncRef { name, .. } if ctx.class_ids.contains_key(name)) || matches!(object, Expr::PropertyGet { object: inner, property, .. } diff --git a/crates/perry/tests/issue_10893_getter_call.rs b/crates/perry/tests/issue_10893_getter_call.rs new file mode 100644 index 0000000000..abcbd23468 --- /dev/null +++ b/crates/perry/tests/issue_10893_getter_call.rs @@ -0,0 +1,71 @@ +//! Direct calls through class getters invoke the value returned by the getter. + +use std::path::PathBuf; +use std::process::Command; + +#[test] +fn direct_calls_through_instance_and_static_getters() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let binary = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#" +let reads = 0; +class C { + get g() { reads++; return (n: number) => n + 2; } +} +const c = new C(); +console.log("instance", c.g(1), reads); +const instanceFn = c.g; +console.log("read then call", instanceFn(1), reads); + +function make() { + const cache = new Map(); + return class { + static get g() { + if (!cache.has(this)) cache.set(this, (n: number) => n + 8); + return cache.get(this); + } + }; +} +class G extends make() {} +class H extends G {} +const staticFn = G.g; +console.log("static read", staticFn(1)); +console.log("static direct", G.g(1), H.g(2)); +"#, + ) + .expect("write fixture"); + + let compile = Command::new(PathBuf::from(env!("CARGO_BIN_EXE_perry"))) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&binary) + .arg("--no-cache") + .output() + .expect("compile fixture"); + assert!( + compile.status.success(), + "compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(binary) + .current_dir(dir.path()) + .output() + .expect("run fixture"); + assert!( + run.status.success(), + "fixture failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "instance 3 1\nread then call 3 2\nstatic read 9\nstatic direct 9 10\n" + ); +} From 3eac49fb4748bd6a6c1d7b669411468eea9c9282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 18:33:30 +0200 Subject: [PATCH 04/17] docs: note inherited static getter call fix --- changelog.d/11012-inherited-static-getter-call.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/11012-inherited-static-getter-call.md diff --git a/changelog.d/11012-inherited-static-getter-call.md b/changelog.d/11012-inherited-static-getter-call.md new file mode 100644 index 0000000000..4c0c1444cb --- /dev/null +++ b/changelog.d/11012-inherited-static-getter-call.md @@ -0,0 +1,3 @@ +### Fixed + +- Direct calls through static getters inherited from a factory-produced class now invoke the getter's returned function, including on further subclasses. From 2492b03141ce1ebf21fd56d56df20036a79e434c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 18:58:31 +0200 Subject: [PATCH 05/17] fix: preserve tagged error names across class evaluations --- .../src/lower/lower_expr/arm_class.rs | 11 ++- .../src/lower_decl/class_decl/from_ast.rs | 12 +++ .../class_registry/evaluation_heritage.rs | 6 ++ .../class_registry/prototype_objects.rs | 86 ++++++++++++++++ .../src/object/class_registry/state.rs | 74 +++++++++----- .../test_issue_10890_tagged_error_name.ts | 98 +++++++++++++++++++ 6 files changed, 261 insertions(+), 26 deletions(-) create mode 100644 test-files/test_issue_10890_tagged_error_name.ts 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 4bd9822d1d..318b9dfb6b 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_class.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_class.rs @@ -226,6 +226,7 @@ pub(crate) fn lower_class_expr( || computed_statics.iter().any(|(_, value)| uses_self(value)) || computed_name_evaluations.iter().any(uses_self) }); + let has_static_methods = !class.static_methods.is_empty(); ctx.pending_classes.push(class); // #1772/#5893: a class EXPRESSION that carries per-evaluation static // fields, captures, or private elements lowers to a @@ -301,7 +302,15 @@ pub(crate) fn lower_class_expr( || !captured_args.is_empty() || !static_block_names.is_empty() || has_private_elements - || self_binding_used) + || self_binding_used + // A factory-created superclass is a fresh class object with its + // own mutable prototype. A shared ClassRef for the child links to + // the template prototype instead of that evaluated parent (e.g. + // Effect's Base.prototype.name = tag). Keep the runtime parent + // value on a fresh child class. The shared path remains for class + // expressions with static methods until those methods can be + // installed on fresh class objects. + || (parent_expr.is_some() && !has_static_methods)) { // #6438: a class expression WITH heritage (`class extends `) used // to be excluded here and fell back to the shared-template `ClassRef` diff --git a/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs b/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs index cf9f617a4b..89b4cbe636 100644 --- a/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs +++ b/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs @@ -200,6 +200,18 @@ pub(crate) fn lower_class_from_ast( Ok(expr) => (None, Some(parent_name), None, Some(Box::new(expr))), Err(_) => (None, Some(parent_name), None, None), } + } else if ctx.scope_depth > 0 && ctx.locals.lookup(ident.sym.as_ref()).is_some() { + // A function-local class declaration is a fresh class + // object each time its enclosing function runs. Preserve + // its static id for method/layout analysis, but also + // record the evaluated local as the actual superclass. + // Otherwise a fresh child class expression links to the + // shared template prototype and loses writes such as + // `Base.prototype.name = tag` (Effect TaggedError). + match lower_class_heritage_expr(ctx, super_class) { + Ok(expr) => (parent_cid, Some(parent_name), None, Some(Box::new(expr))), + Err(_) => (parent_cid, Some(parent_name), None, None), + } } else { (parent_cid, Some(parent_name), None, None) } diff --git a/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs b/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs index 303b3e5dac..1dc6718a87 100644 --- a/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs +++ b/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs @@ -205,6 +205,12 @@ pub(crate) fn pin_instance_constructing_class(inst: *mut ObjectHeader, classobj_ if class_ptr.is_null() || class_object_pinned_parent(class_ptr).is_none() { return; } + // A derived constructor may replay several fresh ancestors. Keep the + // first (most derived) class object: later super() legs would otherwise + // replace it with a deeper ancestor and lose the nearer prototype chain. + if instance_pinned_constructing_class(inst).is_some() { + return; + } // `js_class_object_pin_parent` already armed `CLASS_OBJECT_HERITAGE_PIN_LATCH` // before writing `class_ptr`'s own pin above (the ordering rule in // `registry_latch.rs`) — that write happens-before this one in this diff --git a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs index 235da8406e..c6d24e4cf1 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs @@ -525,6 +525,55 @@ unsafe fn inherited_proto_accessor_value( )) } +/// Read the actual prototype objects of a class whose parent is a fresh class +/// evaluation. The template-id walk below follows the parent's shared class +/// registry entry; that entry cannot see writes to this evaluation's +/// `Base.prototype` (such as an Effect tagged error's `name`). +unsafe fn evaluated_parent_instance_field( + decl_proto: *mut ObjectHeader, + key: *const crate::StringHeader, + receiver: f64, +) -> Option { + if decl_proto.is_null() || key.is_null() { + return None; + } + let mut link = Some(crate::value::js_nanbox_pointer(decl_proto as i64).to_bits()); + for _ in 0..32 { + let bits = link?; + if bits == crate::value::TAG_NULL { + return None; + } + let value = f64::from_bits(bits); + if crate::proxy::js_proxy_is_proxy(value) != 0 { + return super::super::prototype_chain::resolve_inherited_field_from_prototype( + decl_proto as usize, + bits, + key, + ); + } + let addr = match bits >> 48 { + 0x7FFD => (bits & crate::value::POINTER_MASK) as usize, + 0 if crate::value::addr_class::is_above_handle_band(bits as usize) => bits as usize, + _ => return None, + }; + let Some(header) = crate::value::addr_class::try_read_gc_header(addr) else { + return None; + }; + if header.obj_type != crate::gc::GC_TYPE_OBJECT { + return None; + } + let proto = addr as *mut ObjectHeader; + if let Some(value) = inherited_proto_accessor_value(proto, key, receiver) { + return Some(value); + } + if let Some(value) = super::super::field_get_set::own_data_field_by_name(proto, key) { + return Some(value); + } + link = super::super::prototype_chain::object_static_prototype(addr); + } + None +} + /// `constructor_side`: this walk serves a read on the class CONSTRUCTOR, so a /// name that is a declared INSTANCE member must not resolve through it. /// @@ -579,6 +628,43 @@ unsafe fn resolve_proto_chain_field_inner( receiver: Option, constructor_side: bool, ) -> Option { + if let Some(receiver) = receiver { + let receiver_value = JSValue::from_bits(receiver.to_bits()); + if receiver_value.is_pointer() { + let receiver_obj = receiver_value.as_pointer::(); + if !receiver_obj.is_null() { + if let Some(pin) = instance_pinned_constructing_class(receiver_obj) { + // A factory-created class can be evaluated again after + // this instance was built. Its template class id then + // points at the later evaluation. Resolve through this + // instance's pinned class object and its own prototype. + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(receiver); + let pin = scope.root_nanbox_f64(pin); + let key = scope.root_string_ptr(key as *mut crate::StringHeader); + let pin_obj = JSValue::from_bits(pin.get_nanbox_f64().to_bits()) + .as_pointer::(); + let proto_value = + super::super::field_get_set::class_object_prototype_value(pin_obj); + let proto = JSValue::from_bits(proto_value.bits()).as_pointer::(); + if !proto.is_null() { + let proto = scope.root_raw_mut_ptr(proto as *mut ObjectHeader); + if let Some(value) = proto.with_mut_ptr::(|proto| { + key.with_const_ptr::(|key| { + evaluated_parent_instance_field( + proto, + key, + receiver.get_nanbox_f64(), + ) + }) + }) { + return Some(value); + } + } + } + } + } + } // Resolved once: `class_instance_has_member` already walks the parent // chain, so a parent's instance method is excluded from a subclass's // constructor read too. diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index 0430c81ecd..edc34ef1ab 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -1118,30 +1118,50 @@ pub(crate) fn class_decl_prototype_value(class_id: u32) -> f64 { // Object.prototype default. Some(crate::value::TAG_NULL) } else { - let registered_parent_proto = get_parent_class_id(class_id) - .filter(|parent_id| *parent_id != 0 && *parent_id != class_id) - .and_then(|parent_id| { - let parent_proto = class_decl_prototype_value(parent_id); - let parent_bits = parent_proto.to_bits(); - if (parent_bits >> 48) == 0x7FFD { - return Some(parent_bits); - } - // #10599: `parent_id` may be a RESERVED native-builtin class id - // rather than a declared class -- `builtin_parent_reserved_class_id` - // in perry-codegen wires this edge for `class Sub extends - // EventEmitter {}`, which has no `js_register_class_name` - // registration of its own. `class_decl_prototype_value` bails - // immediately for such an id (`class_name_for_id` is `None`), so - // without this fallback the lookup above always misses and - // execution falls through to the runtime-function-valued branch - // below, which also misses (there is no dynamic-parent VALUE for - // a statically-resolved reserved id) -- landing `Sub.prototype`'s - // `[[Prototype]]` on `Object.prototype` instead of - // `EventEmitter.prototype`. - reserved_native_parent_prototype_bits(parent_id) - }); - if registered_parent_proto.is_some() { - registered_parent_proto + // A dynamically evaluated class has its own prototype object even + // when it shares a template class id with other evaluations. Use the + // parent VALUE recorded at this class definition, before consulting + // the template's parent-id edge. The latter loses assignments such as + // Effect's `Base.prototype.name = tag` on the actual parent object. + let evaluated_parent_proto = { + let parent_value = dynamic_parent.get_nanbox_f64(); + if super::is_class_object_value(parent_value) { + let parent_obj = crate::value::JSValue::from_bits(parent_value.to_bits()) + .as_pointer::(); + let parent_proto = unsafe { + super::super::field_get_set::class_object_prototype_value(parent_obj) + }; + class_parent_prototype_bits(f64::from_bits(parent_proto.bits())) + } else { + None + } + }; + let parent_proto = evaluated_parent_proto.or_else(|| { + get_parent_class_id(class_id) + .filter(|parent_id| *parent_id != 0 && *parent_id != class_id) + .and_then(|parent_id| { + let parent_proto = class_decl_prototype_value(parent_id); + let parent_bits = parent_proto.to_bits(); + if (parent_bits >> 48) == 0x7FFD { + return Some(parent_bits); + } + // #10599: `parent_id` may be a RESERVED native-builtin class id + // rather than a declared class -- `builtin_parent_reserved_class_id` + // in perry-codegen wires this edge for `class Sub extends + // EventEmitter {}`, which has no `js_register_class_name` + // registration of its own. `class_decl_prototype_value` bails + // immediately for such an id (`class_name_for_id` is `None`), so + // without this fallback the lookup above always misses and + // execution falls through to the runtime-function-valued branch + // below, which also misses (there is no dynamic-parent VALUE for + // a statically-resolved reserved id) -- landing `Sub.prototype`'s + // `[[Prototype]]` on `Object.prototype` instead of + // `EventEmitter.prototype`. + reserved_native_parent_prototype_bits(parent_id) + }) + }); + if parent_proto.is_some() { + parent_proto } else { // A runtime function-valued superclass (including Intl service // constructors) has no class-id edge. Link the declared prototype @@ -1177,9 +1197,13 @@ pub(crate) fn class_decl_prototype_value(class_id: u32) -> f64 { } }; if let Some(bits) = parent_proto_bits { + let bits = scope.root_heap_word_u64(bits); let proto = class_decl_prototype_object(class_id); if !proto.is_null() { - super::super::prototype_chain::object_set_static_prototype(proto as usize, bits); + super::super::prototype_chain::object_set_static_prototype( + proto as usize, + bits.get_heap_word_u64(), + ); } } diff --git a/test-files/test_issue_10890_tagged_error_name.ts b/test-files/test_issue_10890_tagged_error_name.ts new file mode 100644 index 0000000000..65d48cf5e3 --- /dev/null +++ b/test-files/test_issue_10890_tagged_error_name.ts @@ -0,0 +1,98 @@ +// Dynamic Error subclasses, like Effect's Schema.TaggedError, set the +// inherited name on a factory-created base prototype. +class Plain extends Error {} +Plain.prototype.name = "PlainTag"; +console.log("plain", new Plain("message").name); + +function makeBase(tag: string) { + class Base extends Error {} + Base.prototype.name = tag; + return class Tagged extends Base {}; +} +const Factory = makeBase("FactoryTag"); +console.log("factory", new Factory("message").name); + +function makeObjectBase(tag: string) { + const O = { Base: class extends Error {} }; + O.Base.prototype.name = tag; + return class Tagged extends O.Base {}; +} +const ObjectBase = makeObjectBase("ObjectTag"); +console.log("object-base", new ObjectBase("message").name); + +function makeDeepBase(tag: string) { + class Base extends Error {} + Base.prototype.name = tag; + const makeClass = (Ctor: typeof Base) => class Mid extends Ctor {}; + return class Tagged extends makeClass(Base) {}; +} +const DeepBase = makeDeepBase("DeepTag"); +console.log("deep-base", new DeepBase("message").name); + +function makeTagged(tag: string) { + class Base extends Error {} + Base.prototype.name = tag; + return class Tagged extends Base { + static _tag = tag; + }; +} +class First extends makeTagged("FirstTag") {} +class Second extends makeTagged("SecondTag") {} +const first = new First("message"); +const second = new Second("message"); +console.log("prototypes", First.prototype.name, Second.prototype.name); +console.log("two-tags", first.name, second.name, first instanceof Second); +console.log("to-string", String(first), String(second)); +console.log("own-name", Object.prototype.hasOwnProperty.call(first, "name")); + +// Effect's Data.Error adds factory-created ancestors beyond the tagged Base. +// Constructor replay must retain the nearest class evaluation: pinning the +// deepest ancestor makes this instance read Error.prototype.name instead. +const YieldableError = (function () { + class YieldableError extends Error { + toJSON() { + return { ...this }; + } + } + return YieldableError; +})(); +const DataError = (function () { + const classes = { + BaseEffectError: class extends YieldableError { + constructor(args: any) { + super(args?.message); + if (args) Object.assign(this, args); + } + }, + }; + return classes.BaseEffectError; +})(); +function makeSchemaClass(Base: any) { + const klass = class extends Base { + constructor(props: any = {}) { + super(props); + } + static get ast() { + return "ast"; + } + }; + return klass; +} +function makeEffectTagged(tag: string) { + class Base extends DataError {} + Base.prototype.name = tag; + class TaggedErrorClass extends makeSchemaClass(Base) { + static _tag = tag; + } + return TaggedErrorClass; +} +class NestedFirst extends makeEffectTagged("NestedFirstTag") {} +class NestedSecond extends makeEffectTagged("NestedSecondTag") {} +const nestedFirst = new NestedFirst({ message: "message" }); +const nestedSecond = new NestedSecond({ message: "message" }); +console.log( + "nested-tags", + nestedFirst.name, + nestedSecond.name, + nestedFirst instanceof NestedSecond, +); From 3c6d03713add4f4c0b06d6c40700bb630ca2707e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 18:59:13 +0200 Subject: [PATCH 06/17] docs: note Effect tagged error name fix --- changelog.d/11014-effect-tagged-error-name.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 changelog.d/11014-effect-tagged-error-name.md diff --git a/changelog.d/11014-effect-tagged-error-name.md b/changelog.d/11014-effect-tagged-error-name.md new file mode 100644 index 0000000000..7ead2aec7c --- /dev/null +++ b/changelog.d/11014-effect-tagged-error-name.md @@ -0,0 +1,12 @@ +Fixed inherited `name` on Effect tagged errors (#10890). + +A factory-created `Base.prototype.name` could be lost when a class expression +or function-local class declaration used a shared template parent instead of +its evaluated parent. Nested `super()` replay also replaced the instance's +class pin with a deeper Error ancestor, so property reads found `Error` before +the tag. Perry now retains the evaluated heritage and first constructor pin, +then reads inherited properties from that evaluation's prototype chain. + +The parity fixture covers distinct tags, `String(error)`, and Effect's nested +`Data.Error` inheritance shape. The pinned Effect package repro now matches +Node for `_tag`, `name`, `instanceof`, the declared field, and `String(error)`. From cadeb45996ad8f33e57d52c7602ac4c726f44bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 19:01:42 +0200 Subject: [PATCH 07/17] fix(runtime): transition shapes when integrity flags change --- .../src/object/object_ops_frozen.rs | 37 ++++++++++++++----- .../src/object/shape_rules_tests.rs | 37 +++++++++++++++++++ 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/crates/perry-runtime/src/object/object_ops_frozen.rs b/crates/perry-runtime/src/object/object_ops_frozen.rs index 835cb1a549..2b9baba4c6 100644 --- a/crates/perry-runtime/src/object/object_ops_frozen.rs +++ b/crates/perry-runtime/src/object/object_ops_frozen.rs @@ -149,6 +149,18 @@ unsafe fn integrity_flags_are_writable(obj: *const ObjectHeader) -> bool { !obj.is_null() && crate::value::addr_class::try_read_tracked_gc_header(obj as usize).is_some() } +/// Extensibility is part of a shaped object's semantics. Retire its previous +/// ShapeId when an integrity flag changes so a shape-keyed add path cannot +/// reuse an edge learned while the object was extensible. +unsafe fn set_integrity_flags(obj: *mut ObjectHeader, flags: u16) { + let gc = gc_header_for(obj); + let added = (*gc)._reserved & flags != flags; + (*gc)._reserved |= flags; + if added { + shapes::transition_object_shape_semantics(obj); + } +} + #[no_mangle] pub extern "C" fn js_object_freeze(obj_value: f64) -> f64 { crate::array::subclass_elements::deopt_value(obj_value); @@ -167,10 +179,12 @@ pub extern "C" fn js_object_freeze(obj_value: f64) -> f64 { // no-op-and-return-the-value behaviour for a rejected receiver is // unchanged (`test_gap_handle_band_object_ops` `Object.freeze(blob)`). if integrity_flags_are_writable(obj) { - let gc = gc_header_for(obj); - (*gc)._reserved |= crate::gc::OBJ_FLAG_FROZEN - | crate::gc::OBJ_FLAG_SEALED - | crate::gc::OBJ_FLAG_NO_EXTEND; + set_integrity_flags( + obj, + crate::gc::OBJ_FLAG_FROZEN + | crate::gc::OBJ_FLAG_SEALED + | crate::gc::OBJ_FLAG_NO_EXTEND, + ); // TypedArray receivers are NOT `ObjectHeader`s — the key walk // below would read a garbage `keys_array` off the TA header and // can fault depending on heap layout. The GC flags above are the @@ -277,8 +291,10 @@ pub extern "C" fn js_object_seal(obj_value: f64) -> f64 { unsafe { let obj = extract_obj_ptr(obj_value); if integrity_flags_are_writable(obj) { - let gc = gc_header_for(obj); - (*gc)._reserved |= crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND; + set_integrity_flags( + obj, + crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND, + ); } } return obj_value; @@ -286,8 +302,10 @@ pub extern "C" fn js_object_seal(obj_value: f64) -> f64 { unsafe { let obj = extract_obj_ptr(obj_value); if integrity_flags_are_writable(obj) { - let gc = gc_header_for(obj); - (*gc)._reserved |= crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND; + set_integrity_flags( + obj, + crate::gc::OBJ_FLAG_SEALED | crate::gc::OBJ_FLAG_NO_EXTEND, + ); // TypedArray receivers: GC flags only — see `js_object_freeze`. if crate::typedarray::lookup_typed_array_kind(obj as usize).is_some() || crate::typedarray_props::typed_array_addr_from_value(obj_value).is_some() @@ -389,8 +407,7 @@ pub extern "C" fn js_object_prevent_extensions(obj_value: f64) -> f64 { crate::typedarray_props::typed_array_mark_no_extend(owner); return obj_value; } - let gc = gc_header_for(obj); - (*gc)._reserved |= crate::gc::OBJ_FLAG_NO_EXTEND; + set_integrity_flags(obj, crate::gc::OBJ_FLAG_NO_EXTEND); } } obj_value diff --git a/crates/perry-runtime/src/object/shape_rules_tests.rs b/crates/perry-runtime/src/object/shape_rules_tests.rs index 61704a990f..132a00f28b 100644 --- a/crates/perry-runtime/src/object/shape_rules_tests.rs +++ b/crates/perry-runtime/src/object/shape_rules_tests.rs @@ -80,6 +80,43 @@ fn accessor() -> AccessorDescriptor { const DEFAULT_ATTRS: PropertyAttrs = PropertyAttrs::new(true, true, true); const FROZEN_ATTRS: PropertyAttrs = PropertyAttrs::new(false, true, false); +/// Keyless receivers expose the flag transition directly: no descriptor +/// install can incidentally mint a successor shape for these operations. +#[test] +fn rule1_integrity_flags_transition_keyless_shapes() { + let _lock = crate::gc::global_side_table_test_lock(); + for (name, operation) in [ + ( + "preventExtensions", + super::js_object_prevent_extensions as extern "C" fn(f64) -> f64, + ), + ("seal", super::js_object_seal), + ("freeze", super::js_object_freeze), + ] { + unsafe { + let obj = shaped_object(&[]); + let sibling = shaped_object(&[]); + let before = shapes::object_shape_stamp(obj); + assert_eq!(before, shapes::object_shape_stamp(sibling)); + let value = crate::value::js_nanbox_pointer(obj as i64); + operation(value); + let after = shapes::object_shape_stamp(obj); + assert_ne!(before, after, "{name} must retire the extensible shape"); + assert_eq!( + before, + shapes::object_shape_stamp(sibling), + "{name} must not change a sibling's shape" + ); + operation(value); + assert_eq!( + after, + shapes::object_shape_stamp(obj), + "repeated {name} must not mint another shape for unchanged flags" + ); + } + } +} + #[test] fn rule1_set_property_attrs_transitions() { assert_shape_moves("set_property_attrs", |addr| { From 916942a0ba2bda81f53a533d98333079f4751dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 19:02:21 +0200 Subject: [PATCH 08/17] docs: add PR 11015 changelog fragment --- changelog.d/11015-integrity-shape-transition.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 changelog.d/11015-integrity-shape-transition.md diff --git a/changelog.d/11015-integrity-shape-transition.md b/changelog.d/11015-integrity-shape-transition.md new file mode 100644 index 0000000000..8aae0c7bd0 --- /dev/null +++ b/changelog.d/11015-integrity-shape-transition.md @@ -0,0 +1,10 @@ +`Object.preventExtensions`, `Object.seal`, and `Object.freeze` now publish a new +ShapeId when they change an ordinary object's integrity flags. Previously, +`preventExtensions` left the shape unchanged, so a future shape-keyed property +add cache could reuse an edge learned while the object was extensible. Seal and +freeze only changed the shape indirectly when they updated an existing key's +descriptor, leaving keyless objects with the same gap. + +Repeated calls with no new flag changes preserve the current ShapeId. A runtime +test covers all three operations on keyless objects and verifies that sibling +objects retain their original shape. From efd874ec86b411fd9b3a8aae71ac9d770e39fc91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 19:50:45 +0200 Subject: [PATCH 09/17] fix(runtime): preserve spread iterator throws in debug builds --- crates/perry-runtime/src/array/iterator.rs | 33 +++++++++++++++++++ .../tests/issue_10058_push_spread_scaling.rs | 3 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/array/iterator.rs b/crates/perry-runtime/src/array/iterator.rs index 78ea691b39..9a48155987 100644 --- a/crates/perry-runtime/src/array/iterator.rs +++ b/crates/perry-runtime/src/array/iterator.rs @@ -1241,8 +1241,27 @@ pub(crate) fn array_from_spread_value(value: f64) -> *mut ArrayHeader { throw_not_iterable(value()); } +// This helper runs the user-observable iterator protocol and can therefore +// throw through the generated caller. Debug/test archives transport that +// throw with Rust unwinding, so their outer ABI must permit it. Production +// uses Perry's raw exception transport and must retain the plain C boundary; +// see closure/dispatch/value_call.rs (#8479). +#[cfg(panic = "abort")] #[no_mangle] pub extern "C" fn js_array_spread_append(dest: *mut ArrayHeader, source: f64) -> *mut ArrayHeader { + js_array_spread_append_impl(dest, source) +} + +#[cfg(not(panic = "abort"))] +#[no_mangle] +pub extern "C-unwind" fn js_array_spread_append( + dest: *mut ArrayHeader, + source: f64, +) -> *mut ArrayHeader { + js_array_spread_append_impl(dest, source) +} + +fn js_array_spread_append_impl(dest: *mut ArrayHeader, source: f64) -> *mut ArrayHeader { // Materializing an intercepted iterator can allocate and move the // destination. Keep it rooted across that protocol walk and re-read it // before appending. Ordinary dense arrays need no temporary: the same @@ -1557,8 +1576,22 @@ fn settled_promise_value(value: f64) -> Option { /// Used by spread on generators, Array.from on generators, etc. /// Calls `.next()` in a loop until `.done` is true, collecting `.value` entries. +// `.next()` is arbitrary user code. Match the conditional ABI on the dynamic +// call bridges so a debug/test archive can carry a catchable throw across this +// exported helper without rustc's abort-on-unwind guard. +#[cfg(panic = "abort")] #[no_mangle] pub extern "C" fn js_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { + js_iterator_to_array_impl(iter_f64) +} + +#[cfg(not(panic = "abort"))] +#[no_mangle] +pub extern "C-unwind" fn js_iterator_to_array(iter_f64: f64) -> *mut ArrayHeader { + js_iterator_to_array_impl(iter_f64) +} + +fn js_iterator_to_array_impl(iter_f64: f64) -> *mut ArrayHeader { use crate::closure; use crate::object::{js_object_get_field_by_name, ObjectHeader}; use crate::string::js_string_from_bytes; diff --git a/crates/perry/tests/issue_10058_push_spread_scaling.rs b/crates/perry/tests/issue_10058_push_spread_scaling.rs index f14502549a..80a376cac1 100644 --- a/crates/perry/tests/issue_10058_push_spread_scaling.rs +++ b/crates/perry/tests/issue_10058_push_spread_scaling.rs @@ -153,7 +153,8 @@ fn spread_push_is_iterator_correct_and_gc_safe_on_reused_destinations() { assert_success("Node oracle", &node); for moving_gc in [false, true] { let perry = run(&binary, moving_gc); - assert_success("compiled fixture", &perry); + let mode = if moving_gc { "moving GC" } else { "plain" }; + assert_success(&format!("compiled fixture ({mode})"), &perry); assert_eq!( perry.stdout, node.stdout, From aed6794da4e2dc19be762db38369f9ce72c6a046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 19:51:24 +0200 Subject: [PATCH 10/17] docs(changelog): note spread iterator unwind fix --- changelog.d/11022-spread-iterator-debug-unwind.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/11022-spread-iterator-debug-unwind.md diff --git a/changelog.d/11022-spread-iterator-debug-unwind.md b/changelog.d/11022-spread-iterator-debug-unwind.md new file mode 100644 index 0000000000..a23dcc0c3e --- /dev/null +++ b/changelog.d/11022-spread-iterator-debug-unwind.md @@ -0,0 +1,3 @@ +### Fixed + +- Preserve exceptions thrown by custom iterators during array push spread when using debug or test runtime archives, so JavaScript `catch` handlers receive them instead of the process aborting (#11010). From 9b38fc3b0ffa58cc9d79c745dd787e2c926d470f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 20:25:30 +0200 Subject: [PATCH 11/17] fix(streams): lower namespace ReadableStream.from --- .../src/destructuring/var_decl/native_new.rs | 46 ++++--------- .../src/destructuring/var_decl/type_infer.rs | 3 + .../lower/expr_call/static_and_instance.rs | 24 +++++++ crates/perry-hir/src/lower_types.rs | 63 +++++++++++++++++ .../tests/readable_stream_from_lowering.rs | 69 +++++++++++++++++++ .../test_gap_10568_readable_stream_from.ts | 9 +++ 6 files changed, 180 insertions(+), 34 deletions(-) create mode 100644 test-files/test_gap_10568_readable_stream_from.ts diff --git a/crates/perry-hir/src/destructuring/var_decl/native_new.rs b/crates/perry-hir/src/destructuring/var_decl/native_new.rs index 142778d870..d34dc93d98 100644 --- a/crates/perry-hir/src/destructuring/var_decl/native_new.rs +++ b/crates/perry-hir/src/destructuring/var_decl/native_new.rs @@ -147,46 +147,24 @@ pub(crate) fn register_native_from_new_and_calls( } } - // #1645: `const rs = ReadableStream.from(iterable)` — the `.from` + // #1645/#10568: `const rs = ReadableStream.from(iterable)` — including + // the namespace-import spelling `(streamWeb.ReadableStream as any).from`. // Call result is typed Any, so register the binding as a // ReadableStream native instance (mirroring `new ReadableStream`'s // typing). Without this, `rs.getReader()` / `for await (const c of // rs)` fall to generic dispatch on the numeric stream handle and // fail. The Call itself is routed to `js_readable_stream_from_iterable` // in codegen (expr/calls.rs). - if let Some(init_expr) = &decl.init { - if let ast::Expr::Call(call) = init_expr.as_ref() { - if let ast::Callee::Expr(callee) = &call.callee { - if let ast::Expr::Member(m) = callee.as_ref() { - if let ast::MemberProp::Ident(prop) = &m.prop { - if prop.sym.as_ref() == "from" { - let mut obj_inner: &ast::Expr = m.obj.as_ref(); - loop { - obj_inner = match obj_inner { - ast::Expr::TsAs(x) => &x.expr, - ast::Expr::TsNonNull(x) => &x.expr, - ast::Expr::TsSatisfies(x) => &x.expr, - ast::Expr::TsTypeAssertion(x) => &x.expr, - ast::Expr::TsConstAssertion(x) => &x.expr, - ast::Expr::Paren(x) => &x.expr, - _ => break, - }; - } - if matches!( - obj_inner, - ast::Expr::Ident(i) if i.sym.as_ref() == "ReadableStream" - ) { - ctx.register_native_instance( - name.to_string(), - "readable_stream".to_string(), - "ReadableStream".to_string(), - ); - } - } - } - } - } - } + if decl + .init + .as_deref() + .is_some_and(|init| crate::lower_types::is_web_readable_stream_from_call(ctx, init)) + { + ctx.register_native_instance( + name.to_string(), + "readable_stream".to_string(), + "ReadableStream".to_string(), + ); } // Check if this is an awaited native class instantiation (e.g., await new Redis()) diff --git a/crates/perry-hir/src/destructuring/var_decl/type_infer.rs b/crates/perry-hir/src/destructuring/var_decl/type_infer.rs index 952495e3bc..bd462df364 100644 --- a/crates/perry-hir/src/destructuring/var_decl/type_infer.rs +++ b/crates/perry-hir/src/destructuring/var_decl/type_infer.rs @@ -203,6 +203,9 @@ pub(crate) fn infer_decl_type( // dispatches via the native-instance registry, not this declared type. if matches!(ty, Type::Any) { if let Some(init_expr) = &decl.init { + if crate::lower_types::is_web_readable_stream_from_call(ctx, init_expr) { + ty = Type::Named("ReadableStream".to_string()); + } if let ast::Expr::Call(call) = init_expr.as_ref() { if let ast::Callee::Expr(callee) = &call.callee { if let ast::Expr::Member(m) = callee.as_ref() { diff --git a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs index bf9f783866..e50e0a5ba9 100644 --- a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs +++ b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs @@ -71,6 +71,30 @@ pub(super) fn try_static_method_and_instance( // handle it. Refs test262 language/arguments-object // cls-*-static-*-spread-operator. let static_call_has_spread = call.args.iter().any(|a| a.spread.is_some()); + + // `import * as web from "node:stream/web"; (web.ReadableStream as + // any).from(xs)` has a nested namespace receiver. Route it through the + // same native factory as the named-import form before the generic + // module.Class.staticMethod arm sees it as `stream/web.ReadableStream`. + if !static_call_has_spread { + if let ast::Expr::Member(member) = expr { + if matches!(&member.prop, ast::MemberProp::Ident(prop) if prop.sym.as_ref() == "from") + && crate::lower_types::is_web_readable_stream_constructor_ref( + ctx, + member.obj.as_ref(), + ) + { + return Ok(Ok(Expr::NativeMethodCall { + module: "readable_stream".to_string(), + class_name: Some("ReadableStream".to_string()), + object: None, + method: "from".to_string(), + args, + })); + } + } + } + // Check for static method calls (e.g., Counter.increment()) if let ast::Expr::Member(member) = expr { if let ast::Expr::Ident(obj_ident) = unwrap_ts_wrappers(member.obj.as_ref()) { diff --git a/crates/perry-hir/src/lower_types.rs b/crates/perry-hir/src/lower_types.rs index 8a85fe7512..c648c1e408 100644 --- a/crates/perry-hir/src/lower_types.rs +++ b/crates/perry-hir/src/lower_types.rs @@ -1137,6 +1137,69 @@ pub(crate) fn is_node_readable_static_factory_call( && is_node_readable_constructor_ref(ctx, member.obj.as_ref()) } +fn is_web_readable_stream_module_alias(ctx: &LoweringContext, name: &str) -> bool { + matches!( + ctx.lookup_native_module(name), + Some(("stream/web" | "node:stream/web", None)) + ) || matches!( + ctx.namespace_import_sources.get(name).map(String::as_str), + Some("stream/web" | "node:stream/web") + ) +} + +pub(crate) fn is_web_readable_stream_constructor_ref( + ctx: &LoweringContext, + expr: &ast::Expr, +) -> bool { + match expr { + ast::Expr::Ident(ident) => { + let name = ident.sym.as_ref(); + matches!( + ctx.lookup_native_module(name), + Some(("stream/web" | "node:stream/web", Some("ReadableStream"))) + ) || (name == "ReadableStream" && !ctx.shadows_unqualified_global(name)) + } + ast::Expr::Member(member) => { + let (ast::Expr::Ident(obj), ast::MemberProp::Ident(prop)) = + (member.obj.as_ref(), &member.prop) + else { + return false; + }; + prop.sym.as_ref() == "ReadableStream" + && is_web_readable_stream_module_alias(ctx, obj.sym.as_ref()) + } + ast::Expr::Paren(paren) => is_web_readable_stream_constructor_ref(ctx, &paren.expr), + ast::Expr::TsAs(ts_as) => is_web_readable_stream_constructor_ref(ctx, &ts_as.expr), + ast::Expr::TsTypeAssertion(ts_assert) => { + is_web_readable_stream_constructor_ref(ctx, &ts_assert.expr) + } + ast::Expr::TsNonNull(non_null) => { + is_web_readable_stream_constructor_ref(ctx, &non_null.expr) + } + ast::Expr::TsConstAssertion(const_assert) => { + is_web_readable_stream_constructor_ref(ctx, &const_assert.expr) + } + ast::Expr::TsSatisfies(satisfies) => { + is_web_readable_stream_constructor_ref(ctx, &satisfies.expr) + } + _ => false, + } +} + +pub(crate) fn is_web_readable_stream_from_call(ctx: &LoweringContext, expr: &ast::Expr) -> bool { + let ast::Expr::Call(call) = expr else { + return false; + }; + let ast::Callee::Expr(callee) = &call.callee else { + return false; + }; + let ast::Expr::Member(member) = callee.as_ref() else { + return false; + }; + matches!(&member.prop, ast::MemberProp::Ident(prop) if prop.sym.as_ref() == "from") + && is_web_readable_stream_constructor_ref(ctx, member.obj.as_ref()) +} + fn expr_may_have_typed_receiver(expr: &ast::Expr, ctx: &LoweringContext) -> bool { match expr { ast::Expr::Lit(ast::Lit::Str(_)) => true, diff --git a/crates/perry-hir/tests/readable_stream_from_lowering.rs b/crates/perry-hir/tests/readable_stream_from_lowering.rs index bf93ac6a7e..724ed37691 100644 --- a/crates/perry-hir/tests/readable_stream_from_lowering.rs +++ b/crates/perry-hir/tests/readable_stream_from_lowering.rs @@ -58,3 +58,72 @@ fn readable_stream_from_static_factory_lowers_to_native_factory() { other => panic!("expected ReadableStream.from NativeMethodCall, got: {other:#?}"), } } + +#[test] +fn namespace_readable_stream_from_lowers_to_native_factory_and_reader() { + let module = lower( + r#" + import * as streamWeb from "node:stream/web"; + const rs: any = (streamWeb.ReadableStream as any).from(["a"]); + const reader = rs.getReader(); + const result = reader.read(); + "#, + ); + + let lets: Vec<(&str, &Expr)> = module + .init + .iter() + .filter_map(|stmt| match stmt { + Stmt::Let { + name, + init: Some(expr), + .. + } => Some((name.as_str(), expr)), + _ => None, + }) + .collect(); + + assert!(matches!( + lets.as_slice(), + [ + ( + "rs", + Expr::NativeMethodCall { + module, + class_name: Some(class_name), + object: None, + method, + .. + } + ), + ( + "reader", + Expr::NativeMethodCall { + module: reader_module, + class_name: Some(reader_class), + object: Some(_), + method: reader_method, + .. + } + ), + ( + "result", + Expr::NativeMethodCall { + module: read_module, + class_name: Some(read_class), + object: Some(_), + method: read_method, + .. + } + ) + ] if module == "readable_stream" + && class_name == "ReadableStream" + && method == "from" + && reader_module == "readable_stream" + && reader_class == "ReadableStream" + && reader_method == "getReader" + && read_module == "readable_stream_reader" + && read_class == "ReadableStreamDefaultReader" + && read_method == "read" + )); +} diff --git a/test-files/test_gap_10568_readable_stream_from.ts b/test-files/test_gap_10568_readable_stream_from.ts new file mode 100644 index 0000000000..a82f8ccfd0 --- /dev/null +++ b/test-files/test_gap_10568_readable_stream_from.ts @@ -0,0 +1,9 @@ +import * as streamWeb from "node:stream/web"; + +const stream: any = (streamWeb.ReadableStream as any).from(["x", "y", "z"]); +const reader = stream.getReader(); + +for (let index = 0; index < 4; index++) { + const result: any = await reader.read(); + console.log(index, result.done, result.value, JSON.stringify(Object.keys(result))); +} From 95782ec2d46ac6ce25d4a7e9710e7a8fed41cbe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 20:30:13 +0200 Subject: [PATCH 12/17] docs: add changelog for #11026 --- changelog.d/11026-readable-stream-from-namespace.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/11026-readable-stream-from-namespace.md diff --git a/changelog.d/11026-readable-stream-from-namespace.md b/changelog.d/11026-readable-stream-from-namespace.md new file mode 100644 index 0000000000..606250f9f1 --- /dev/null +++ b/changelog.d/11026-readable-stream-from-namespace.md @@ -0,0 +1,7 @@ +### Fixed + +- `ReadableStream.from()` now works through a `node:stream/web` namespace + import, including TypeScript-cast forms such as + `(streamWeb.ReadableStream as any).from(items)`. The returned stream and + reader retain their native types, so `read()` yields `{ done, value }` + objects and iterable drain loops terminate. From 70c30d9180f917fc2b7ec8a2b67194b9df9bd72f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 20:42:44 +0200 Subject: [PATCH 13/17] fix(stream): honor PassThrough subclass transforms --- crates/perry-codegen/src/codegen/helpers.rs | 1 + crates/perry-codegen/src/codegen/method.rs | 5 +- .../perry-codegen/src/expr/this_super_call.rs | 3 ++ .../perry-codegen/src/expr/write_barrier.rs | 1 + crates/perry-codegen/src/lower_call/new.rs | 2 + .../src/lower_call/new_helpers.rs | 1 + .../stdlib_ffi/streams_events.rs | 5 ++ crates/perry-hir/src/lower/tests.rs | 1 + .../tests/issue_10745_passthrough_heritage.rs | 28 ++++++++++ crates/perry-hir/src/lower_decl/class_decl.rs | 17 ++++-- .../src/lower_decl/class_decl/from_ast.rs | 2 +- .../src/node_stream_constructors.rs | 9 ++-- .../src/node_stream_constructors/builders.rs | 18 +++++++ .../src/node_stream_keepalive.rs | 4 ++ .../src/node_stream_state_tests.rs | 33 ++++++++++++ .../src/object/global_this/fetch_globals.rs | 11 ++-- .../test_gap_10745_passthrough_subclass.ts | 54 +++++++++++++++++++ 17 files changed, 177 insertions(+), 18 deletions(-) create mode 100644 crates/perry-hir/src/lower/tests/issue_10745_passthrough_heritage.rs create mode 100644 test-files/test_gap_10745_passthrough_subclass.ts diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 584ba953f8..a1553d9dab 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -927,6 +927,7 @@ pub(super) fn node_stream_parent_kind( "Readable" => return Some("readable"), "Duplex" => return Some("duplex"), "Transform" => return Some("transform"), + "PassThrough" => return Some("passthrough"), _ => {} } cur = classes diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 0e77806ec2..94665e9bbf 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -900,6 +900,7 @@ pub(super) fn compile_method( Some("Writable") => Some("js_node_stream_writable_subclass_init"), Some("Duplex") => Some("js_node_stream_duplex_subclass_init"), Some("Transform") => Some("js_node_stream_transform_subclass_init"), + Some("PassThrough") => Some("js_node_stream_passthrough_subclass_init"), _ => None, }; let mut effective_parent: Option<&str> = if builtin_parent_runtime.is_some() { @@ -936,7 +937,8 @@ pub(super) fn compile_method( // inline path for dynamic-parent classes. if let Some(pname) = effective_parent.filter(|_| dynamic_parent_owner.is_none()) { let pname_owned = pname.to_string(); - let node_stream_kind = if pname_owned == "Readable" { + let node_stream_kind = if matches!(pname_owned.as_str(), "Readable" | "PassThrough") + { node_stream_parent_kind(ctx.classes, class) } else { None @@ -958,6 +960,7 @@ pub(super) fn compile_method( "readable" => "js_node_stream_readable_subclass_init", "duplex" => "js_node_stream_duplex_subclass_init", "transform" => "js_node_stream_transform_subclass_init", + "passthrough" => "js_node_stream_passthrough_subclass_init", _ => unreachable!("node stream parent kind {}", kind), }; ctx.block().call( diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index f0d5af3d44..0c4d74d0d7 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -623,6 +623,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { | "Writable" | "Duplex" | "Transform" + | "PassThrough" | "ReadableStream" | "WritableStream" | "TransformStream" @@ -785,6 +786,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "Writable" => Some("writable"), "Duplex" => Some("duplex"), "Transform" => Some("transform"), + "PassThrough" => Some("passthrough"), _ => None, }; if let Some(kind) = node_stream_kind { @@ -851,6 +853,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "Writable" => Some("writable"), "Duplex" => Some("duplex"), "Transform" => Some("transform"), + "PassThrough" => Some("passthrough"), _ => None, }; if let Some(kind) = node_stream_kind { diff --git a/crates/perry-codegen/src/expr/write_barrier.rs b/crates/perry-codegen/src/expr/write_barrier.rs index a1b6c65c82..2036188faf 100644 --- a/crates/perry-codegen/src/expr/write_barrier.rs +++ b/crates/perry-codegen/src/expr/write_barrier.rs @@ -1202,6 +1202,7 @@ pub(crate) fn lower_node_stream_super_init( "writable" => "js_node_stream_writable_subclass_init", "duplex" => "js_node_stream_duplex_subclass_init", "transform" => "js_node_stream_transform_subclass_init", + "passthrough" => "js_node_stream_passthrough_subclass_init", _ => unreachable!( "lower_node_stream_super_init: unexpected Node stream kind {}", kind diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index ab8ed03fdf..2b641a2f1a 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -1092,6 +1092,7 @@ fn lower_new_impl_inner<'a>( Some("Writable") => Some("js_node_stream_writable_subclass_init"), Some("Duplex") => Some("js_node_stream_duplex_subclass_init"), Some("Transform") => Some("js_node_stream_transform_subclass_init"), + Some("PassThrough") => Some("js_node_stream_passthrough_subclass_init"), _ => None, } } else { @@ -1390,6 +1391,7 @@ fn lower_new_impl_inner<'a>( "readable" => "js_node_stream_readable_subclass_init", "duplex" => "js_node_stream_duplex_subclass_init", "transform" => "js_node_stream_transform_subclass_init", + "passthrough" => "js_node_stream_passthrough_subclass_init", _ => unreachable!("node stream parent kind {}", kind), }; ctx.block().call( diff --git a/crates/perry-codegen/src/lower_call/new_helpers.rs b/crates/perry-codegen/src/lower_call/new_helpers.rs index 3ed72efe24..043124c67a 100644 --- a/crates/perry-codegen/src/lower_call/new_helpers.rs +++ b/crates/perry-codegen/src/lower_call/new_helpers.rs @@ -620,6 +620,7 @@ pub(super) fn node_stream_parent_kind( "Readable" => return Some("readable"), "Duplex" => return Some("duplex"), "Transform" => return Some("transform"), + "PassThrough" => return Some("passthrough"), _ => {} } if ctx.imported_class_ctors.contains_key(name) { diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs index 5681b17671..da2ebbaec7 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs @@ -47,6 +47,11 @@ pub(crate) fn declare_streams_events(module: &mut LlModule) { &[DOUBLE, DOUBLE], ); module.declare_function("js_node_stream_passthrough_new", DOUBLE, &[DOUBLE]); + module.declare_function( + "js_node_stream_passthrough_subclass_init", + DOUBLE, + &[DOUBLE, DOUBLE], + ); module.declare_function("js_node_stream_readable_from", DOUBLE, &[DOUBLE]); module.declare_function( "js_node_stream_readable_from_options", diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 7ad4e18935..75adeb6b7b 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -1986,5 +1986,6 @@ mod subclass_ctor_inherited_method; mod ui_widget_add_child; mod issue_10623_require_destructured_native_super; +mod issue_10745_passthrough_heritage; mod hoisted_sibling_in_later_closure; diff --git a/crates/perry-hir/src/lower/tests/issue_10745_passthrough_heritage.rs b/crates/perry-hir/src/lower/tests/issue_10745_passthrough_heritage.rs new file mode 100644 index 0000000000..ebf11535ed --- /dev/null +++ b/crates/perry-hir/src/lower/tests/issue_10745_passthrough_heritage.rs @@ -0,0 +1,28 @@ +//! #10745: `PassThrough` is a classic `node:stream` native parent just like +//! `Transform`. Both class-lowering paths must retain that identity so codegen +//! can initialize the derived object in place and honor its `_transform`. + +#[test] +fn passthrough_import_alias_is_a_native_parent_for_decls_and_expressions() { + let source = r#" + import { PassThrough as PT } from "node:stream"; + class Decl extends PT { _transform(chunk, enc, cb) { cb(null, chunk); } } + const Expr = class extends PT { _transform(chunk, enc, cb) { cb(null, chunk); } }; + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + + for name in ["Decl", "Expr"] { + let class = hir + .classes + .iter() + .find(|class| class.name == name) + .unwrap_or_else(|| panic!("{name} is lowered")); + assert_eq!(class.extends_name.as_deref(), Some("PassThrough")); + assert_eq!( + class.native_extends, + Some(("node_stream".to_string(), "PassThrough".to_string())) + ); + assert!(class.extends_expr.is_none()); + } +} diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index 56c34a7975..05de5e433c 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -12,7 +12,10 @@ use crate::lower_types::*; /// imports are registered under the local binding while preserving this export. fn canonical_native_parent_name<'a>(ctx: &'a LoweringContext, name: &str) -> Option<&'a str> { match ctx.lookup_native_module(name) { - Some(("stream", Some(class @ ("Readable" | "Writable" | "Duplex" | "Transform")))) + Some(( + "stream", + Some(class @ ("Readable" | "Writable" | "Duplex" | "Transform" | "PassThrough")), + )) | Some(("events", Some(class @ ("EventEmitter" | "EventEmitterAsyncResource")))) | Some(("async_hooks", Some(class @ ("AsyncLocalStorage" | "AsyncResource")))) | Some(("ws", Some(class @ "WebSocketServer"))) @@ -30,10 +33,16 @@ fn canonical_native_parent_name<'a>(ctx: &'a LoweringContext, name: &str) -> Opt /// minified local binding such as `Readable as ut`. fn is_genuine_node_stream_parent(ctx: &LoweringContext, name: &str) -> bool { match ctx.lookup_native_module(name) { - Some(("stream", Some("Readable" | "Writable" | "Duplex" | "Transform"))) => true, + Some(( + "stream", + Some("Readable" | "Writable" | "Duplex" | "Transform" | "PassThrough"), + )) => true, // Preserve the historical name-based treatment of a namespace/default // binding whose local name itself is a classic stream constructor. - Some(("stream", None)) => matches!(name, "Readable" | "Writable" | "Duplex" | "Transform"), + Some(("stream", None)) => matches!( + name, + "Readable" | "Writable" | "Duplex" | "Transform" | "PassThrough" + ), _ => false, } } @@ -236,7 +245,7 @@ pub fn lower_class_decl( // so a userland stream-shim binding (readable-stream's // `Transform`, winston) falls through to the dynamic // `extends_expr` parent path and runs its real constructor. - "Readable" | "Writable" | "Duplex" | "Transform" + "Readable" | "Writable" | "Duplex" | "Transform" | "PassThrough" if is_genuine_node_stream_parent(ctx, &parent_name) => { Some(("node_stream".to_string(), canonical_parent_name.clone())) diff --git a/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs b/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs index 89b4cbe636..1c55042a5b 100644 --- a/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs +++ b/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs @@ -117,7 +117,7 @@ pub(crate) fn lower_class_from_ast( // `is_genuine_node_stream_parent` so a userland stream-shim // binding (readable-stream's `Transform`) falls through to the // dynamic `extends_expr` parent path. - "Readable" | "Writable" | "Duplex" | "Transform" + "Readable" | "Writable" | "Duplex" | "Transform" | "PassThrough" if is_genuine_node_stream_parent(ctx, &parent_name) => { Some(("node_stream".to_string(), canonical_parent_name.clone())) diff --git a/crates/perry-runtime/src/node_stream_constructors.rs b/crates/perry-runtime/src/node_stream_constructors.rs index 1dae81e4f5..50bf98b33a 100644 --- a/crates/perry-runtime/src/node_stream_constructors.rs +++ b/crates/perry-runtime/src/node_stream_constructors.rs @@ -363,10 +363,11 @@ pub use builders::{ js_array_subclass_init, js_event_emitter_async_resource_subclass_init, js_event_emitter_subclass_init, js_node_stream_duplex_new, js_node_stream_duplex_subclass_init, js_node_stream_legacy_subclass_init, js_node_stream_passthrough_new, - js_node_stream_readable_from, js_node_stream_readable_from_options, - js_node_stream_readable_new, js_node_stream_readable_subclass_init, - js_node_stream_transform_new, js_node_stream_transform_subclass_init, - js_node_stream_writable_new, js_node_stream_writable_subclass_init, + js_node_stream_passthrough_subclass_init, js_node_stream_readable_from, + js_node_stream_readable_from_options, js_node_stream_readable_new, + js_node_stream_readable_subclass_init, js_node_stream_transform_new, + js_node_stream_transform_subclass_init, js_node_stream_writable_new, + js_node_stream_writable_subclass_init, }; pub use introspection::{ diff --git a/crates/perry-runtime/src/node_stream_constructors/builders.rs b/crates/perry-runtime/src/node_stream_constructors/builders.rs index 0f9a5bff6a..5f2cbc7a08 100644 --- a/crates/perry-runtime/src/node_stream_constructors/builders.rs +++ b/crates/perry-runtime/src/node_stream_constructors/builders.rs @@ -566,6 +566,24 @@ pub extern "C" fn js_node_stream_passthrough_new(opts: f64) -> f64 { passthrough } +/// Initialize `class X extends PassThrough` without replacing the derived +/// instance. A subclass-provided `_transform` wins; otherwise retain +/// PassThrough's identity transform instead of falling into Transform's +/// missing-method error. +#[no_mangle] +pub extern "C" fn js_node_stream_passthrough_subclass_init(this: f64, opts: f64) -> f64 { + let passthrough = js_node_stream_transform_subclass_init(this, opts); + if transform_hidden_callback(passthrough).is_none() { + set_hidden_value( + passthrough, + hidden_transform_passthrough_key(), + f64::from_bits(TAG_TRUE), + ); + } + init_constructor(passthrough, "PassThrough"); + passthrough +} + /// `Readable.from(iterable)` — Node's static factory. Returns a /// Readable object and retains simple iterable chunks so /// `node:stream/consumers` can drain the current stub stream surface. diff --git a/crates/perry-runtime/src/node_stream_keepalive.rs b/crates/perry-runtime/src/node_stream_keepalive.rs index 9c1654d74f..b168bc121e 100644 --- a/crates/perry-runtime/src/node_stream_keepalive.rs +++ b/crates/perry-runtime/src/node_stream_keepalive.rs @@ -196,6 +196,10 @@ static KEEP_NS_TRANSFORM_NEW: extern "C" fn(f64) -> f64 = super::js_node_stream_ static KEEP_NS_PASSTHROUGH_NEW: extern "C" fn(f64) -> f64 = super::js_node_stream_passthrough_new; #[cfg(feature = "keepalive-anchors")] #[used(compiler)] +static KEEP_NS_PASSTHROUGH_SUBCLASS_INIT: extern "C" fn(f64, f64) -> f64 = + super::js_node_stream_passthrough_subclass_init; +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] static KEEP_NS_READABLE_FROM: extern "C" fn(f64) -> f64 = super::js_node_stream_readable_from; #[cfg(feature = "keepalive-anchors")] #[used(compiler)] diff --git a/crates/perry-runtime/src/node_stream_state_tests.rs b/crates/perry-runtime/src/node_stream_state_tests.rs index 4cc4fb0547..1e61ec2241 100644 --- a/crates/perry-runtime/src/node_stream_state_tests.rs +++ b/crates/perry-runtime/src/node_stream_state_tests.rs @@ -116,6 +116,39 @@ fn stream_object_mode_flags_default_false_and_follow_options() { ); } +#[test] +fn passthrough_subclass_uses_override_or_identity_transform() { + crate::closure::js_register_closure_arity(super::tests::noop_listener as *const u8, 0); + let callback = + box_pointer(js_closure_alloc(super::tests::noop_listener as *const u8, 0) as *const u8); + + let overridden_obj = crate::object::js_object_alloc(0, 1); + js_object_set_field_by_name(overridden_obj, hidden_key(b"_transform"), callback); + let overridden = js_node_stream_passthrough_subclass_init( + box_pointer(overridden_obj as *const u8), + f64::from_bits(TAG_UNDEFINED), + ); + assert_eq!( + transform_hidden_callback(overridden).map(f64::to_bits), + Some(callback.to_bits()) + ); + assert!(!has_truthy_hidden( + overridden, + hidden_transform_passthrough_key() + )); + + let default_obj = crate::object::js_object_alloc(0, 0); + let default = js_node_stream_passthrough_subclass_init( + box_pointer(default_obj as *const u8), + f64::from_bits(TAG_UNDEFINED), + ); + assert!(transform_hidden_callback(default).is_none()); + assert!(has_truthy_hidden( + default, + hidden_transform_passthrough_key() + )); +} + #[test] fn stream_dynamic_instanceof_follows_node_stream_inheritance() { let readable = crate::object::bound_native_callable_export_value("stream", "Readable"); diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 745c2b8d87..7ae27ab868 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -790,14 +790,6 @@ pub unsafe extern "C" fn js_fetch_or_value_super( // `lower_node_stream_super_init`), so every heritage shape installs the // override onto `this` identically. // - // `PassThrough` is deliberately NOT handled here: HIR never recognizes - // it as a node:stream native parent at all, even via a bare import - // (`canonical_native_parent_name` lists Readable/Writable/Duplex/ - // Transform but not PassThrough), so the hidden `_transform` field this - // shim reads is never pre-seeded for ANY `PassThrough` heritage shape — - // that's a separate, deeper HIR-level gap needing its own fix; adding an - // arm here alone was confirmed (empirically) to change nothing. - // // #10798: `Stream` (the legacy `node:stream` base that `Readable` and // friends themselves derive from) is a DIFFERENT shape than // `PassThrough`: it carries no hidden per-instance state at all — in @@ -840,6 +832,9 @@ pub unsafe extern "C" fn js_fetch_or_value_super( "Transform" => Some(crate::node_stream::js_node_stream_transform_subclass_init( this_box, opts, )), + "PassThrough" => Some( + crate::node_stream::js_node_stream_passthrough_subclass_init(this_box, opts), + ), "Stream" => Some(crate::node_stream::js_node_stream_legacy_subclass_init( this_box, )), diff --git a/test-files/test_gap_10745_passthrough_subclass.ts b/test-files/test_gap_10745_passthrough_subclass.ts new file mode 100644 index 0000000000..d4d645338b --- /dev/null +++ b/test-files/test_gap_10745_passthrough_subclass.ts @@ -0,0 +1,54 @@ +import { PassThrough, PassThrough as PT } from "node:stream"; + +class Direct extends PassThrough { + _transform(chunk: any, _encoding: string, callback: any) { + callback(null, "direct:" + String(chunk).toUpperCase()); + } +} + +class ImportedAlias extends PT { + _transform(chunk: any, _encoding: string, callback: any) { + callback(null, "import-alias:" + String(chunk).toUpperCase()); + } +} + +const LocalAlias = PassThrough; +const ClassExpression = class extends LocalAlias { + _transform(chunk: any, _encoding: string, callback: any) { + callback(null, "class-expr:" + String(chunk).toUpperCase()); + } +}; + +class Middle extends PassThrough {} +class Indirect extends Middle { + _transform(chunk: any, _encoding: string, callback: any) { + callback(null, "indirect:" + String(chunk).toUpperCase()); + } +} + +class DefaultPassThrough extends PassThrough {} + +function run(name: string, Constructor: any): Promise { + return new Promise((resolve) => { + const stream = new Constructor(); + let output = ""; + stream.on("data", (chunk: any) => (output += String(chunk))); + stream.on("error", (error: any) => { + console.log(name, "error", error.code || error.message); + resolve(); + }); + stream.on("end", () => { + console.log(name, JSON.stringify(output)); + resolve(); + }); + stream.end("ab"); + }); +} + +(async () => { + await run("direct", Direct); + await run("import-alias", ImportedAlias); + await run("class-expr", ClassExpression); + await run("indirect", Indirect); + await run("default", DefaultPassThrough); +})(); From 9d591c5d3d0a79716bc440b81674fae8c631d1c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 20:43:41 +0200 Subject: [PATCH 14/17] docs: add changelog for PR 11028 --- changelog.d/11028-passthrough-subclass.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/11028-passthrough-subclass.md diff --git a/changelog.d/11028-passthrough-subclass.md b/changelog.d/11028-passthrough-subclass.md new file mode 100644 index 0000000000..1f39b18791 --- /dev/null +++ b/changelog.d/11028-passthrough-subclass.md @@ -0,0 +1,5 @@ +### Fixed + +- Honor `_transform` overrides on classes derived from `node:stream`'s + `PassThrough`, including aliased, dynamic, and indirect inheritance forms, + while preserving the default identity transform. From ffd2c7ae216366e05c3b0b58733a211da63094bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 21:07:09 +0200 Subject: [PATCH 15/17] fix(fetch): preserve shorthand Headers option --- .../perry-hir/src/lower/expr_call/globals.rs | 3 ++ .../tests/fetch_dynamic_headers_lowering.rs | 21 +++++++++++ .../test_gap_11024_fetch_shorthand_headers.ts | 35 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 test-files/test_gap_11024_fetch_shorthand_headers.ts diff --git a/crates/perry-hir/src/lower/expr_call/globals.rs b/crates/perry-hir/src/lower/expr_call/globals.rs index 2551653daa..a05d4883d0 100644 --- a/crates/perry-hir/src/lower/expr_call/globals.rs +++ b/crates/perry-hir/src/lower/expr_call/globals.rs @@ -567,6 +567,9 @@ pub(super) fn try_global_builtins( match key.as_str() { "method" => method = value, "body" => body = value, + "headers" => { + headers_dynamic = Some(Box::new(value)) + } "signal" => signal = Some(Box::new(value)), _ => {} } diff --git a/crates/perry-hir/tests/fetch_dynamic_headers_lowering.rs b/crates/perry-hir/tests/fetch_dynamic_headers_lowering.rs index 4933a306c8..db2239be5f 100644 --- a/crates/perry-hir/tests/fetch_dynamic_headers_lowering.rs +++ b/crates/perry-hir/tests/fetch_dynamic_headers_lowering.rs @@ -80,6 +80,27 @@ fn variable_headers_are_captured_as_dynamic() { ); } +#[test] +fn shorthand_headers_are_captured_as_dynamic() { + let module = lower_src( + r#" + const headers = new Headers({ Authorization: "Bearer x" }); + fetch("http://x/", { method: "POST", headers, body: "b" }); + "#, + ) + .expect("fetch with shorthand headers should lower"); + + let (static_pairs, has_dynamic) = find_fetch(&module); + assert_eq!( + static_pairs, 0, + "a shorthand headers value has no static pairs" + ); + assert!( + has_dynamic, + "shorthand headers must be captured in headers_dynamic (#11024)" + ); +} + #[test] fn spread_literal_headers_are_captured_as_dynamic() { // `{ ...h }` is an object literal, but its spread prop cannot be enumerated diff --git a/test-files/test_gap_11024_fetch_shorthand_headers.ts b/test-files/test_gap_11024_fetch_shorthand_headers.ts new file mode 100644 index 0000000000..9438a6edfe --- /dev/null +++ b/test-files/test_gap_11024_fetch_shorthand_headers.ts @@ -0,0 +1,35 @@ +import http from "node:http"; + +const server = http.createServer((req, res) => { + res.writeHead(200, { "content-type": "text/plain" }); + res.end(String(req.headers["x-keep"] || "")); +}); + +async function main(): Promise { + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + + const address = server.address() as any; + const base = "http://127.0.0.1:" + address.port; + const headers = new Headers(); + headers.set("x-keep", "yes"); + + const explicit = await fetch(base + "/explicit", { + method: "PUT", + body: "a", + headers: headers, + }); + console.log("explicit", await explicit.text()); + + const shorthand = await fetch(base + "/shorthand", { + method: "PUT", + body: "b", + headers, + }); + console.log("shorthand", await shorthand.text()); + + await new Promise((resolve) => server.close(() => resolve())); +} + +main(); From 448cb009e5e0fb19061f0b11fbbaa2d9da328a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 21:08:03 +0200 Subject: [PATCH 16/17] docs: add changelog for PR 11031 --- changelog.d/11031-fetch-shorthand-headers.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/11031-fetch-shorthand-headers.md diff --git a/changelog.d/11031-fetch-shorthand-headers.md b/changelog.d/11031-fetch-shorthand-headers.md new file mode 100644 index 0000000000..1a7f9b734e --- /dev/null +++ b/changelog.d/11031-fetch-shorthand-headers.md @@ -0,0 +1,5 @@ +### Fixed + +- Preserve `Headers` instances passed through the shorthand `fetch(url, { + headers })` option so their entries are sent like the explicit + `headers: headers` form. From c2a2997da45891e6c77937f0b505cb7776233455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 03:09:24 +0200 Subject: [PATCH 17/17] chore: bisect half B (not for landing) --- CLAUDE.md | 2 +- Cargo.lock | 132 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 68 insertions(+), 68 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dd36ff0f47..c432efb73d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1640 +**Current Version:** 0.5.1641 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 0814134c3a..d487623294 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5447,7 +5447,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "base64 0.22.1", @@ -5508,7 +5508,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-dispatch", "serde", @@ -5516,7 +5516,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "cc", "libc", @@ -5525,7 +5525,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "aho-corasick", "anyhow", @@ -5542,7 +5542,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-hir", @@ -5550,7 +5550,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-hir", @@ -5558,7 +5558,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-dispatch", @@ -5567,7 +5567,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-hir", @@ -5575,7 +5575,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "base64 0.22.1", @@ -5587,7 +5587,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-hir", @@ -5595,7 +5595,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "async-trait", "clap", @@ -5619,14 +5619,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", ] [[package]] name = "perry-db-turnloop" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "perry-tls-turnloop", @@ -5634,7 +5634,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "serde", "serde_json", @@ -5642,7 +5642,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1640" +version = "0.5.1641" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5653,7 +5653,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "clap", @@ -5668,7 +5668,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "block2", "objc2", @@ -5678,7 +5678,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "argon2", "perry-ffi", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "bcrypt", "perry-ffi", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "rusqlite", @@ -5703,7 +5703,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "scraper", @@ -5711,7 +5711,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "rust_decimal", @@ -5719,7 +5719,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5727,7 +5727,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "perry-runtime", @@ -5735,7 +5735,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "bytes", @@ -5767,7 +5767,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "lazy_static", "perry-db-turnloop", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "bson", "futures-util", @@ -5795,7 +5795,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "bytes", "perry-ffi", @@ -5811,7 +5811,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "turnloop-smtp", @@ -5820,7 +5820,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "notify", "perry-ffi", @@ -5832,7 +5832,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "printpdf", @@ -5840,7 +5840,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "fast_image_resize", "image", @@ -5851,7 +5851,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "lazy_static", "perry-ffi", @@ -5860,7 +5860,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-ffi", @@ -5880,7 +5880,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "perry-runtime", @@ -5889,7 +5889,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "lazy_static", "perry-ffi", @@ -5904,7 +5904,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "brotli", "flate2", @@ -5914,7 +5914,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -5924,7 +5924,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-api-manifest", @@ -5944,7 +5944,7 @@ dependencies = [ [[package]] name = "perry-http-client" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "perry-tls-session", @@ -5957,7 +5957,7 @@ dependencies = [ [[package]] name = "perry-http-server" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "http", "httpdate", @@ -5967,11 +5967,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1640" +version = "0.5.1641" [[package]] name = "perry-parser" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-diagnostics", @@ -5984,7 +5984,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perex", "regex", @@ -5992,7 +5992,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "ahash", "base64 0.22.1", @@ -6051,14 +6051,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6140,21 +6140,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-tls-session" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "turnloop-tls", ] [[package]] name = "perry-tls-turnloop" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "perry-tls-session", @@ -6163,14 +6163,14 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "dirs", "perry-ffi", @@ -6180,7 +6180,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "jni", @@ -6195,7 +6195,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "rand 0.10.2", "serde", @@ -6205,7 +6205,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "async-channel", "async-executor", @@ -6230,7 +6230,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "block2", @@ -6247,7 +6247,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "block2", @@ -6264,7 +6264,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1640" +version = "0.5.1641" [[package]] name = "perry-ui-test" @@ -6275,11 +6275,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1640" +version = "0.5.1641" [[package]] name = "perry-ui-tvos" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "block2", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "block2", @@ -6313,7 +6313,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "block2", "libc", @@ -6327,7 +6327,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "libc", @@ -6346,7 +6346,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "libc", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "base64 0.22.1", @@ -6374,7 +6374,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index f7ed172df0..3a49f64edc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -320,7 +320,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1640" +version = "0.5.1641" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"