From fc0d0e3bdded76e9beff062b75452fd35b2d05f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 22:31:20 +0200 Subject: [PATCH 1/8] test(perry): bind the dynamic serializer helper (#9206) --- crates/perry/tests/issue_6559_dyn_function_interpreter.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/perry/tests/issue_6559_dyn_function_interpreter.rs b/crates/perry/tests/issue_6559_dyn_function_interpreter.rs index 1f06457591..31c5641193 100644 --- a/crates/perry/tests/issue_6559_dyn_function_interpreter.rs +++ b/crates/perry/tests/issue_6559_dyn_function_interpreter.rs @@ -248,6 +248,10 @@ const serializer: any = { asBoolean(b: any): string { return b ? "true" : "false"; }, asInteger(n: any): string { return String(Math.trunc(Number(n))); }, }; +// Real serializer generators bind receiver-dependent helpers before their +// generated body destructures and calls them as free functions. Keep that +// receiver contract explicit: method shorthand has dynamic `this` (#9134). +serializer.asString = serializer.asString.bind(serializer); const lines: string[] = [ 'const {', From 271326efd4cb4f1167fb04dc8dae1a81c5d5d846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 22:31:20 +0200 Subject: [PATCH 2/8] fix(runtime): preserve dynamic Function throws in release (#9207) --- ...-dynamic-function-panic-abort-transport.md | 3 ++ .../src/object/global_this/builtin_thunks.rs | 38 +++++++++++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 changelog.d/9207-dynamic-function-panic-abort-transport.md diff --git a/changelog.d/9207-dynamic-function-panic-abort-transport.md b/changelog.d/9207-dynamic-function-panic-abort-transport.md new file mode 100644 index 0000000000..14f56e4d43 --- /dev/null +++ b/changelog.d/9207-dynamic-function-panic-abort-transport.md @@ -0,0 +1,3 @@ +### Fixed + +- Unsupported constructs in runtime-built `Function` bodies now throw a catchable diagnostic in shipped panic-abort binaries instead of aborting at the Function-constructor FFI boundary. diff --git a/crates/perry-runtime/src/object/global_this/builtin_thunks.rs b/crates/perry-runtime/src/object/global_this/builtin_thunks.rs index 918c439856..944af62286 100644 --- a/crates/perry-runtime/src/object/global_this/builtin_thunks.rs +++ b/crates/perry-runtime/src/object/global_this/builtin_thunks.rs @@ -407,14 +407,28 @@ pub(crate) extern "C" fn global_this_error_is_error_thunk( /// `fn`. Unrecognized templates fall back to a non-callable placeholder object /// (prior behavior); there is no general eval. #[no_mangle] -// This is a generated-code boundary and the dyn-eval-off refusal below starts -// a JS unwind to the caller's catch landing pad. A plain `extern "C"` installs -// an abort-on-unwind guard in debug/static runtimes, turning zod's harmless -// `new Function("")` capability probe into a process abort (#8958). +// This generated-code boundary needs different ABI spellings for Perry's two +// panic strategies. Test/debug runtimes use panic=unwind, where plain `C` +// installs an RFC-2945 abort guard; `C-unwind` keeps the dyn-eval-off refusal +// catchable (#8958). Shipping runtimes use panic=abort, where the inverse is +// true for a raw Perry exception passing through this frame (#8479): marking +// the frame `C-unwind` installs the guard that aborts an unsupported dyn-eval +// construct before the generated catch landing pad can receive it (#9207). +#[cfg(not(panic = "abort"))] pub extern "C-unwind" fn js_function_ctor_from_strings( args_ptr: *const f64, args_len: usize, ) -> f64 { + js_function_ctor_from_strings_impl(args_ptr, args_len) +} + +#[no_mangle] +#[cfg(panic = "abort")] +pub extern "C" fn js_function_ctor_from_strings(args_ptr: *const f64, args_len: usize) -> f64 { + js_function_ctor_from_strings_impl(args_ptr, args_len) +} + +fn js_function_ctor_from_strings_impl(args_ptr: *const f64, args_len: usize) -> f64 { let arg_str = |i: usize| -> String { if i >= args_len || args_ptr.is_null() { return String::new(); @@ -517,14 +531,24 @@ extern "C" fn depd_wrapfunction_outer_thunk( #[cfg(feature = "keepalive-anchors")] #[used] +#[cfg(not(panic = "abort"))] static KEEP_JS_FUNCTION_CTOR_FROM_STRINGS: extern "C-unwind" fn(*const f64, usize) -> f64 = js_function_ctor_from_strings; -// Keep the unwind-capable ABI checked even in stripped builds that omit the -// keepalive anchor: this helper conditionally originates, rather than merely -// passes through, a raw Perry exception. +#[cfg(feature = "keepalive-anchors")] +#[used] +#[cfg(panic = "abort")] +static KEEP_JS_FUNCTION_CTOR_FROM_STRINGS: extern "C" fn(*const f64, usize) -> f64 = + js_function_ctor_from_strings; + +// Keep the panic-strategy-specific ABI checked even in stripped builds that +// omit the keepalive anchor. +#[cfg(not(panic = "abort"))] const _: extern "C-unwind" fn(*const f64, usize) -> f64 = js_function_ctor_from_strings; +#[cfg(panic = "abort")] +const _: extern "C" fn(*const f64, usize) -> f64 = js_function_ctor_from_strings; + /// #2904: `Error.prepareStackTrace` default — Node leaves a hook here that /// formats the stack from structured frames. Perry's stack strings are /// coarse; the installed default returns the existing `error.stack` string From 842e01ddfab89527815270f4708fd3ca2d8d481c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 22:31:20 +0200 Subject: [PATCH 3/8] fix(runtime): return tombstone deletes to opt-in (#9200) --- changelog.d/9200-tombstone-delete-default-off.md | 3 +++ crates/perry-runtime/src/object/delete_rest.rs | 12 ++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 changelog.d/9200-tombstone-delete-default-off.md diff --git a/changelog.d/9200-tombstone-delete-default-off.md b/changelog.d/9200-tombstone-delete-default-off.md new file mode 100644 index 0000000000..b23f419d2a --- /dev/null +++ b/changelog.d/9200-tombstone-delete-default-off.md @@ -0,0 +1,3 @@ +### Fixed + +- Object-property tombstone deletes are opt-in again while an evacuating-GC interaction with class dispatch is repaired, preventing deleted class instances from losing their keys or returning incorrect field values after collection. diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index 7c101f6df2..8145bed5a8 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -1493,13 +1493,13 @@ fn object_tombstone_deletes_enabled() -> bool { } static ON: std::sync::OnceLock = std::sync::OnceLock::new(); *ON.get_or_init(|| { - // Default ON (#9029 shipped the mechanism flag-gated; the walker - // audit and churn-bound tests are the default-on prerequisites). - // `PERRY_OBJECT_TOMBSTONES=0` is the kill switch, mirroring the - // moving-scavenge rollout's `PERRY_GC_MOVING_LOOP_POLLS=0` pattern. - !matches!( + // #9200: keep tombstones opt-in until an evacuating-GC interaction + // with class dispatch is fixed. The default-on route can restore a + // deleted receiver to its canonical class shape after relocation, + // making Object.keys() empty and fixed-slot reads return wrong data. + matches!( std::env::var("PERRY_OBJECT_TOMBSTONES").as_deref(), - Ok("0") | Ok("off") | Ok("false") + Ok("1") | Ok("on") | Ok("true") ) }) } From 1ca012fcd328834c2729ad04db34fe6be0326a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 22:37:25 +0200 Subject: [PATCH 4/8] fix(runtime): bound dyn-eval preparation throws (#9207) --- .../src/object/global_this/builtin_thunks.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/object/global_this/builtin_thunks.rs b/crates/perry-runtime/src/object/global_this/builtin_thunks.rs index 944af62286..6204459d9f 100644 --- a/crates/perry-runtime/src/object/global_this/builtin_thunks.rs +++ b/crates/perry-runtime/src/object/global_this/builtin_thunks.rs @@ -484,7 +484,18 @@ fn js_function_ctor_from_strings_impl(args_ptr: *const f64, args_len: usize) -> #[cfg(feature = "dyn-eval")] { let args_vec: Vec = (0..args_len).map(arg_str).collect(); - return crate::dyn_eval::dyn_function_from_strings(&args_vec); + // Source preparation descends through the parser and SWC visitor + // stack. Keep a Rust-owned setjmp handler innermost while those frames + // exist: a subset diagnostic is a Perry exception, and allowing its + // raw system unwind to cross an opaque dependency frame can trip that + // frame's abort-on-unwind guard (#9207). Once the stack is back at this + // generated-code boundary, rethrow to the caller's invoke/landingpad. + return match crate::exception::js_call_catching(|| { + crate::dyn_eval::dyn_function_from_strings(&args_vec) + }) { + Ok(function) => function, + Err(error) => crate::exception::js_throw(error), + }; } // Without the `dyn-eval` feature (size-optimized builds that carry no // dynamic-eval site), keep the historical clean throw: it lets From de95daaebcebf7d03586a45f25fbef74a7567a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 22:52:25 +0200 Subject: [PATCH 5/8] fix(runtime): allow interpreted throws through rest dispatch (#9207) --- crates/perry-runtime/src/closure/registry.rs | 16 ++++++++++++++++ crates/perry-runtime/src/dyn_eval/interp.rs | 18 ++++++++++++++++++ .../src/object/global_this/builtin_thunks.rs | 13 +------------ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/crates/perry-runtime/src/closure/registry.rs b/crates/perry-runtime/src/closure/registry.rs index e516034218..77a8c2c0bb 100644 --- a/crates/perry-runtime/src/closure/registry.rs +++ b/crates/perry-runtime/src/closure/registry.rs @@ -842,12 +842,20 @@ pub unsafe fn dispatch_rest_bundled( }; ($($i:tt),* $(,)?) => {{ if let Some(arguments_double) = all_arguments_double { + #[cfg(panic = "abort")] let f: extern "C" fn(*const ClosureHeader $(, rest_arm!(@ty $i))*, f64, f64) -> f64 = std::mem::transmute(func_ptr); + #[cfg(not(panic = "abort"))] + let f: extern "C-unwind" fn(*const ClosureHeader $(, rest_arm!(@ty $i))*, f64, f64) -> f64 = + std::mem::transmute(func_ptr); f(closure $(, a!($i))*, rest_double, arguments_double) } else { + #[cfg(panic = "abort")] let f: extern "C" fn(*const ClosureHeader $(, rest_arm!(@ty $i))*, f64) -> f64 = std::mem::transmute(func_ptr); + #[cfg(not(panic = "abort"))] + let f: extern "C-unwind" fn(*const ClosureHeader $(, rest_arm!(@ty $i))*, f64) -> f64 = + std::mem::transmute(func_ptr); f(closure $(, a!($i))*, rest_double) } }}; @@ -856,12 +864,20 @@ pub unsafe fn dispatch_rest_bundled( match k { 0 => { if let Some(arguments_double) = all_arguments_double { + #[cfg(panic = "abort")] let f: extern "C" fn(*const ClosureHeader, f64, f64) -> f64 = std::mem::transmute(func_ptr); + #[cfg(not(panic = "abort"))] + let f: extern "C-unwind" fn(*const ClosureHeader, f64, f64) -> f64 = + std::mem::transmute(func_ptr); f(closure, rest_double, arguments_double) } else { + #[cfg(panic = "abort")] let f: extern "C" fn(*const ClosureHeader, f64) -> f64 = std::mem::transmute(func_ptr); + #[cfg(not(panic = "abort"))] + let f: extern "C-unwind" fn(*const ClosureHeader, f64) -> f64 = + std::mem::transmute(func_ptr); f(closure, rest_double) } } diff --git a/crates/perry-runtime/src/dyn_eval/interp.rs b/crates/perry-runtime/src/dyn_eval/interp.rs index e62115169c..f896c3bc19 100644 --- a/crates/perry-runtime/src/dyn_eval/interp.rs +++ b/crates/perry-runtime/src/dyn_eval/interp.rs @@ -279,7 +279,25 @@ fn ensure_thunk_registered() { /// The single native entry every interpreted closure shares. Reads its /// identity + environment from capture slots and runs the tree-walker. +// Like the generic closure dispatchers, this frame can pass a Perry exception +// from interpreted code to a generated caller. Debug/test runtimes need the +// unwind-capable spelling so Rust does not install an RFC-2945 guard. Shipped +// panic-abort runtimes deliberately use plain C so the raw exception crosses +// without the inverse panic-abort C-unwind guard (#8479, #9207). +#[cfg(not(panic = "abort"))] +extern "C-unwind" fn interp_thunk( + closure: *const crate::closure::ClosureHeader, + raw_args: f64, +) -> f64 { + interp_thunk_impl(closure, raw_args) +} + +#[cfg(panic = "abort")] extern "C" fn interp_thunk(closure: *const crate::closure::ClosureHeader, raw_args: f64) -> f64 { + interp_thunk_impl(closure, raw_args) +} + +fn interp_thunk_impl(closure: *const crate::closure::ClosureHeader, raw_args: f64) -> f64 { let fn_id = crate::closure::js_closure_get_capture_f64(closure, 0) as u32; let def_env = f64::from_bits(crate::closure::js_closure_get_capture_bits(closure, 1)); let this_bits = crate::closure::js_closure_get_capture_bits(closure, 2); diff --git a/crates/perry-runtime/src/object/global_this/builtin_thunks.rs b/crates/perry-runtime/src/object/global_this/builtin_thunks.rs index 6204459d9f..944af62286 100644 --- a/crates/perry-runtime/src/object/global_this/builtin_thunks.rs +++ b/crates/perry-runtime/src/object/global_this/builtin_thunks.rs @@ -484,18 +484,7 @@ fn js_function_ctor_from_strings_impl(args_ptr: *const f64, args_len: usize) -> #[cfg(feature = "dyn-eval")] { let args_vec: Vec = (0..args_len).map(arg_str).collect(); - // Source preparation descends through the parser and SWC visitor - // stack. Keep a Rust-owned setjmp handler innermost while those frames - // exist: a subset diagnostic is a Perry exception, and allowing its - // raw system unwind to cross an opaque dependency frame can trip that - // frame's abort-on-unwind guard (#9207). Once the stack is back at this - // generated-code boundary, rethrow to the caller's invoke/landingpad. - return match crate::exception::js_call_catching(|| { - crate::dyn_eval::dyn_function_from_strings(&args_vec) - }) { - Ok(function) => function, - Err(error) => crate::exception::js_throw(error), - }; + return crate::dyn_eval::dyn_function_from_strings(&args_vec); } // Without the `dyn-eval` feature (size-optimized builds that carry no // dynamic-eval site), keep the historical clean throw: it lets From b7740de5efc340be79bac13ad6cb6008eaa76049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 22:58:52 +0200 Subject: [PATCH 6/8] fix(runtime): let interpreted throws cross call0 in tests (#9207) --- ...07-dynamic-function-panic-abort-transport.md | 2 +- .../perry-runtime/src/closure/dispatch/calln.rs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/changelog.d/9207-dynamic-function-panic-abort-transport.md b/changelog.d/9207-dynamic-function-panic-abort-transport.md index 14f56e4d43..48070e62a1 100644 --- a/changelog.d/9207-dynamic-function-panic-abort-transport.md +++ b/changelog.d/9207-dynamic-function-panic-abort-transport.md @@ -1,3 +1,3 @@ ### Fixed -- Unsupported constructs in runtime-built `Function` bodies now throw a catchable diagnostic in shipped panic-abort binaries instead of aborting at the Function-constructor FFI boundary. +- Unsupported constructs in runtime-built `Function` bodies now throw a catchable diagnostic instead of aborting at the Function-constructor or zero-argument closure-dispatch FFI boundaries. diff --git a/crates/perry-runtime/src/closure/dispatch/calln.rs b/crates/perry-runtime/src/closure/dispatch/calln.rs index 894563448d..7d09ad1aaf 100644 --- a/crates/perry-runtime/src/closure/dispatch/calln.rs +++ b/crates/perry-runtime/src/closure/dispatch/calln.rs @@ -10,8 +10,25 @@ use super::*; /// Call a closure with 0 arguments, returning f64 +#[cfg(panic = "abort")] #[no_mangle] pub extern "C" fn js_closure_call0(closure: *const ClosureHeader) -> f64 { + js_closure_call0_impl(closure) +} + +/// Test/debug builds use Rust unwinding for JS exceptions. Keep this entry +/// point unwind-capable there so an interpreted throw can reach a generated +/// caller's catch landing pad. Production builds use the plain-C definition +/// above because their raw Itanium exceptions must cross it without Rust's +/// abort-on-unwind guard (#8479). +#[cfg(not(panic = "abort"))] +#[no_mangle] +pub extern "C-unwind" fn js_closure_call0(closure: *const ClosureHeader) -> f64 { + js_closure_call0_impl(closure) +} + +#[inline(always)] +fn js_closure_call0_impl(closure: *const ClosureHeader) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { return dispatch_proxy_callee_or_throw(closure, &[]); From 3cf24e252983865d08f12b284733c7468d66158a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 23:07:54 +0200 Subject: [PATCH 7/8] fix(runtime): let interpreted throws cross receiverless calls (#9207) --- ...9207-dynamic-function-panic-abort-transport.md | 2 +- .../perry-runtime/src/closure/dispatch/calln.rs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/changelog.d/9207-dynamic-function-panic-abort-transport.md b/changelog.d/9207-dynamic-function-panic-abort-transport.md index 48070e62a1..4dfab698f1 100644 --- a/changelog.d/9207-dynamic-function-panic-abort-transport.md +++ b/changelog.d/9207-dynamic-function-panic-abort-transport.md @@ -1,3 +1,3 @@ ### Fixed -- Unsupported constructs in runtime-built `Function` bodies now throw a catchable diagnostic instead of aborting at the Function-constructor or zero-argument closure-dispatch FFI boundaries. +- Unsupported constructs in runtime-built `Function` bodies now throw a catchable diagnostic instead of aborting at the Function-constructor or closure-dispatch FFI boundaries. diff --git a/crates/perry-runtime/src/closure/dispatch/calln.rs b/crates/perry-runtime/src/closure/dispatch/calln.rs index 7d09ad1aaf..e3c576fe9d 100644 --- a/crates/perry-runtime/src/closure/dispatch/calln.rs +++ b/crates/perry-runtime/src/closure/dispatch/calln.rs @@ -103,7 +103,22 @@ fn dispatch_call1_resolved( #[no_mangle] // Same unwind contract as `js_closure_call1` above: keep `extern "C"`, not // `extern "C-unwind"`, so raw JS exceptions can traverse this bridge. +#[cfg(panic = "abort")] pub extern "C" fn js_closure_call1_receiverless(closure: *const ClosureHeader, arg0: f64) -> f64 { + js_closure_call1_receiverless_impl(closure, arg0) +} + +#[no_mangle] +#[cfg(not(panic = "abort"))] +pub extern "C-unwind" fn js_closure_call1_receiverless( + closure: *const ClosureHeader, + arg0: f64, +) -> f64 { + js_closure_call1_receiverless_impl(closure, arg0) +} + +#[inline(always)] +fn js_closure_call1_receiverless_impl(closure: *const ClosureHeader, arg0: f64) -> f64 { let func_ptr = get_valid_func_ptr(closure); let strategy = (!func_ptr.is_null()).then(|| resolve_strategy(func_ptr)); if let Some(arrow_strategy) = strategy.filter(|strategy| strategy.is_arrow()) { From 14e625fa5af017c75143fb27a9dcc9967f618683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 23:14:00 +0200 Subject: [PATCH 8/8] fix(runtime): let interpreted throws cross value calls (#9207) --- .../src/closure/dispatch/value_call.rs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/closure/dispatch/value_call.rs b/crates/perry-runtime/src/closure/dispatch/value_call.rs index 5dc56cd58b..161af918b0 100644 --- a/crates/perry-runtime/src/closure/dispatch/value_call.rs +++ b/crates/perry-runtime/src/closure/dispatch/value_call.rs @@ -13,7 +13,6 @@ use super::*; /// /// NOTE: This function is named js_native_call_value to avoid symbol collision /// with js_call_value in perry-jsruntime which handles V8 JavaScript values. -#[no_mangle] // A dynamically-dispatched closure can throw into a generated caller's catch // landing pad. Keep this value-call bridge unwind-capable just like // `js_native_call_method`; otherwise debug/static runtime builds install an @@ -27,11 +26,31 @@ use super::*; // throw trips ("panic in a function that cannot unwind"). #8416 introduced // the first two such guards here; #8464 added ~40 more and measurably // regressed main (+20 gap crashes, gc-stress) before being reverted. +#[cfg(panic = "abort")] +#[no_mangle] pub unsafe extern "C" fn js_native_call_value( func_value: f64, args_ptr: *const f64, args_len: usize, ) -> f64 { + unsafe { js_native_call_value_impl(func_value, args_ptr, args_len) } +} + +// Debug/test static archives transport Perry exceptions with Rust unwinding, +// so this outer value-call boundary must permit them to reach an interpreted +// caller's catch handler. Production keeps the plain-C wrapper above (#8479). +#[cfg(not(panic = "abort"))] +#[no_mangle] +pub unsafe extern "C-unwind" fn js_native_call_value( + func_value: f64, + args_ptr: *const f64, + args_len: usize, +) -> f64 { + unsafe { js_native_call_value_impl(func_value, args_ptr, args_len) } +} + +#[inline(always)] +unsafe fn js_native_call_value_impl(func_value: f64, args_ptr: *const f64, args_len: usize) -> f64 { use crate::value::JSValue; let jsval = JSValue::from_bits(func_value.to_bits());