Skip to content
Merged
3 changes: 3 additions & 0 deletions changelog.d/9200-tombstone-delete-default-off.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions changelog.d/9207-dynamic-function-panic-abort-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed

- Unsupported constructs in runtime-built `Function` bodies now throw a catchable diagnostic instead of aborting at the Function-constructor or closure-dispatch FFI boundaries.
32 changes: 32 additions & 0 deletions crates/perry-runtime/src/closure/dispatch/calln.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &[]);
Expand Down Expand Up @@ -86,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()) {
Expand Down
21 changes: 20 additions & 1 deletion crates/perry-runtime/src/closure/dispatch/value_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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());
Expand Down
16 changes: 16 additions & 0 deletions crates/perry-runtime/src/closure/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}};
Expand All @@ -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)
}
}
Expand Down
18 changes: 18 additions & 0 deletions crates/perry-runtime/src/dyn_eval/interp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 6 additions & 6 deletions crates/perry-runtime/src/object/delete_rest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1493,13 +1493,13 @@ fn object_tombstone_deletes_enabled() -> bool {
}
static ON: std::sync::OnceLock<bool> = 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")
)
})
}
Expand Down
38 changes: 31 additions & 7 deletions crates/perry-runtime/src/object/global_this/builtin_thunks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions crates/perry/tests/issue_6559_dyn_function_interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {',
Expand Down
Loading