diff --git a/changelog.d/9446-trampoline-unwind-frame.md b/changelog.d/9446-trampoline-unwind-frame.md new file mode 100644 index 0000000000..78859f685f --- /dev/null +++ b/changelog.d/9446-trampoline-unwind-frame.md @@ -0,0 +1,63 @@ +**The all-f64 call trampoline is now unwindable while its callee runs** +(#9446) — the deterministic `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1` +SIGSEGV on the Claude Code bundle at safepoint 4266, and a latent root-loss +and lost-`catch` defect behind it on x86-64. + +`abi_trampoline::call_all_f64` (`perry-runtime/src/abi_trampoline.rs`) is how +every dynamic vtable dispatch (`class_registry::dispatch::call_vtable_method`: +by-name method calls, bound methods, `new` on a class value) invokes a callee +whose arity is only known at runtime. With more than eight f64 arguments — +`this` counts, and a synthesized capture-stashing constructor in a bundle has +dozens — it lowers the stack pointer by a runtime amount to spill the rest and +leaves it there across the call. It did that inside an `asm!` block in an +ordinary Rust function, so the frame description LLVM emitted for that function +never knew: measured on x86-64 Linux, the FDE says `CFA = rsp+32` at the `call` +while `rsp` is really `stack_bytes` lower. + +Every unwinder that steps through the trampoline while the callee runs then +reads the trampoline's return address from the wrong slot — a spilled argument +or a saved register: + +- **The GC's native-root walk** (`gc/roots/stack_maps.rs`, an + `_Unwind_Backtrace`) stops at the trampoline when the garbage is a mapped + address, silently dropping every frame ABOVE it from the root set for that + collection: a young object the caller holds across the call is not copied, + the caller reads a recycled cell later, and nothing names the collection. + When the garbage is not mapped, libgcc's fallback frame probe dereferences it + and the collector dies inside `_Unwind_Backtrace` — which is #9446's crash: + a minor at a loop poll inside `new LoggerProvider(…)` (OpenTelemetry's class + expression, replayed through the constructor table with its capture params), + `rax = 0xa` at `cmpb $0x48,(%rax)`. +- **The exception transport** (`_Unwind_RaiseException`, the system unwinder + on x86-64): a `throw` inside such a callee never finds the `catch` above the + trampoline and is reported as uncaught. + +aarch64 never showed either: LLVM happened to keep a frame pointer for the +trampoline function there, so the CFA was `x29`-relative and immune to `sp`. + +Both trampolines are now **naked functions** (`#[unsafe(naked)]` / +`naked_asm!`) that set `rbp` / `x29` from the entry stack pointer before +anything moves, define the CFA off that register with their own +`.cfi_startproc … .cfi_endproc` region, and drop the spill area through it after +the call. The dynamic adjustment is then invisible to unwinding on every target +and under every frame-pointer setting, and the frame record is what a +frame-pointer chain walk expects too. Argument marshalling is unchanged +(`split_register_and_stacked` feeds the same eight register slots and the +16-byte-rounded spill area the inline version built). Windows ARM64 keeps the +inline-`asm!` shape: it unwinds through SEH metadata the compiler emits for its +own frame-chained prologue, which already covers the adjustment. + +Validation: + +- `abi_trampoline::tests::unwind_through_the_trampoline::the_unwinder_steps_through_a_trampoline_with_stacked_args` + walks the stack with `_Unwind_Backtrace` from inside a 12-argument callee + (four stacked on both ABIs) and requires the frames above the caller to be + the same ones the caller's own walk sees. On the old trampolines it dies with + SIGSEGV on x86-64 Linux — the collector's crash, in a unit test. +- `test-files/test_gap_9446_trampoline_unwind.ts` is the JS-level witness with + no GC knobs: a throw through a 9-parameter dynamically dispatched method, a + throw through a 9-parameter class-expression constructor, and a nursery + collection inside a 9-parameter dynamically dispatched method while the + caller holds a young object. +- Claude Code (`cli_2.1.112.js`) under `PERRY_GC_SCHEDULE_SEED=1 + PERRY_GC_SCHEDULE_RATE=1` no longer dies at safepoint 4266. diff --git a/crates/perry-runtime/src/abi_trampoline.rs b/crates/perry-runtime/src/abi_trampoline.rs index 61c4ab4ad5..0aaf313aac 100644 --- a/crates/perry-runtime/src/abi_trampoline.rs +++ b/crates/perry-runtime/src/abi_trampoline.rs @@ -15,10 +15,12 @@ //! Because EVERY argument is an `f64`, the platform C ABI is fully determined: //! the first 8 floating-point args go in FP argument registers and the rest are //! spilled to a 16-byte-aligned stack area. This module implements that call -//! directly with inline assembly for the two hosted architectures (aarch64 + -//! x86-64); other targets fall back to a fixed-arity dispatch good to 16 args -//! (no Perry target other than the two asm ones exercises high-arity dynamic -//! ctor dispatch today). +//! directly in assembly for the two hosted architectures (aarch64 + x86-64) — +//! as naked functions that carry their own frame pointer and unwind +//! description, because the unwinder has to step through them while the +//! callee runs (#9446; see the comment above the trampolines). Other targets +//! fall back to a fixed-arity dispatch good to 16 args (no Perry target other +//! than the two asm ones exercises high-arity dynamic ctor dispatch today). /// Call `func_ptr` (a `extern "C" double(double, …)` with `args.len()` f64 /// params) passing every element of `args` as an f64 argument. Returns the f64 @@ -31,7 +33,14 @@ pub(crate) unsafe fn call_all_f64(func_ptr: usize, args: &[f64]) -> f64 { #[cfg(target_arch = "aarch64")] { - call_all_f64_aarch64(func_ptr, args) + let (reg, stacked, stack_bytes) = split_register_and_stacked(args); + call_all_f64_aarch64( + func_ptr, + reg.as_ptr(), + stacked.as_ptr(), + stacked.len(), + stack_bytes, + ) } // NOTE: gated to NON-Windows x86-64. The asm below is the SysV ABI (FP args // in xmm0..xmm7, no shadow space). The Windows x64 ABI passes FP args in @@ -39,7 +48,14 @@ pub(crate) unsafe fn call_all_f64(func_ptr: usize, args: &[f64]) -> f64 { // mis-pass 5+ args. Win64 falls through to the portable fallback instead. #[cfg(all(target_arch = "x86_64", not(target_os = "windows")))] { - call_all_f64_x86_64(func_ptr, args) + let (reg, stacked, stack_bytes) = split_register_and_stacked(args); + call_all_f64_x86_64( + func_ptr, + reg.as_ptr(), + stacked.as_ptr(), + stacked.len(), + stack_bytes, + ) } #[cfg(not(any( target_arch = "aarch64", @@ -50,40 +66,160 @@ pub(crate) unsafe fn call_all_f64(func_ptr: usize, args: &[f64]) -> f64 { } } -/// AAPCS64: the first 8 f64 args go in v0–v7; args 9+ are spilled to the stack -/// in order, each occupying 8 bytes, with the stack 16-byte aligned at the call. -#[cfg(target_arch = "aarch64")] -#[inline(never)] -unsafe fn call_all_f64_aarch64(func_ptr: usize, args: &[f64]) -> f64 { - use core::arch::asm; - - let n = args.len(); - // Register args (up to 8); pad missing with 0.0 (callee won't read them). +/// Both asm ABIs (AAPCS64 and SysV x86-64) agree on the split: the first eight +/// f64 args ride in FP argument registers, the rest are spilled to the stack in +/// order, eight bytes each, with the stack pointer 16-byte aligned at the call. +/// Register slots past `args.len()` are padded with `0.0` — the callee has no +/// such parameter and never reads them. +#[cfg(any( + target_arch = "aarch64", + all(target_arch = "x86_64", not(target_os = "windows")) +))] +#[inline] +fn split_register_and_stacked(args: &[f64]) -> ([f64; 8], &[f64], usize) { let mut reg = [0.0f64; 8]; - for (i, slot) in reg.iter_mut().enumerate() { - if i < n { - *slot = args[i]; - } - } + let in_regs = args.len().min(reg.len()); + reg[..in_regs].copy_from_slice(&args[..in_regs]); + let stacked = if args.len() > reg.len() { + &args[reg.len()..] + } else { + &[][..] + }; + // A 16-byte multiple keeps the stack aligned across the call. + let stack_bytes = (stacked.len() * 8 + 15) & !15; + (reg, stacked, stack_bytes) +} + +// --------------------------------------------------------------------------- +// The two asm trampolines are NAKED functions that set up their own frame +// pointer and describe it to the unwinder, rather than inline `asm!` blocks +// inside an ordinary Rust function. That is the whole point of their shape, +// and the reason is #9446. +// +// The trampoline has to lower the stack pointer by a runtime-computed amount +// (the spilled-argument area) and leave it there ACROSS the call. Inside an +// `asm!` block the compiler does not know that, so the frame description it +// emits for the surrounding function still says "CFA = rsp + 32" (measured on +// x86-64 Linux, where LLVM keeps no frame pointer) while rsp is really +// `stack_bytes` lower. Every unwinder that steps through the trampoline while +// the callee runs — the GC's native-root walk (`gc/roots/stack_maps.rs`, an +// `_Unwind_Backtrace`), the exception transport (`_Unwind_RaiseException`), +// gdb — then reads the trampoline's return address from the wrong slot. What it +// finds there is a spilled argument or a saved register, so the walk either +// stops (silently dropping every frame ABOVE the trampoline: the collector +// never sees those frames' roots and frees or moves what they hold) or, when +// the garbage is not a mapped address, faults inside libgcc's fallback frame +// probe. The second shape is `PERRY_GC_SCHEDULE_SEED=1 PERRY_GC_SCHEDULE_RATE=1` +// on the Claude Code bundle at safepoint 4266: a minor at a loop poll inside a +// dynamically constructed class whose synthesized constructor takes more than +// eight params. aarch64 never showed it only because LLVM happened to keep a +// frame pointer there, so the CFA was `x29`-relative and immune to `sp`. +// +// With a naked function the frame IS the code below: `rbp`/`x29` is set from +// the entry stack pointer before anything moves, the CFA is defined off that +// register, and the callee restores it like any callee-saved register. The +// dynamic `rsp`/`sp` adjustment is then invisible to unwinding, on every +// target and under every frame-pointer setting, and the frame record is also +// exactly what a frame-pointer chain walk expects. +// `tests::the_unwinder_steps_through_a_trampoline_with_stacked_args` pins the +// contract from inside a stacked-argument callee. +// +// The one target that keeps the inline-`asm!` shape is Windows ARM64, where +// unwinding is SEH and its metadata comes from the compiler's own prologue — +// see `call_all_f64_aarch64`'s Windows twin below. +// --------------------------------------------------------------------------- + +/// The unwind directives inside the naked trampolines: DWARF CFI, which is what +/// the Itanium unwinder — the GC's native-root walk and the exception transport +/// on every ELF and Mach-O host — reads. +#[cfg(all( + not(target_os = "windows"), + any(target_arch = "aarch64", target_arch = "x86_64") +))] +macro_rules! cfi { + ($directive:literal) => { + $directive + }; +} + +/// AAPCS64: `x0` = callee, `x1` = the eight register args, `x2`/`x3` = the +/// spilled args and their count, `x4` = the 16-byte-rounded spill area size. +/// Register args go in `d0`–`d7`; args 9+ are copied to `[sp + i*8]` in order. +#[cfg(all(target_arch = "aarch64", not(target_os = "windows")))] +#[unsafe(naked)] +unsafe extern "C" fn call_all_f64_aarch64( + func_ptr: usize, + reg: *const f64, + stacked: *const f64, + stacked_count: usize, + stack_bytes: usize, +) -> f64 { + core::arch::naked_asm!( + // Frame record first, before sp moves by a runtime amount. + // Naked functions get no CFI region from rustc; this body is its own. + cfi!(".cfi_startproc"), + "stp x29, x30, [sp, #-16]!", + cfi!(".cfi_def_cfa_offset 16"), + cfi!(".cfi_offset w30, -8"), + cfi!(".cfi_offset w29, -16"), + "mov x29, sp", + cfi!(".cfi_def_cfa w29, 16"), + // Reserve the (16-byte multiple) spill area and copy args 9+ into it. + "sub sp, sp, x4", + "mov x9, xzr", + "cbz x3, 3f", + "2:", + "ldr x10, [x2, x9, lsl #3]", + "str x10, [sp, x9, lsl #3]", + "add x9, x9, #1", + "cmp x9, x3", + "b.lo 2b", + "3:", + // FP argument registers, loaded last so nothing above clobbers them. + "ldp d0, d1, [x1]", + "ldp d2, d3, [x1, #16]", + "ldp d4, d5, [x1, #32]", + "ldp d6, d7, [x1, #48]", + "blr x0", + // The callee preserved x29; drop the spill area through it. + "mov sp, x29", + "ldp x29, x30, [sp], #16", + cfi!(".cfi_def_cfa wsp, 0"), + cfi!(".cfi_restore w30"), + cfi!(".cfi_restore w29"), + "ret", + cfi!(".cfi_endproc"), + ) +} - // Stack-spilled args: args[8..]. Bytes = stacked_count * 8, rounded up to a - // 16-byte multiple so `sp` stays 16-aligned across the `blr`. - let stacked = if n > 8 { &args[8..] } else { &[][..] }; - let stacked_count = stacked.len(); - let raw_bytes = stacked_count * 8; - let stack_bytes = (raw_bytes + 15) & !15; +/// Windows ARM64 keeps the previous shape: an `asm!` block inside an ordinary +/// Rust function. Unwinding there is SEH (`js_throw` raises with +/// `RaiseException`; the runtime walks no native frames — shadow frames), and +/// SEH reads `.pdata`/`.xdata` unwind codes that the COMPILER emits for the +/// prologue it generates — a frame-chained one on this ABI, so the dynamic +/// `sp` adjustment below is already invisible to it. A naked function would +/// have to hand-write those codes with nothing in this tree able to test +/// them, and would be treated as a leaf until it did — the callee's `blr` +/// has overwritten `x30`, so a throw through it could not find its handler. +#[cfg(all(target_arch = "aarch64", target_os = "windows"))] +#[inline(never)] +unsafe extern "C" fn call_all_f64_aarch64( + func_ptr: usize, + reg: *const f64, + stacked: *const f64, + stacked_count: usize, + stack_bytes: usize, +) -> f64 { + use core::arch::asm; + let reg: [f64; 8] = unsafe { *reg.cast::<[f64; 8]>() }; let ret: f64; asm!( - // Stash the current sp in a CALLEE-SAVED register (x20, declared as a - // clobber below so the compiler saves/restores it around this asm). The - // callee preserves callee-saved registers, so x20 survives the `blr` — - // restoring sp from a caller-saved copy or by re-adding a clobbered - // `stack_bytes` would corrupt the stack. + // Stash the pre-adjust sp in a CALLEE-SAVED register (x20, declared as + // a clobber so the compiler saves/restores it around this asm): it + // survives the callee, unlike anything caller-saved. "mov x20, sp", - // Reserve aligned stack space for the spilled args. "sub sp, sp, {stack_bytes}", - // Copy spilled args from the source pointer into [sp + i*8]. "mov {i}, xzr", "cbz {cnt}, 3f", "2:", @@ -94,16 +230,14 @@ unsafe fn call_all_f64_aarch64(func_ptr: usize, args: &[f64]) -> f64 { "b.lo 2b", "3:", "blr {func}", - // Restore sp from the callee-saved copy. "mov sp, x20", func = in(reg) func_ptr, - src = in(reg) stacked.as_ptr(), + src = in(reg) stacked, cnt = in(reg) stacked_count, stack_bytes = in(reg) stack_bytes, i = out(reg) _, tmp = out(reg) _, out("x20") _, - // FP argument registers v0–v7. inout("d0") reg[0] => ret, inout("d1") reg[1] => _, inout("d2") reg[2] => _, @@ -112,9 +246,8 @@ unsafe fn call_all_f64_aarch64(func_ptr: usize, args: &[f64]) -> f64 { inout("d5") reg[5] => _, inout("d6") reg[6] => _, inout("d7") reg[7] => _, - // Caller-saved registers the callee may clobber (AAPCS64). x0–x17 and - // x30(lr) are call-clobbered GPRs; v8–v15 lower 64 bits are - // callee-saved (preserved), v16–v31 are caller-saved. + // Caller-saved registers the callee may clobber (AAPCS64): x0–x17, + // x30 (lr), and the caller-saved vector registers v16–v31. lateout("x0") _, lateout("x1") _, lateout("x2") _, lateout("x3") _, lateout("x4") _, lateout("x5") _, lateout("x6") _, lateout("x7") _, lateout("x8") _, lateout("x9") _, lateout("x10") _, lateout("x11") _, @@ -128,86 +261,65 @@ unsafe fn call_all_f64_aarch64(func_ptr: usize, args: &[f64]) -> f64 { ret } -/// SysV x86-64: the first 8 f64 args go in xmm0–xmm7; args 9+ are spilled to the -/// stack (each 8 bytes), with the stack 16-byte aligned at the `call`. `al` must -/// hold the number of vector registers used for a (possibly) variadic callee; -/// Perry callees are non-variadic, but setting `al` is harmless and matches the -/// ABI requirement for safety. +/// SysV x86-64: `rdi` = callee, `rsi` = the eight register args, `rdx`/`rcx` = +/// the spilled args and their count, `r8` = the 16-byte-rounded spill area +/// size. Register args go in `xmm0`–`xmm7`; args 9+ are copied to +/// `[rsp + i*8]` in order. `al` carries the vector-register count a variadic +/// callee would want; Perry callees are non-variadic, but setting it is +/// harmless and matches the ABI requirement. +/// +/// Alignment: `rsp ≡ 8 (mod 16)` at entry, `≡ 0` after the `push`, unchanged +/// by the 16-byte-multiple `sub`, so the `call` leaves the callee entry at +/// `≡ 8` per SysV. #[cfg(all(target_arch = "x86_64", not(target_os = "windows")))] -#[inline(never)] -unsafe fn call_all_f64_x86_64(func_ptr: usize, args: &[f64]) -> f64 { - use core::arch::asm; - - let n = args.len(); - let mut reg = [0.0f64; 8]; - for (i, slot) in reg.iter_mut().enumerate() { - if i < n { - *slot = args[i]; - } - } - - let stacked = if n > 8 { &args[8..] } else { &[][..] }; - let stacked_count = stacked.len(); - // Stack must be 16-aligned at the call instruction. The `call` pushes an - // 8-byte return address, so before the `call` we need `sp % 16 == 0`. We - // reserve a 16-byte multiple for the spilled args; if `stacked_count` is - // odd, the natural 8-byte total would misalign, so round up. - let raw_bytes = stacked_count * 8; - let stack_bytes = (raw_bytes + 15) & !15; - - let ret: f64; - asm!( - // Stash the pre-adjust rsp in r12 (CALLEE-SAVED, declared as a clobber - // below so the compiler saves/restores it). The callee preserves r12, so - // the sp restore survives the callee clobbering every caller-saved - // register (including any holding `stack_bytes`). We use r12 rather than - // rbx because LLVM reserves rbx internally and rejects it as an explicit - // inline-asm operand; r12 is an equivalent callee-saved scratch. - "mov r12, rsp", - // Reserve space for spilled args, then force rsp 16-aligned so that the - // `call` (which pushes the 8-byte return address) leaves the callee - // entry with rsp ≡ 8 (mod 16), per SysV. `stack_bytes` is a 16-multiple, - // so aligning rsp down by clearing the low 4 bits keeps room for all - // spilled slots (they are written relative to the post-align rsp). - "sub rsp, {stack_bytes}", - "and rsp, -16", - "xor {i:e}, {i:e}", - "test {cnt}, {cnt}", +#[unsafe(naked)] +unsafe extern "C" fn call_all_f64_x86_64( + func_ptr: usize, + reg: *const f64, + stacked: *const f64, + stacked_count: usize, + stack_bytes: usize, +) -> f64 { + core::arch::naked_asm!( + // Frame pointer first, before rsp moves by a runtime amount. + // Naked functions get no CFI region from rustc; this body is its own. + cfi!(".cfi_startproc"), + "push rbp", + cfi!(".cfi_def_cfa_offset 16"), + cfi!(".cfi_offset rbp, -16"), + "mov rbp, rsp", + cfi!(".cfi_def_cfa_register rbp"), + // Reserve the (16-byte multiple) spill area and copy args 9+ into it. + "sub rsp, r8", + "xor eax, eax", + "test rcx, rcx", "jz 3f", "2:", - "mov {tmp}, qword ptr [{src} + {i}*8]", - "mov qword ptr [rsp + {i}*8], {tmp}", - "inc {i}", - "cmp {i}, {cnt}", + "mov r9, qword ptr [rdx + rax*8]", + "mov qword ptr [rsp + rax*8], r9", + "inc rax", + "cmp rax, rcx", "jb 2b", "3:", - "call {func}", - "mov rsp, r12", - func = in(reg) func_ptr, - src = in(reg) stacked.as_ptr(), - cnt = in(reg) stacked_count, - stack_bytes = in(reg) stack_bytes, - i = out(reg) _, - tmp = out(reg) _, - out("r12") _, - inout("xmm0") reg[0] => ret, - inout("xmm1") reg[1] => _, - inout("xmm2") reg[2] => _, - inout("xmm3") reg[3] => _, - inout("xmm4") reg[4] => _, - inout("xmm5") reg[5] => _, - inout("xmm6") reg[6] => _, - inout("xmm7") reg[7] => _, - // Caller-saved GPRs the callee may clobber (SysV). `al` (in rax) is set - // to the FP-register count for variadic safety. xmm8–xmm15 are - // caller-saved on SysV too. - inout("rax") 8u64 => _, lateout("rcx") _, lateout("rdx") _, lateout("rsi") _, - lateout("rdi") _, lateout("r8") _, lateout("r9") _, lateout("r10") _, - lateout("r11") _, - lateout("xmm8") _, lateout("xmm9") _, lateout("xmm10") _, lateout("xmm11") _, - lateout("xmm12") _, lateout("xmm13") _, lateout("xmm14") _, lateout("xmm15") _, - ); - ret + // FP argument registers, loaded last so nothing above clobbers them. + "movsd xmm0, qword ptr [rsi]", + "movsd xmm1, qword ptr [rsi + 8]", + "movsd xmm2, qword ptr [rsi + 16]", + "movsd xmm3, qword ptr [rsi + 24]", + "movsd xmm4, qword ptr [rsi + 32]", + "movsd xmm5, qword ptr [rsi + 40]", + "movsd xmm6, qword ptr [rsi + 48]", + "movsd xmm7, qword ptr [rsi + 56]", + "mov eax, 8", + "call rdi", + // The callee preserved rbp; drop the spill area through it. + "mov rsp, rbp", + "pop rbp", + cfi!(".cfi_def_cfa rsp, 8"), + cfi!(".cfi_restore rbp"), + "ret", + cfi!(".cfi_endproc"), + ) } /// Portable fallback for non-asm targets (incl. Windows x64, whose ABI differs @@ -416,4 +528,141 @@ mod tests { let got = unsafe { call_all_f64(pick as *const () as usize, &args) }; assert_eq!(got, 42.0 * 1000.0 + 7.0); } + + // #9446: the unwinder must be able to step THROUGH the trampoline while the + // callee runs. With stacked arguments the trampoline has lowered the stack + // pointer by a runtime amount; a frame description that does not account + // for that hands every unwinder — the GC's native-root walk, the exception + // transport, gdb — a garbage return address for the trampoline's caller, + // and the walk either stops there (silently dropping every frame above the + // trampoline from the root set) or faults probing the garbage. This is a + // differential test: the frames ABOVE the caller must look identical + // whether the stack is walked from the caller itself or from inside a + // callee reached through the trampoline with four stacked arguments. + // `_Unwind_Backtrace` is what `gc/roots/stack_maps.rs` walks with, so it + // is what this asks. + #[cfg(all( + any(target_os = "linux", target_vendor = "apple"), + any( + target_arch = "aarch64", + all(target_arch = "x86_64", not(target_os = "windows")) + ) + ))] + mod unwind_through_the_trampoline { + use super::call_all_f64; + use crate::eh::UnwindContext; + use std::cell::RefCell; + use std::ffi::c_void; + + unsafe extern "C" { + fn _Unwind_Backtrace( + trace: unsafe extern "C" fn(*mut UnwindContext, *mut c_void) -> i32, + argument: *mut c_void, + ) -> i32; + fn _Unwind_GetIP(context: *mut UnwindContext) -> usize; + } + + /// `_URC_NO_REASON`, the only code that continues the walk. + const URC_NO_REASON: i32 = 0; + /// `_URC_END_OF_STACK`: stop, normally. + const URC_END_OF_STACK: i32 = 5; + /// Well above any test-harness depth; a walk that runs away must fail + /// the assertion, not hang the suite. + const MAX_FRAMES: usize = 256; + + unsafe extern "C" fn collect(context: *mut UnwindContext, argument: *mut c_void) -> i32 { + let out = unsafe { &mut *argument.cast::>() }; + out.push(unsafe { _Unwind_GetIP(context) }); + if out.len() >= MAX_FRAMES { + URC_END_OF_STACK + } else { + URC_NO_REASON + } + } + + /// Return addresses innermost-first, starting at this function's own + /// frame. `inline(never)` so both walks below start one frame deep and + /// the indexing that follows means the same thing on every host. + #[inline(never)] + fn return_addresses() -> Vec { + let mut ips: Vec = Vec::new(); + unsafe { + _Unwind_Backtrace(collect, (&mut ips as *mut Vec).cast::()); + } + std::hint::black_box(ips) + } + + thread_local! { + static SEEN_FROM_CALLEE: RefCell> = const { RefCell::new(Vec::new()) }; + } + + /// Twelve f64 params: eight in registers, four stacked on both asm + /// ABIs. Walks the stack from inside, and returns two of the stacked + /// args so a mis-passed spill is caught by the same test. + extern "C" fn walking_callee( + a0: f64, + a1: f64, + a2: f64, + a3: f64, + a4: f64, + a5: f64, + a6: f64, + a7: f64, + a8: f64, + a9: f64, + a10: f64, + a11: f64, + ) -> f64 { + let _ = (a0, a1, a2, a3, a4, a5, a6, a7, a9, a10); + SEEN_FROM_CALLEE.with(|seen| *seen.borrow_mut() = return_addresses()); + a8 * 1000.0 + a11 + } + + /// The caller frame the two walks have in common. Returns the walk + /// taken from here and the walk taken from inside the callee. + #[inline(never)] + fn walk_from_caller_and_from_callee() -> (Vec, Vec) { + let from_caller = return_addresses(); + let args: Vec = (0..12).map(f64::from).collect(); + let got = unsafe { call_all_f64(walking_callee as *const () as usize, &args) }; + assert_eq!(got, 8.0 * 1000.0 + 11.0, "stacked args mis-passed"); + let from_callee = SEEN_FROM_CALLEE.with(|seen| std::mem::take(&mut *seen.borrow_mut())); + (from_caller, from_callee) + } + + #[test] + fn the_unwinder_steps_through_a_trampoline_with_stacked_args() { + let (from_caller, from_callee) = walk_from_caller_and_from_callee(); + // `from_caller` is [return_addresses, walk_from_caller_and_from_callee, + // harness…]; `from_callee` is [return_addresses, walking_callee, + // trampoline, walk_from_caller_and_from_callee, harness…]. The two + // sites inside the shared caller differ, everything above it must + // not. + assert!( + from_caller.len() >= 3, + "the control walk saw only {} frame(s); the harness above the \ + caller is what the comparison is made of", + from_caller.len() + ); + let above_caller = &from_caller[2..]; + assert!( + from_callee.len() >= above_caller.len() + 4, + "walked from inside the trampoline's callee the unwinder saw \ + {} frame(s), but the caller's own walk saw {} frames above \ + the caller alone — the walk stopped at the trampoline, which \ + is #9446: every frame above it is invisible to the GC's root \ + scan while a dynamically dispatched callee runs.\n from \ + callee: {from_callee:#x?}\n from caller: {from_caller:#x?}", + from_callee.len(), + above_caller.len() + ); + let tail = &from_callee[from_callee.len() - above_caller.len()..]; + assert_eq!( + tail, above_caller, + "the frames above the caller must be reachable, and the same, \ + through the trampoline.\n from callee: {from_callee:#x?}\n \ + from caller: {from_caller:#x?}" + ); + } + } } diff --git a/test-files/test_gap_9446_trampoline_unwind.ts b/test-files/test_gap_9446_trampoline_unwind.ts new file mode 100644 index 0000000000..b2d1823ad7 --- /dev/null +++ b/test-files/test_gap_9446_trampoline_unwind.ts @@ -0,0 +1,166 @@ +// #9446: the runtime's all-f64 call trampoline (`abi_trampoline::call_all_f64`) +// is what every dynamic vtable dispatch and every dynamic `new` on a class +// value goes through when the callee's arity is only known at runtime. With +// more than eight f64 arguments (`this` counts) it lowers the stack pointer by +// a runtime amount to spill the rest — and, as an inline asm block inside an +// ordinary Rust function, it did so behind a frame description that still said +// the stack pointer had not moved. Every unwinder stepping through the +// trampoline while the callee ran then read the trampoline's return address +// from a spilled argument or a saved register: +// +// * a `throw` inside the callee never found the `catch` above the trampoline +// (the exception transport is the system unwinder on x86-64), so a caught +// exception was reported as uncaught; +// * a collection inside the callee never saw the frames ABOVE the +// trampoline, so their roots were dropped: a young object the caller holds +// across the call is not copied, and the caller reads a recycled cell. +// +// Neither showed on aarch64, where LLVM keeps a frame pointer for the +// trampoline function and the description is frame-pointer-relative. On +// x86-64 Linux the second shape is `PERRY_GC_SCHEDULE_SEED=1 +// PERRY_GC_SCHEDULE_RATE=1` crashing the Claude Code bundle at safepoint 4266. +// +// Every dispatch below is dynamic ON PURPOSE — a computed method name, or a +// class value the `new` site cannot see through — so the call reaches the +// runtime vtable path and the trampoline rather than an inlined class-id +// tower. Nine declared parameters plus `this` puts two arguments on the stack. +// +// Expected output: +// throw-through-method: from wide 36 +// throw-through-ctor: ctor 45 +// after-churn: 7/seven wide:36 +// control: other:36 + +class Wide { + tag: string; + constructor(tag: string) { + this.tag = tag; + } + probe( + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + g: number, + h: number, + mode: string, + ): string { + const sum = a + b + c + d + e + f + g + h; + if (mode === "throw") { + throw new Error("from " + this.tag + " " + sum); + } + if (mode === "churn") { + churn(600000); + } + return this.tag + ":" + sum; + } +} + +// A second implementor keeps `probe` a genuine dispatch, not a single callee. +class Other { + tag: string = "other"; + probe( + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + g: number, + h: number, + _mode: string, + ): string { + return this.tag + ":" + (a + b + c + d + e + f + g + h); + } +} + +// `any`, so the receiver's class is not statically known. +function make(k: number): any { + return k % 2 === 0 ? new Wide("wide") : new Other(); +} + +// A computed member name: the call site cannot pick an implementor, so the +// method is resolved by name at runtime and invoked through the vtable. +function methodName(): string { + return "pro" + "be"; +} + +// A class EXPRESSION handed out as a value: `new` on it replays the constructor +// through the runtime constructor table — the same trampoline. Its arity is +// nine user params plus the synthesized capture params. +function makeClass(label: string): any { + return class { + total: number; + constructor( + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + g: number, + h: number, + i: number, + ) { + this.total = a + b + c + d + e + f + g + h + i; + throw new Error(label + " " + this.total); + } + }; +} + +// Allocates past the 16 MiB nursery cap with cells that ESCAPE (they are +// pushed into an array), so a copying minor runs at a loop poll INSIDE the +// dynamically dispatched callee. A scalar-replaced literal would allocate +// nothing and the probe could not fail. +function churn(n: number): void { + let keep: any[] = []; + for (let i = 0; i < n; i++) { + const cell = { a: i, b: i + 1, c: i + 2, d: i + 3 }; + keep.push(cell); + if (keep.length >= 1024) { + keep = []; + } + } +} + +// `any`, so the cell is a real heap object the caller's frame has to root. +function makeCell(n: number): any { + return { a: n, b: "seven" }; +} + +function main(): void { + const m = methodName(); + + // 1. A throw inside a 9-param dynamically dispatched method, caught here. + let caught = ""; + try { + make(0)[m](1, 2, 3, 4, 5, 6, 7, 8, "throw"); + } catch (e: any) { + caught = e.message; + } + console.log("throw-through-method: " + caught); + + // 2. A throw inside a 9-param constructor reached through a class value. + const K = makeClass("ctor"); + caught = ""; + try { + new K(1, 2, 3, 4, 5, 6, 7, 8, 9); + } catch (e: any) { + caught = e.message; + } + console.log("throw-through-ctor: " + caught); + + // 3. A collection inside the callee must still see THIS frame's roots: + // `keep` is young, live only here, and read after the call returns. + const keep = makeCell(7); + const r = make(2)[m](1, 2, 3, 4, 5, 6, 7, 8, "churn"); + console.log("after-churn: " + keep.a + "/" + keep.b + " " + r); + + // CONTRACT, NOT A GAP: the same dynamic dispatch with nothing unwinding + // through it. Here so a fix that mis-passes a stacked argument is caught. + console.log("control: " + make(1)[m](1, 2, 3, 4, 5, 6, 7, 8, "plain")); +} + +main();