From 7eabafdfe04984d0e93a11d41a78bef8e991f7f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 14 Jun 2026 22:43:28 +0200 Subject: [PATCH 1/2] fix(error): forward ES2022 cause through super(message, options) in Error subclasses (#5127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Error subclass that forwarded options via super(message, options) lost the cause — this.cause was undefined afterward, even though plain new Error(msg, { cause }) worked. Root cause: the Error-like super(...) codegen arm only assigned this.message = args[0] and this.name = ; it ignored args[1] (the options object), so the cause never reached the (generic-object) subclass instance. Fix: add js_error_apply_cause_to_object(this, options) — mirrors the existing apply_cause_from_options but installs a non-enumerable own cause property on the instance object (matching Node's InstallErrorCause). The super-call arm now calls it whenever a second argument is forwarded. --- .../perry-codegen/src/expr/this_super_call.rs | 11 +++ .../src/runtime_decls/objects.rs | 3 + crates/perry-runtime/src/error.rs | 22 +++++ .../tests/issue_5127_error_cause_super.rs | 82 +++++++++++++++++++ 4 files changed, 118 insertions(+) create mode 100644 crates/perry/tests/issue_5127_error_cause_super.rs diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index b150a914ca..21ee590789 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -600,6 +600,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { (DOUBLE, &name_val_box), ], ); + // #5127: `super(message, options)` must forward the + // ES2022 `cause` option. The instance is a generic + // object, so install a non-enumerable `cause` + // property from args[1] when present. + if let Some(opts_val) = lowered_args.get(1) { + let blk = ctx.block(); + blk.call_void( + "js_error_apply_cause_to_object", + &[(I64, &this_handle), (DOUBLE, opts_val)], + ); + } } } return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index bfa9edbade..0d7e322885 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -58,6 +58,9 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { VOID, &[I64, I64, DOUBLE], ); + // #5127: apply ES2022 `cause` from a `super(message, options)` forward to + // a user Error-subclass instance (a generic object). (this_handle, options) + module.declare_function("js_error_apply_cause_to_object", VOID, &[I64, DOUBLE]); module.declare_function("js_with_has_binding", I32, &[DOUBLE, I64]); module.declare_function("js_with_get_binding", DOUBLE, &[DOUBLE, I64]); module.declare_function("js_with_set_binding", DOUBLE, &[DOUBLE, I64, DOUBLE, I32]); diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index 05714c1cca..09647d203e 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -506,6 +506,28 @@ unsafe fn apply_cause_from_options(error: *mut ErrorHeader, options: f64) { } } +/// #5127: apply the ES2022 `cause` option to a user `Error` *subclass* +/// instance when its constructor forwards `super(message, options)`. Such an +/// instance is a generic heap object (not an `ErrorHeader`) — the super-call +/// codegen sets `message`/`name` as object properties — so the cause must be +/// installed as a (non-enumerable) own `cause` property on the object too, +/// matching Node's `InstallErrorCause`. Mirrors `apply_cause_from_options`: +/// reads `options.cause` via the generic getter (works for object literals +/// and runtime-held options alike) and is a no-op for non-object options. +#[no_mangle] +pub extern "C" fn js_error_apply_cause_to_object(obj: *mut crate::object::ObjectHeader, options: f64) { + let opts = crate::value::JSValue::from_bits(options.to_bits()); + if !opts.is_pointer() { + return; + } + let key = js_string_from_bytes(b"cause".as_ptr(), 5); + let key_f64 = crate::value::js_nanbox_string(key as i64); + let cause = crate::value::js_dyn_index_get(options, key_f64); + if cause.to_bits() != TAG_UNDEFINED_BITS { + crate::object::js_object_set_field_by_name_nonenum(obj, key as *const StringHeader, cause); + } +} + /// #2836: allocate an Error (or native subclass) carrying a `{ cause }` /// option read from an arbitrary runtime options value. `kind` selects the /// ERROR_KIND_* discriminant so `instanceof TypeError`/etc. keep working. diff --git a/crates/perry/tests/issue_5127_error_cause_super.rs b/crates/perry/tests/issue_5127_error_cause_super.rs new file mode 100644 index 0000000000..9614a1c08f --- /dev/null +++ b/crates/perry/tests/issue_5127_error_cause_super.rs @@ -0,0 +1,82 @@ +//! Regression test for #5127: an `Error` subclass that forwards options via +//! `super(message, options)` dropped the ES2022 `cause` — `this.cause` was +//! `undefined` afterward, even though a plain `new Error(msg, { cause })` +//! (no subclass) worked. +//! +//! Root cause: the Error-like `super(...)` codegen arm only assigned +//! `this.message = args[0]` and `this.name = `; it ignored `args[1]` +//! (the options object). Fix: when a second arg is present, call +//! `js_error_apply_cause_to_object`, which installs a non-enumerable own +//! `cause` property on the subclass instance from `options.cause` (matching +//! Node's `InstallErrorCause`). + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(dir: &std::path::Path, source: &str) -> String { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +#[test] +fn error_subclass_forwards_cause_through_super() { + let dir = tempfile::tempdir().expect("tempdir"); + let stdout = compile_and_run( + dir.path(), + r#" +class AppError extends Error { + constructor(msg: string, opts?: ErrorOptions) { super(msg, opts); this.name = "AppError"; } +} +const e = new AppError("high-level", { cause: new TypeError("low-level") }); +console.log(e.message, (e.cause as Error)?.message); +console.log(e.name, e instanceof Error); +// `cause` is a non-enumerable own property (Node semantics). +console.log(Object.keys(e).includes("cause"), Object.prototype.hasOwnProperty.call(e, "cause")); + +// Plain (non-subclass) Error with cause still works. +const p = new Error("m", { cause: 42 }); +console.log(p.cause); + +// No options forwarded => no cause. +class B extends Error { constructor(m: string){ super(m); } } +console.log(new B("x").cause); +"#, + ); + assert_eq!( + stdout, + "high-level low-level\nAppError true\nfalse true\n42\nundefined\n" + ); +} From 44c905e940156e2d4ea4d4d190f8fd73e352171c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 15 Jun 2026 07:37:40 +0200 Subject: [PATCH 2/2] style: rustfmt pass on error.rs --- crates/perry-runtime/src/error.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index 09647d203e..35ed5ddd27 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -515,7 +515,10 @@ unsafe fn apply_cause_from_options(error: *mut ErrorHeader, options: f64) { /// reads `options.cause` via the generic getter (works for object literals /// and runtime-held options alike) and is a no-op for non-object options. #[no_mangle] -pub extern "C" fn js_error_apply_cause_to_object(obj: *mut crate::object::ObjectHeader, options: f64) { +pub extern "C" fn js_error_apply_cause_to_object( + obj: *mut crate::object::ObjectHeader, + options: f64, +) { let opts = crate::value::JSValue::from_bits(options.to_bits()); if !opts.is_pointer() { return;